Merged master into fix/add-missing-cache
This commit is contained in:
@@ -44,3 +44,5 @@ GOOGLE_DYNAMIC_MAP_ID="test"
|
|||||||
HIDE_FOR_NEXT_RELEASE="true"
|
HIDE_FOR_NEXT_RELEASE="true"
|
||||||
SALESFORCE_PREFERENCE_BASE_URL="test"
|
SALESFORCE_PREFERENCE_BASE_URL="test"
|
||||||
USE_NEW_REWARDS_ENDPOINT="true"
|
USE_NEW_REWARDS_ENDPOINT="true"
|
||||||
|
|
||||||
|
TZ=UTC
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||||
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import { LangParams, PageArgs } from "@/types/params"
|
import { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import styles from "./page.module.css"
|
|||||||
|
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export { generateMetadataAccountPage as generateMetadata } from "@/utils/generateMetadata"
|
export { generateMetadata } from "@/utils/generateMetadata"
|
||||||
|
|
||||||
export default async function MyPages({
|
export default async function MyPages({
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Suspense } from "react"
|
|||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import Sidebar from "@/components/MyPages/Sidebar"
|
import Sidebar from "@/components/MyPages/Sidebar"
|
||||||
|
import Surprises from "@/components/MyPages/Surprises"
|
||||||
|
|
||||||
// import Surprises from "@/components/MyPages/Surprises"
|
|
||||||
import styles from "./layout.module.css"
|
import styles from "./layout.module.css"
|
||||||
|
|
||||||
export default async function MyPagesLayout({
|
export default async function MyPagesLayout({
|
||||||
@@ -24,9 +24,7 @@ export default async function MyPagesLayout({
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* TODO: Waiting on new API stuff
|
<Surprises />
|
||||||
<Surprises />
|
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ProfilePage from "../page"
|
import ProfilePage from "../page"
|
||||||
|
|
||||||
export { generateMetadataAccountPage as generateMetadata } from "@/utils/generateMetadata"
|
export { generateMetadata } from "@/utils/generateMetadata"
|
||||||
|
|
||||||
export default ProfilePage
|
export default ProfilePage
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { setLang } from "@/i18n/serverContext"
|
|||||||
|
|
||||||
import { LangParams, PageArgs } from "@/types/params"
|
import { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export { generateMetadataAccountPage as generateMetadata } from "@/utils/generateMetadata"
|
export { generateMetadata } from "@/utils/generateMetadata"
|
||||||
|
|
||||||
export default async function ProfilePage({ params }: PageArgs<LangParams>) {
|
export default async function ProfilePage({ params }: PageArgs<LangParams>) {
|
||||||
setLang(params.lang)
|
setLang(params.lang)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||||
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import { LangParams, PageArgs } from "@/types/params"
|
import { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
|
|||||||
|
|
||||||
import { isSignupPage } from "@/constants/routes/signup"
|
import { isSignupPage } from "@/constants/routes/signup"
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
import { getHotelPage } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import HotelPage from "@/components/ContentType/HotelPage"
|
import HotelPage from "@/components/ContentType/HotelPage"
|
||||||
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
|
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
|
|
||||||
export { generateMetadata } from "@/utils/generateMetadata"
|
export { generateMetadata } from "@/utils/generateMetadata"
|
||||||
|
|
||||||
export default function ContentTypePage({
|
export default async function ContentTypePage({
|
||||||
params,
|
params,
|
||||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
||||||
setLang(params.lang)
|
setLang(params.lang)
|
||||||
@@ -57,7 +58,12 @@ export default function ContentTypePage({
|
|||||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||||
return notFound()
|
return notFound()
|
||||||
}
|
}
|
||||||
return <HotelPage />
|
const hotelPageData = await getHotelPage()
|
||||||
|
return hotelPageData ? (
|
||||||
|
<HotelPage hotelId={hotelPageData.hotel_page_id} />
|
||||||
|
) : (
|
||||||
|
notFound()
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
const type: never = params.contentType
|
const type: never = params.contentType
|
||||||
console.error(`Unsupported content type given: ${type}`)
|
console.error(`Unsupported content type given: ${type}`)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
|
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
|
||||||
|
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
|
||||||
|
import Summary from "@/components/HotelReservation/BookingConfirmation/Summary"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import styles from "./page.module.css"
|
import styles from "./page.module.css"
|
||||||
@@ -12,11 +14,12 @@ export default async function BookingConfirmationPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams, { confirmationNumber: string }>) {
|
}: PageArgs<LangParams, { confirmationNumber: string }>) {
|
||||||
setLang(params.lang)
|
setLang(params.lang)
|
||||||
const confirmationNumber = searchParams.confirmationNumber
|
void getBookingConfirmation(searchParams.confirmationNumber)
|
||||||
void getBookingConfirmation(confirmationNumber)
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.main}>
|
<div className={styles.main}>
|
||||||
<BookingConfirmation confirmationNumber={confirmationNumber} />
|
<Header confirmationNumber={searchParams.confirmationNumber} />
|
||||||
</main>
|
<Rooms />
|
||||||
|
<Summary />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.layout {
|
||||||
|
background-color: var(--Base-Background-Primary-Normal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
import styles from "./layout.module.css"
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import {
|
||||||
|
BOOKING_CONFIRMATION_NUMBER,
|
||||||
|
PaymentErrorCodeEnum,
|
||||||
|
} from "@/constants/booking"
|
||||||
|
import { Lang } from "@/constants/languages"
|
||||||
|
import {
|
||||||
|
bookingConfirmation,
|
||||||
|
payment,
|
||||||
|
} from "@/constants/routes/hotelReservation"
|
||||||
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
|
import PaymentCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback"
|
||||||
|
|
||||||
|
import { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
|
export default async function PaymentCallbackPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageArgs<
|
||||||
|
LangParams,
|
||||||
|
{ status: "error" | "success" | "cancel"; confirmationNumber?: string }
|
||||||
|
>) {
|
||||||
|
console.log(`[payment-callback] callback started`)
|
||||||
|
const lang = params.lang
|
||||||
|
const status = searchParams.status
|
||||||
|
const confirmationNumber = searchParams.confirmationNumber
|
||||||
|
|
||||||
|
if (status === "success" && confirmationNumber) {
|
||||||
|
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}`
|
||||||
|
|
||||||
|
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
|
||||||
|
redirect(confirmationUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnUrl = payment(lang)
|
||||||
|
const searchObject = new URLSearchParams()
|
||||||
|
|
||||||
|
if (confirmationNumber) {
|
||||||
|
try {
|
||||||
|
const bookingStatus = await serverClient().booking.status({
|
||||||
|
confirmationNumber,
|
||||||
|
})
|
||||||
|
if (bookingStatus.metadata) {
|
||||||
|
searchObject.set(
|
||||||
|
"errorCode",
|
||||||
|
bookingStatus.metadata.errorCode?.toString() ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
|
||||||
|
)
|
||||||
|
if (status === "cancel") {
|
||||||
|
searchObject.set("errorCode", PaymentErrorCodeEnum.Cancelled.toString())
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
searchObject.set("errorCode", PaymentErrorCodeEnum.Failed.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaymentCallback
|
||||||
|
returnUrl={returnUrl.toString()}
|
||||||
|
searchObject={searchObject}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,3 @@
|
|||||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
export default function HotelSidePeekSlot() {
|
||||||
|
return null
|
||||||
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} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
import { getCityCoordinates, getLocations } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils"
|
import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils"
|
||||||
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
||||||
@@ -58,6 +58,10 @@ export default async function SelectHotelMapPage({
|
|||||||
|
|
||||||
const hotelPins = getHotelPins(hotels)
|
const hotelPins = getHotelPins(hotels)
|
||||||
const filterList = getFiltersFromHotels(hotels)
|
const filterList = getFiltersFromHotels(hotels)
|
||||||
|
const cityCoordinates = await getCityCoordinates({
|
||||||
|
city: city.name,
|
||||||
|
hotel: { address: hotels[0].hotelData.address.streetAddress },
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapModal>
|
<MapModal>
|
||||||
@@ -67,6 +71,7 @@ export default async function SelectHotelMapPage({
|
|||||||
mapId={googleMapId}
|
mapId={googleMapId}
|
||||||
hotels={hotels}
|
hotels={hotels}
|
||||||
filterList={filterList}
|
filterList={filterList}
|
||||||
|
cityCoordinates={cityCoordinates}
|
||||||
/>
|
/>
|
||||||
</MapModal>
|
</MapModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
|
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.cityInformation {
|
.cityInformation {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -65,13 +69,19 @@
|
|||||||
var(--Spacing-x5);
|
var(--Spacing-x5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header nav {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--max-width-navigation);
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sorter {
|
.sorter {
|
||||||
display: block;
|
display: block;
|
||||||
width: 339px;
|
width: 339px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0 auto;
|
margin: var(--Spacing-x3) auto 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: var(--max-width-navigation);
|
max-width: var(--max-width-navigation);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
import {
|
||||||
|
selectHotel,
|
||||||
|
selectHotelMap,
|
||||||
|
} from "@/constants/routes/hotelReservation"
|
||||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +23,8 @@ import {
|
|||||||
import { ChevronRightIcon } from "@/components/Icons"
|
import { ChevronRightIcon } from "@/components/Icons"
|
||||||
import StaticMap from "@/components/Maps/StaticMap"
|
import StaticMap from "@/components/Maps/StaticMap"
|
||||||
import Alert from "@/components/TempDesignSystem/Alert"
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
|
||||||
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
@@ -45,8 +51,12 @@ export default async function SelectHotelPage({
|
|||||||
(location) =>
|
(location) =>
|
||||||
location.name.toLowerCase() === searchParams.city.toLowerCase()
|
location.name.toLowerCase() === searchParams.city.toLowerCase()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!city) return notFound()
|
if (!city) return notFound()
|
||||||
|
|
||||||
|
const isCityWithCountry = (city: any): city is { country: string } =>
|
||||||
|
"country" in city
|
||||||
|
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
const selectHotelParams = new URLSearchParams(searchParams)
|
const selectHotelParams = new URLSearchParams(searchParams)
|
||||||
const selectHotelParamsObject =
|
const selectHotelParamsObject =
|
||||||
@@ -65,12 +75,36 @@ export default async function SelectHotelPage({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filterList = getFiltersFromHotels(hotels)
|
const filterList = getFiltersFromHotels(hotels)
|
||||||
|
const breadcrumbs = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: "Home" }),
|
||||||
|
href: `/${params.lang}`,
|
||||||
|
uid: "home-page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: "Hotel reservation" }),
|
||||||
|
href: `/${params.lang}/hotelreservation`,
|
||||||
|
uid: "hotel-reservation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: "Select hotel" }),
|
||||||
|
href: `${selectHotel(params.lang)}/?${selectHotelParams}`,
|
||||||
|
uid: "select-hotel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: city.name,
|
||||||
|
uid: city.id,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
|
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
|
<Suspense fallback={<BreadcrumbsSkeleton />}>
|
||||||
|
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||||
|
</Suspense>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<div className={styles.cityInformation}>
|
<div className={styles.cityInformation}>
|
||||||
<Subtitle>{city.name}</Subtitle>
|
<Subtitle>{city.name}</Subtitle>
|
||||||
@@ -94,6 +128,7 @@ export default async function SelectHotelPage({
|
|||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
<StaticMap
|
<StaticMap
|
||||||
city={searchParams.city}
|
city={searchParams.city}
|
||||||
|
country={isCityWithCountry(city) ? city.country : undefined}
|
||||||
width={340}
|
width={340}
|
||||||
height={180}
|
height={180}
|
||||||
zoomLevel={11}
|
zoomLevel={11}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "@jest/globals"
|
||||||
|
|
||||||
|
import { getValidFromDate, getValidToDate } from "./getValidDates"
|
||||||
|
|
||||||
|
const NOW = new Date("2020-10-01T00:00:00Z")
|
||||||
|
let originalTz: string | undefined
|
||||||
|
|
||||||
|
describe("getValidFromDate", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers({ now: NOW })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getValidFromDate", () => {
|
||||||
|
it("returns today when empty string is provided", () => {
|
||||||
|
const actual = getValidFromDate("")
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns today when undefined is provided", () => {
|
||||||
|
const actual = getValidFromDate(undefined)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns given date in utc", () => {
|
||||||
|
const actual = getValidFromDate("2024-01-01")
|
||||||
|
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getValidToDate", () => {
|
||||||
|
it("returns day after fromDate when empty string is provided", () => {
|
||||||
|
const actual = getValidToDate("", NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns day after fromDate when undefined is provided", () => {
|
||||||
|
const actual = getValidToDate(undefined, NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns given date in utc", () => {
|
||||||
|
const actual = getValidToDate("2024-01-01", NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fallsback to day after fromDate when given date is before fromDate", () => {
|
||||||
|
const actual = getValidToDate("2020-09-30", NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Dayjs } from "dayjs"
|
||||||
|
|
||||||
|
import { dt } from "@/lib/dt"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid dates from stringFromDate and stringToDate making sure that they are not in the past and chronologically correct
|
||||||
|
* @example const { fromDate, toDate} = getValidDates("2021-01-01", "2021-01-02")
|
||||||
|
*/
|
||||||
|
export function getValidDates(
|
||||||
|
stringFromDate: string | undefined,
|
||||||
|
stringToDate: string | undefined
|
||||||
|
): { fromDate: Dayjs; toDate: Dayjs } {
|
||||||
|
const fromDate = getValidFromDate(stringFromDate)
|
||||||
|
const toDate = getValidToDate(stringToDate, fromDate)
|
||||||
|
|
||||||
|
return { fromDate, toDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid fromDate from stringFromDate making sure that it is not in the past
|
||||||
|
*/
|
||||||
|
export function getValidFromDate(stringFromDate: string | undefined): Dayjs {
|
||||||
|
const now = dt().utc()
|
||||||
|
if (!stringFromDate) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
const toDate = dt(stringFromDate)
|
||||||
|
|
||||||
|
const yesterday = now.subtract(1, "day")
|
||||||
|
if (!toDate.isAfter(yesterday)) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
return toDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid toDate from stringToDate making sure that it is after fromDate
|
||||||
|
*/
|
||||||
|
export function getValidToDate(
|
||||||
|
stringToDate: string | undefined,
|
||||||
|
fromDate: Dayjs | Date
|
||||||
|
): Dayjs {
|
||||||
|
const tomorrow = dt().utc().add(1, "day")
|
||||||
|
if (!stringToDate) {
|
||||||
|
return tomorrow
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDate = dt(stringToDate)
|
||||||
|
if (toDate.isAfter(fromDate)) {
|
||||||
|
return toDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return tomorrow
|
||||||
|
}
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
import { getHotelData, getLocations } from "@/lib/trpc/memoizedRequests"
|
||||||
import {
|
|
||||||
getHotelData,
|
|
||||||
getLocations,
|
|
||||||
getProfileSafely,
|
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
|
||||||
|
|
||||||
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||||
import Rooms from "@/components/HotelReservation/SelectRate/Rooms"
|
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainer"
|
||||||
import {
|
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainerSkeleton"
|
||||||
generateChildrenString,
|
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
getHotelReservationQueryParams,
|
|
||||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
import { safeTry } from "@/utils/safeTry"
|
||||||
|
|
||||||
|
import { getValidDates } from "./getValidDates"
|
||||||
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
|
||||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
@@ -45,71 +40,44 @@ export default async function SelectRatePage({
|
|||||||
return notFound()
|
return notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const validFromDate =
|
const { fromDate, toDate } = getValidDates(
|
||||||
searchParams.fromDate &&
|
searchParams.fromDate,
|
||||||
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
|
searchParams.toDate
|
||||||
? searchParams.fromDate
|
|
||||||
: dt().utc().format("YYYY-MM-DD")
|
|
||||||
const validToDate =
|
|
||||||
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
|
|
||||||
? searchParams.toDate
|
|
||||||
: dt().utc().add(1, "day").format("YYYY-MM-DD")
|
|
||||||
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
|
|
||||||
const childrenCount = selectRoomParamsObject.room[0].child?.length
|
|
||||||
const children = selectRoomParamsObject.room[0].child
|
|
||||||
? generateChildrenString(selectRoomParamsObject.room[0].child)
|
|
||||||
: undefined // TODO: Handle multiple rooms
|
|
||||||
|
|
||||||
const [hotelData, roomsAvailability, packages, user] = await Promise.all([
|
|
||||||
getHotelData({ hotelId: searchParams.hotel, language: params.lang }),
|
|
||||||
serverClient().hotel.availability.rooms({
|
|
||||||
hotelId: parseInt(searchParams.hotel, 10),
|
|
||||||
roomStayStartDate: validFromDate,
|
|
||||||
roomStayEndDate: validToDate,
|
|
||||||
adults,
|
|
||||||
children,
|
|
||||||
}),
|
|
||||||
serverClient().hotel.packages.get({
|
|
||||||
hotelId: searchParams.hotel,
|
|
||||||
startDate: searchParams.fromDate,
|
|
||||||
endDate: searchParams.toDate,
|
|
||||||
adults,
|
|
||||||
children: childrenCount,
|
|
||||||
packageCodes: [
|
|
||||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
|
||||||
RoomPackageCodeEnum.PET_ROOM,
|
|
||||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
getProfileSafely(),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!roomsAvailability) {
|
|
||||||
return "No rooms found" // TODO: Add a proper error message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hotelData) {
|
|
||||||
return "No hotel data found" // TODO: Add a proper error message
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomCategories = hotelData?.included
|
|
||||||
|
|
||||||
const noRoomsAvailable = roomsAvailability.roomConfigurations.reduce(
|
|
||||||
(acc, room) => {
|
|
||||||
return acc && room.status === "NotAvailable"
|
|
||||||
},
|
|
||||||
true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
|
||||||
|
const children = selectRoomParamsObject.room[0].child // TODO: Handle multiple rooms
|
||||||
|
|
||||||
|
const [hotelData, hotelDataError] = await safeTry(
|
||||||
|
getHotelData({ hotelId: searchParams.hotel, language: params.lang })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hotelData && !hotelDataError) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelId = +searchParams.hotel
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HotelInfoCard hotelData={hotelData} noAvailability={noRoomsAvailable} />
|
<HotelInfoCard
|
||||||
<Rooms
|
hotelId={hotelId}
|
||||||
roomsAvailability={roomsAvailability}
|
lang={params.lang}
|
||||||
roomCategories={roomCategories ?? []}
|
fromDate={fromDate.toDate()}
|
||||||
user={user}
|
toDate={toDate.toDate()}
|
||||||
packages={packages ?? []}
|
adultCount={adults}
|
||||||
|
childArray={children}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Suspense key={hotelId} fallback={<RoomsContainerSkeleton />}>
|
||||||
|
<RoomsContainer
|
||||||
|
hotelId={hotelId}
|
||||||
|
lang={params.lang}
|
||||||
|
fromDate={fromDate.toDate()}
|
||||||
|
toDate={toDate.toDate()}
|
||||||
|
adultCount={adults}
|
||||||
|
childArray={children}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
|
||||||
|
|
||||||
export default function LoadingHotelHeader() {
|
|
||||||
return <LoadingSpinner />
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
|
||||||
|
|
||||||
export default function LoadingSummaryHeader() {
|
|
||||||
return <LoadingSpinner />
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 =
|
|
||||||
user && availability.memberRate
|
|
||||||
? {
|
|
||||||
local: {
|
|
||||||
price: availability.memberRate.localPrice.pricePerStay,
|
|
||||||
currency: availability.memberRate.localPrice.currency,
|
|
||||||
},
|
|
||||||
euro: availability.memberRate.requestedPrice
|
|
||||||
? {
|
|
||||||
price: availability.memberRate.requestedPrice.pricePerStay,
|
|
||||||
currency: availability.memberRate.requestedPrice.currency,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
local: {
|
|
||||||
price: availability.publicRate.localPrice.pricePerStay,
|
|
||||||
currency: availability.publicRate.localPrice.currency,
|
|
||||||
},
|
|
||||||
euro: availability.publicRate?.requestedPrice
|
|
||||||
? {
|
|
||||||
price: availability.publicRate?.requestedPrice.pricePerStay,
|
|
||||||
currency: availability.publicRate?.requestedPrice.currency,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.mobileSummary}>
|
|
||||||
<SummaryBottomSheet>
|
|
||||||
<div className={styles.summary}>
|
|
||||||
<Summary
|
|
||||||
showMemberPrice={!!(user && availability.memberRate)}
|
|
||||||
room={{
|
|
||||||
roomType: availability.selectedRoom.roomType,
|
|
||||||
localPrice: prices.local,
|
|
||||||
euroPrice: prices.euro,
|
|
||||||
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,
|
|
||||||
localPrice: prices.local,
|
|
||||||
euroPrice: prices.euro,
|
|
||||||
adults,
|
|
||||||
children,
|
|
||||||
rateDetails: availability.rateDetails,
|
|
||||||
cancellationText: availability.cancellationText,
|
|
||||||
packages,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.shadow} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import {
|
|
||||||
getCreditCardsSafely,
|
|
||||||
getProfileSafely,
|
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
|
||||||
|
|
||||||
export function preload() {
|
|
||||||
void getProfileSafely()
|
|
||||||
void getCreditCardsSafely()
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import "./enterDetailsLayout.css"
|
|
||||||
|
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBreakfastPackages,
|
getBreakfastPackages,
|
||||||
getCreditCardsSafely,
|
getCreditCardsSafely,
|
||||||
getHotelData,
|
getHotelData,
|
||||||
|
getPackages,
|
||||||
getProfileSafely,
|
getProfileSafely,
|
||||||
getSelectedRoomAvailability,
|
getSelectedRoomAvailability,
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
@@ -13,16 +13,21 @@ import {
|
|||||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||||
|
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
|
||||||
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
||||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||||
|
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||||
import {
|
import {
|
||||||
generateChildrenString,
|
generateChildrenString,
|
||||||
getQueryParamsForEnterDetails,
|
getQueryParamsForEnterDetails,
|
||||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import StepsProvider from "@/providers/StepsProvider"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||||
|
|
||||||
|
import styles from "./page.module.css"
|
||||||
|
|
||||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import { StepEnum } from "@/types/enums/step"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
@@ -36,60 +41,78 @@ export default async function StepPage({
|
|||||||
params: { lang },
|
params: { lang },
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
||||||
|
if (!isValidStep(searchParams.step)) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
setLang(lang)
|
||||||
|
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
const selectRoomParams = new URLSearchParams(searchParams)
|
const selectRoomParams = new URLSearchParams(searchParams)
|
||||||
|
// Deleting step to avoid double searchparams after rewrite
|
||||||
selectRoomParams.delete("step")
|
selectRoomParams.delete("step")
|
||||||
const searchParamsString = selectRoomParams.toString()
|
const booking = getQueryParamsForEnterDetails(selectRoomParams)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hotel: hotelId,
|
hotel: hotelId,
|
||||||
rooms,
|
rooms: [
|
||||||
|
{ adults, children, roomTypeCode, rateCode, packages: packageCodes },
|
||||||
|
], // TODO: Handle multiple rooms
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
} = booking
|
||||||
|
|
||||||
const {
|
|
||||||
adults,
|
|
||||||
children,
|
|
||||||
roomTypeCode,
|
|
||||||
rateCode,
|
|
||||||
packages: packageCodes,
|
|
||||||
} = rooms[0] // TODO: Handle multiple rooms
|
|
||||||
|
|
||||||
const childrenAsString = children && generateChildrenString(children)
|
const childrenAsString = children && generateChildrenString(children)
|
||||||
|
|
||||||
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
||||||
void getBreakfastPackages(breakfastInput)
|
const selectedRoomAvailabilityInput = {
|
||||||
void getSelectedRoomAvailability({
|
|
||||||
hotelId,
|
|
||||||
adults,
|
adults,
|
||||||
children: childrenAsString,
|
children: childrenAsString,
|
||||||
|
hotelId,
|
||||||
|
packageCodes,
|
||||||
|
rateCode,
|
||||||
roomStayStartDate: fromDate,
|
roomStayStartDate: fromDate,
|
||||||
roomStayEndDate: toDate,
|
roomStayEndDate: toDate,
|
||||||
rateCode,
|
|
||||||
roomTypeCode,
|
roomTypeCode,
|
||||||
packageCodes,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const roomAvailability = await getSelectedRoomAvailability({
|
void getProfileSafely()
|
||||||
hotelId,
|
void getCreditCardsSafely()
|
||||||
adults,
|
void getBreakfastPackages(breakfastInput)
|
||||||
children: childrenAsString,
|
void getSelectedRoomAvailability(selectedRoomAvailabilityInput)
|
||||||
roomStayStartDate: fromDate,
|
if (packageCodes?.length) {
|
||||||
roomStayEndDate: toDate,
|
void getPackages({
|
||||||
rateCode,
|
adults,
|
||||||
roomTypeCode,
|
children: children?.length,
|
||||||
packageCodes,
|
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({
|
const hotelData = await getHotelData({
|
||||||
hotelId,
|
hotelId,
|
||||||
language: lang,
|
|
||||||
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||||
|
language: lang,
|
||||||
})
|
})
|
||||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||||
const user = await getProfileSafely()
|
const user = await getProfileSafely()
|
||||||
const savedCreditCards = await getCreditCardsSafely()
|
const savedCreditCards = await getCreditCardsSafely()
|
||||||
|
|
||||||
if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
|
if (!hotelData || !roomAvailability) {
|
||||||
return notFound()
|
return notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,66 +144,96 @@ export default async function StepPage({
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StepsProvider
|
<EnterDetailsProvider
|
||||||
bedTypes={roomAvailability.bedTypes}
|
bedTypes={roomAvailability.bedTypes}
|
||||||
|
booking={booking}
|
||||||
breakfastPackages={breakfastPackages}
|
breakfastPackages={breakfastPackages}
|
||||||
isMember={!!user}
|
packages={packages}
|
||||||
searchParams={searchParamsString}
|
roomRate={{
|
||||||
|
memberRate: roomAvailability.memberRate,
|
||||||
|
publicRate: roomAvailability.publicRate,
|
||||||
|
}}
|
||||||
|
searchParamsStr={selectRoomParams.toString()}
|
||||||
step={searchParams.step}
|
step={searchParams.step}
|
||||||
|
user={user}
|
||||||
>
|
>
|
||||||
<section>
|
<main>
|
||||||
<HistoryStateManager />
|
<HotelHeader hotelData={hotelData} />
|
||||||
<SelectedRoom
|
<div className={styles.container}>
|
||||||
hotelId={hotelId}
|
<div className={styles.content}>
|
||||||
room={roomAvailability.selectedRoom}
|
<section>
|
||||||
rateDescription={roomAvailability.cancellationText}
|
<HistoryStateManager />
|
||||||
/>
|
<SelectedRoom
|
||||||
|
hotelId={hotelId}
|
||||||
|
room={roomAvailability.selectedRoom}
|
||||||
|
rateDescription={roomAvailability.cancellationText}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* TODO: How to handle no beds found? */}
|
{/* TODO: How to handle no beds found? */}
|
||||||
{roomAvailability.bedTypes ? (
|
{roomAvailability.bedTypes ? (
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
header={intl.formatMessage({ id: "Select bed" })}
|
header={intl.formatMessage({ id: "Select bed" })}
|
||||||
step={StepEnum.selectBed}
|
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
step={StepEnum.selectBed}
|
||||||
>
|
>
|
||||||
<BedType bedTypes={roomAvailability.bedTypes} />
|
<BedType bedTypes={roomAvailability.bedTypes} />
|
||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{breakfastPackages?.length ? (
|
{breakfastPackages?.length ? (
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
header={intl.formatMessage({ id: "Food options" })}
|
header={intl.formatMessage({ id: "Food options" })}
|
||||||
step={StepEnum.breakfast}
|
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
step={StepEnum.breakfast}
|
||||||
>
|
>
|
||||||
<Breakfast packages={breakfastPackages} />
|
<Breakfast packages={breakfastPackages} />
|
||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
header={intl.formatMessage({ id: "Details" })}
|
header={intl.formatMessage({ id: "Details" })}
|
||||||
step={StepEnum.details}
|
step={StepEnum.details}
|
||||||
label={intl.formatMessage({ id: "Enter your details" })}
|
label={intl.formatMessage({ id: "Enter your details" })}
|
||||||
>
|
>
|
||||||
<Details user={user} memberPrice={memberPrice} />
|
<Details user={user} memberPrice={memberPrice} />
|
||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
|
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||||
step={StepEnum.payment}
|
step={StepEnum.payment}
|
||||||
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
label={
|
||||||
>
|
mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod
|
||||||
<Payment
|
}
|
||||||
roomPrice={roomPrice}
|
>
|
||||||
otherPaymentOptions={
|
<Suspense>
|
||||||
hotelData.data.attributes.merchantInformationData
|
<Payment
|
||||||
.alternatePaymentOptions
|
user={user}
|
||||||
}
|
roomPrice={roomPrice}
|
||||||
savedCreditCards={savedCreditCards}
|
otherPaymentOptions={
|
||||||
mustBeGuaranteed={mustBeGuaranteed}
|
hotelData.data.attributes.merchantInformationData
|
||||||
/>
|
.alternatePaymentOptions
|
||||||
</SectionAccordion>
|
}
|
||||||
</section>
|
savedCreditCards={savedCreditCards}
|
||||||
</StepsProvider>
|
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>
|
||||||
|
</EnterDetailsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import TrpcProvider from "@/lib/trpc/Provider"
|
|||||||
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||||
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
||||||
import VwoScript from "@/components/Current/VwoScript"
|
import VwoScript from "@/components/Current/VwoScript"
|
||||||
|
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
|
||||||
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
|
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
|
||||||
import { preloadUserTracking } from "@/components/TrackingSDK"
|
import { preloadUserTracking } from "@/components/TrackingSDK"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
@@ -64,6 +65,7 @@ export default async function RootLayout({
|
|||||||
{footer}
|
{footer}
|
||||||
<ToastHandler />
|
<ToastHandler />
|
||||||
<TokenRefresher />
|
<TokenRefresher />
|
||||||
|
<StorageCleaner />
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
</ServerIntlProvider>
|
</ServerIntlProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
|
||||||
|
|
||||||
import {
|
|
||||||
BOOKING_CONFIRMATION_NUMBER,
|
|
||||||
PaymentErrorCodeEnum,
|
|
||||||
} from "@/constants/booking"
|
|
||||||
import { Lang } from "@/constants/languages"
|
|
||||||
import {
|
|
||||||
bookingConfirmation,
|
|
||||||
payment,
|
|
||||||
} from "@/constants/routes/hotelReservation"
|
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
|
||||||
import { getPublicURL } from "@/server/utils"
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { lang: string; status: string } }
|
|
||||||
): Promise<NextResponse> {
|
|
||||||
const publicURL = getPublicURL(request)
|
|
||||||
|
|
||||||
console.log(`[payment-callback] callback started`)
|
|
||||||
const lang = params.lang as Lang
|
|
||||||
const status = params.status
|
|
||||||
|
|
||||||
const queryParams = request.nextUrl.searchParams
|
|
||||||
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
|
|
||||||
|
|
||||||
if (status === "success" && confirmationNumber) {
|
|
||||||
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
|
|
||||||
confirmationUrl.searchParams.set(
|
|
||||||
BOOKING_CONFIRMATION_NUMBER,
|
|
||||||
confirmationNumber
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
|
|
||||||
return NextResponse.redirect(confirmationUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
|
|
||||||
returnUrl.search = queryParams.toString()
|
|
||||||
|
|
||||||
if (confirmationNumber) {
|
|
||||||
try {
|
|
||||||
const bookingStatus = await serverClient().booking.status({
|
|
||||||
confirmationNumber,
|
|
||||||
})
|
|
||||||
if (bookingStatus.metadata) {
|
|
||||||
returnUrl.searchParams.set(
|
|
||||||
"errorCode",
|
|
||||||
bookingStatus.metadata.errorCode?.toString() ?? ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (status === "cancel") {
|
|
||||||
returnUrl.searchParams.set(
|
|
||||||
"errorCode",
|
|
||||||
PaymentErrorCodeEnum.Cancelled.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (status === "error") {
|
|
||||||
returnUrl.searchParams.set(
|
|
||||||
"errorCode",
|
|
||||||
PaymentErrorCodeEnum.Failed.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
|
|
||||||
return NextResponse.redirect(returnUrl)
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,7 @@ const validateJsonBody = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
locale: z.nativeEnum(Lang),
|
locale: z.nativeEnum(Lang),
|
||||||
|
publish_details: z.object({ locale: z.nativeEnum(Lang) }).optional(),
|
||||||
uid: z.string(),
|
uid: z.string(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
page_settings: z
|
page_settings: z
|
||||||
@@ -67,15 +68,19 @@ export async function POST(request: NextRequest) {
|
|||||||
data: { content_type, entry },
|
data: { content_type, entry },
|
||||||
},
|
},
|
||||||
} = validatedData
|
} = validatedData
|
||||||
const refsTag = generateRefsResponseTag(entry.locale, entry.uid)
|
|
||||||
const refTag = generateRefTag(entry.locale, content_type.uid, entry.uid)
|
// The publish_details.locale is the locale that the entry is published in, regardless if it is "localized" or not
|
||||||
const tag = generateTag(entry.locale, entry.uid)
|
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(
|
const languageSwitcherTag = generateTag(
|
||||||
entry.locale,
|
entryLocale,
|
||||||
entry.uid,
|
entry.uid,
|
||||||
languageSwitcherAffix
|
languageSwitcherAffix
|
||||||
)
|
)
|
||||||
const metadataTag = generateTag(entry.locale, entry.uid, metadataAffix)
|
const metadataTag = generateTag(entryLocale, entry.uid, metadataAffix)
|
||||||
|
|
||||||
console.info(`Revalidating refsTag: ${refsTag}`)
|
console.info(`Revalidating refsTag: ${refsTag}`)
|
||||||
revalidateTag(refsTag)
|
revalidateTag(refsTag)
|
||||||
@@ -94,12 +99,12 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (entry.breadcrumbs) {
|
if (entry.breadcrumbs) {
|
||||||
const breadcrumbsRefsTag = generateRefsResponseTag(
|
const breadcrumbsRefsTag = generateRefsResponseTag(
|
||||||
entry.locale,
|
entryLocale,
|
||||||
entry.uid,
|
entry.uid,
|
||||||
breadcrumbsAffix
|
breadcrumbsAffix
|
||||||
)
|
)
|
||||||
const breadcrumbsTag = generateTag(
|
const breadcrumbsTag = generateTag(
|
||||||
entry.locale,
|
entryLocale,
|
||||||
entry.uid,
|
entry.uid,
|
||||||
breadcrumbsAffix
|
breadcrumbsAffix
|
||||||
)
|
)
|
||||||
@@ -113,7 +118,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (entry.page_settings?.hide_booking_widget) {
|
if (entry.page_settings?.hide_booking_widget) {
|
||||||
const bookingwidgetTag = generateTag(
|
const bookingwidgetTag = generateTag(
|
||||||
entry.locale,
|
entryLocale,
|
||||||
entry.uid,
|
entry.uid,
|
||||||
bookingwidgetAffix
|
bookingwidgetAffix
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.container}>
|
<section className={styles.container}>
|
||||||
<div className={styles.titleContainer}>
|
<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." })}
|
{intl.formatMessage({ id: "You have no upcoming stays." })}
|
||||||
<span className={styles.burgundyTitle}>
|
<span className={styles.burgundyTitle}>
|
||||||
{intl.formatMessage({ id: "Where should you go next?" })}
|
{intl.formatMessage({ id: "Where should you go next?" })}
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() {
|
|||||||
return (
|
return (
|
||||||
<section className={styles.container}>
|
<section className={styles.container}>
|
||||||
<div className={styles.titleContainer}>
|
<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." })}
|
{intl.formatMessage({ id: "You have no upcoming stays." })}
|
||||||
<span className={styles.burgundyTitle}>
|
<span className={styles.burgundyTitle}>
|
||||||
{intl.formatMessage({ id: "Where should you go next?" })}
|
{intl.formatMessage({ id: "Where should you go next?" })}
|
||||||
|
|||||||
@@ -1,60 +1,25 @@
|
|||||||
import { serverClient } from "@/lib/trpc/server"
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
import { ChevronRightSmallIcon,HouseIcon } from "@/components/Icons"
|
import BreadcrumbsComp from "@/components/TempDesignSystem/Breadcrumbs"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import { generateBreadcrumbsSchema } from "@/utils/jsonSchemas"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
|
||||||
|
|
||||||
import styles from "./breadcrumbs.module.css"
|
|
||||||
|
|
||||||
export default async function Breadcrumbs() {
|
export default async function Breadcrumbs() {
|
||||||
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
|
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
|
||||||
|
|
||||||
if (!breadcrumbs?.length) {
|
if (!breadcrumbs?.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const jsonSchema = generateBreadcrumbsSchema(breadcrumbs)
|
||||||
|
|
||||||
const homeBreadcrumb = breadcrumbs.shift()
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.breadcrumbs}>
|
<>
|
||||||
<ul className={styles.list}>
|
<script
|
||||||
{homeBreadcrumb ? (
|
type={jsonSchema.type}
|
||||||
<li className={styles.listItem}>
|
dangerouslySetInnerHTML={{
|
||||||
<Link
|
__html: JSON.stringify(jsonSchema.jsonLd),
|
||||||
className={styles.homeLink}
|
}}
|
||||||
color="peach80"
|
/>
|
||||||
href={homeBreadcrumb.href!}
|
<BreadcrumbsComp breadcrumbs={breadcrumbs} />
|
||||||
variant="breadcrumb"
|
</>
|
||||||
>
|
|
||||||
<HouseIcon width={16} height={16} color="peach80" />
|
|
||||||
</Link>
|
|
||||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{breadcrumbs.map((breadcrumb) => {
|
|
||||||
if (breadcrumb.href) {
|
|
||||||
return (
|
|
||||||
<li key={breadcrumb.uid} className={styles.listItem}>
|
|
||||||
<Link
|
|
||||||
color="peach80"
|
|
||||||
href={breadcrumb.href}
|
|
||||||
variant="breadcrumb"
|
|
||||||
>
|
|
||||||
{breadcrumb.title}
|
|
||||||
</Link>
|
|
||||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={breadcrumb.uid} className={styles.listItem}>
|
|
||||||
<Footnote color="burgundy" type="bold">
|
|
||||||
{breadcrumb.title}
|
|
||||||
</Footnote>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
import getSingleDecimal from "@/utils/numberFormatting"
|
||||||
|
|
||||||
import styles from "./hotelListingItem.module.css"
|
import styles from "./hotelListingItem.module.css"
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export default async function HotelListingItem({
|
|||||||
<Caption color="uiTextPlaceholder">
|
<Caption color="uiTextPlaceholder">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "Distance in km to city centre" },
|
{ id: "Distance in km to city centre" },
|
||||||
{ number: distanceToCentre }
|
{ number: getSingleDecimal(distanceToCentre / 1000) }
|
||||||
)}
|
)}
|
||||||
</Caption>
|
</Caption>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { about } from "@/constants/routes/hotelPageParams"
|
import { about } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
import { ChevronRightSmallIcon, TripAdvisorIcon } from "@/components/Icons"
|
||||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
@@ -9,6 +8,7 @@ import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
|||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import { getLang } from "@/i18n/serverContext"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
import getSingleDecimal from "@/utils/numberFormatting"
|
||||||
|
|
||||||
import styles from "./introSection.module.css"
|
import styles from "./introSection.module.css"
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default async function IntroSection({
|
|||||||
const { distanceToCentre } = location
|
const { distanceToCentre } = location
|
||||||
const formattedDistanceText = intl.formatMessage(
|
const formattedDistanceText = intl.formatMessage(
|
||||||
{ id: "Distance in km to city centre" },
|
{ id: "Distance in km to city centre" },
|
||||||
{ number: distanceToCentre }
|
{ number: getSingleDecimal(distanceToCentre / 1000) }
|
||||||
)
|
)
|
||||||
const lang = getLang()
|
const lang = getLang()
|
||||||
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
||||||
|
|||||||
@@ -60,13 +60,20 @@ export default function Sidebar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseEnter(poiName: string) {
|
function handleMouseEnter(poiName: string | undefined) {
|
||||||
|
if (!poiName) return
|
||||||
|
|
||||||
if (!isClicking) {
|
if (!isClicking) {
|
||||||
onActivePoiChange(poiName)
|
onActivePoiChange(poiName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
|
function handlePoiClick(
|
||||||
|
poiName: string | undefined,
|
||||||
|
poiCoordinates: Coordinates
|
||||||
|
) {
|
||||||
|
if (!poiName || !poiCoordinates) return
|
||||||
|
|
||||||
setIsClicking(true)
|
setIsClicking(true)
|
||||||
toggleFullScreenSidebar()
|
toggleFullScreenSidebar()
|
||||||
onActivePoiChange(poiName)
|
onActivePoiChange(poiName)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function DynamicMap({
|
|||||||
activePoi={activePoi}
|
activePoi={activePoi}
|
||||||
hotelName={hotelName}
|
hotelName={hotelName}
|
||||||
pointsOfInterest={pointsOfInterest}
|
pointsOfInterest={pointsOfInterest}
|
||||||
onActivePoiChange={setActivePoi}
|
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
|
||||||
coordinates={coordinates}
|
coordinates={coordinates}
|
||||||
/>
|
/>
|
||||||
<InteractiveMap
|
<InteractiveMap
|
||||||
@@ -121,7 +121,7 @@ export default function DynamicMap({
|
|||||||
coordinates={coordinates}
|
coordinates={coordinates}
|
||||||
pointsOfInterest={pointsOfInterest}
|
pointsOfInterest={pointsOfInterest}
|
||||||
activePoi={activePoi}
|
activePoi={activePoi}
|
||||||
onActivePoiChange={setActivePoi}
|
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
|
||||||
mapId={mapId}
|
mapId={mapId}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import useSidePeekStore from "@/stores/sidepeek"
|
|
||||||
|
|
||||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||||
import ImageGallery from "@/components/ImageGallery"
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
|
||||||
|
import { getRoomNameAsParam } from "../../utils"
|
||||||
|
|
||||||
import styles from "./roomCard.module.css"
|
import styles from "./roomCard.module.css"
|
||||||
|
|
||||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
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 { images, name, roomSize, occupancy } = room
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
|
||||||
|
|
||||||
const size =
|
const size =
|
||||||
roomSize?.min === roomSize?.max
|
roomSize?.min === roomSize?.max
|
||||||
@@ -51,21 +50,11 @@ export function RoomCard({ hotelId, room }: RoomCardProps) {
|
|||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button intent="text" type="button" size="medium" theme="base" asChild>
|
||||||
intent="text"
|
<Link scroll={false} href={`?s=${getRoomNameAsParam(name)}`}>
|
||||||
type="button"
|
{intl.formatMessage({ id: "See room details" })}
|
||||||
size="medium"
|
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||||
theme="base"
|
</Link>
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import styles from "./rooms.module.css"
|
|||||||
import type { RoomsProps } from "@/types/components/hotelPage/room"
|
import type { RoomsProps } from "@/types/components/hotelPage/room"
|
||||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||||
|
|
||||||
export function Rooms({ hotelId, rooms }: RoomsProps) {
|
export function Rooms({ rooms }: RoomsProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const showToggleButton = rooms.length > 3
|
const showToggleButton = rooms.length > 3
|
||||||
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
||||||
@@ -45,7 +45,7 @@ export function Rooms({ hotelId, rooms }: RoomsProps) {
|
|||||||
>
|
>
|
||||||
{rooms.map((room) => (
|
{rooms.map((room) => (
|
||||||
<div key={room.id}>
|
<div key={room.id}>
|
||||||
<RoomCard hotelId={hotelId} room={room} />
|
<RoomCard room={room} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Grids.Stackable>
|
</Grids.Stackable>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
.information {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
grid-template-areas:
|
||||||
|
"address drivingDirections"
|
||||||
|
"contact socials"
|
||||||
|
"email email"
|
||||||
|
"ecoLabel ecoLabel";
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
grid-area: address;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drivingDirections {
|
||||||
|
grid-area: drivingDirections;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
grid-area: contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socials {
|
||||||
|
grid-area: socials;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socialIcons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
grid-area: email;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecoLabel {
|
||||||
|
grid-area: ecoLabel;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
|
||||||
|
import Image from "@/components/Image"
|
||||||
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
|
import styles from "./contactInformation.module.css"
|
||||||
|
|
||||||
|
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/contactInformation"
|
||||||
|
|
||||||
|
export default async function ContactInformation({
|
||||||
|
hotelAddress,
|
||||||
|
coordinates,
|
||||||
|
contact,
|
||||||
|
socials,
|
||||||
|
ecoLabels,
|
||||||
|
}: ContactInformationProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const lang = getLang()
|
||||||
|
|
||||||
|
const { latitude, longitude } = coordinates
|
||||||
|
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Subtitle color="burgundy" asChild>
|
||||||
|
<Title level="h3">
|
||||||
|
{intl.formatMessage({ id: "Practical information" })}
|
||||||
|
</Title>
|
||||||
|
</Subtitle>
|
||||||
|
<div className={styles.information}>
|
||||||
|
<div className={styles.address}>
|
||||||
|
<Body textTransform="bold">
|
||||||
|
{intl.formatMessage({ id: "Address" })}
|
||||||
|
</Body>
|
||||||
|
<Body color="uiTextHighContrast">{hotelAddress.streetAddress}</Body>
|
||||||
|
<Body color="uiTextHighContrast">{hotelAddress.city}</Body>
|
||||||
|
</div>
|
||||||
|
<div className={styles.drivingDirections}>
|
||||||
|
<Body textTransform="bold">
|
||||||
|
{intl.formatMessage({ id: "Driving directions" })}
|
||||||
|
</Body>
|
||||||
|
<Link
|
||||||
|
href={directionsUrl}
|
||||||
|
target="_blank"
|
||||||
|
color="peach80"
|
||||||
|
textDecoration="underline"
|
||||||
|
>
|
||||||
|
Google Maps
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.contact}>
|
||||||
|
<Body textTransform="bold">
|
||||||
|
{intl.formatMessage({ id: "Contact us" })}
|
||||||
|
</Body>
|
||||||
|
<Body>
|
||||||
|
<Link
|
||||||
|
href={`tel:+${contact.phoneNumber}`}
|
||||||
|
color="peach80"
|
||||||
|
textDecoration="underline"
|
||||||
|
>
|
||||||
|
{contact.phoneNumber}
|
||||||
|
</Link>
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
<div className={styles.socials}>
|
||||||
|
<Body textTransform="bold">
|
||||||
|
{intl.formatMessage({ id: "Follow us" })}
|
||||||
|
</Body>
|
||||||
|
<div className={styles.socialIcons}>
|
||||||
|
{socials.instagram && (
|
||||||
|
<Link href={socials.instagram}>
|
||||||
|
<InstagramIcon color="burgundy" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{socials.facebook && (
|
||||||
|
<Link href={socials.facebook}>
|
||||||
|
<FacebookIcon color="burgundy" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.email}>
|
||||||
|
<Body textTransform="bold">
|
||||||
|
{intl.formatMessage({ id: "Email" })}
|
||||||
|
</Body>
|
||||||
|
<Link
|
||||||
|
href={`mailto:${contact.email}`}
|
||||||
|
color="peach80"
|
||||||
|
textDecoration="underline"
|
||||||
|
>
|
||||||
|
{contact.email}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{ecoLabels.nordicEcoLabel && (
|
||||||
|
<div className={styles.ecoLabel}>
|
||||||
|
<Image
|
||||||
|
height={38}
|
||||||
|
width={38}
|
||||||
|
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
|
||||||
|
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Caption color="uiTextPlaceholder">
|
||||||
|
{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
|
||||||
|
</Caption>
|
||||||
|
<Caption color="uiTextPlaceholder">
|
||||||
|
{ecoLabels.svanenEcoLabelCertificateNumber}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { about } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
|
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||||
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
|
import ContactInformation from "./ContactInformation"
|
||||||
|
|
||||||
|
import styles from "./aboutTheHotel.module.css"
|
||||||
|
|
||||||
|
import type { AboutTheHotelSidePeekProps } from "@/types/components/hotelPage/sidepeek/aboutTheHotel"
|
||||||
|
|
||||||
|
export default async function AboutTheHotelSidePeek({
|
||||||
|
hotelAddress,
|
||||||
|
coordinates,
|
||||||
|
contact,
|
||||||
|
socials,
|
||||||
|
ecoLabels,
|
||||||
|
descriptions,
|
||||||
|
}: AboutTheHotelSidePeekProps) {
|
||||||
|
const lang = getLang()
|
||||||
|
const intl = await getIntl()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidePeek
|
||||||
|
contentKey={about[lang]}
|
||||||
|
title={intl.formatMessage({ id: "About the hotel" })}
|
||||||
|
>
|
||||||
|
<section className={styles.wrapper}>
|
||||||
|
<ContactInformation
|
||||||
|
hotelAddress={hotelAddress}
|
||||||
|
coordinates={coordinates}
|
||||||
|
contact={contact}
|
||||||
|
socials={socials}
|
||||||
|
ecoLabels={ecoLabels}
|
||||||
|
/>
|
||||||
|
<Divider color="baseSurfaceSutbleHover" />
|
||||||
|
<Preamble>{descriptions.descriptions.medium}</Preamble>
|
||||||
|
<Body>{descriptions.facilityInformation}</Body>
|
||||||
|
</section>
|
||||||
|
</SidePeek>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
components/ContentType/HotelPage/SidePeeks/Room/index.tsx
Normal file
118
components/ContentType/HotelPage/SidePeeks/Room/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export default async function Facility({ data }: FacilityProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{image.imageSizes.medium && (
|
{image?.imageSizes.medium && (
|
||||||
<Image
|
<Image
|
||||||
src={image.imageSizes.medium}
|
src={image.imageSizes.medium}
|
||||||
alt={image.metaData.altText || ""}
|
alt={image.metaData.altText || ""}
|
||||||
|
|||||||
3
components/ContentType/HotelPage/SidePeeks/index.ts
Normal file
3
components/ContentType/HotelPage/SidePeeks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
|
||||||
|
export { default as RoomSidePeek } from "./Room"
|
||||||
|
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"
|
||||||
@@ -14,8 +14,8 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
|
|||||||
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
|
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
|
||||||
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
|
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
|
||||||
[FacilityEnum.FreeWiFi]: IconName.Wifi,
|
[FacilityEnum.FreeWiFi]: IconName.Wifi,
|
||||||
[FacilityEnum.MeetingRooms]: IconName.People2,
|
[FacilityEnum.MeetingRooms]: IconName.Business,
|
||||||
[FacilityEnum.MeetingConferenceFacilities]: IconName.People2,
|
[FacilityEnum.MeetingConferenceFacilities]: IconName.Business,
|
||||||
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
|
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
|
||||||
[FacilityEnum.Sauna]: IconName.Sauna,
|
[FacilityEnum.Sauna]: IconName.Sauna,
|
||||||
[FacilityEnum.Restaurant]: IconName.Restaurant,
|
[FacilityEnum.Restaurant]: IconName.Restaurant,
|
||||||
|
|||||||
@@ -1,77 +1,128 @@
|
|||||||
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 { env } from "@/env/server"
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
import { getHotelData, getHotelPage } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import AccordionSection from "@/components/Blocks/Accordion"
|
import AccordionSection from "@/components/Blocks/Accordion"
|
||||||
import HotelReservationSidePeek from "@/components/HotelReservation/SidePeek"
|
|
||||||
import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider"
|
import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider"
|
||||||
import Alert from "@/components/TempDesignSystem/Alert"
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import { getLang } from "@/i18n/serverContext"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
import { getRestaurantHeading } from "@/utils/facilityCards"
|
import { getRestaurantHeading } from "@/utils/facilityCards"
|
||||||
|
import { generateHotelSchema } from "@/utils/jsonSchemas"
|
||||||
|
|
||||||
import DynamicMap from "./Map/DynamicMap"
|
import DynamicMap from "./Map/DynamicMap"
|
||||||
import MapCard from "./Map/MapCard"
|
import MapCard from "./Map/MapCard"
|
||||||
import MapWithCardWrapper from "./Map/MapWithCard"
|
import MapWithCardWrapper from "./Map/MapWithCard"
|
||||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||||
import StaticMap from "./Map/StaticMap"
|
import StaticMap from "./Map/StaticMap"
|
||||||
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
|
|
||||||
import AmenitiesList from "./AmenitiesList"
|
import AmenitiesList from "./AmenitiesList"
|
||||||
import Facilities from "./Facilities"
|
import Facilities from "./Facilities"
|
||||||
import IntroSection from "./IntroSection"
|
import IntroSection from "./IntroSection"
|
||||||
import PreviewImages from "./PreviewImages"
|
import PreviewImages from "./PreviewImages"
|
||||||
import { Rooms } from "./Rooms"
|
import { Rooms } from "./Rooms"
|
||||||
|
import {
|
||||||
|
AboutTheHotelSidePeek,
|
||||||
|
RoomSidePeek,
|
||||||
|
WellnessAndExerciseSidePeek,
|
||||||
|
} from "./SidePeeks"
|
||||||
import TabNavigation from "./TabNavigation"
|
import TabNavigation from "./TabNavigation"
|
||||||
|
|
||||||
import styles from "./hotelPage.module.css"
|
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 { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||||
|
import type { Facility } from "@/types/hotel"
|
||||||
|
|
||||||
export default async function HotelPage() {
|
export default async function HotelPage({ hotelId }: HotelPageProps) {
|
||||||
const intl = await getIntl()
|
|
||||||
const lang = getLang()
|
const lang = getLang()
|
||||||
|
const [intl, hotelPageData, hotelData] = await Promise.all([
|
||||||
|
getIntl(),
|
||||||
|
getHotelPage(),
|
||||||
|
getHotelData({ hotelId, language: lang }),
|
||||||
|
])
|
||||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||||
const hotelData = await serverClient().hotel.get()
|
|
||||||
if (!hotelData) {
|
if (!hotelData?.data || !hotelPageData) {
|
||||||
return null
|
return notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jsonSchema = generateHotelSchema(hotelData.data.attributes)
|
||||||
|
const { faq, content } = hotelPageData
|
||||||
const {
|
const {
|
||||||
hotelId,
|
name,
|
||||||
hotelName,
|
address,
|
||||||
hotelDescription,
|
|
||||||
hotelLocation,
|
|
||||||
hotelAddress,
|
|
||||||
hotelRatings,
|
|
||||||
hotelDetailedFacilities,
|
|
||||||
hotelImages,
|
|
||||||
roomCategories,
|
|
||||||
activitiesCard,
|
|
||||||
pointsOfInterest,
|
pointsOfInterest,
|
||||||
facilities,
|
gallery,
|
||||||
faq,
|
specialAlerts,
|
||||||
alerts,
|
healthAndWellness,
|
||||||
|
restaurantImages,
|
||||||
|
conferencesAndMeetings,
|
||||||
|
hotelContent,
|
||||||
|
detailedFacilities,
|
||||||
healthFacilities,
|
healthFacilities,
|
||||||
} = 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 topThreePois = pointsOfInterest.slice(0, 3)
|
||||||
|
|
||||||
const coordinates = {
|
const coordinates = {
|
||||||
lat: hotelLocation.latitude,
|
lat: location.latitude,
|
||||||
lng: hotelLocation.longitude,
|
lng: location.longitude,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.pageContainer}>
|
<div className={styles.pageContainer}>
|
||||||
|
<script
|
||||||
|
type={jsonSchema.type}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(jsonSchema.jsonLd),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className={styles.hotelImages}>
|
<div className={styles.hotelImages}>
|
||||||
{hotelImages?.length && (
|
{images?.length && <PreviewImages images={images} hotelName={name} />}
|
||||||
<PreviewImages images={hotelImages} hotelName={hotelName} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<TabNavigation
|
<TabNavigation
|
||||||
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
|
restaurantTitle={getRestaurantHeading(detailedFacilities)}
|
||||||
hasActivities={!!activitiesCard}
|
hasActivities={!!activitiesCard}
|
||||||
hasFAQ={!!faq.accordions.length}
|
hasFAQ={!!faq.accordions.length}
|
||||||
/>
|
/>
|
||||||
@@ -79,18 +130,18 @@ export default async function HotelPage() {
|
|||||||
<div id={HotelHashValues.overview} className={styles.overview}>
|
<div id={HotelHashValues.overview} className={styles.overview}>
|
||||||
<div className={styles.introContainer}>
|
<div className={styles.introContainer}>
|
||||||
<IntroSection
|
<IntroSection
|
||||||
hotelName={hotelName}
|
hotelName={name}
|
||||||
hotelDescription={hotelDescription}
|
hotelDescription={description}
|
||||||
location={hotelLocation}
|
location={location}
|
||||||
address={hotelAddress}
|
address={address}
|
||||||
tripAdvisor={hotelRatings?.tripAdvisor}
|
tripAdvisor={ratings?.tripAdvisor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
|
<AmenitiesList detailedFacilities={detailedFacilities} />
|
||||||
</div>
|
</div>
|
||||||
{alerts.length ? (
|
{specialAlerts.length ? (
|
||||||
<div className={styles.alertsContainer}>
|
<div className={styles.alertsContainer}>
|
||||||
{alerts.map((alert) => (
|
{specialAlerts.map((alert) => (
|
||||||
<Alert
|
<Alert
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
type={alert.type}
|
type={alert.type}
|
||||||
@@ -101,7 +152,7 @@ export default async function HotelPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Rooms hotelId={hotelId} rooms={roomCategories} />
|
<Rooms rooms={roomCategories} />
|
||||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||||
{faq.accordions.length > 0 && (
|
{faq.accordions.length > 0 && (
|
||||||
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
||||||
@@ -111,14 +162,14 @@ export default async function HotelPage() {
|
|||||||
<>
|
<>
|
||||||
<aside className={styles.mapContainer}>
|
<aside className={styles.mapContainer}>
|
||||||
<MapWithCardWrapper>
|
<MapWithCardWrapper>
|
||||||
<StaticMap coordinates={coordinates} hotelName={hotelName} />
|
<StaticMap coordinates={coordinates} hotelName={name} />
|
||||||
<MapCard hotelName={hotelName} pois={topThreePois} />
|
<MapCard hotelName={name} pois={topThreePois} />
|
||||||
</MapWithCardWrapper>
|
</MapWithCardWrapper>
|
||||||
</aside>
|
</aside>
|
||||||
<MobileMapToggle />
|
<MobileMapToggle />
|
||||||
<DynamicMap
|
<DynamicMap
|
||||||
apiKey={googleMapsApiKey}
|
apiKey={googleMapsApiKey}
|
||||||
hotelName={hotelName}
|
hotelName={name}
|
||||||
coordinates={coordinates}
|
coordinates={coordinates}
|
||||||
pointsOfInterest={pointsOfInterest}
|
pointsOfInterest={pointsOfInterest}
|
||||||
mapId={googleMapId}
|
mapId={googleMapId}
|
||||||
@@ -126,22 +177,23 @@ export default async function HotelPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<SidePeekProvider>
|
<SidePeekProvider>
|
||||||
{/* eslint-disable import/no-named-as-default-member */}
|
|
||||||
<SidePeek
|
<SidePeek
|
||||||
contentKey={hotelPageParams.amenities[lang]}
|
contentKey={amenities[lang]}
|
||||||
title={intl.formatMessage({ id: "Amenities" })}
|
title={intl.formatMessage({ id: "Amenities" })}
|
||||||
>
|
>
|
||||||
{/* TODO: Render amenities as per the design. */}
|
{/* TODO: Render amenities as per the design. */}
|
||||||
Read more about the amenities here
|
Read more about the amenities here
|
||||||
</SidePeek>
|
</SidePeek>
|
||||||
|
<AboutTheHotelSidePeek
|
||||||
|
hotelAddress={address}
|
||||||
|
coordinates={location}
|
||||||
|
contact={contactInformation}
|
||||||
|
socials={socialMedia}
|
||||||
|
ecoLabels={hotelFacts.ecoLabels}
|
||||||
|
descriptions={hotelContent.texts}
|
||||||
|
/>
|
||||||
<SidePeek
|
<SidePeek
|
||||||
contentKey={hotelPageParams.about[lang]}
|
contentKey={restaurantAndBar[lang]}
|
||||||
title={intl.formatMessage({ id: "Read more about the hotel" })}
|
|
||||||
>
|
|
||||||
Some additional information about the hotel
|
|
||||||
</SidePeek>
|
|
||||||
<SidePeek
|
|
||||||
contentKey={hotelPageParams.restaurantAndBar[lang]}
|
|
||||||
title={intl.formatMessage({ id: "Restaurant & Bar" })}
|
title={intl.formatMessage({ id: "Restaurant & Bar" })}
|
||||||
>
|
>
|
||||||
{/* TODO */}
|
{/* TODO */}
|
||||||
@@ -152,22 +204,23 @@ export default async function HotelPage() {
|
|||||||
buttonUrl="#"
|
buttonUrl="#"
|
||||||
/>
|
/>
|
||||||
<SidePeek
|
<SidePeek
|
||||||
contentKey={hotelPageParams.activities[lang]}
|
contentKey={activities[lang]}
|
||||||
title={intl.formatMessage({ id: "Activities" })}
|
title={intl.formatMessage({ id: "Activities" })}
|
||||||
>
|
>
|
||||||
{/* TODO */}
|
{/* TODO */}
|
||||||
Activities
|
Activities
|
||||||
</SidePeek>
|
</SidePeek>
|
||||||
<SidePeek
|
<SidePeek
|
||||||
contentKey={hotelPageParams.meetingsAndConferences[lang]}
|
contentKey={meetingsAndConferences[lang]}
|
||||||
title={intl.formatMessage({ id: "Meetings & Conferences" })}
|
title={intl.formatMessage({ id: "Meetings & Conferences" })}
|
||||||
>
|
>
|
||||||
{/* TODO */}
|
{/* TODO */}
|
||||||
Meetings & Conferences
|
Meetings & Conferences
|
||||||
</SidePeek>
|
</SidePeek>
|
||||||
{/* eslint-enable import/no-named-as-default-member */}
|
{roomCategories.map((room) => (
|
||||||
|
<RoomSidePeek key={room.name} room={room} />
|
||||||
|
))}
|
||||||
</SidePeekProvider>
|
</SidePeekProvider>
|
||||||
<HotelReservationSidePeek hotel={null} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
3
components/ContentType/HotelPage/utils.ts
Normal file
3
components/ContentType/HotelPage/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function getRoomNameAsParam(roomName: string) {
|
||||||
|
return roomName.replace(/[()]/g, "").replaceAll(" ", "-").toLowerCase()
|
||||||
|
}
|
||||||
@@ -33,9 +33,18 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
|||||||
|
|
||||||
const [isSelectingFrom, setIsSelectingFrom] = useState(true)
|
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)
|
setIsOpen(false)
|
||||||
}
|
}, [name, setValue, selectedDate])
|
||||||
|
|
||||||
function showOnFocus() {
|
function showOnFocus() {
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
@@ -72,19 +81,10 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
|||||||
const closeIfOutside = useCallback(
|
const closeIfOutside = useCallback(
|
||||||
(target: HTMLElement) => {
|
(target: HTMLElement) => {
|
||||||
if (ref.current && target && !ref.current.contains(target)) {
|
if (ref.current && target && !ref.current.contains(target)) {
|
||||||
if (!selectedDate.toDate) {
|
close()
|
||||||
setValue(name, {
|
|
||||||
fromDate: selectedDate.fromDate,
|
|
||||||
toDate: dt(selectedDate.fromDate)
|
|
||||||
.add(1, "day")
|
|
||||||
.format("YYYY-MM-DD"),
|
|
||||||
})
|
|
||||||
setIsSelectingFrom(true)
|
|
||||||
}
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setIsOpen, setValue, setIsSelectingFrom, selectedDate, name, ref]
|
[close, ref]
|
||||||
)
|
)
|
||||||
|
|
||||||
function closeOnBlur(evt: FocusEvent) {
|
function closeOnBlur(evt: FocusEvent) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
content: "·";
|
content: "·";
|
||||||
margin-left: var(--Spacing-x1);
|
margin-left: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -56,12 +57,14 @@
|
|||||||
.details {
|
.details {
|
||||||
padding: var(--Spacing-x6) var(--Spacing-x5) var(--Spacing-x4);
|
padding: var(--Spacing-x6) var(--Spacing-x5) var(--Spacing-x4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomContainer {
|
.bottomContainer {
|
||||||
border-top: 1px solid var(--Base-Text-Medium-contrast);
|
border-top: 1px solid var(--Base-Text-Medium-contrast);
|
||||||
padding-top: var(--Spacing-x2);
|
padding-top: var(--Spacing-x2);
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigationContainer {
|
.navigationContainer {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
.mainNavigationItem {
|
.mainNavigationItem {
|
||||||
padding: var(--Spacing-x3) 0;
|
padding: var(--Spacing-x3) 0;
|
||||||
border-bottom: 1px solid var(--Base-Border-Normal);
|
border-bottom: 1px solid var(--Base-Border-Normal);
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
zipCode: "",
|
zipCode: "",
|
||||||
},
|
},
|
||||||
password: "",
|
password: "",
|
||||||
|
termsAccepted: false,
|
||||||
},
|
},
|
||||||
mode: "all",
|
mode: "all",
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
resolver: zodResolver(signUpSchema),
|
resolver: zodResolver(signUpSchema),
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
|
shouldFocusError: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(data: SignUpSchema) {
|
async function onSubmit(data: SignUpSchema) {
|
||||||
@@ -145,7 +147,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
</header>
|
</header>
|
||||||
<NewPassword
|
<NewPassword
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Password"
|
|
||||||
label={intl.formatMessage({ id: "Password" })}
|
label={intl.formatMessage({ id: "Password" })}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -157,17 +158,21 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
</header>
|
</header>
|
||||||
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
||||||
<Body>
|
<Body>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage<React.ReactNode>(
|
||||||
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
|
{ id: "signupPage.terms" },
|
||||||
})}{" "}
|
{
|
||||||
<Link
|
termsLink: (str) => (
|
||||||
variant="underscored"
|
<Link
|
||||||
color="peach80"
|
variant="underscored"
|
||||||
target="_blank"
|
color="peach80"
|
||||||
href={privacyPolicy[lang]}
|
target="_blank"
|
||||||
>
|
href={privacyPolicy[lang]}
|
||||||
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
|
>
|
||||||
</Link>
|
{str}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</section>
|
</section>
|
||||||
@@ -181,7 +186,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
{!methods.formState.isValid ? (
|
{!methods.formState.isValid ? (
|
||||||
<Button
|
<Button
|
||||||
className={styles.signUpButton}
|
className={styles.signUpButton}
|
||||||
type="button"
|
type="submit"
|
||||||
theme="base"
|
theme="base"
|
||||||
intent="primary"
|
intent="primary"
|
||||||
onClick={() => methods.trigger()}
|
onClick={() => methods.trigger()}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { CalendarAddIcon, DownloadIcon, EditIcon } from "@/components/Icons"
|
||||||
CalendarIcon,
|
|
||||||
ContractIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
PrinterIcon,
|
|
||||||
} from "@/components/Icons"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import styles from "./actions.module.css"
|
import styles from "./actions.module.css"
|
||||||
@@ -15,20 +9,13 @@ export default async function Actions() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||||
<CalendarIcon />
|
<CalendarAddIcon />
|
||||||
{intl.formatMessage({ id: "Add to calendar" })}
|
{intl.formatMessage({ id: "Add to calendar" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Divider color="subtle" variant="vertical" />
|
|
||||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||||
<ContractIcon />
|
<EditIcon />
|
||||||
{intl.formatMessage({ id: "View terms" })}
|
{intl.formatMessage({ id: "Manage booking" })}
|
||||||
</Button>
|
</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>
|
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
{intl.formatMessage({ id: "Download invoice" })}
|
{intl.formatMessage({ id: "Download invoice" })}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
.header,
|
.header,
|
||||||
.hgroup {
|
.hgroup {
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x2);
|
||||||
|
grid-area: header;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hgroup {
|
.hgroup {
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
max-width: 560px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import Actions from "./Actions"
|
||||||
|
|
||||||
import styles from "./header.module.css"
|
import styles from "./header.module.css"
|
||||||
|
|
||||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||||
@@ -30,31 +31,15 @@ export default async function Header({
|
|||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<hgroup className={styles.hgroup}>
|
<hgroup className={styles.hgroup}>
|
||||||
<BiroScript color="red" tilted="small" type="two">
|
<Title as="h2" color="red" textTransform="uppercase" type="h2">
|
||||||
{intl.formatMessage({ id: "See you soon!" })}
|
|
||||||
</BiroScript>
|
|
||||||
<Title
|
|
||||||
as="h4"
|
|
||||||
color="red"
|
|
||||||
textAlign="center"
|
|
||||||
textTransform="regular"
|
|
||||||
type="h2"
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
||||||
</Title>
|
</Title>
|
||||||
<Title
|
<Title as="h2" color="burgundy" textTransform="uppercase" type="h1">
|
||||||
as="h4"
|
|
||||||
color="burgundy"
|
|
||||||
textAlign="center"
|
|
||||||
textTransform="regular"
|
|
||||||
type="h1"
|
|
||||||
>
|
|
||||||
{hotel.name}
|
{hotel.name}
|
||||||
</Title>
|
</Title>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
<Body className={styles.body} textAlign="center">
|
<Body className={styles.body}>{text}</Body>
|
||||||
{text}
|
<Actions />
|
||||||
</Body>
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import styles from "./room.module.css"
|
||||||
|
|
||||||
|
export default function Room() {
|
||||||
|
return <article className={styles.room}></article>
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import styles from "./rooms.module.css"
|
||||||
|
|
||||||
|
export default function Rooms() {
|
||||||
|
return <section className={styles.rooms}></section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.rooms {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x9);
|
||||||
|
grid-area: booking;
|
||||||
|
}
|
||||||
@@ -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 styles from "./summary.module.css"
|
||||||
|
|
||||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
export default function Summary() {
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
return <aside className={styles.summary}>SUMMARY</aside>
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,4 @@
|
|||||||
.summary {
|
.summary {
|
||||||
display: grid;
|
background-color: hotpink;
|
||||||
gap: var(--Spacing-x3);
|
grid-area: summary;
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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%));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import FacebookIcon from "@/components/Icons/Facebook"
|
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
|
||||||
import InstagramIcon from "@/components/Icons/Instagram"
|
|
||||||
import Image from "@/components/Image"
|
import Image from "@/components/Image"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
|
|
||||||
import { useDetailsStore } from "@/stores/details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
import { useStepsStore } from "@/stores/steps"
|
|
||||||
|
|
||||||
import { KingBedIcon } from "@/components/Icons"
|
import { KingBedIcon } from "@/components/Icons"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -20,12 +19,19 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
|
|
||||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||||
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
|
const initialBedType = useEnterDetailsStore(
|
||||||
const completeStep = useStepsStore((state) => state.completeStep)
|
(state) => state.formValues?.bedType?.roomTypeCode
|
||||||
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
)
|
||||||
|
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>({
|
const methods = useForm<BedTypeFormSchema>({
|
||||||
defaultValues: bedType ? { bedType } : undefined,
|
defaultValues: initialBedType ? { bedType: initialBedType } : undefined,
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(bedTypeFormSchema),
|
resolver: zodResolver(bedTypeFormSchema),
|
||||||
@@ -43,10 +49,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
|||||||
roomTypeCode: matchingRoom.value,
|
roomTypeCode: matchingRoom.value,
|
||||||
}
|
}
|
||||||
updateBedType(bedType)
|
updateBedType(bedType)
|
||||||
completeStep()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bedTypes, completeStep, updateBedType]
|
[bedTypes, updateBedType]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,6 +81,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
|||||||
subtitle={width}
|
subtitle={width}
|
||||||
title={roomType.description}
|
title={roomType.description}
|
||||||
value={roomType.value}
|
value={roomType.value}
|
||||||
|
handleSelectedOnClick={
|
||||||
|
bedType === roomType.value ? completeStep : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useCallback, useEffect } from "react"
|
|||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useDetailsStore } from "@/stores/details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
import { useStepsStore } from "@/stores/steps"
|
|
||||||
|
|
||||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -24,20 +23,30 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|||||||
export default function Breakfast({ packages }: BreakfastProps) {
|
export default function Breakfast({ packages }: BreakfastProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
const breakfast = useDetailsStore(({ data }) =>
|
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
|
||||||
data.breakfast
|
formValues?.breakfast
|
||||||
? data.breakfast.code
|
? formValues.breakfast.code
|
||||||
: data.breakfast === false
|
: formValues?.breakfast === false
|
||||||
? "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
|
(state) => state.actions.updateBreakfast
|
||||||
)
|
)
|
||||||
const completeStep = useStepsStore((state) => state.completeStep)
|
|
||||||
|
|
||||||
const methods = useForm<BreakfastFormSchema>({
|
const methods = useForm<BreakfastFormSchema>({
|
||||||
defaultValues: breakfast ? { breakfast } : undefined,
|
defaultValues: formValuesBreakfast
|
||||||
|
? { breakfast: formValuesBreakfast }
|
||||||
|
: undefined,
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(breakfastFormSchema),
|
resolver: zodResolver(breakfastFormSchema),
|
||||||
@@ -52,9 +61,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
} else {
|
} else {
|
||||||
updateBreakfast(false)
|
updateBreakfast(false)
|
||||||
}
|
}
|
||||||
completeStep()
|
|
||||||
},
|
},
|
||||||
[completeStep, packages, updateBreakfast]
|
[packages, updateBreakfast]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,6 +105,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
})}
|
})}
|
||||||
title={intl.formatMessage({ id: "Breakfast buffet" })}
|
title={intl.formatMessage({ id: "Breakfast buffet" })}
|
||||||
value={pkg.code}
|
value={pkg.code}
|
||||||
|
handleSelectedOnClick={
|
||||||
|
breakfast === pkg.code ? completeStep : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<RadioCard
|
<RadioCard
|
||||||
@@ -113,6 +124,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
})}
|
})}
|
||||||
title={intl.formatMessage({ id: "No breakfast" })}
|
title={intl.formatMessage({ id: "No breakfast" })}
|
||||||
value="false"
|
value="false"
|
||||||
|
handleSelectedOnClick={
|
||||||
|
breakfast === "false" ? completeStep : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import useLang from "@/hooks/useLang"
|
|||||||
|
|
||||||
import styles from "./joinScandicFriendsCard.module.css"
|
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({
|
export default function JoinScandicFriendsCard({
|
||||||
name,
|
name,
|
||||||
@@ -65,7 +65,6 @@ export default function JoinScandicFriendsCard({
|
|||||||
position="enter details"
|
position="enter details"
|
||||||
trackingId="join-scandic-friends-enter-details"
|
trackingId="join-scandic-friends-enter-details"
|
||||||
variant="breadcrumb"
|
variant="breadcrumb"
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "Log in" })}
|
{intl.formatMessage({ id: "Log in" })}
|
||||||
</LoginButton>
|
</LoginButton>
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useCallback } from "react"
|
|||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useDetailsStore } from "@/stores/details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
import { useStepsStore } from "@/stores/steps"
|
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||||
@@ -27,45 +26,35 @@ import type {
|
|||||||
const formID = "enter-details"
|
const formID = "enter-details"
|
||||||
export default function Details({ user, memberPrice }: DetailsProps) {
|
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const initialData = useDetailsStore((state) => ({
|
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
|
||||||
countryCode: state.data.countryCode,
|
const join = useEnterDetailsStore((state) => state.guest.join)
|
||||||
email: state.data.email,
|
const updateDetails = useEnterDetailsStore(
|
||||||
firstName: state.data.firstName,
|
(state) => state.actions.updateDetails
|
||||||
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 methods = useForm<DetailsSchema>({
|
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",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
||||||
reValidateMode: "onChange",
|
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(
|
const onSubmit = useCallback(
|
||||||
(values: DetailsSchema) => {
|
(values: DetailsSchema) => {
|
||||||
updateDetails(values)
|
updateDetails(values)
|
||||||
completeStep()
|
|
||||||
},
|
},
|
||||||
[completeStep, updateDetails]
|
[updateDetails]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
|||||||
join: z.literal<boolean>(true),
|
join: z.literal<boolean>(true),
|
||||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||||
membershipNo: z.string().optional(),
|
membershipNo: z.string().default(""),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,9 +50,16 @@ export const guestDetailsSchema = z.discriminatedUnion("join", [
|
|||||||
// For signed in users we accept partial or invalid data. Users cannot
|
// 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.
|
// change their info in this flow, so we don't want to validate it.
|
||||||
export const signedInDetailsSchema = z.object({
|
export const signedInDetailsSchema = z.object({
|
||||||
countryCode: z.string().optional(),
|
countryCode: z.string().default(""),
|
||||||
email: z.string().optional(),
|
email: z.string().default(""),
|
||||||
firstName: z.string().optional(),
|
firstName: z.string().default(""),
|
||||||
lastName: z.string().optional(),
|
lastName: z.string().default(""),
|
||||||
phoneNumber: z.string().optional(),
|
membershipNo: z.string().default(""),
|
||||||
|
phoneNumber: z.string().default(""),
|
||||||
|
join: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.transform((_) => false),
|
||||||
|
dateOfBirth: z.string().default(""),
|
||||||
|
zipCode: z.string().default(""),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
components/HotelReservation/EnterDetails/Header/index.tsx
Normal file
53
components/HotelReservation/EnterDetails/Header/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
|
|
||||||
import { useStepsStore } from "@/stores/steps"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
|
||||||
export default function HistoryStateManager() {
|
export default function HistoryStateManager() {
|
||||||
const setCurrentStep = useStepsStore((state) => state.setStep)
|
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
|
||||||
const currentStep = useStepsStore((state) => state.currentStep)
|
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||||
|
|
||||||
const handleBackButton = useCallback(
|
const handleBackButton = useCallback(
|
||||||
(event: PopStateEvent) => {
|
(event: PopStateEvent) => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { detailsStorageName } from "@/stores/enter-details"
|
||||||
|
|
||||||
|
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
import type { DetailsState } from "@/types/stores/enter-details"
|
||||||
|
|
||||||
|
export default function PaymentCallback({
|
||||||
|
returnUrl,
|
||||||
|
searchObject,
|
||||||
|
}: {
|
||||||
|
returnUrl: string
|
||||||
|
searchObject: URLSearchParams
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||||
|
|
||||||
|
if (bookingData) {
|
||||||
|
const detailsStorage: Record<
|
||||||
|
"state",
|
||||||
|
Pick<DetailsState, "booking">
|
||||||
|
> = JSON.parse(bookingData)
|
||||||
|
const searchParams = createQueryParamsForEnterDetails(
|
||||||
|
detailsStorage.state.booking,
|
||||||
|
searchObject
|
||||||
|
)
|
||||||
|
|
||||||
|
if (searchParams.size > 0) {
|
||||||
|
router.replace(`${returnUrl}?${searchParams.toString()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [returnUrl, router, searchObject])
|
||||||
|
|
||||||
|
return <LoadingSpinner />
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { Label as AriaLabel } from "react-aria-components"
|
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
@@ -16,9 +15,10 @@ import {
|
|||||||
bookingTermsAndConditions,
|
bookingTermsAndConditions,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
} from "@/constants/currentWebHrefs"
|
} from "@/constants/currentWebHrefs"
|
||||||
|
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||||
import { env } from "@/env/client"
|
import { env } from "@/env/client"
|
||||||
import { trpc } from "@/lib/trpc/client"
|
import { trpc } from "@/lib/trpc/client"
|
||||||
import { useDetailsStore } from "@/stores/details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
@@ -27,11 +27,13 @@ import Link from "@/components/TempDesignSystem/Link"
|
|||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
|
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
||||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||||
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||||
|
import PriceChangeDialog from "../PriceChangeDialog"
|
||||||
import GuaranteeDetails from "./GuaranteeDetails"
|
import GuaranteeDetails from "./GuaranteeDetails"
|
||||||
import PaymentOption from "./PaymentOption"
|
import PaymentOption from "./PaymentOption"
|
||||||
import { PaymentFormData, paymentSchema } from "./schema"
|
import { PaymentFormData, paymentSchema } from "./schema"
|
||||||
@@ -39,7 +41,7 @@ import { PaymentFormData, paymentSchema } from "./schema"
|
|||||||
import styles from "./payment.module.css"
|
import styles from "./payment.module.css"
|
||||||
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||||
|
|
||||||
const maxRetries = 4
|
const maxRetries = 4
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
@@ -51,6 +53,7 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Payment({
|
export default function Payment({
|
||||||
|
user,
|
||||||
roomPrice,
|
roomPrice,
|
||||||
otherPaymentOptions,
|
otherPaymentOptions,
|
||||||
savedCreditCards,
|
savedCreditCards,
|
||||||
@@ -59,30 +62,28 @@ export default function Payment({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const queryParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
||||||
const setIsSubmittingDisabled = useDetailsStore(
|
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
|
(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 [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||||
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||||
useState(otherPaymentOptions)
|
useState(false)
|
||||||
|
|
||||||
|
const availablePaymentOptions =
|
||||||
|
useAvailablePaymentOptions(otherPaymentOptions)
|
||||||
|
const [priceChangeData, setPriceChangeData] = useState<{
|
||||||
|
oldPrice: number
|
||||||
|
newPrice: number
|
||||||
|
} | null>()
|
||||||
|
|
||||||
usePaymentFailedToast()
|
usePaymentFailedToast()
|
||||||
|
|
||||||
@@ -103,6 +104,15 @@ export default function Payment({
|
|||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
if (result?.confirmationNumber) {
|
if (result?.confirmationNumber) {
|
||||||
setConfirmationNumber(result.confirmationNumber)
|
setConfirmationNumber(result.confirmationNumber)
|
||||||
|
|
||||||
|
if (result.metadata?.priceChangedMetadata) {
|
||||||
|
setPriceChangeData({
|
||||||
|
oldPrice: roomPrice.publicPrice,
|
||||||
|
newPrice: result.metadata.priceChangedMetadata.totalPrice,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setIsPollingForBookingStatus(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
intl.formatMessage({
|
intl.formatMessage({
|
||||||
@@ -121,25 +131,31 @@ export default function Payment({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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({
|
const bookingStatus = useHandleBookingStatus({
|
||||||
confirmationNumber,
|
confirmationNumber,
|
||||||
expectedStatus: BookingStatusEnum.BookingCompleted,
|
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
retryInterval,
|
retryInterval,
|
||||||
|
enabled: isPollingForBookingStatus,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.ApplePaySession) {
|
|
||||||
setAvailablePaymentOptions(otherPaymentOptions)
|
|
||||||
} else {
|
|
||||||
setAvailablePaymentOptions(
|
|
||||||
otherPaymentOptions.filter(
|
|
||||||
(option) => option !== PaymentMethodEnum.applePay
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [otherPaymentOptions, setAvailablePaymentOptions])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookingStatus?.data?.paymentUrl) {
|
if (bookingStatus?.data?.paymentUrl) {
|
||||||
router.push(bookingStatus.data.paymentUrl)
|
router.push(bookingStatus.data.paymentUrl)
|
||||||
@@ -162,76 +178,102 @@ export default function Payment({
|
|||||||
setIsSubmittingDisabled,
|
setIsSubmittingDisabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
function handleSubmit(data: PaymentFormData) {
|
const handleSubmit = useCallback(
|
||||||
const allQueryParams =
|
(data: PaymentFormData) => {
|
||||||
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
|
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
|
// set payment method to card if saved card is submitted
|
||||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||||
? data.paymentMethod
|
? data.paymentMethod
|
||||||
: PaymentMethodEnum.card
|
: PaymentMethodEnum.card
|
||||||
|
|
||||||
const savedCreditCard = savedCreditCards?.find(
|
const savedCreditCard = savedCreditCards?.find(
|
||||||
(card) => card.id === data.paymentMethod
|
(card) => card.id === data.paymentMethod
|
||||||
)
|
)
|
||||||
|
|
||||||
initiateBooking.mutate({
|
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
|
||||||
hotelId: hotel,
|
|
||||||
checkInDate: fromDate,
|
initiateBooking.mutate({
|
||||||
checkOutDate: toDate,
|
hotelId: hotel,
|
||||||
rooms: rooms.map((room) => ({
|
checkInDate: fromDate,
|
||||||
adults: room.adults,
|
checkOutDate: toDate,
|
||||||
childrenAges: room.children?.map((child) => ({
|
rooms: rooms.map((room) => ({
|
||||||
age: child.age,
|
adults: room.adults,
|
||||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
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,
|
||||||
})),
|
})),
|
||||||
rateCode: room.rateCode,
|
payment: {
|
||||||
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
paymentMethod,
|
||||||
guest: {
|
card: savedCreditCard
|
||||||
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,
|
alias: savedCreditCard.alias,
|
||||||
expiryDate: savedCreditCard.expirationDate,
|
expiryDate: savedCreditCard.expirationDate,
|
||||||
cardType: savedCreditCard.cardType,
|
cardType: savedCreditCard.cardType,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
|
success: `${paymentRedirectUrl}/success`,
|
||||||
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
|
error: `${paymentRedirectUrl}/error`,
|
||||||
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
|
cancel: `${paymentRedirectUrl}/cancel`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
[
|
||||||
|
breakfast,
|
||||||
|
bedType,
|
||||||
|
userData,
|
||||||
|
booking,
|
||||||
|
roomPrice,
|
||||||
|
savedCreditCards,
|
||||||
|
lang,
|
||||||
|
user,
|
||||||
|
initiateBooking,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
initiateBooking.isPending ||
|
initiateBooking.isPending ||
|
||||||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
|
(isPollingForBookingStatus && !bookingStatus.data?.paymentUrl)
|
||||||
) {
|
) {
|
||||||
return <LoadingSpinner />
|
return <LoadingSpinner />
|
||||||
}
|
}
|
||||||
@@ -241,79 +283,70 @@ export default function Payment({
|
|||||||
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
|
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<>
|
||||||
<form
|
<FormProvider {...methods}>
|
||||||
className={styles.paymentContainer}
|
<form
|
||||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
className={styles.paymentContainer}
|
||||||
id={formId}
|
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||||
>
|
id={formId}
|
||||||
{mustBeGuaranteed ? (
|
>
|
||||||
<section className={styles.section}>
|
{mustBeGuaranteed ? (
|
||||||
<Body>
|
<section className={styles.section}>
|
||||||
{intl.formatMessage({
|
<Body>
|
||||||
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.",
|
{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 />
|
</Body>
|
||||||
</section>
|
<GuaranteeDetails />
|
||||||
) : null}
|
</section>
|
||||||
{savedCreditCards?.length ? (
|
) : null}
|
||||||
<section className={styles.section}>
|
{savedCreditCards?.length ? (
|
||||||
<Body color="uiTextHighContrast" textTransform="bold">
|
<section className={styles.section}>
|
||||||
{intl.formatMessage({ id: "MY SAVED CARDS" })}
|
<Body color="uiTextHighContrast" textTransform="bold">
|
||||||
</Body>
|
{intl.formatMessage({ id: "MY SAVED CARDS" })}
|
||||||
<div className={styles.paymentOptionContainer}>
|
</Body>
|
||||||
{savedCreditCards?.map((savedCreditCard) => (
|
<div className={styles.paymentOptionContainer}>
|
||||||
<PaymentOption
|
{savedCreditCards?.map((savedCreditCard) => (
|
||||||
key={savedCreditCard.id}
|
<PaymentOption
|
||||||
name="paymentMethod"
|
key={savedCreditCard.id}
|
||||||
value={savedCreditCard.id}
|
name="paymentMethod"
|
||||||
label={
|
value={savedCreditCard.id}
|
||||||
PAYMENT_METHOD_TITLES[
|
label={
|
||||||
|
PAYMENT_METHOD_TITLES[
|
||||||
savedCreditCard.cardType as PaymentMethodEnum
|
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]
|
||||||
}
|
}
|
||||||
cardNumber={savedCreditCard.truncatedNumber}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
<section className={styles.section}>
|
||||||
<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>
|
<Caption>
|
||||||
{intl.formatMessage<React.ReactNode>(
|
{intl.formatMessage<React.ReactNode>(
|
||||||
{
|
{
|
||||||
@@ -344,19 +377,48 @@ export default function Payment({
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Caption>
|
</Caption>
|
||||||
</AriaLabel>
|
<Checkbox name="termsAndConditions">
|
||||||
</section>
|
<Caption>
|
||||||
<div className={styles.submitButton}>
|
{intl.formatMessage({
|
||||||
<Button
|
id: "I accept the terms and conditions",
|
||||||
type="submit"
|
})}
|
||||||
disabled={
|
</Caption>
|
||||||
!methods.formState.isValid || methods.formState.isSubmitting
|
</Checkbox>
|
||||||
}
|
<Checkbox name="smsConfirmation">
|
||||||
>
|
<Caption>
|
||||||
{intl.formatMessage({ id: "Complete booking" })}
|
{intl.formatMessage({
|
||||||
</Button>
|
id: "I would like to get my booking confirmation via sms",
|
||||||
</div>
|
})}
|
||||||
</form>
|
</Caption>
|
||||||
</FormProvider>
|
</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}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useDetailsStore } from "@/stores/details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
import { useStepsStore } from "@/stores/steps"
|
|
||||||
|
|
||||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
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 styles from "./sectionAccordion.module.css"
|
||||||
|
|
||||||
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
|
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
|
||||||
import { StepEnum } from "@/types/enums/step"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
|
|
||||||
export default function SectionAccordion({
|
export default function SectionAccordion({
|
||||||
|
children,
|
||||||
header,
|
header,
|
||||||
label,
|
label,
|
||||||
step,
|
step,
|
||||||
children,
|
|
||||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const currentStep = useStepsStore((state) => state.currentStep)
|
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||||
const [isComplete, setIsComplete] = useState(false)
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const isValid = useDetailsStore((state) => state.isValid[step])
|
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||||
const navigate = useStepsStore((state) => state.navigate)
|
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
|
||||||
const stepData = useDetailsStore((state) => state.data)
|
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
|
||||||
const stepStoreKey = StepStoreKeys[step]
|
bedType: state.bedType,
|
||||||
|
breakfast: state.breakfast,
|
||||||
|
}))
|
||||||
const [title, setTitle] = useState(label)
|
const [title, setTitle] = useState(label)
|
||||||
|
|
||||||
|
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||||
|
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === StepEnum.selectBed) {
|
if (step === StepEnum.selectBed && bedType) {
|
||||||
const value = stepData.bedType
|
setTitle(bedType.description)
|
||||||
value && setTitle(value.description)
|
|
||||||
}
|
}
|
||||||
// If breakfast step, check if an option has been selected
|
// If breakfast step, check if an option has been selected
|
||||||
if (
|
if (step === StepEnum.breakfast && breakfast !== undefined) {
|
||||||
step === StepEnum.breakfast &&
|
if (breakfast === false) {
|
||||||
(stepData.breakfast || stepData.breakfast === false)
|
setTitle(noBreakfastTitle)
|
||||||
) {
|
|
||||||
const value = stepData.breakfast
|
|
||||||
if (value === false) {
|
|
||||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
|
||||||
} else {
|
} else {
|
||||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
setTitle(breakfastTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [stepData, stepStoreKey, step, intl])
|
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// We need to set the state on mount because of hydration errors
|
|
||||||
setIsComplete(isValid)
|
setIsComplete(isValid)
|
||||||
}, [isValid])
|
}, [isValid, setIsComplete])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(currentStep === step)
|
setIsOpen(currentStep === step)
|
||||||
}, [currentStep, step])
|
}, [currentStep, setIsOpen, step])
|
||||||
|
|
||||||
function onModify() {
|
function onModify() {
|
||||||
navigate(step)
|
navigate(step)
|
||||||
|
|||||||
@@ -48,8 +48,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
font-weight: 450;
|
|
||||||
font-size: var(--typography-Title-4-fontSize);
|
|
||||||
grid-area: selection;
|
grid-area: selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ChevronRight from "@/components/Icons/ChevronRight"
|
|||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
|
||||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
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({
|
export default function ToggleSidePeek({
|
||||||
hotelId,
|
hotelId,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ToggleSidePeek from "./ToggleSidePeek"
|
|||||||
|
|
||||||
import styles from "./selectedRoom.module.css"
|
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({
|
export default function SelectedRoom({
|
||||||
hotelId,
|
hotelId,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"title button"
|
"title title"
|
||||||
"description button";
|
"description button";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +25,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-weight: 450;
|
|
||||||
font-size: var(--typography-Title-4-fontSize);
|
|
||||||
grid-area: description;
|
grid-area: description;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
grid-area: button;
|
grid-area: button;
|
||||||
justify-self: flex-end;
|
justify-self: flex-end;
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
|
|||||||
26
components/HotelReservation/EnterDetails/StorageCleaner.tsx
Normal file
26
components/HotelReservation/EnterDetails/StorageCleaner.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { hotelreservation } from "@/constants/routes/hotelReservation"
|
||||||
|
import { detailsStorageName } from "@/stores/enter-details"
|
||||||
|
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup component to make sure no stale data is left
|
||||||
|
* from previous booking when user is not in the booking
|
||||||
|
* flow anymore
|
||||||
|
*/
|
||||||
|
export default function StorageCleaner() {
|
||||||
|
const lang = useLang()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname.startsWith(hotelreservation(lang))) {
|
||||||
|
sessionStorage.removeItem(detailsStorageName)
|
||||||
|
}
|
||||||
|
}, [lang, pathname])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
97
components/HotelReservation/EnterDetails/Summary/Client.tsx
Normal file
97
components/HotelReservation/EnterDetails/Summary/Client.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,309 +1,57 @@
|
|||||||
"use client"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||||
import { ChevronDown } from "react-feather"
|
import {
|
||||||
import { useIntl } from "react-intl"
|
getProfileSafely,
|
||||||
|
getSelectedRoomAvailability,
|
||||||
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
import { useDetailsStore } from "@/stores/details"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import { ArrowRightIcon } from "@/components/Icons"
|
import ClientSummary from "./Client"
|
||||||
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 styles from "./summary.module.css"
|
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
|
||||||
|
|
||||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
export default async function Summary({
|
||||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
adults,
|
||||||
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
fromDate,
|
||||||
import type { DetailsState } from "@/types/stores/details"
|
hotelId,
|
||||||
|
kids,
|
||||||
|
packageCodes,
|
||||||
|
rateCode,
|
||||||
|
roomTypeCode,
|
||||||
|
toDate,
|
||||||
|
}: SummaryPageProps) {
|
||||||
|
const lang = getLang()
|
||||||
|
|
||||||
function storeSelector(state: DetailsState) {
|
const availability = await getSelectedRoomAvailability({
|
||||||
return {
|
adults,
|
||||||
fromDate: state.data.booking.fromDate,
|
children: kids ? generateChildrenString(kids) : undefined,
|
||||||
toDate: state.data.booking.toDate,
|
hotelId,
|
||||||
bedType: state.data.bedType,
|
packageCodes,
|
||||||
breakfast: state.data.breakfast,
|
rateCode,
|
||||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
roomStayStartDate: fromDate,
|
||||||
setTotalPrice: state.actions.setTotalPrice,
|
roomStayEndDate: toDate,
|
||||||
totalPrice: state.totalPrice,
|
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,
|
|
||||||
} = useDetailsStore(storeSelector)
|
|
||||||
|
|
||||||
const diff = dt(toDate).diff(fromDate, "days")
|
|
||||||
|
|
||||||
const nights = intl.formatMessage(
|
|
||||||
{ id: "booking.nights" },
|
|
||||||
{ totalNights: diff }
|
|
||||||
)
|
|
||||||
|
|
||||||
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
|
|
||||||
if (showMemberPrice) {
|
|
||||||
color = "red"
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = room.localPrice.price + additionalPackageCost.local
|
|
||||||
const roomsPriceEuro = room.euroPrice
|
|
||||||
? room.euroPrice.price + additionalPackageCost.euro
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setChosenBed(bedType)
|
|
||||||
|
|
||||||
if (breakfast || breakfast === false) {
|
|
||||||
setChosenBreakfast(breakfast)
|
|
||||||
if (breakfast === false) {
|
|
||||||
setTotalPrice({
|
|
||||||
local: {
|
|
||||||
price: roomsPriceLocal,
|
|
||||||
currency: room.localPrice.currency,
|
|
||||||
},
|
|
||||||
euro:
|
|
||||||
room.euroPrice && roomsPriceEuro
|
|
||||||
? {
|
|
||||||
price: roomsPriceEuro,
|
|
||||||
currency: room.euroPrice.currency,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setTotalPrice({
|
|
||||||
local: {
|
|
||||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
|
||||||
currency: room.localPrice.currency,
|
|
||||||
},
|
|
||||||
euro:
|
|
||||||
room.euroPrice && roomsPriceEuro
|
|
||||||
? {
|
|
||||||
price:
|
|
||||||
roomsPriceEuro +
|
|
||||||
parseInt(breakfast.requestedPrice.totalPrice),
|
|
||||||
currency: room.euroPrice.currency,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
bedType,
|
|
||||||
breakfast,
|
|
||||||
roomsPriceLocal,
|
|
||||||
room.localPrice.currency,
|
|
||||||
room.euroPrice,
|
|
||||||
roomsPriceEuro,
|
|
||||||
setTotalPrice,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.summary}>
|
<ClientSummary
|
||||||
<header className={styles.header}>
|
adults={adults}
|
||||||
<Subtitle className={styles.title} type="two">
|
cancellationText={availability.cancellationText}
|
||||||
{intl.formatMessage({ id: "Summary" })}
|
isMember={!!user}
|
||||||
</Subtitle>
|
kids={kids}
|
||||||
<Body className={styles.date} color="baseTextMediumContrast">
|
memberRate={availability.memberRate}
|
||||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
rateDetails={availability.rateDetails}
|
||||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
roomType={availability.selectedRoom.roomType}
|
||||||
{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}>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "{amount} {currency}" },
|
|
||||||
{
|
|
||||||
amount: intl.formatNumber(room.localPrice.price),
|
|
||||||
currency: room.localPrice.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: room.localPrice.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: room.localPrice.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>
|
|
||||||
<Body textTransform="bold">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "{amount} {currency}" },
|
|
||||||
{
|
|
||||||
amount: intl.formatNumber(totalPrice.local.price),
|
|
||||||
currency: totalPrice.local.currency,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Body>
|
|
||||||
{totalPrice.euro && (
|
|
||||||
<Caption color="uiTextMediumContrast">
|
|
||||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "{amount} {currency}" },
|
|
||||||
{
|
|
||||||
amount: intl.formatNumber(totalPrice.euro.price),
|
|
||||||
currency: totalPrice.euro.currency,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,68 @@
|
|||||||
|
.mobileSummary {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopSummary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
border-radius: var(--Corner-radius-Large);
|
background-color: var(--Main-Grey-White);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
gap: var(--Spacing-x2);
|
border-style: solid;
|
||||||
padding: var(--Spacing-x3);
|
border-width: 1px;
|
||||||
height: 100%;
|
border-bottom: none;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.hider {
|
||||||
display: grid;
|
display: none;
|
||||||
grid-template-areas: "title button" "date button";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.shadow {
|
||||||
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 {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.bottomDivider {
|
.mobileSummary {
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevronButton {
|
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noRooms {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Spacing-x1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prices {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--Spacing-x-one-and-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
margin: var(--Spacing-x-half) 0;
|
margin: var(--Spacing-x-half) 0;
|
||||||
}
|
}
|
||||||
@@ -37,9 +25,3 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
font-size: var(--typography-Caption-Regular-fontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
|
||||||
.prices {
|
|
||||||
max-width: 260px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
|||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
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"
|
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||||
|
|
||||||
@@ -1,66 +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 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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={styles.priceCard}>
|
|
||||||
<div className={styles.noRooms}>
|
|
||||||
<div>
|
|
||||||
<ErrorCircleIcon color="red" />
|
|
||||||
</div>
|
|
||||||
<Body>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "There are no rooms available that match your request.",
|
|
||||||
})}
|
|
||||||
</Body>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user