Merge branch 'master' into feature/tracking
This commit is contained in:
@@ -51,4 +51,6 @@ GOOGLE_STATIC_MAP_SIGNATURE_SECRET=""
|
||||
GOOGLE_STATIC_MAP_ID=""
|
||||
GOOGLE_DYNAMIC_MAP_ID=""
|
||||
|
||||
HIDE_FOR_NEXT_RELEASE="true"
|
||||
HIDE_FOR_NEXT_RELEASE="false"
|
||||
SHOW_SIGNUP_FLOW="true"
|
||||
USE_NEW_REWARDS_ENDPOINT="true"
|
||||
|
||||
@@ -43,3 +43,4 @@ GOOGLE_STATIC_MAP_ID="test"
|
||||
GOOGLE_DYNAMIC_MAP_ID="test"
|
||||
HIDE_FOR_NEXT_RELEASE="true"
|
||||
SALESFORCE_PREFERENCE_BASE_URL="test"
|
||||
USE_NEW_REWARDS_ENDPOINT="true"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { headers } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { isSignupPage } from "@/constants/routes/signup"
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import HotelPage from "@/components/ContentType/HotelPage"
|
||||
@@ -22,17 +24,33 @@ export default function ContentTypePage({
|
||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
||||
setLang(params.lang)
|
||||
|
||||
const pathname = headers().get("x-pathname") || ""
|
||||
|
||||
switch (params.contentType) {
|
||||
case "collection-page":
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
}
|
||||
return <CollectionPage />
|
||||
case "content-page":
|
||||
case "content-page": {
|
||||
const isSignupRoute = isSignupPage(pathname)
|
||||
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
// Hide content pages for next release for non-signup routes.
|
||||
if (!isSignupRoute) {
|
||||
return notFound()
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.SHOW_SIGNUP_FLOW) {
|
||||
// Hide content pages for signup routes when signup flow is disabled.
|
||||
if (isSignupRoute) {
|
||||
return notFound()
|
||||
}
|
||||
}
|
||||
|
||||
return <ContentPage />
|
||||
}
|
||||
case "loyalty-page":
|
||||
return <LoyaltyPage />
|
||||
case "hotel-page":
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
.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,25 +1,39 @@
|
||||
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 } = rooms[0] // TODO: Handle multiple rooms
|
||||
const {
|
||||
adults,
|
||||
children,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
packages: packageCodes,
|
||||
} = rooms[0] // TODO: Handle multiple rooms
|
||||
|
||||
const availability = await getSelectedRoomAvailability({
|
||||
hotelId: hotel,
|
||||
@@ -29,49 +43,88 @@ export default async function SummaryPage({
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
|
||||
if (!availability) {
|
||||
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
|
||||
return null
|
||||
redirect(selectRate[params.lang])
|
||||
}
|
||||
|
||||
const prices =
|
||||
user && availability.memberRate
|
||||
? {
|
||||
local: {
|
||||
price: availability.memberRate?.localPrice.pricePerStay,
|
||||
currency: availability.memberRate?.localPrice.currency,
|
||||
price: availability.memberRate.localPrice.pricePerStay,
|
||||
currency: availability.memberRate.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: availability.memberRate?.requestedPrice?.pricePerStay,
|
||||
currency: availability.memberRate?.requestedPrice?.currency,
|
||||
price: availability.memberRate.requestedPrice.pricePerStay,
|
||||
currency: availability.memberRate.requestedPrice.currency,
|
||||
},
|
||||
}
|
||||
: {
|
||||
local: {
|
||||
price: availability.publicRate?.localPrice.pricePerStay,
|
||||
currency: availability.publicRate?.localPrice.currency,
|
||||
price: availability.publicRate.localPrice.pricePerStay,
|
||||
currency: availability.publicRate.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: availability.publicRate?.requestedPrice?.pricePerStay,
|
||||
currency: availability.publicRate?.requestedPrice?.currency,
|
||||
price: availability.publicRate.requestedPrice.pricePerStay,
|
||||
currency: availability.publicRate.requestedPrice.currency,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Summary
|
||||
showMemberPrice={!!(user && availability.memberRate)}
|
||||
room={{
|
||||
roomType: availability.selectedRoom.roomType,
|
||||
localPrice: prices.local,
|
||||
euroPrice: prices.euro,
|
||||
adults,
|
||||
children,
|
||||
cancellationText: availability.cancellationText,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<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,
|
||||
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,
|
||||
cancellationText: availability.cancellationText,
|
||||
packages,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,94 +8,38 @@
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
}
|
||||
|
||||
.enter-details-layout__content {
|
||||
.enter-details-layout__container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x9);
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
.enter-details-layout__content {
|
||||
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.enter-details-layout__summaryContainer {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1/-1;
|
||||
}
|
||||
|
||||
.enter-details-layout__summary {
|
||||
background-color: var(--Main-Grey-White);
|
||||
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.enter-details-layout__hider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.enter-details-layout__shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.enter-details-layout__summaryContainer {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.enter-details-layout__summary {
|
||||
position: sticky;
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) +
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x-one-and-half)
|
||||
);
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
border-bottom: none;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.enter-details-layout__hider {
|
||||
display: block;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
position: sticky;
|
||||
margin-top: var(--Spacing-x4);
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) +
|
||||
var(--booking-widget-desktop-height) - 6px
|
||||
);
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.enter-details-layout__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;
|
||||
}
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.enter-details-layout__summary {
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
||||
var(--Spacing-x-half)
|
||||
.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__hider {
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
.enter-details-layout__summaryContainer {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1/-1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,10 @@ export default async function StepLayout({
|
||||
<EnterDetailsProvider step={params.step} isMember={!!user}>
|
||||
<main className="enter-details-layout__layout">
|
||||
{hotelHeader}
|
||||
<div className={"enter-details-layout__content"}>
|
||||
{children}
|
||||
<aside className="enter-details-layout__summaryContainer">
|
||||
<div className="enter-details-layout__hider" />
|
||||
<div className="enter-details-layout__summary">{summary}</div>
|
||||
<div className="enter-details-layout__shadow" />
|
||||
<div className={"enter-details-layout__container"}>
|
||||
<div className={"enter-details-layout__content"}>{children}</div>
|
||||
<aside className={"enter-details-layout__summaryContainer"}>
|
||||
{summary}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -46,7 +46,13 @@ export default async function StepPage({
|
||||
toDate,
|
||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const { adults, children, roomTypeCode, rateCode } = rooms[0] // TODO: Handle multiple rooms
|
||||
const {
|
||||
adults,
|
||||
children,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
packages: packageCodes,
|
||||
} = rooms[0] // TODO: Handle multiple rooms
|
||||
|
||||
const childrenAsString = children && generateChildrenString(children)
|
||||
|
||||
@@ -60,12 +66,9 @@ export default async function StepPage({
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
|
||||
const hotelData = await getHotelData({
|
||||
hotelId,
|
||||
language: lang,
|
||||
})
|
||||
const roomAvailability = await getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
adults,
|
||||
@@ -74,6 +77,12 @@ export default async function StepPage({
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
const hotelData = await getHotelData({
|
||||
hotelId,
|
||||
language: lang,
|
||||
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||
})
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const user = await getProfileSafely()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
|
||||
import { env } from "@/env/server"
|
||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils"
|
||||
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
||||
import {
|
||||
generateChildrenString,
|
||||
@@ -11,11 +12,7 @@ import {
|
||||
import { MapModal } from "@/components/MapModal"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getCentralCoordinates,
|
||||
getHotelPins,
|
||||
} from "../../utils"
|
||||
import { fetchAvailableHotels } from "../../utils"
|
||||
|
||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
@@ -61,16 +58,12 @@ export default async function SelectHotelMapPage({
|
||||
|
||||
const hotelPins = getHotelPins(hotels)
|
||||
|
||||
const centralCoordinates = getCentralCoordinates(hotelPins)
|
||||
|
||||
return (
|
||||
<MapModal>
|
||||
<SelectHotelMap
|
||||
apiKey={googleMapsApiKey}
|
||||
coordinates={centralCoordinates}
|
||||
hotelPins={hotelPins}
|
||||
mapId={googleMapId}
|
||||
isModal={true}
|
||||
hotels={hotels}
|
||||
/>
|
||||
</MapModal>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
.main {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
|
||||
padding: 0 var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2);
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: 100dvh;
|
||||
flex-direction: column;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
|
||||
var(--Spacing-x5);
|
||||
justify-content: space-between;
|
||||
max-width: var(--max-width);
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.cityInformation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.sideBar {
|
||||
@@ -38,7 +40,31 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hotelList {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main {
|
||||
padding: var(--Spacing-x5);
|
||||
}
|
||||
.header {
|
||||
display: block;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
|
||||
var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
max-width: var(--max-width-navigation);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.link {
|
||||
display: flex;
|
||||
padding-bottom: var(--Spacing-x6);
|
||||
@@ -50,13 +76,6 @@
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
.mapLinkText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x0);
|
||||
}
|
||||
.main {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x5);
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import StaticMap from "@/components/Maps/StaticMap"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
TrackingSDKHotelInfo,
|
||||
TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectHotelPage({
|
||||
@@ -99,17 +104,44 @@ export default async function SelectHotelPage({
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div>{city.name}</div>
|
||||
<HotelSorter />
|
||||
<div className={styles.title}>
|
||||
<div className={styles.cityInformation}>
|
||||
<Subtitle>{city.name}</Subtitle>
|
||||
<Preamble>{hotels.length} hotels</Preamble>
|
||||
</div>
|
||||
<HotelSorter />
|
||||
</div>
|
||||
<MobileMapButtonContainer city={searchParams.city} />
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.sideBar}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
keepSearchParams
|
||||
>
|
||||
{hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
keepSearchParams
|
||||
>
|
||||
<div className={styles.mapContainer}>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
width={340}
|
||||
height={180}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
<Button wrapping size="medium" intent="text" theme="base">
|
||||
{intl.formatMessage({ id: "See map" })}
|
||||
<ChevronRightIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.mapContainer}>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
@@ -119,16 +151,22 @@ export default async function SelectHotelPage({
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
<div className={styles.mapLinkText}>
|
||||
{intl.formatMessage({ id: "Show map" })}
|
||||
<ChevronRightIcon color="burgundy" width={20} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<MobileMapButtonContainer city={searchParams.city} />
|
||||
)}
|
||||
<HotelFilter filters={filterList} />
|
||||
</div>
|
||||
<HotelCardListing hotelData={hotels} />
|
||||
<div className={styles.hotelList}>
|
||||
{!hotels.length && (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<HotelCardListing hotelData={hotels} />
|
||||
</div>
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
hotelInfo={hotelsTrackingData}
|
||||
|
||||
@@ -87,38 +87,3 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
|
||||
{ facilityFilters: [], surroundingsFilters: [] }
|
||||
)
|
||||
}
|
||||
|
||||
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
return hotels.map((hotel) => ({
|
||||
coordinates: {
|
||||
lat: hotel.hotelData.location.latitude,
|
||||
lng: hotel.hotelData.location.longitude,
|
||||
},
|
||||
name: hotel.hotelData.name,
|
||||
publicPrice: hotel.price?.regularAmount ?? null,
|
||||
memberPrice: hotel.price?.memberAmount ?? null,
|
||||
currency: hotel.price?.currency || null,
|
||||
images: [
|
||||
hotel.hotelData.hotelContent.images,
|
||||
...(hotel.hotelData.gallery?.heroImages ?? []),
|
||||
],
|
||||
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
|
||||
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCentralCoordinates(hotels: HotelPin[]) {
|
||||
const centralCoordinates = hotels.reduce(
|
||||
(acc, pin) => {
|
||||
acc.lat += pin.coordinates.lat
|
||||
acc.lng += pin.coordinates.lng
|
||||
return acc
|
||||
},
|
||||
{ lat: 0, lng: 0 }
|
||||
)
|
||||
|
||||
centralCoordinates.lat /= hotels.length
|
||||
centralCoordinates.lng /= hotels.length
|
||||
|
||||
return centralCoordinates
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectRatePage({
|
||||
params,
|
||||
@@ -49,11 +49,11 @@ export default async function SelectRatePage({
|
||||
searchParams.fromDate &&
|
||||
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
|
||||
? searchParams.fromDate
|
||||
: dt().utc().format("YYYY-MM-D")
|
||||
: 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-D")
|
||||
: 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
|
||||
@@ -94,9 +94,16 @@ export default async function SelectRatePage({
|
||||
|
||||
const roomCategories = hotelData?.included
|
||||
|
||||
const noRoomsAvailable = roomsAvailability.roomConfigurations.reduce(
|
||||
(acc, room) => {
|
||||
return acc && room.status === "NotAvailable"
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<HotelInfoCard hotelData={hotelData} />
|
||||
<HotelInfoCard hotelData={hotelData} noAvailability={noRoomsAvailable} />
|
||||
<Rooms
|
||||
roomsAvailability={roomsAvailability}
|
||||
roomCategories={roomCategories ?? []}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import styles from "./loading.module.css"
|
||||
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
|
||||
|
||||
export default function LoadingBookingWidget() {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
return <BookingWidgetSkeleton />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import CurrentLoadingSpinner from "@/components/Current/LoadingSpinner"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { FooterDetailsSkeleton } from "@/components/Footer/Details"
|
||||
import { FooterNavigationSkeleton } from "@/components/Footer/Navigation"
|
||||
|
||||
export default function LoadingFooter() {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return <CurrentLoadingSpinner />
|
||||
}
|
||||
return <LoadingSpinner />
|
||||
return (
|
||||
<footer>
|
||||
<FooterNavigationSkeleton />
|
||||
<FooterDetailsSkeleton />
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ export default async function RootLayout({
|
||||
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
||||
<RouterTracking />
|
||||
{header}
|
||||
<BookingWidget />
|
||||
{children}
|
||||
<Footer />
|
||||
<TokenRefresher />
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
--max-width-navigation: 89.5rem;
|
||||
|
||||
--main-menu-mobile-height: 75px;
|
||||
--main-menu-desktop-height: 129px;
|
||||
--main-menu-desktop-height: 125px;
|
||||
--booking-widget-mobile-height: 75px;
|
||||
--booking-widget-desktop-height: 77px;
|
||||
--hotel-page-map-desktop-width: 23.75rem;
|
||||
@@ -114,6 +114,8 @@
|
||||
/* Z-INDEX */
|
||||
--header-z-index: 11;
|
||||
--menu-overlay-z-index: 11;
|
||||
--booking-widget-z-index: 10;
|
||||
--booking-widget-open-z-index: 100;
|
||||
--dialog-z-index: 9;
|
||||
--sidepeek-z-index: 100;
|
||||
--lightbox-z-index: 150;
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AccordionSection({ accordion, title }: AccordionProps) {
|
||||
|
||||
return (
|
||||
<SectionContainer id={HotelHashValues.faq}>
|
||||
{title && <SectionHeader textTransform="uppercase" title={title} />}
|
||||
<SectionHeader textTransform="uppercase" title={title} />
|
||||
<Accordion
|
||||
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
|
||||
theme="light"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
@@ -14,8 +14,8 @@ import type { SignUpVerificationProps } from "@/types/components/blocks/dynamicC
|
||||
export default async function SignUpVerification({
|
||||
dynamic_content,
|
||||
}: SignUpVerificationProps) {
|
||||
const session = await auth()
|
||||
if (session) {
|
||||
const user = await getProfileSafely()
|
||||
if (user) {
|
||||
redirect(overview[getLang()])
|
||||
}
|
||||
const intl = await getIntl()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import SignupForm from "@/components/Forms/Signup"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
@@ -11,8 +11,8 @@ import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent
|
||||
export default async function SignupFormWrapper({
|
||||
dynamic_content,
|
||||
}: SignupFormWrapperProps) {
|
||||
const session = await auth()
|
||||
if (session) {
|
||||
const user = await getProfileSafely()
|
||||
if (user) {
|
||||
// We don't want to allow users to access signup if they are already authenticated.
|
||||
redirect(overview[getLang()])
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function TableBlock({ data }: TableBlockProps) {
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
{heading ? <SectionHeader preamble={preamble} title={heading} /> : null}
|
||||
<SectionHeader preamble={preamble} title={heading} />
|
||||
<div className={styles.tableWrapper}>
|
||||
<ScrollWrapper>
|
||||
<Table
|
||||
|
||||
@@ -6,14 +6,18 @@ import { FormProvider, useForm } from "react-hook-form"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import Form from "@/components/Forms/BookingWidget"
|
||||
import Form, {
|
||||
BookingWidgetFormSkeleton,
|
||||
} from "@/components/Forms/BookingWidget"
|
||||
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
import { getFormattedUrlQueryParams } from "@/utils/url"
|
||||
|
||||
import MobileToggleButton from "./MobileToggleButton"
|
||||
import MobileToggleButton, {
|
||||
MobileToggleButtonSkeleton,
|
||||
} from "./MobileToggleButton"
|
||||
|
||||
import styles from "./bookingWidget.module.css"
|
||||
|
||||
@@ -38,11 +42,11 @@ export default function BookingWidgetClient({
|
||||
|
||||
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
|
||||
searchParams
|
||||
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
|
||||
? getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
|
||||
adults: "number",
|
||||
age: "number",
|
||||
bed: "number",
|
||||
}) as BookingWidgetSearchParams)
|
||||
})
|
||||
: undefined
|
||||
|
||||
const getLocationObj = (destination: string): Location | undefined => {
|
||||
@@ -75,6 +79,16 @@ export default function BookingWidgetClient({
|
||||
)
|
||||
: undefined
|
||||
|
||||
const defaultRoomsData = bookingWidgetSearchData?.room?.map((room) => ({
|
||||
adults: room.adults,
|
||||
child: room.child ?? [],
|
||||
})) ?? [
|
||||
{
|
||||
adults: 1,
|
||||
child: [],
|
||||
},
|
||||
]
|
||||
|
||||
const methods = useForm<BookingWidgetSchema>({
|
||||
defaultValues: {
|
||||
search: selectedLocation?.name ?? "",
|
||||
@@ -92,12 +106,7 @@ export default function BookingWidgetClient({
|
||||
bookingCode: "",
|
||||
redemption: false,
|
||||
voucher: false,
|
||||
rooms: bookingWidgetSearchData?.room ?? [
|
||||
{
|
||||
adults: 1,
|
||||
child: [],
|
||||
},
|
||||
],
|
||||
rooms: defaultRoomsData,
|
||||
},
|
||||
shouldFocusError: false,
|
||||
mode: "all",
|
||||
@@ -154,21 +163,37 @@ export default function BookingWidgetClient({
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section ref={bookingWidgetRef} className={styles.containerDesktop}>
|
||||
<Form locations={locations} type={type} />
|
||||
</section>
|
||||
<section className={styles.containerMobile} data-open={isOpen}>
|
||||
<button
|
||||
className={styles.close}
|
||||
onClick={closeMobileSearch}
|
||||
type="button"
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
<Form locations={locations} type={type} />
|
||||
<section
|
||||
ref={bookingWidgetRef}
|
||||
className={styles.wrapper}
|
||||
data-open={isOpen}
|
||||
>
|
||||
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
||||
<div className={styles.formContainer}>
|
||||
<button
|
||||
className={styles.close}
|
||||
onClick={closeMobileSearch}
|
||||
type="button"
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
<Form locations={locations} type={type} setIsOpen={setIsOpen} />
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
||||
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<section className={styles.wrapper} style={{ top: 0 }}>
|
||||
<MobileToggleButtonSkeleton />
|
||||
<div className={styles.formContainer}>
|
||||
<BookingWidgetFormSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Spacing-x2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dt } from "@/lib/dt"
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import { EditIcon, SearchIcon } from "@/components/Icons"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -24,19 +25,12 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
export default function MobileToggleButton({
|
||||
openMobileSearch,
|
||||
}: BookingWidgetToggleButtonProps) {
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const d = useWatch({ name: "date" })
|
||||
const location = useWatch({ name: "location" })
|
||||
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
|
||||
|
||||
const bookingWidgetMobileRef = useRef(null)
|
||||
useStickyPosition({
|
||||
ref: bookingWidgetMobileRef,
|
||||
name: StickyElementNameEnum.BOOKING_WIDGET_MOBILE,
|
||||
})
|
||||
|
||||
const parsedLocation: Location | null = location
|
||||
? JSON.parse(decodeURIComponent(location))
|
||||
: null
|
||||
@@ -46,14 +40,6 @@ export default function MobileToggleButton({
|
||||
const selectedFromDate = dt(d.fromDate).locale(lang).format("D MMM")
|
||||
const selectedToDate = dt(d.toDate).locale(lang).format("D MMM")
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!hasMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
const locationAndDateIsSet = parsedLocation && d
|
||||
|
||||
const totalRooms = rooms.length
|
||||
@@ -75,7 +61,6 @@ export default function MobileToggleButton({
|
||||
className={locationAndDateIsSet ? styles.complete : styles.partial}
|
||||
onClick={openMobileSearch}
|
||||
role="button"
|
||||
ref={bookingWidgetMobileRef}
|
||||
>
|
||||
{!locationAndDateIsSet && (
|
||||
<>
|
||||
@@ -133,3 +118,28 @@ export default function MobileToggleButton({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileToggleButtonSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.partial}>
|
||||
<div>
|
||||
<Caption type="bold" color="red">
|
||||
{intl.formatMessage({ id: "Where to" })}
|
||||
</Caption>
|
||||
<SkeletonShimmer height="24px" />
|
||||
</div>
|
||||
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
|
||||
<div>
|
||||
<Caption type="bold" color="red">
|
||||
{intl.formatMessage({ id: "booking.nights" }, { totalNights: 0 })}
|
||||
</Caption>
|
||||
<SkeletonShimmer height="24px" />
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,63 @@
|
||||
.containerDesktop,
|
||||
.containerMobile,
|
||||
.close {
|
||||
display: none;
|
||||
.wrapper {
|
||||
position: sticky;
|
||||
z-index: var(--booking-widget-z-index);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.containerMobile {
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
bottom: -100%;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-template-rows: 36px 1fr;
|
||||
height: calc(100dvh - 20px);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: fixed;
|
||||
transition: bottom 300ms ease;
|
||||
width: 100%;
|
||||
z-index: 10000;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
.formContainer {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
gap: var(--Spacing-x3);
|
||||
height: calc(100dvh - 20px);
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: fixed;
|
||||
bottom: -100%;
|
||||
transition: bottom 300ms ease;
|
||||
}
|
||||
|
||||
.containerMobile[data-open="true"] {
|
||||
bottom: 0;
|
||||
}
|
||||
.wrapper[data-open="true"] {
|
||||
z-index: var(--booking-widget-open-z-index);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
.wrapper[data-open="true"] .formContainer {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.containerMobile[data-open="true"] + .backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
justify-self: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] + .backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: calc(var(--booking-widget-open-z-index) - 1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.containerDesktop {
|
||||
display: block;
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
position: sticky;
|
||||
.wrapper {
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
z-index: 9;
|
||||
.formContainer {
|
||||
display: block;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
height: auto;
|
||||
position: static;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default async function HotelListingItem({
|
||||
</div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance to city centre" },
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: distanceToCentre }
|
||||
)}
|
||||
</Caption>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default async function IntroSection({
|
||||
const { streetAddress, city } = address
|
||||
const { distanceToCentre } = location
|
||||
const formattedDistanceText = intl.formatMessage(
|
||||
{ id: "Distance to city centre" },
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: distanceToCentre }
|
||||
)
|
||||
const lang = getLang()
|
||||
|
||||
@@ -1,44 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import Lightbox from "@/components/Lightbox/"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./previewImages.module.css"
|
||||
|
||||
import type { PreviewImagesProps } from "@/types/components/hotelPage/previewImages"
|
||||
|
||||
export default async function PreviewImages({
|
||||
export default function PreviewImages({
|
||||
images,
|
||||
hotelName,
|
||||
}: PreviewImagesProps) {
|
||||
const intl = await getIntl()
|
||||
const imageGalleryText = intl.formatMessage({ id: "Image gallery" })
|
||||
const dialogTitle = `${hotelName} - ${imageGalleryText}`
|
||||
const intl = useIntl()
|
||||
const [lightboxIsOpen, setLightboxIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Lightbox images={images} dialogTitle={dialogTitle}>
|
||||
<div className={styles.imageWrapper}>
|
||||
{images.slice(0, 3).map((image, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
src={image.imageSizes.medium}
|
||||
alt={image.metaData.altText}
|
||||
title={image.metaData.title}
|
||||
width={index === 0 ? 752 : 292}
|
||||
height={index === 0 ? 540 : 266}
|
||||
className={styles.image}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
size="small"
|
||||
id="lightboxTrigger"
|
||||
className={styles.seeAllButton}
|
||||
>
|
||||
{intl.formatMessage({ id: "See all photos" })}
|
||||
</Button>
|
||||
</div>
|
||||
</Lightbox>
|
||||
<div className={styles.imageWrapper}>
|
||||
{images.slice(0, 3).map((image, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
src={image.imageSizes.medium}
|
||||
alt={image.metaData.altText}
|
||||
title={image.metaData.title}
|
||||
width={index === 0 ? 752 : 292}
|
||||
height={index === 0 ? 540 : 266}
|
||||
className={styles.image}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
size="small"
|
||||
onClick={() => setLightboxIsOpen(true)}
|
||||
className={styles.seeAllButton}
|
||||
>
|
||||
{intl.formatMessage({ id: "See all photos" })}
|
||||
</Button>
|
||||
<Lightbox
|
||||
images={images}
|
||||
dialogTitle={intl.formatMessage(
|
||||
{ id: "Image gallery" },
|
||||
{ name: hotelName }
|
||||
)}
|
||||
isOpen={lightboxIsOpen}
|
||||
onClose={() => setLightboxIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,62 +2,38 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import RoomDetailsButton from "../RoomDetailsButton"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
|
||||
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
const { images, name, roomSize, occupancy, id } = room
|
||||
const { images, name, roomSize, occupancy } = room
|
||||
const intl = useIntl()
|
||||
const mainImage = images[0]
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
const size =
|
||||
roomSize?.min === roomSize?.max
|
||||
? `${roomSize.min} m²`
|
||||
: `${roomSize.min} - ${roomSize.max} m²`
|
||||
|
||||
const personLabel = intl.formatMessage(
|
||||
{ id: "hotelPages.rooms.roomCard.persons" },
|
||||
{ totalOccupancy: occupancy.total }
|
||||
)
|
||||
|
||||
const subtitle = `${size} (${personLabel})`
|
||||
|
||||
function handleImageClick() {
|
||||
// TODO: Implement opening of a model with carousel
|
||||
console.log("Image clicked: ", id)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.roomCard}>
|
||||
<button className={styles.imageWrapper} onClick={handleImageClick}>
|
||||
{/* TODO: re-enable once we have support for badge text from API team. */}
|
||||
{/* {badgeTextTransKey && ( */}
|
||||
{/* <span className={styles.badge}> */}
|
||||
{/* {intl.formatMessage({ id: badgeTextTransKey })} */}
|
||||
{/* </span> */}
|
||||
{/* )} */}
|
||||
<span className={styles.imageCount}>
|
||||
<GalleryIcon color="white" />
|
||||
{images.length}
|
||||
</span>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={mainImage.imageSizes.large}
|
||||
alt={mainImage.metaData.altText}
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery
|
||||
images={images}
|
||||
title={intl.formatMessage({ id: "Image gallery" }, { name })}
|
||||
height={200}
|
||||
width={300}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.innerContent}>
|
||||
<Subtitle
|
||||
@@ -65,16 +41,32 @@ export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
textAlign="center"
|
||||
type="one"
|
||||
color="black"
|
||||
className={styles.title}
|
||||
>
|
||||
{name}
|
||||
</Subtitle>
|
||||
<Body color="grey">{subtitle}</Body>
|
||||
<Body color="grey">
|
||||
{intl.formatMessage(
|
||||
{ id: "hotelPages.rooms.roomCard.persons" },
|
||||
{ size, totalOccupancy: occupancy.total }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<RoomDetailsButton
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypes[0].code}
|
||||
/>
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={() =>
|
||||
openSidePeek({
|
||||
key: SidePeekEnum.roomDetails,
|
||||
hotelId,
|
||||
roomTypeCode: room.roomTypes[0].code,
|
||||
})
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -3,34 +3,7 @@
|
||||
background-color: var(--UI-Opacity-White-100);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/*TODO: Build Chip/Badge component. */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: var(--Spacing-x1);
|
||||
left: var(--Spacing-x1);
|
||||
background-color: var(--Tertiary-Dark-Surface-Hover);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
color: var(--Tertiary-Dark-On-Surface-Text);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--typography-Chip-fontSize-Placeholder);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.imageCount {
|
||||
position: absolute;
|
||||
right: var(--Spacing-x1);
|
||||
bottom: var(--Spacing-x1);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
background-color: var(--UI-Grey-90);
|
||||
opacity: 90%;
|
||||
color: var(--UI-Input-Controls-Fill-Normal);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -46,32 +19,7 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title:first-child {
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-top-left-radius: var(--Corner-radius-Medium);
|
||||
border-top-right-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function RoomDetailsButton({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
.header {
|
||||
display: grid;
|
||||
background-color: var(--Main-Grey-White);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.container {
|
||||
--header-height: 68px;
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 120px;
|
||||
|
||||
display: grid;
|
||||
@@ -11,12 +11,10 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
align-self: flex-start;
|
||||
align-self: flex-end;
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
grid-template-columns: 1fr 24px;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
.hideWrapper {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
if (!dt(selected).isBefore(dt(), "day")) {
|
||||
if (isSelectingFrom) {
|
||||
setValue(name, {
|
||||
fromDate: dt(selected).format("YYYY-MM-D"),
|
||||
fromDate: dt(selected).format("YYYY-MM-DD"),
|
||||
toDate: undefined,
|
||||
})
|
||||
setIsSelectingFrom(false)
|
||||
@@ -57,11 +57,11 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
if (toDate.isAfter(fromDate)) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: toDate.format("YYYY-MM-D"),
|
||||
toDate: toDate.format("YYYY-MM-DD"),
|
||||
})
|
||||
} else {
|
||||
setValue(name, {
|
||||
fromDate: toDate.format("YYYY-MM-D"),
|
||||
fromDate: toDate.format("YYYY-MM-DD"),
|
||||
toDate: selectedDate.fromDate,
|
||||
})
|
||||
}
|
||||
@@ -75,7 +75,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
if (!selectedDate.toDate) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-D"),
|
||||
toDate: dt(selectedDate.fromDate)
|
||||
.add(1, "day")
|
||||
.format("YYYY-MM-DD"),
|
||||
})
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
@@ -121,7 +123,12 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
data-isopen={isOpen}
|
||||
ref={ref}
|
||||
>
|
||||
<button className={styles.btn} onFocus={showOnFocus} type="button">
|
||||
<button
|
||||
className={styles.btn}
|
||||
onFocus={showOnFocus}
|
||||
onClick={() => setIsOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Body className={styles.body} asChild>
|
||||
<span>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getFooter, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
||||
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
|
||||
import Image from "@/components/Image"
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import { getIntl } from "@/i18n"
|
||||
@@ -92,3 +93,40 @@ export default async function FooterDetails() {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export async function FooterDetailsSkeleton() {
|
||||
const lang = getLang()
|
||||
const intl = await getIntl()
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<section className={styles.details}>
|
||||
<div className={styles.topContainer}>
|
||||
<Link href={`/${lang}`}>
|
||||
<Image
|
||||
alt="Scandic Hotels logo"
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype-white.svg"
|
||||
width={103}
|
||||
/>
|
||||
</Link>
|
||||
<nav className={styles.socialNav}>
|
||||
<SkeletonShimmer width="10ch" height="20px" contrast="dark" />
|
||||
</nav>
|
||||
</div>
|
||||
<div className={styles.bottomContainer}>
|
||||
<div className={styles.copyrightContainer}>
|
||||
<Footnote type="label" textTransform="uppercase">
|
||||
© {currentYear}{" "}
|
||||
{intl.formatMessage({ id: "Copyright all rights reserved" })}
|
||||
</Footnote>
|
||||
</div>
|
||||
<div className={styles.navigationContainer}>
|
||||
<nav className={styles.navigation}>
|
||||
<SkeletonShimmer width="40ch" height="20px" contrast="dark" />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
@@ -30,3 +31,24 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function FooterMainNavSkeleton() {
|
||||
const items = Array.from({ length: 4 }).map((_, i) => i)
|
||||
|
||||
return (
|
||||
<nav className={styles.mainNavigation}>
|
||||
<ul className={styles.mainNavigationList}>
|
||||
{items.map((x) => (
|
||||
<li key={x} className={styles.mainNavigationItem}>
|
||||
<Subtitle color="baseTextMediumContrast" type="two" asChild>
|
||||
<span className={styles.mainNavigationLink}>
|
||||
<SkeletonShimmer width="80%" />
|
||||
<ArrowRightIcon color="peach80" />
|
||||
</span>
|
||||
</Subtitle>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "@/components/Image"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
@@ -80,3 +80,46 @@ export default function FooterSecondaryNav({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FooterSecondaryNavSkeleton() {
|
||||
return (
|
||||
<div className={styles.secondaryNavigation}>
|
||||
<nav className={styles.secondaryNavigationGroup}>
|
||||
<SkeletonShimmer width="10ch" />
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
<li className={styles.appDownloadItem}>
|
||||
<SkeletonShimmer width="16ch" />
|
||||
</li>
|
||||
<li className={styles.appDownloadItem}>
|
||||
<SkeletonShimmer width="16ch" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav className={styles.secondaryNavigationGroup}>
|
||||
<SkeletonShimmer width="20ch" />
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
<li className={styles.secondaryNavigationItem}>
|
||||
<SkeletonShimmer width="25ch" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav className={styles.secondaryNavigationGroup}>
|
||||
<SkeletonShimmer width="15ch" />
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
<li className={styles.secondaryNavigationItem}>
|
||||
<SkeletonShimmer width="30ch" />
|
||||
</li>
|
||||
<li className={styles.secondaryNavigationItem}>
|
||||
<SkeletonShimmer width="36ch" />
|
||||
</li>
|
||||
<li className={styles.secondaryNavigationItem}>
|
||||
<SkeletonShimmer width="12ch" />
|
||||
</li>
|
||||
<li className={styles.secondaryNavigationItem}>
|
||||
<SkeletonShimmer width="20ch" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getFooter } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import FooterMainNav from "./MainNav"
|
||||
import FooterSecondaryNav from "./SecondaryNav"
|
||||
import FooterMainNav, { FooterMainNavSkeleton } from "./MainNav"
|
||||
import FooterSecondaryNav, { FooterSecondaryNavSkeleton } from "./SecondaryNav"
|
||||
|
||||
import styles from "./navigation.module.css"
|
||||
|
||||
@@ -10,6 +10,7 @@ export default async function FooterNavigation() {
|
||||
if (!footer) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.maxWidth}>
|
||||
@@ -22,3 +23,14 @@ export default async function FooterNavigation() {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function FooterNavigationSkeleton() {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.maxWidth}>
|
||||
<FooterMainNavSkeleton />
|
||||
<FooterSecondaryNavSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Input from "../Input"
|
||||
@@ -203,3 +204,18 @@ export default function Search({ locations }: SearchProps) {
|
||||
</Downshift>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchSkeleton() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.label}>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>Where to</span>
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.input}>
|
||||
<SkeletonShimmer />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
position: relative;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
@@ -78,3 +79,54 @@ export default function Voucher() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VoucherSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const useVouchers = intl.formatMessage({ id: "Use code/voucher" })
|
||||
const addVouchers = intl.formatMessage({ id: "Add code" })
|
||||
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book reward night" })
|
||||
|
||||
const form = useForm()
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.vouchers}>
|
||||
<label>
|
||||
<Caption color="disabled" type="bold" asChild>
|
||||
<span>{vouchers}</span>
|
||||
</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<Input type="text" placeholder={addVouchers} disabled />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<div className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<Checkbox name="useVouchers" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{useVouchers}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -7,13 +6,13 @@ import { dt } from "@/lib/dt"
|
||||
|
||||
import DatePicker from "@/components/DatePicker"
|
||||
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
|
||||
import GuestsRoomsProvider from "@/components/GuestsRoomsPicker/Provider/GuestsRoomsProvider"
|
||||
import { SearchIcon } from "@/components/Icons"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Search from "./Search"
|
||||
import Voucher from "./Voucher"
|
||||
import Search, { SearchSkeleton } from "./Search"
|
||||
import Voucher, { VoucherSkeleton } from "./Voucher"
|
||||
|
||||
import styles from "./formContent.module.css"
|
||||
|
||||
@@ -26,12 +25,10 @@ export default function FormContent({
|
||||
const intl = useIntl()
|
||||
const selectedDate = useWatch({ name: "date" })
|
||||
|
||||
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
|
||||
const roomsLabel = intl.formatMessage({ id: "Guests & Rooms" })
|
||||
|
||||
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
|
||||
|
||||
const selectedGuests = useWatch({ name: "rooms" })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.input}>
|
||||
@@ -51,12 +48,10 @@ export default function FormContent({
|
||||
<div className={styles.rooms}>
|
||||
<label>
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{rooms}</span>
|
||||
<span>{roomsLabel}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<GuestsRoomsProvider selectedGuests={selectedGuests}>
|
||||
<GuestsRoomsPickerForm name="rooms" />
|
||||
</GuestsRoomsProvider>
|
||||
<GuestsRoomsPickerForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.voucherContainer}>
|
||||
@@ -90,3 +85,53 @@ export default function FormContent({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetFormContentSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<SearchSkeleton />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" type="bold">
|
||||
{intl.formatMessage({ id: "booking.nights" }, { totalNights: 0 })}
|
||||
</Caption>
|
||||
<SkeletonShimmer />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{intl.formatMessage({ id: "Guests & Rooms" })}</span>
|
||||
</Caption>
|
||||
<SkeletonShimmer />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.voucherContainer}>
|
||||
<VoucherSkeleton />
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
disabled
|
||||
>
|
||||
<Caption
|
||||
color="white"
|
||||
type="bold"
|
||||
className={styles.buttonText}
|
||||
asChild
|
||||
>
|
||||
<span>{intl.formatMessage({ id: "Search" })}</span>
|
||||
</Caption>
|
||||
<span className={styles.icon}>
|
||||
<SearchIcon color="white" width={28} height={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { selectHotel, selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
import FormContent, { BookingWidgetFormContentSkeleton } from "./FormContent"
|
||||
import { bookingWidgetVariants } from "./variants"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
@@ -17,7 +17,11 @@ import { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
export default function Form({
|
||||
locations,
|
||||
type,
|
||||
setIsOpen,
|
||||
}: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
|
||||
@@ -52,7 +56,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
setIsOpen(false)
|
||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||
}
|
||||
|
||||
@@ -69,3 +73,17 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetFormSkeleton() {
|
||||
const classNames = bookingWidgetVariants({
|
||||
type: "full",
|
||||
})
|
||||
|
||||
return (
|
||||
<section className={classNames}>
|
||||
<form className={styles.form}>
|
||||
<BookingWidgetFormContentSkeleton />
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const guestRoomSchema = z.object({
|
||||
adults: z.number().default(1),
|
||||
child: z.array(
|
||||
z.object({
|
||||
age: z.number().nonnegative(),
|
||||
bed: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
export const guestRoomSchema = z
|
||||
.object({
|
||||
adults: z.number().default(1),
|
||||
child: z
|
||||
.array(
|
||||
z.object({
|
||||
age: z.number().min(0, "Age is required"),
|
||||
bed: z.number().min(0, "Bed choice is required"),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const childrenInAdultsBed = value.child.filter(
|
||||
(c) => c.bed === ChildBedMapEnum.IN_ADULTS_BED
|
||||
)
|
||||
if (value.adults < childrenInAdultsBed.length) {
|
||||
const lastAdultBedIndex = value.child
|
||||
.map((c) => c.bed)
|
||||
.lastIndexOf(ChildBedMapEnum.IN_ADULTS_BED)
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"You cannot have more children in adults bed than adults in the room",
|
||||
path: ["child", lastAdultBedIndex],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const guestRoomsSchema = z.array(guestRoomSchema)
|
||||
|
||||
|
||||
@@ -3,54 +3,32 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Counter from "../Counter"
|
||||
|
||||
import styles from "./adult-selector.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import {
|
||||
AdultSelectorProps,
|
||||
Child,
|
||||
} from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
|
||||
export default function AdultSelector({
|
||||
roomIndex = 0,
|
||||
currentAdults,
|
||||
}: SelectorProps) {
|
||||
const name = `rooms.${roomIndex}.adults`
|
||||
const intl = useIntl()
|
||||
const adultsLabel = intl.formatMessage({ id: "Adults" })
|
||||
const { setValue } = useFormContext()
|
||||
const { adults, child, childrenInAdultsBed } = useGuestsRoomsStore(
|
||||
(state) => state.rooms[roomIndex]
|
||||
)
|
||||
const increaseAdults = useGuestsRoomsStore((state) => state.increaseAdults)
|
||||
const decreaseAdults = useGuestsRoomsStore((state) => state.decreaseAdults)
|
||||
|
||||
function increaseAdultsCount(roomIndex: number) {
|
||||
if (adults < 6) {
|
||||
increaseAdults(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.adults`, adults + 1)
|
||||
function increaseAdultsCount() {
|
||||
if (currentAdults < 6) {
|
||||
setValue(name, currentAdults + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function decreaseAdultsCount(roomIndex: number) {
|
||||
if (adults > 1) {
|
||||
decreaseAdults(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.adults`, adults - 1)
|
||||
if (childrenInAdultsBed > adults) {
|
||||
const toUpdateIndex = child.findIndex(
|
||||
(child: Child) => child.bed == ChildBedMapEnum.IN_ADULTS_BED
|
||||
)
|
||||
if (toUpdateIndex != -1) {
|
||||
setValue(
|
||||
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
|
||||
child[toUpdateIndex].age < 3
|
||||
? ChildBedMapEnum.IN_CRIB
|
||||
: ChildBedMapEnum.IN_EXTRA_BED
|
||||
)
|
||||
}
|
||||
}
|
||||
function decreaseAdultsCount() {
|
||||
if (currentAdults > 1) {
|
||||
setValue(name, currentAdults - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +38,11 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
|
||||
{adultsLabel}
|
||||
</Caption>
|
||||
<Counter
|
||||
count={adults}
|
||||
handleOnDecrease={() => {
|
||||
decreaseAdultsCount(roomIndex)
|
||||
}}
|
||||
handleOnIncrease={() => {
|
||||
increaseAdultsCount(roomIndex)
|
||||
}}
|
||||
disableDecrease={adults == 1}
|
||||
disableIncrease={adults == 6}
|
||||
count={currentAdults}
|
||||
handleOnDecrease={decreaseAdultsCount}
|
||||
handleOnIncrease={increaseAdultsCount}
|
||||
disableDecrease={currentAdults == 1}
|
||||
disableIncrease={currentAdults == 6}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -17,55 +15,35 @@ import {
|
||||
ChildInfoSelectorProps,
|
||||
} from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
const ageList = [...Array(13)].map((_, i) => ({
|
||||
label: i.toString(),
|
||||
value: i,
|
||||
}))
|
||||
|
||||
export default function ChildInfoSelector({
|
||||
child = { age: -1, bed: -1 },
|
||||
childrenInAdultsBed,
|
||||
adults,
|
||||
index = 0,
|
||||
roomIndex = 0,
|
||||
}: ChildInfoSelectorProps) {
|
||||
const ageFieldName = `rooms.${roomIndex}.child.${index}.age`
|
||||
const bedFieldName = `rooms.${roomIndex}.child.${index}.bed`
|
||||
const intl = useIntl()
|
||||
const ageLabel = intl.formatMessage({ id: "Age" })
|
||||
const ageReqdErrMsg = intl.formatMessage({ id: "Child age is required" })
|
||||
const bedLabel = intl.formatMessage({ id: "Bed" })
|
||||
const { setValue } = useFormContext()
|
||||
const { adults, childrenInAdultsBed } = useGuestsRoomsStore(
|
||||
(state) => state.rooms[roomIndex]
|
||||
)
|
||||
const {
|
||||
isValidated,
|
||||
updateChildAge,
|
||||
updateChildBed,
|
||||
increaseChildInAdultsBed,
|
||||
decreaseChildInAdultsBed,
|
||||
} = useGuestsRoomsStore((state) => ({
|
||||
isValidated: state.isValidated,
|
||||
updateChildAge: state.updateChildAge,
|
||||
updateChildBed: state.updateChildBed,
|
||||
increaseChildInAdultsBed: state.increaseChildInAdultsBed,
|
||||
decreaseChildInAdultsBed: state.decreaseChildInAdultsBed,
|
||||
}))
|
||||
|
||||
const ageList = Array.from(Array(13).keys()).map((age) => ({
|
||||
label: `${age}`,
|
||||
value: age,
|
||||
}))
|
||||
|
||||
function updateSelectedAge(age: number) {
|
||||
updateChildAge(age, roomIndex, index)
|
||||
setValue(`rooms.${roomIndex}.child.${index}.age`, age, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
const availableBedTypes = getAvailableBeds(age)
|
||||
updateSelectedBed(availableBedTypes[0].value)
|
||||
}
|
||||
const errorMessage = intl.formatMessage({ id: "Child age is required" })
|
||||
const { setValue, formState, register, trigger } = useFormContext()
|
||||
|
||||
function updateSelectedBed(bed: number) {
|
||||
if (bed == ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
increaseChildInAdultsBed(roomIndex)
|
||||
} else if (child.bed == ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
decreaseChildInAdultsBed(roomIndex)
|
||||
}
|
||||
updateChildBed(bed, roomIndex, index)
|
||||
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
|
||||
trigger()
|
||||
}
|
||||
|
||||
function updateSelectedAge(age: number) {
|
||||
setValue(`rooms.${roomIndex}.child.${index}.age`, age)
|
||||
const availableBedTypes = getAvailableBeds(age)
|
||||
updateSelectedBed(availableBedTypes[0].value)
|
||||
}
|
||||
|
||||
const allBedTypes: ChildBed[] = [
|
||||
@@ -97,6 +75,12 @@ export default function ChildInfoSelector({
|
||||
return availableBedTypes
|
||||
}
|
||||
|
||||
//@ts-expect-error: formState is typed with FormValues
|
||||
const roomErrors = formState.errors.rooms?.[roomIndex]?.child?.[index]
|
||||
|
||||
const ageError = roomErrors?.age
|
||||
const bedError = roomErrors?.bed
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={index} className={styles.childInfoContainer}>
|
||||
@@ -110,13 +94,15 @@ export default function ChildInfoSelector({
|
||||
onSelect={(key) => {
|
||||
updateSelectedAge(key as number)
|
||||
}}
|
||||
name={`rooms.${roomIndex}.child.${index}.age`}
|
||||
placeholder={ageLabel}
|
||||
maxHeight={150}
|
||||
{...register(ageFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{child.age !== -1 ? (
|
||||
{child.age >= 0 ? (
|
||||
<Select
|
||||
items={getAvailableBeds(child.age)}
|
||||
label={bedLabel}
|
||||
@@ -125,16 +111,26 @@ export default function ChildInfoSelector({
|
||||
onSelect={(key) => {
|
||||
updateSelectedBed(key as number)
|
||||
}}
|
||||
name={`rooms.${roomIndex}.child.${index}.age`}
|
||||
placeholder={bedLabel}
|
||||
{...register(bedFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isValidated && child.age < 0 ? (
|
||||
|
||||
{roomErrors && roomErrors.message ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<ErrorCircleIcon color="red" />
|
||||
{ageReqdErrMsg}
|
||||
{roomErrors.message}
|
||||
</Caption>
|
||||
) : null}
|
||||
|
||||
{ageError || bedError ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<ErrorCircleIcon color="red" />
|
||||
{errorMessage}
|
||||
</Caption>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Counter from "../Counter"
|
||||
@@ -12,40 +10,30 @@ import ChildInfoSelector from "./ChildInfoSelector"
|
||||
|
||||
import styles from "./child-selector.module.css"
|
||||
|
||||
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import { ChildSelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
|
||||
export default function ChildSelector({
|
||||
roomIndex = 0,
|
||||
currentAdults,
|
||||
childrenInAdultsBed,
|
||||
currentChildren,
|
||||
}: SelectorProps) {
|
||||
const intl = useIntl()
|
||||
const childrenLabel = intl.formatMessage({ id: "Children" })
|
||||
const { setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
const children = useGuestsRoomsStore((state) => state.rooms[roomIndex].child)
|
||||
const increaseChildren = useGuestsRoomsStore(
|
||||
(state) => state.increaseChildren
|
||||
)
|
||||
const decreaseChildren = useGuestsRoomsStore(
|
||||
(state) => state.decreaseChildren
|
||||
)
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
function increaseChildrenCount(roomIndex: number) {
|
||||
if (children.length < 5) {
|
||||
increaseChildren(roomIndex)
|
||||
setValue(
|
||||
`rooms.${roomIndex}.child.${children.length}`,
|
||||
{
|
||||
age: -1,
|
||||
bed: -1,
|
||||
},
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
if (currentChildren.length < 5) {
|
||||
setValue(`rooms.${roomIndex}.child.${currentChildren.length}`, {
|
||||
age: undefined,
|
||||
bed: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
function decreaseChildrenCount(roomIndex: number) {
|
||||
if (children.length > 0) {
|
||||
const newChildrenList = decreaseChildren(roomIndex)
|
||||
setValue(`rooms.${roomIndex}.child`, newChildrenList, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
if (currentChildren.length > 0) {
|
||||
currentChildren.pop()
|
||||
setValue(`rooms.${roomIndex}.child`, currentChildren)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,23 +44,25 @@ export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
|
||||
{childrenLabel}
|
||||
</Caption>
|
||||
<Counter
|
||||
count={children.length}
|
||||
count={currentChildren.length}
|
||||
handleOnDecrease={() => {
|
||||
decreaseChildrenCount(roomIndex)
|
||||
}}
|
||||
handleOnIncrease={() => {
|
||||
increaseChildrenCount(roomIndex)
|
||||
}}
|
||||
disableDecrease={children.length == 0}
|
||||
disableIncrease={children.length == 5}
|
||||
disableDecrease={currentChildren.length == 0}
|
||||
disableIncrease={currentChildren.length == 5}
|
||||
/>
|
||||
</section>
|
||||
{children.map((child, index) => (
|
||||
{currentChildren.map((child, index) => (
|
||||
<ChildInfoSelector
|
||||
roomIndex={roomIndex}
|
||||
index={index}
|
||||
child={child}
|
||||
adults={currentAdults}
|
||||
key={"child_" + index}
|
||||
childrenInAdultsBed={childrenInAdultsBed}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Counter({
|
||||
>
|
||||
<MinusIcon color="burgundy" />
|
||||
</Button>
|
||||
<Body color="textHighContrast" textAlign="center">
|
||||
<Body color="baseTextHighContrast" textAlign="center">
|
||||
{count}
|
||||
</Body>
|
||||
<Button
|
||||
|
||||
167
components/GuestsRoomsPicker/Form.tsx
Normal file
167
components/GuestsRoomsPicker/Form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
|
||||
import Button from "../TempDesignSystem/Button"
|
||||
import Divider from "../TempDesignSystem/Divider"
|
||||
import Subtitle from "../TempDesignSystem/Text/Subtitle"
|
||||
import { Tooltip } from "../TempDesignSystem/Tooltip"
|
||||
import AdultSelector from "./AdultSelector"
|
||||
import ChildSelector from "./ChildSelector"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function GuestsRoomsPickerDialog({
|
||||
rooms,
|
||||
onClose,
|
||||
}: {
|
||||
rooms: GuestsRoom[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const doneLabel = intl.formatMessage({ id: "Done" })
|
||||
const roomLabel = intl.formatMessage({ id: "Room" })
|
||||
const disabledBookingOptionsHeader = intl.formatMessage({
|
||||
id: "Disabled booking options header",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Disabled adding room",
|
||||
})
|
||||
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
|
||||
|
||||
const { getFieldState, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
const roomsValue = useWatch({ name: "rooms" })
|
||||
|
||||
async function handleOnClose() {
|
||||
const state = await trigger("rooms")
|
||||
if (state) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const fieldState = getFieldState("rooms")
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldState.invalid) {
|
||||
trigger("rooms")
|
||||
}
|
||||
}, [roomsValue, fieldState.invalid, trigger])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.contentWrapper}>
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.close} onClick={onClose}>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.contentContainer}>
|
||||
{rooms.map((room, index) => {
|
||||
const currentAdults = room.adults
|
||||
const currentChildren = room.child
|
||||
const childrenInAdultsBed =
|
||||
currentChildren.filter(
|
||||
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
|
||||
).length ?? 0
|
||||
|
||||
return (
|
||||
<div className={styles.roomContainer} key={index}>
|
||||
<section className={styles.roomDetailsContainer}>
|
||||
<Subtitle type="two" className={styles.roomHeading}>
|
||||
{roomLabel} {index + 1}
|
||||
</Subtitle>
|
||||
<AdultSelector
|
||||
roomIndex={index}
|
||||
currentAdults={currentAdults}
|
||||
currentChildren={currentChildren}
|
||||
childrenInAdultsBed={childrenInAdultsBed}
|
||||
/>
|
||||
<ChildSelector
|
||||
roomIndex={index}
|
||||
currentAdults={currentAdults}
|
||||
currentChildren={currentChildren}
|
||||
childrenInAdultsBed={childrenInAdultsBed}
|
||||
/>
|
||||
</section>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
fullWidth
|
||||
>
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
onPress={handleOnClose}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
className={styles.hideOnMobile}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="small"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleOnClose}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
className={styles.hideOnDesktop}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="large"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
|
||||
import Button from "../TempDesignSystem/Button"
|
||||
import Divider from "../TempDesignSystem/Divider"
|
||||
import Subtitle from "../TempDesignSystem/Text/Subtitle"
|
||||
import { Tooltip } from "../TempDesignSystem/Tooltip"
|
||||
import AdultSelector from "./AdultSelector"
|
||||
import ChildSelector from "./ChildSelector"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import { GuestsRoomsPickerProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function GuestsRoomsPicker({
|
||||
closePicker,
|
||||
}: GuestsRoomsPickerProps) {
|
||||
const intl = useIntl()
|
||||
const doneLabel = intl.formatMessage({ id: "Done" })
|
||||
const roomLabel = intl.formatMessage({ id: "Room" })
|
||||
const disabledBookingOptionsHeader = intl.formatMessage({
|
||||
id: "Disabled booking options header",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Disabled adding room",
|
||||
})
|
||||
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
|
||||
|
||||
const { getFieldState } = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
const rooms = useGuestsRoomsStore((state) => state.rooms)
|
||||
|
||||
// Not in MVP
|
||||
// const increaseRoom = useGuestsRoomsStore.use.increaseRoom()
|
||||
// const decreaseRoom = useGuestsRoomsStore.use.decreaseRoom()
|
||||
|
||||
return (
|
||||
<div className={styles.pickerContainer}>
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.close} onClick={closePicker}>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.contentContainer}>
|
||||
{rooms.map((room, index) => (
|
||||
<div className={styles.roomContainer} key={index}>
|
||||
<section className={styles.roomDetailsContainer}>
|
||||
<Subtitle type="two" className={styles.roomHeading}>
|
||||
{roomLabel} {index + 1}
|
||||
</Subtitle>
|
||||
<AdultSelector roomIndex={index} />
|
||||
<ChildSelector roomIndex={index} />
|
||||
</section>
|
||||
{/* Not in MVP
|
||||
{index > 0 ? (
|
||||
<Button intent="text" onClick={() => decreaseRoom(index)}>
|
||||
Remove Room
|
||||
</Button>
|
||||
) : null} */}
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
fullWidth
|
||||
>
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
onClick={closePicker}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
className={styles.hideOnMobile}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="small"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={closePicker}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
className={styles.hideOnDesktop}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
size="large"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import {
|
||||
GuestsRoomsContext,
|
||||
type GuestsRoomsStore,
|
||||
initGuestsRoomsState,
|
||||
} from "@/stores/guests-rooms"
|
||||
|
||||
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function GuestsRoomsProvider({
|
||||
selectedGuests,
|
||||
children,
|
||||
}: PropsWithChildren<{ selectedGuests?: GuestsRoom[] }>) {
|
||||
const initialStore = useRef<GuestsRoomsStore>()
|
||||
if (!initialStore.current) {
|
||||
initialStore.current = initGuestsRoomsState(selectedGuests)
|
||||
}
|
||||
|
||||
return (
|
||||
<GuestsRoomsContext.Provider value={initialStore.current}>
|
||||
{children}
|
||||
</GuestsRoomsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
.container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
&[data-isopen="true"] {
|
||||
overflow: visible;
|
||||
}
|
||||
.triggerDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pickerContainerMobile {
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 140px;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 20px;
|
||||
transition: top 300ms ease;
|
||||
z-index: 100;
|
||||
}
|
||||
.contentWrapper {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"content";
|
||||
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
|
||||
}
|
||||
|
||||
.pickerContainerDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
@@ -14,9 +36,6 @@
|
||||
gap: var(--Spacing-x2);
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.hideWrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
}
|
||||
.roomHeading {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
@@ -29,43 +48,14 @@
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: auto;
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.hideWrapper {
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
transition: top 300ms ease;
|
||||
z-index: 10002;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container[data-isopen="true"] .hideWrapper {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.pickerContainer {
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 140px;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"content";
|
||||
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
|
||||
position: relative;
|
||||
}
|
||||
.contentContainer {
|
||||
grid-area: content;
|
||||
overflow-y: scroll;
|
||||
@@ -73,7 +63,6 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
@@ -101,11 +90,10 @@
|
||||
rgba(255, 255, 255, 0) 7.5%,
|
||||
#ffffff 82.5%
|
||||
);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x7);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer .hideOnMobile {
|
||||
@@ -121,17 +109,40 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.hideWrapper {
|
||||
.pickerContainerMobile {
|
||||
display: none;
|
||||
}
|
||||
.contentWrapper {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.triggerMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.triggerDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pickerContainerDesktop {
|
||||
--header-height: 72px;
|
||||
--sticky-button-height: 140px;
|
||||
|
||||
background-color: var(--Main-Grey-White);
|
||||
display: grid;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
left: calc((var(--Spacing-x1) + var(--Spacing-x2)) * -1);
|
||||
max-width: calc(100vw - 20px);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: absolute;
|
||||
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
|
||||
width: 360px;
|
||||
max-height: calc(100dvh - 77px - var(--Spacing-x6));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pickerContainerDesktop:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -140,6 +151,7 @@
|
||||
|
||||
.footer {
|
||||
grid-template-columns: auto auto;
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.footer .hideOnDesktop,
|
||||
|
||||
@@ -1,67 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
Popover,
|
||||
} from "react-aria-components"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { guestRoomsSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import GuestsRoomsPicker from "./GuestsRoomsPicker"
|
||||
import PickerForm from "./Form"
|
||||
|
||||
import styles from "./guests-rooms-picker.module.css"
|
||||
|
||||
export default function GuestsRoomsPickerForm({
|
||||
name = "rooms",
|
||||
}: {
|
||||
name: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { setValue } = useFormContext()
|
||||
const { rooms, adultCount, childCount, setIsValidated } = useGuestsRoomsStore(
|
||||
(state) => ({
|
||||
rooms: state.rooms,
|
||||
adultCount: state.adultCount,
|
||||
childCount: state.childCount,
|
||||
setIsValidated: state.setIsValidated,
|
||||
})
|
||||
)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
function handleOnClick() {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen)
|
||||
}
|
||||
const closePicker = useCallback(() => {
|
||||
const guestRoomsValidData = guestRoomsSchema.safeParse(rooms)
|
||||
if (guestRoomsValidData.success) {
|
||||
setIsOpen(false)
|
||||
setIsValidated(false)
|
||||
setValue(name, guestRoomsValidData.data, { shouldValidate: true })
|
||||
} else {
|
||||
setIsValidated(true)
|
||||
}
|
||||
}, [rooms, name, setValue, setIsValidated, setIsOpen])
|
||||
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(evt: Event) {
|
||||
const target = evt.target as HTMLElement
|
||||
if (ref.current && target && !ref.current.contains(target)) {
|
||||
closePicker()
|
||||
export default function GuestsRoomsPickerForm() {
|
||||
const { watch } = useFormContext()
|
||||
const rooms = watch("rooms") as GuestsRoom[]
|
||||
|
||||
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
|
||||
const [isDesktop, setIsDesktop] = useState(true)
|
||||
|
||||
const htmlElement =
|
||||
typeof window !== "undefined" ? document.querySelector("body") : null
|
||||
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
|
||||
function setOverflowClip(isOpen: boolean) {
|
||||
if (htmlElement) {
|
||||
if (isOpen) {
|
||||
htmlElement.style.overflow = "visible"
|
||||
} else {
|
||||
// !important needed to override 'overflow: hidden' set by react-aria.
|
||||
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
|
||||
htmlElement.style.overflow = "clip !important"
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
}
|
||||
}, [closePicker])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(checkIsDesktop)
|
||||
}, [checkIsDesktop])
|
||||
|
||||
return isDesktop ? (
|
||||
<DialogTrigger onOpenChange={setOverflowClip}>
|
||||
<Trigger rooms={rooms} className={styles.triggerDesktop} />
|
||||
<Popover placement="bottom start" offset={36}>
|
||||
<Dialog className={styles.pickerContainerDesktop}>
|
||||
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger>
|
||||
<Trigger rooms={rooms} className={styles.triggerMobile} />
|
||||
<Modal>
|
||||
<Dialog className={styles.pickerContainerMobile}>
|
||||
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Trigger({
|
||||
rooms,
|
||||
className,
|
||||
}: {
|
||||
rooms: GuestsRoom[]
|
||||
className: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-isopen={isOpen} ref={ref}>
|
||||
<button className={styles.btn} onClick={handleOnClick} type="button">
|
||||
<Body className={styles.body} asChild>
|
||||
<span>
|
||||
<Button className={`${className} ${styles.btn}`} type="button">
|
||||
<Body>
|
||||
{rooms.map((room, i) => (
|
||||
<span key={i}>
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.rooms" },
|
||||
{ totalRooms: rooms.length }
|
||||
@@ -69,21 +88,18 @@ export default function GuestsRoomsPickerForm({
|
||||
{", "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: adultCount }
|
||||
{ totalAdults: room.adults }
|
||||
)}
|
||||
{childCount > 0
|
||||
{room.child.length > 0
|
||||
? ", " +
|
||||
intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: childCount }
|
||||
{ totalChildren: room.child.length }
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</Body>
|
||||
</button>
|
||||
<div aria-modal className={styles.hideWrapper} role="dialog">
|
||||
<GuestsRoomsPicker closePicker={closePicker} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Body>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,5 +2,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.headerLink:hover {
|
||||
color: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
.headerLink .icon * {
|
||||
fill: var(--Base-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.headerLink:hover .icon * {
|
||||
fill: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Link from "next/link"
|
||||
|
||||
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./headerLink.module.css"
|
||||
|
||||
@@ -6,16 +9,19 @@ import type { HeaderLinkProps } from "@/types/components/header/headerLink"
|
||||
|
||||
export default function HeaderLink({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
href,
|
||||
iconName,
|
||||
iconSize = 20,
|
||||
}: HeaderLinkProps) {
|
||||
const Icon = getIconByIconName(iconName)
|
||||
return (
|
||||
<Link
|
||||
color="burgundy"
|
||||
className={`${styles.headerLink} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
<Caption type="regular" color="textMediumContrast" asChild>
|
||||
<Link href={href} className={styles.headerLink}>
|
||||
{Icon ? (
|
||||
<Icon className={styles.icon} width={iconSize} height={iconSize} />
|
||||
) : null}
|
||||
{children}
|
||||
</Link>
|
||||
</Caption>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,25 +3,27 @@
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { Dialog, Modal } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { GiftIcon, SearchIcon, ServiceIcon } from "@/components/Icons"
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import useMediaQuery from "@/hooks/useMediaQuery"
|
||||
|
||||
import HeaderLink from "../../HeaderLink"
|
||||
import TopLink from "../../TopLink"
|
||||
|
||||
import styles from "./mobileMenu.module.css"
|
||||
|
||||
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
|
||||
import type { MobileMenuProps } from "@/types/components/header/mobileMenu"
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default function MobileMenu({
|
||||
children,
|
||||
languageUrls,
|
||||
topLink,
|
||||
isLoggedIn,
|
||||
}: React.PropsWithChildren<MobileMenuProps>) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
@@ -75,20 +77,13 @@ export default function MobileMenu({
|
||||
className={styles.dialog}
|
||||
aria-label={intl.formatMessage({ id: "Menu" })}
|
||||
>
|
||||
<Suspense fallback={"Loading nav"}>{children}</Suspense>
|
||||
{children}
|
||||
<footer className={styles.footer}>
|
||||
<HeaderLink href="#">
|
||||
<SearchIcon width={20} height={20} color="burgundy" />
|
||||
<HeaderLink href="#" iconName={IconName.Search}>
|
||||
{intl.formatMessage({ id: "Find booking" })}
|
||||
</HeaderLink>
|
||||
{topLink.link ? (
|
||||
<HeaderLink href={topLink.link.url}>
|
||||
<GiftIcon width={20} height={20} color="burgundy" />
|
||||
{topLink.title}
|
||||
</HeaderLink>
|
||||
) : null}
|
||||
<HeaderLink href="#">
|
||||
<ServiceIcon width={20} height={20} color="burgundy" />
|
||||
<TopLink isLoggedIn={isLoggedIn} topLink={topLink} iconSize={20} />
|
||||
<HeaderLink href="#" iconName={IconName.Service}>
|
||||
{intl.formatMessage({ id: "Customer service" })}
|
||||
</HeaderLink>
|
||||
<LanguageSwitcher type="mobileHeader" urls={languageUrls} />
|
||||
@@ -98,3 +93,20 @@ export default function MobileMenu({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileMenuSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={styles.hamburger}
|
||||
aria-label={intl.formatMessage({
|
||||
id: "Open menu",
|
||||
})}
|
||||
>
|
||||
<span className={styles.bar} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
||||
import {
|
||||
getHeader,
|
||||
getLanguageSwitcher,
|
||||
getName,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import MobileMenu from "../MobileMenu"
|
||||
|
||||
@@ -8,13 +12,18 @@ export default async function MobileMenuWrapper({
|
||||
// preloaded
|
||||
const languages = await getLanguageSwitcher()
|
||||
const header = await getHeader()
|
||||
const user = await getName()
|
||||
|
||||
if (!languages || !header) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileMenu languageUrls={languages.urls} topLink={header.data.topLink}>
|
||||
<MobileMenu
|
||||
languageUrls={languages.urls}
|
||||
topLink={header.data.topLink}
|
||||
isLoggedIn={!!user}
|
||||
>
|
||||
{children}
|
||||
</MobileMenu>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
@@ -47,7 +48,7 @@ export default function MyPagesMenu({
|
||||
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
|
||||
>
|
||||
<Avatar initials={getInitials(user.firstName, user.lastName)} />
|
||||
<Body textTransform="bold" color="textHighContrast" asChild>
|
||||
<Body textTransform="bold" color="baseTextHighContrast" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({ id: "Hi" })} {user.firstName}!
|
||||
</span>
|
||||
@@ -73,3 +74,15 @@ export default function MyPagesMenu({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MyPagesMenuSkeleton() {
|
||||
return (
|
||||
<div className={styles.myPagesMenu}>
|
||||
<MainMenuButton>
|
||||
<Avatar />
|
||||
<SkeletonShimmer width="10ch" />
|
||||
<ChevronDownSmallIcon className={`${styles.chevron}`} color="red" />
|
||||
</MainMenuButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import Avatar from "../Avatar"
|
||||
import MyPagesMenu from "../MyPagesMenu"
|
||||
import MyPagesMobileMenu from "../MyPagesMobileMenu"
|
||||
import MyPagesMenu, { MyPagesMenuSkeleton } from "../MyPagesMenu"
|
||||
import MyPagesMobileMenu, {
|
||||
MyPagesMobileMenuSkeleton,
|
||||
} from "../MyPagesMobileMenu"
|
||||
|
||||
import styles from "./myPagesMenuWrapper.module.css"
|
||||
|
||||
@@ -62,3 +64,12 @@ export default async function MyPagesMenuWrapper() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function MyPagesMenuWrapperSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<MyPagesMenuSkeleton />
|
||||
<MyPagesMobileMenuSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useEffect } from "react"
|
||||
import { Dialog, Modal } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import useMediaQuery from "@/hooks/useMediaQuery"
|
||||
import { getInitials } from "@/utils/user"
|
||||
|
||||
import Avatar from "../Avatar"
|
||||
@@ -76,3 +76,13 @@ export default function MyPagesMobileMenu({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MyPagesMobileMenuSkeleton() {
|
||||
return (
|
||||
<div className={styles.myPagesMobileMenu}>
|
||||
<MainMenuButton className={styles.button}>
|
||||
<Avatar />
|
||||
</MainMenuButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import NavigationMenuItem from "../NavigationMenuItem"
|
||||
|
||||
import styles from "./navigationMenuList.module.css"
|
||||
@@ -20,3 +24,13 @@ export default function NavigationMenuList({
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavigationMenuListSkeleton() {
|
||||
return (
|
||||
<ul className={cx(styles.navigationMenu, styles.desktop)}>
|
||||
<li className={styles.item}>
|
||||
<SkeletonShimmer width="30ch" />
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,37 +5,31 @@ import Image from "@/components/Image"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { NavigationMenuListSkeleton } from "./NavigationMenu/NavigationMenuList"
|
||||
import { MobileMenuSkeleton } from "./MobileMenu"
|
||||
import MobileMenuWrapper from "./MobileMenuWrapper"
|
||||
import MyPagesMenuWrapper from "./MyPagesMenuWrapper"
|
||||
import MyPagesMenuWrapper, {
|
||||
MyPagesMenuWrapperSkeleton,
|
||||
} from "./MyPagesMenuWrapper"
|
||||
import NavigationMenu from "./NavigationMenu"
|
||||
|
||||
import styles from "./mainMenu.module.css"
|
||||
|
||||
export default async function MainMenu() {
|
||||
const lang = getLang()
|
||||
|
||||
const intl = await getIntl()
|
||||
|
||||
export default function MainMenu() {
|
||||
return (
|
||||
<div className={styles.mainMenu}>
|
||||
<nav className={styles.nav}>
|
||||
<NextLink className={styles.logoLink} href={`/${lang}`}>
|
||||
<Image
|
||||
alt={intl.formatMessage({ id: "Back to scandichotels.com" })}
|
||||
className={styles.logo}
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype.svg"
|
||||
width={103}
|
||||
/>
|
||||
</NextLink>
|
||||
<Suspense fallback={<Logo alt="..." />}>
|
||||
<MainMenuLogo />
|
||||
</Suspense>
|
||||
<div className={styles.menus}>
|
||||
<Suspense fallback={"Loading nav"}>
|
||||
<Suspense fallback={<NavigationMenuListSkeleton />}>
|
||||
<NavigationMenu isMobile={false} />
|
||||
</Suspense>
|
||||
<Suspense fallback={"Loading profile"}>
|
||||
<Suspense fallback={<MyPagesMenuWrapperSkeleton />}>
|
||||
<MyPagesMenuWrapper />
|
||||
</Suspense>
|
||||
<Suspense fallback={"Loading menu"}>
|
||||
<Suspense fallback={<MobileMenuSkeleton />}>
|
||||
<MobileMenuWrapper>
|
||||
<NavigationMenu isMobile={true} />
|
||||
</MobileMenuWrapper>
|
||||
@@ -45,3 +39,25 @@ export default async function MainMenu() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function MainMenuLogo() {
|
||||
const intl = await getIntl()
|
||||
|
||||
return <Logo alt={intl.formatMessage({ id: "Back to scandichotels.com" })} />
|
||||
}
|
||||
|
||||
function Logo({ alt }: { alt: string }) {
|
||||
const lang = getLang()
|
||||
|
||||
return (
|
||||
<NextLink className={styles.logoLink} href={`/${lang}`}>
|
||||
<Image
|
||||
alt={alt}
|
||||
className={styles.logo}
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype.svg"
|
||||
width={103}
|
||||
/>
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.mainMenu {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
padding: var(--Spacing-x2);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
26
components/Header/TopLink/index.tsx
Normal file
26
components/Header/TopLink/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import HeaderLink from "../HeaderLink"
|
||||
|
||||
import type { TopLinkProps } from "@/types/components/header/topLink"
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default function TopLink({
|
||||
isLoggedIn,
|
||||
topLink,
|
||||
iconSize = 16,
|
||||
}: TopLinkProps) {
|
||||
const linkData = isLoggedIn ? topLink.logged_in : topLink.logged_out
|
||||
|
||||
if (!linkData?.link?.url || !linkData?.title) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderLink
|
||||
href={linkData.link.url}
|
||||
iconName={linkData.icon || IconName.Gift}
|
||||
iconSize={iconSize}
|
||||
>
|
||||
{linkData.title}
|
||||
</HeaderLink>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
||||
import {
|
||||
getHeader,
|
||||
getLanguageSwitcher,
|
||||
getName,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { GiftIcon, SearchIcon } from "@/components/Icons"
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import HeaderLink from "../HeaderLink"
|
||||
import TopLink from "../TopLink"
|
||||
|
||||
import styles from "./topMenu.module.css"
|
||||
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default async function TopMenu() {
|
||||
// cached
|
||||
const intl = await getIntl()
|
||||
// both preloaded
|
||||
const languages = await getLanguageSwitcher()
|
||||
const header = await getHeader()
|
||||
const user = await getName()
|
||||
|
||||
if (!languages || !header) {
|
||||
return null
|
||||
@@ -24,28 +31,27 @@ export default async function TopMenu() {
|
||||
return (
|
||||
<div className={styles.topMenu}>
|
||||
<div className={styles.content}>
|
||||
{header.data.topLink.link ? (
|
||||
<Caption type="regular" color="textMediumContrast" asChild>
|
||||
<Link
|
||||
href={header.data.topLink.link.url}
|
||||
color="peach80"
|
||||
variant="icon"
|
||||
>
|
||||
<GiftIcon width={20} height={20} />
|
||||
{header.data.topLink.title}
|
||||
</Link>
|
||||
</Caption>
|
||||
) : null}
|
||||
<TopLink isLoggedIn={!!user} topLink={header.data.topLink} />
|
||||
<div className={styles.options}>
|
||||
<LanguageSwitcher type="desktopHeader" urls={languages.urls} />
|
||||
|
||||
<Caption type="regular" color="textMediumContrast" asChild>
|
||||
<Link href="#" color="peach80" variant="icon">
|
||||
<SearchIcon width={20} height={20} />
|
||||
<HeaderLink href="#" iconName={IconName.Search}>
|
||||
{intl.formatMessage({ id: "Find booking" })}
|
||||
</Link>
|
||||
</HeaderLink>
|
||||
</Caption>
|
||||
<HeaderLink href="#"></HeaderLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TopMenuSkeleton() {
|
||||
return (
|
||||
<div className={styles.topMenu}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.options}>
|
||||
<SkeletonShimmer width="25ch" height="1.2em" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
||||
import {
|
||||
getHeader,
|
||||
getLanguageSwitcher,
|
||||
getName,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import MainMenu from "./MainMenu"
|
||||
import TopMenu from "./TopMenu"
|
||||
import TopMenu, { TopMenuSkeleton } from "./TopMenu"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
export default function Header() {
|
||||
export default async function Header() {
|
||||
void getHeader()
|
||||
void getLanguageSwitcher()
|
||||
void getName()
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Suspense fallback="Loading top menu">
|
||||
<Suspense fallback={<TopMenuSkeleton />}>
|
||||
<TopMenu />
|
||||
</Suspense>
|
||||
<Suspense fallback="Loading main menu">
|
||||
<MainMenu />
|
||||
</Suspense>
|
||||
<MainMenu />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,3 +46,9 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.googleMaps {
|
||||
text-decoration: none;
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
color: var(--Base-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
@@ -32,9 +32,13 @@ export default function Contact({ hotel }: ContactProps) {
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Driving directions" })}
|
||||
</span>
|
||||
<Link href="#" color="peach80">
|
||||
<a
|
||||
href={`https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`}
|
||||
className={styles.googleMaps}
|
||||
target="_blank"
|
||||
>
|
||||
Google Maps
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Label as AriaLabel } from "react-aria-components"
|
||||
@@ -45,6 +44,8 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
const maxRetries = 40
|
||||
const retryInterval = 2000
|
||||
|
||||
export const formId = "submit-booking"
|
||||
|
||||
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
||||
}
|
||||
@@ -59,10 +60,13 @@ export default function Payment({
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { userData, roomData } = useEnterDetailsStore((state) => ({
|
||||
userData: state.userData,
|
||||
roomData: state.roomData,
|
||||
}))
|
||||
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
userData: state.userData,
|
||||
roomData: state.roomData,
|
||||
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
|
||||
})
|
||||
)
|
||||
|
||||
const {
|
||||
firstName,
|
||||
@@ -72,6 +76,7 @@ export default function Payment({
|
||||
countryCode,
|
||||
breakfast,
|
||||
bedType,
|
||||
membershipNo,
|
||||
} = userData
|
||||
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
||||
|
||||
@@ -119,6 +124,16 @@ export default function Payment({
|
||||
}
|
||||
}, [bookingStatus, router])
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmittingDisabled(
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
)
|
||||
}, [
|
||||
methods.formState.isValid,
|
||||
methods.formState.isSubmitting,
|
||||
setIsSubmittingDisabled,
|
||||
])
|
||||
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
const allQueryParams =
|
||||
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
|
||||
@@ -132,17 +147,6 @@ export default function Payment({
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
let phone: string
|
||||
let phoneCountryCodePrefix: string | null = null
|
||||
|
||||
if (isValidPhoneNumber(phoneNumber)) {
|
||||
const parsedPhone = parsePhoneNumber(phoneNumber)
|
||||
phone = parsedPhone.nationalNumber
|
||||
phoneCountryCodePrefix = parsedPhone.countryCallingCode
|
||||
} else {
|
||||
phone = phoneNumber
|
||||
}
|
||||
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel,
|
||||
checkInDate: fromDate,
|
||||
@@ -160,9 +164,9 @@ export default function Payment({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneCountryCodePrefix,
|
||||
phoneNumber: phone,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNumber: membershipNo,
|
||||
},
|
||||
packages: {
|
||||
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
||||
@@ -171,7 +175,8 @@ export default function Payment({
|
||||
petFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
||||
accessibility:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
|
||||
false,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
roomPrice,
|
||||
@@ -209,6 +214,7 @@ export default function Payment({
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{mustBeGuaranteed ? (
|
||||
<section className={styles.section}>
|
||||
@@ -309,15 +315,16 @@ export default function Payment({
|
||||
</Caption>
|
||||
</AriaLabel>
|
||||
</section>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
align-self: flex-start;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.paymentContainer .link {
|
||||
@@ -31,3 +31,10 @@
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.submitButton {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useIntl } from "react-intl"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
@@ -62,6 +61,9 @@ export default function SectionAccordion({
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
}
|
||||
|
||||
const textColor =
|
||||
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
|
||||
return (
|
||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
||||
<div className={styles.iconWrapper}>
|
||||
@@ -72,37 +74,25 @@ export default function SectionAccordion({
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<header className={styles.headerContainer}>
|
||||
<div>
|
||||
<header>
|
||||
<button onClick={onModify} className={styles.modifyButton}>
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color="uiTextHighContrast"
|
||||
color={textColor}
|
||||
>
|
||||
<h2>{header}</h2>
|
||||
</Footnote>
|
||||
<Subtitle
|
||||
type="two"
|
||||
className={styles.selection}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||
{title}
|
||||
</Subtitle>
|
||||
</div>
|
||||
{isComplete && !isOpen && (
|
||||
<Button
|
||||
onClick={onModify}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "Modify" })}{" "}
|
||||
<ChevronDownIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isComplete && !isOpen && (
|
||||
<ChevronDownIcon className={styles.button} color="burgundy" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,25 +2,33 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x3);
|
||||
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.wrapper:not(:last-child)::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x5);
|
||||
height: 100%;
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.wrapper:last-child .main {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modifyButton {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "selection button";
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.button {
|
||||
grid-area: button;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
@@ -31,21 +39,14 @@
|
||||
grid-template-rows: 2em 0fr;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selection {
|
||||
font-weight: 450;
|
||||
font-size: var(--typography-Title-4-fontSize);
|
||||
grid-area: selection;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
top: var(--Spacing-x1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.circle {
|
||||
@@ -78,3 +79,23 @@
|
||||
.content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.wrapper:not(:last-child)::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x7);
|
||||
height: 100%;
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import ChevronRight from "@/components/Icons/ChevronRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
@@ -28,6 +29,7 @@ export default function ToggleSidePeek({
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
<ChevronRight height="14" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,26 +33,25 @@ export default function SelectedRoom({
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.headerContainer}>
|
||||
<div>
|
||||
<Footnote
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
|
||||
</Footnote>
|
||||
<Subtitle
|
||||
type="two"
|
||||
className={styles.selection}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{room.roomType}{" "}
|
||||
<span className={styles.rate}>{`(${rateDescription})`}</span>
|
||||
</Subtitle>
|
||||
</div>
|
||||
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
|
||||
</Footnote>
|
||||
<Subtitle
|
||||
type="two"
|
||||
className={styles.description}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{room.roomType}{" "}
|
||||
<span className={styles.rate}>{rateDescription}</span>
|
||||
</Subtitle>
|
||||
<Link
|
||||
className={styles.button}
|
||||
color="burgundy"
|
||||
href={selectRateUrl}
|
||||
size="small"
|
||||
|
||||
@@ -2,43 +2,41 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x3);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.wrapper::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x5);
|
||||
height: 100%;
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
grid-template-rows: 2em 0fr;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
grid-template-areas:
|
||||
"title button"
|
||||
"description button";
|
||||
}
|
||||
|
||||
.selection {
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-weight: 450;
|
||||
font-size: var(--typography-Title-4-fontSize);
|
||||
grid-area: description;
|
||||
}
|
||||
|
||||
.button {
|
||||
grid-area: button;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
top: var(--Spacing-x1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.circle {
|
||||
@@ -57,9 +55,42 @@
|
||||
|
||||
.rate {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.rate {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.rate::before {
|
||||
content: "(";
|
||||
}
|
||||
.rate::after {
|
||||
content: ")";
|
||||
}
|
||||
|
||||
.wrapper:not(:last-child)::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x7);
|
||||
height: 100%;
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr 7.5em;
|
||||
|
||||
transition: 0.5s ease-in-out;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
.bottomSheet {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x5)
|
||||
var(--Spacing-x3);
|
||||
align-items: flex-start;
|
||||
transition: 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
display: block;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: start;
|
||||
transition: padding 0.5s ease-in-out;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] {
|
||||
grid-template-rows: 1fr 7.5em;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .bottomSheet {
|
||||
grid-template-columns: 0fr auto;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .priceDetailsButton {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="false"] .priceDetailsButton {
|
||||
animation: fadeIn 0.8s ease-in;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.content,
|
||||
.priceDetailsButton {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { PropsWithChildren } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import { formId } from "../../Payment"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
useEnterDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
isSubmittingDisabled: state.isSubmittingDisabled,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<button
|
||||
data-open={isSummaryOpen}
|
||||
onClick={toggleSummaryOpen}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Caption>{intl.formatMessage({ id: "Total price" })}:</Caption>
|
||||
<Subtitle>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.price),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
{intl.formatMessage({ id: "See details" })}
|
||||
</Caption>
|
||||
</button>
|
||||
<Button
|
||||
intent="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isSubmittingDisabled}
|
||||
form={formId}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ChevronDown } from "react-feather"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -21,8 +23,16 @@ import { RoomsData } from "@/types/components/hotelReservation/enterDetails/book
|
||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
function parsePrice(price: string | undefined) {
|
||||
return price ? parseInt(price) : 0
|
||||
function storeSelector(state: EnterDetailsState) {
|
||||
return {
|
||||
fromDate: state.roomData.fromDate,
|
||||
toDate: state.roomData.toDate,
|
||||
bedType: state.userData.bedType,
|
||||
breakfast: state.userData.breakfast,
|
||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
||||
setTotalPrice: state.setTotalPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Summary({
|
||||
@@ -36,20 +46,17 @@ export default function Summary({
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
||||
>()
|
||||
const [totalPrice, setTotalPrice] = useState({
|
||||
local: parsePrice(room.localPrice.price),
|
||||
euro: parsePrice(room.euroPrice.price),
|
||||
})
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
fromDate: state.roomData.fromDate,
|
||||
toDate: state.roomData.toDate,
|
||||
bedType: state.userData.bedType,
|
||||
breakfast: state.userData.breakfast,
|
||||
})
|
||||
)
|
||||
const {
|
||||
fromDate,
|
||||
toDate,
|
||||
bedType,
|
||||
breakfast,
|
||||
setTotalPrice,
|
||||
totalPrice,
|
||||
toggleSummaryOpen,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
@@ -63,51 +70,85 @@ export default function Summary({
|
||||
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.price + additionalPackageCost.euro
|
||||
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
setChosenBreakfast(breakfast)
|
||||
|
||||
if (breakfast) {
|
||||
setChosenBreakfast(breakfast)
|
||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
setTotalPrice({
|
||||
local: parsePrice(room.localPrice.price),
|
||||
euro: parsePrice(room.euroPrice.price),
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local:
|
||||
parsePrice(room.localPrice.price) +
|
||||
parsePrice(breakfast.localPrice.totalPrice),
|
||||
euro:
|
||||
parsePrice(room.euroPrice.price) +
|
||||
parsePrice(breakfast.requestedPrice.totalPrice),
|
||||
})
|
||||
}
|
||||
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice),
|
||||
currency: room.euroPrice.currency,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal,
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: roomsPriceEuro,
|
||||
currency: room.euroPrice.currency,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [bedType, breakfast, room.localPrice, room.euroPrice])
|
||||
}, [
|
||||
bedType,
|
||||
breakfast,
|
||||
roomsPriceLocal,
|
||||
room.localPrice.currency,
|
||||
room.euroPrice.currency,
|
||||
roomsPriceEuro,
|
||||
setTotalPrice,
|
||||
])
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header>
|
||||
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({ id: "Summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={toggleSummaryOpen}
|
||||
>
|
||||
<ChevronDown height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">{room.roomType}</Body>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Caption color={color}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(
|
||||
parseInt(room.localPrice.price ?? "0")
|
||||
),
|
||||
amount: intl.formatNumber(room.localPrice.price),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
@@ -134,17 +175,37 @@ export default function Summary({
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Link>
|
||||
</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="textHighContrast">{chosenBed.description}</Body>
|
||||
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
@@ -156,10 +217,10 @@ export default function Summary({
|
||||
{chosenBreakfast ? (
|
||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
@@ -168,10 +229,10 @@ export default function Summary({
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
@@ -203,8 +264,8 @@ export default function Summary({
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local),
|
||||
currency: room.localPrice.currency,
|
||||
amount: intl.formatNumber(totalPrice.local.price),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
@@ -213,14 +274,14 @@ export default function Summary({
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro),
|
||||
currency: room.euroPrice.currency,
|
||||
amount: intl.formatNumber(totalPrice.euro.price),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -7,11 +7,28 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -38,3 +55,21 @@
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
34
components/HotelReservation/HotelCard/HotelLogo/index.tsx
Normal file
34
components/HotelReservation/HotelCard/HotelLogo/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DowntownCamperIcon,
|
||||
GrandHotelOsloLogoIcon,
|
||||
HaymarketIcon,
|
||||
HotelNorgeIcon,
|
||||
MarskiLogoIcon,
|
||||
ScandicGoLogoIcon,
|
||||
ScandicLogoIcon,
|
||||
} from "@/components/Icons"
|
||||
|
||||
import type { HotelLogoProps } from "@/types/components/hotelReservation/selectHotel/hotelLogoProps"
|
||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||
import { SignatureHotelEnum } from "@/types/enums/signatureHotel"
|
||||
|
||||
export default function HotelLogo({ hotelId, hotelType }: HotelLogoProps) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return <ScandicGoLogoIcon />
|
||||
}
|
||||
|
||||
switch (hotelId) {
|
||||
case SignatureHotelEnum.Haymarket:
|
||||
return <HaymarketIcon />
|
||||
case SignatureHotelEnum.HotelNorge:
|
||||
return <HotelNorgeIcon />
|
||||
case SignatureHotelEnum.DowntownCamper:
|
||||
return <DowntownCamperIcon />
|
||||
case SignatureHotelEnum.GrandHotelOslo:
|
||||
return <GrandHotelOsloLogoIcon />
|
||||
case SignatureHotelEnum.Marski:
|
||||
return <MarskiLogoIcon />
|
||||
default:
|
||||
return <ScandicLogoIcon color="red" />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "../hotelPriceList.module.css"
|
||||
|
||||
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||
|
||||
export default function HotelPriceCard({
|
||||
currency,
|
||||
memberAmount,
|
||||
regularAmount,
|
||||
}: PriceCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<dl className={styles.priceCard}>
|
||||
{memberAmount && (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color="red">
|
||||
{intl.formatMessage({ id: "Member price" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
||||
>
|
||||
{intl.formatMessage({ id: "From" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<div className={styles.price}>
|
||||
<Subtitle
|
||||
type="two"
|
||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
||||
>
|
||||
{memberAmount ? memberAmount : regularAmount}
|
||||
</Subtitle>
|
||||
<Body
|
||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
||||
textTransform="bold"
|
||||
>
|
||||
{currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
{/* TODO add correct local price when API change */}
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color={"disabled"}>
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<Caption color="disabled"> - EUR</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.priceCard {
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.noRooms {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: var(--Spacing-x-quarter) 0;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.perNight {
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
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 }: HotelPriceListProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<>
|
||||
{price ? (
|
||||
<>
|
||||
<HotelPriceCard
|
||||
currency={price?.currency}
|
||||
regularAmount={price?.regularAmount}
|
||||
/>
|
||||
<HotelPriceCard
|
||||
currency={price?.currency}
|
||||
memberAmount={price?.memberAmount}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.priceCard}>
|
||||
<div className={styles.noRooms}>
|
||||
<ErrorCircleIcon color="red" />
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "There are no rooms available that match your request",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"image header"
|
||||
"hotel hotel"
|
||||
"prices prices";
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.active {
|
||||
border: 1px solid var(--Base-Border-Hover);
|
||||
}
|
||||
|
||||
.card.active {
|
||||
@@ -17,14 +17,9 @@
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
grid-area: image;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
display: none;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageContainer img {
|
||||
@@ -32,19 +27,41 @@
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
grid-area: header;
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.hotel {
|
||||
.hotelContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: hotel;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotelDescription {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
margin-top: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.addressContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.address {
|
||||
display: none;
|
||||
font-style: normal;
|
||||
}
|
||||
.facilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x1);
|
||||
margin-top: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.facilitiesItem {
|
||||
@@ -56,66 +73,76 @@
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-area: prices;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.public,
|
||||
.member {
|
||||
max-width: fit-content;
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
.detailsButton {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.specialAlerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.card.pageListing {
|
||||
grid-template-areas:
|
||||
"image header"
|
||||
"image hotel"
|
||||
"image prices";
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pageListing .imageContainer {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
width: 518px;
|
||||
.pageListing .hotelDescription {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pageListing .tripAdvisor {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 7px;
|
||||
top: 7px;
|
||||
.pageListing .imageContainer {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 314px;
|
||||
}
|
||||
|
||||
.pageListing .hotelInformation {
|
||||
padding-top: var(--Spacing-x2);
|
||||
width: min(422px, 100%);
|
||||
padding-right: var(--Spacing-x2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pageListing .hotel {
|
||||
.pageListing .facilities {
|
||||
margin: var(--Spacing-x1) 0;
|
||||
}
|
||||
|
||||
.pageListing .hotelContent {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
padding-left: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.pageListing .titleContainer {
|
||||
margin-bottom: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.pageListing .prices {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: var(--Spacing-x2);
|
||||
padding-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.pageListing .detailsButton {
|
||||
border-bottom: none;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.pageListing .button {
|
||||
width: 160px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageListing .addressMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pageListing .address {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"use client"
|
||||
import { useParams } from "next/dist/client/components/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
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 Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
import ImageGallery from "../SelectRate/ImageGallery"
|
||||
import TripAdvisorChip from "../TripAdvisorChip"
|
||||
import HotelLogo from "./HotelLogo"
|
||||
import HotelPriceList from "./HotelPriceList"
|
||||
import { hotelCardVariants } from "./variants"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
@@ -26,6 +32,8 @@ export default function HotelCard({
|
||||
state = "default",
|
||||
onHotelCardHover,
|
||||
}: HotelCardProps) {
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
const intl = useIntl()
|
||||
|
||||
const { hotelData } = hotel
|
||||
@@ -55,93 +63,104 @@ export default function HotelCard({
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<section className={styles.imageContainer}>
|
||||
{hotelData.gallery && (
|
||||
<div>
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery
|
||||
title={hotelData.name}
|
||||
images={[
|
||||
hotelData.hotelContent.images,
|
||||
...hotelData.gallery.heroImages,
|
||||
]}
|
||||
images={hotelData.galleryImages}
|
||||
fill
|
||||
/>
|
||||
)}
|
||||
<div className={styles.tripAdvisor}>
|
||||
<Chip intent="primary" className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="white" />
|
||||
{hotelData.ratings?.tripAdvisor.rating}
|
||||
</Chip>
|
||||
{hotelData.ratings?.tripAdvisor && (
|
||||
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.hotelInformation}>
|
||||
<ScandicLogoIcon color="red" />
|
||||
<Title as="h4" textTransform="capitalize">
|
||||
{hotelData.name}
|
||||
</Title>
|
||||
<Footnote color="uiTextMediumContrast">
|
||||
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
|
||||
</Footnote>
|
||||
<Footnote color="uiTextMediumContrast">
|
||||
{`${hotelData.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
</Footnote>
|
||||
</section>
|
||||
<section className={styles.hotel}>
|
||||
<div className={styles.facilities}>
|
||||
{amenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && <IconComponent color="grey80" />}
|
||||
<Caption color="textMediumContrast">{facility.name}</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelContent}>
|
||||
<section className={styles.hotelInformation}>
|
||||
<div className={styles.titleContainer}>
|
||||
<HotelLogo
|
||||
hotelId={hotel.hotelData.operaId}
|
||||
hotelType={hotel.hotelData.hotelType}
|
||||
/>
|
||||
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
|
||||
{hotelData.name}
|
||||
</Subtitle>
|
||||
<div className={styles.addressContainer}>
|
||||
<address className={styles.address}>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{hotelData.address.streetAddress}, {hotelData.address.city}
|
||||
</Caption>
|
||||
</address>
|
||||
<Link
|
||||
className={styles.addressMobile}
|
||||
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
|
||||
keepSearchParams
|
||||
>
|
||||
<Caption color="baseTextMediumContrast" type="underline">
|
||||
{hotelData.address.streetAddress}, {hotelData.address.city}
|
||||
</Caption>
|
||||
</Link>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ReadMore
|
||||
label={intl.formatMessage({ id: "See hotel details" })}
|
||||
hotelId={hotelData.operaId}
|
||||
hotel={hotelData}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.prices}>
|
||||
<div>
|
||||
<Chip intent="primary" className={styles.public}>
|
||||
<PriceTagIcon color="white" width={15} height={15} />
|
||||
{intl.formatMessage({ id: "Public price from" })}
|
||||
</Chip>
|
||||
<Caption color="textMediumContrast">
|
||||
{price?.regularAmount} {price?.currency} /
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</Caption>
|
||||
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
|
||||
</div>
|
||||
<div>
|
||||
<Chip intent="primary" className={styles.member}>
|
||||
<PriceTagIcon color="white" width={15} height={15} />
|
||||
{intl.formatMessage({ id: "Member price from" })}
|
||||
</Chip>
|
||||
<Caption color="textMediumContrast">
|
||||
{price?.memberAmount} {price?.currency} /
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
</Caption>
|
||||
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
intent="tertiary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
{/* TODO: Localize link and also use correct search params */}
|
||||
<Link
|
||||
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: hotelData.location.distanceToCentre }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Body className={styles.hotelDescription}>
|
||||
{hotelData.hotelContent.texts.descriptions.short}
|
||||
</Body>
|
||||
<div className={styles.facilities}>
|
||||
{amenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && <IconComponent color="grey80" />}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{facility.name}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ReadMore
|
||||
label={intl.formatMessage({ id: "See hotel details" })}
|
||||
hotelId={hotelData.operaId}
|
||||
hotel={hotelData}
|
||||
showCTA={true}
|
||||
/>
|
||||
{hotelData.specialAlerts.length > 0 && (
|
||||
<div className={styles.specialAlerts}>
|
||||
{hotelData.specialAlerts.map((alert) => (
|
||||
<Alert key={alert.id} type={alert.type} text={alert.text} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className={styles.prices}>
|
||||
<HotelPriceList price={price} />
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
intent="primary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
{/* TODO: Localize link and also use correct search params */}
|
||||
<Link
|
||||
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.name {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
@@ -52,7 +58,7 @@
|
||||
.facilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: 0 var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.facilitiesItem {
|
||||
@@ -67,7 +73,6 @@
|
||||
background: var(--Base-Surface-Secondary-light-Normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.perNight {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
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"
|
||||
@@ -17,13 +22,15 @@ import styles from "./hotelCardDialog.module.css"
|
||||
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelCardDialog({
|
||||
pin,
|
||||
data,
|
||||
isOpen,
|
||||
handleClose,
|
||||
}: HotelCardDialogProps) {
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
const intl = useIntl()
|
||||
|
||||
if (!pin) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -35,7 +42,7 @@ export default function HotelCardDialog({
|
||||
amenities,
|
||||
images,
|
||||
ratings,
|
||||
} = pin
|
||||
} = data
|
||||
|
||||
const firstImage = images[0]?.imageSizes?.small
|
||||
const altText = images[0]?.metaData?.altText
|
||||
@@ -52,20 +59,24 @@ export default function HotelCardDialog({
|
||||
<div className={styles.imageContainer}>
|
||||
<Image src={firstImage} alt={altText} fill />
|
||||
<div className={styles.tripAdvisor}>
|
||||
<Chip intent="primary" className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="white" />
|
||||
<Chip intent="secondary" className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="burgundy" />
|
||||
{ratings}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<Body textTransform="bold">{name}</Body>
|
||||
<div className={styles.name}>
|
||||
<Body textTransform="bold">{name}</Body>
|
||||
</div>
|
||||
<div className={styles.facilities}>
|
||||
{amenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && <IconComponent color="grey80" />}
|
||||
{IconComponent && (
|
||||
<IconComponent width={16} height={16} color="grey80" />
|
||||
)}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{facility.name}
|
||||
</Caption>
|
||||
@@ -90,8 +101,15 @@ export default function HotelCardDialog({
|
||||
</Subtitle>
|
||||
)}
|
||||
</div>
|
||||
<Button size="small" theme="base" className={styles.button}>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
|
||||
<Button asChild theme="base" size="small" className={styles.button}>
|
||||
<Link
|
||||
href={`${selectRate[lang]}?hotel=${data.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
88
components/HotelReservation/HotelCardDialogListing/index.tsx
Normal file
88
components/HotelReservation/HotelCardDialogListing/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import HotelCardDialog from "../HotelCardDialog"
|
||||
import { getHotelPins } from "./utils"
|
||||
|
||||
import type { HotelCardDialogListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelCardDialogListing({
|
||||
hotels,
|
||||
activeCard,
|
||||
onActiveCardChange,
|
||||
}: HotelCardDialogListingProps) {
|
||||
const hotelsPinData = getHotelPins(hotels)
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const cardName = entry.target.getAttribute("data-name")
|
||||
if (cardName) {
|
||||
onActiveCardChange(cardName)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[onActiveCardChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
threshold: 0.5,
|
||||
})
|
||||
|
||||
const elements = document.querySelectorAll("[data-name]")
|
||||
elements.forEach((el) => observerRef.current?.observe(el))
|
||||
|
||||
return () => {
|
||||
elements.forEach((el) => observerRef.current?.unobserve(el))
|
||||
observerRef.current?.disconnect()
|
||||
}
|
||||
}, [handleIntersection])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCardRef.current) {
|
||||
// Temporarily disconnect the observer
|
||||
observerRef.current?.disconnect()
|
||||
|
||||
activeCardRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
})
|
||||
|
||||
// Reconnect the observer after scrolling
|
||||
const elements = document.querySelectorAll("[data-name]")
|
||||
setTimeout(() => {
|
||||
elements.forEach((el) => observerRef.current?.observe(el))
|
||||
}, 500)
|
||||
}
|
||||
}, [activeCard])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotelsPinData?.length &&
|
||||
hotelsPinData.map((data) => {
|
||||
const isActive = data.name === activeCard
|
||||
return (
|
||||
<div
|
||||
key={data.name}
|
||||
ref={isActive ? activeCardRef : null}
|
||||
data-name={data.name}
|
||||
>
|
||||
<HotelCardDialog
|
||||
data={data}
|
||||
isOpen={!!activeCard}
|
||||
handleClose={() => onActiveCardChange(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
components/HotelReservation/HotelCardDialogListing/utils.ts
Normal file
22
components/HotelReservation/HotelCardDialogListing/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
return hotels.map((hotel) => ({
|
||||
coordinates: {
|
||||
lat: hotel.hotelData.location.latitude,
|
||||
lng: hotel.hotelData.location.longitude,
|
||||
},
|
||||
name: hotel.hotelData.name,
|
||||
publicPrice: hotel.price?.regularAmount ?? null,
|
||||
memberPrice: hotel.price?.memberAmount ?? null,
|
||||
currency: hotel.price?.currency || null,
|
||||
images: [
|
||||
hotel.hotelData.hotelContent.images,
|
||||
...(hotel.hotelData.gallery?.heroImages ?? []),
|
||||
],
|
||||
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
|
||||
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
|
||||
operaId: hotel.hotelData.operaId,
|
||||
}))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
.hotelCards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@@ -71,19 +71,17 @@ export default function HotelCardListing({
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
{hotels?.length ? (
|
||||
hotels.map((hotel) => (
|
||||
<HotelCard
|
||||
key={hotel.hotelData.operaId}
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Title>No hotels found</Title>
|
||||
)}
|
||||
{hotels?.length
|
||||
? hotels.map((hotel) => (
|
||||
<HotelCard
|
||||
key={hotel.hotelData.operaId}
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function HotelSelectionHeader({
|
||||
</div>
|
||||
<Caption color="textMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance to city centre" },
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: hotel.location.distanceToCentre }
|
||||
)}
|
||||
</Caption>
|
||||
@@ -41,7 +41,7 @@ export default function HotelSelectionHeader({
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<Body color="textHighContrast">
|
||||
<Body color="baseTextHighContrast">
|
||||
{hotel.hotelContent.texts.descriptions.short}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@ import styles from "./readMore.module.css"
|
||||
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
|
||||
export default function ReadMore({ label, hotelId }: ReadMoreProps) {
|
||||
export default function ReadMore({ label, hotelId, showCTA }: ReadMoreProps) {
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
return (
|
||||
<Button
|
||||
onPress={() => {
|
||||
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })
|
||||
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId, showCTA })
|
||||
}}
|
||||
intent="text"
|
||||
theme="base"
|
||||
@@ -23,7 +23,11 @@ export default function ReadMore({ label, hotelId }: ReadMoreProps) {
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" height={20} width={20} />
|
||||
<ChevronRightIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,30 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.facilities:first-of-type {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.facilities ul {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(min-content, max-content));
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
margin-bottom: var(--Spacing-x-one-and-half);
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,29 @@
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./hotelFilter.module.css"
|
||||
|
||||
import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const { watch, handleSubmit, getValues, register } = useForm<
|
||||
Record<string, boolean | undefined>
|
||||
>({
|
||||
const methods = useForm<Record<string, boolean | undefined>>({
|
||||
defaultValues: searchParams
|
||||
?.get("filters")
|
||||
?.split(",")
|
||||
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
|
||||
})
|
||||
const { watch, handleSubmit, getValues, register } = methods
|
||||
|
||||
const submitFilter = useCallback(() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
@@ -50,43 +53,42 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||
return () => subscription.unsubscribe()
|
||||
}, [handleSubmit, watch, submitFilter])
|
||||
|
||||
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.container}>
|
||||
<div className={styles.facilities}>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(submitFilter)}>
|
||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
||||
<ul>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`checkbox-${filter.id.toString()}`}
|
||||
{...register(filter.id.toString())}
|
||||
/>
|
||||
<label htmlFor={`checkbox-${filter.id.toString()}`}>
|
||||
{filter.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
||||
</Subtitle>
|
||||
<ul>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`checkbox-${filter.id.toString()}`}
|
||||
{...register(filter.id.toString())}
|
||||
/>
|
||||
<label htmlFor={`checkbox-${filter.id.toString()}`}>
|
||||
{filter.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
||||
</Subtitle>
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.container {
|
||||
width: 339px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user