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_STATIC_MAP_ID=""
|
||||||
GOOGLE_DYNAMIC_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"
|
GOOGLE_DYNAMIC_MAP_ID="test"
|
||||||
HIDE_FOR_NEXT_RELEASE="true"
|
HIDE_FOR_NEXT_RELEASE="true"
|
||||||
SALESFORCE_PREFERENCE_BASE_URL="test"
|
SALESFORCE_PREFERENCE_BASE_URL="test"
|
||||||
|
USE_NEW_REWARDS_ENDPOINT="true"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <LoadingSpinner />
|
return <LoadingSpinner fullPage />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { headers } from "next/headers"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { isSignupPage } from "@/constants/routes/signup"
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import HotelPage from "@/components/ContentType/HotelPage"
|
import HotelPage from "@/components/ContentType/HotelPage"
|
||||||
@@ -22,17 +24,33 @@ export default function ContentTypePage({
|
|||||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
||||||
setLang(params.lang)
|
setLang(params.lang)
|
||||||
|
|
||||||
|
const pathname = headers().get("x-pathname") || ""
|
||||||
|
|
||||||
switch (params.contentType) {
|
switch (params.contentType) {
|
||||||
case "collection-page":
|
case "collection-page":
|
||||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||||
return notFound()
|
return notFound()
|
||||||
}
|
}
|
||||||
return <CollectionPage />
|
return <CollectionPage />
|
||||||
case "content-page":
|
case "content-page": {
|
||||||
|
const isSignupRoute = isSignupPage(pathname)
|
||||||
|
|
||||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
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 />
|
return <ContentPage />
|
||||||
|
}
|
||||||
case "loyalty-page":
|
case "loyalty-page":
|
||||||
return <LoyaltyPage />
|
return <LoyaltyPage />
|
||||||
case "hotel-page":
|
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 {
|
import {
|
||||||
|
getPackages,
|
||||||
getProfileSafely,
|
getProfileSafely,
|
||||||
getSelectedRoomAvailability,
|
getSelectedRoomAvailability,
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||||
|
import { SummaryBottomSheet } from "@/components/HotelReservation/EnterDetails/Summary/BottomSheet"
|
||||||
import {
|
import {
|
||||||
generateChildrenString,
|
generateChildrenString,
|
||||||
getQueryParamsForEnterDetails,
|
getQueryParamsForEnterDetails,
|
||||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
|
|
||||||
|
import styles from "./page.module.css"
|
||||||
|
|
||||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import { LangParams, PageArgs, SearchParams } from "@/types/params"
|
import { LangParams, PageArgs, SearchParams } from "@/types/params"
|
||||||
|
|
||||||
export default async function SummaryPage({
|
export default async function SummaryPage({
|
||||||
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
|
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
|
||||||
const selectRoomParams = new URLSearchParams(searchParams)
|
const selectRoomParams = new URLSearchParams(searchParams)
|
||||||
const { hotel, rooms, fromDate, toDate } =
|
const { hotel, rooms, fromDate, toDate } =
|
||||||
getQueryParamsForEnterDetails(selectRoomParams)
|
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({
|
const availability = await getSelectedRoomAvailability({
|
||||||
hotelId: hotel,
|
hotelId: hotel,
|
||||||
@@ -29,49 +43,88 @@ export default async function SummaryPage({
|
|||||||
roomStayEndDate: toDate,
|
roomStayEndDate: toDate,
|
||||||
rateCode,
|
rateCode,
|
||||||
roomTypeCode,
|
roomTypeCode,
|
||||||
|
packageCodes,
|
||||||
})
|
})
|
||||||
const user = await getProfileSafely()
|
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)
|
console.error("No hotel or availability data", availability)
|
||||||
// TODO: handle this case
|
// TODO: handle this case
|
||||||
return null
|
redirect(selectRate[params.lang])
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices =
|
const prices =
|
||||||
user && availability.memberRate
|
user && availability.memberRate
|
||||||
? {
|
? {
|
||||||
local: {
|
local: {
|
||||||
price: availability.memberRate?.localPrice.pricePerStay,
|
price: availability.memberRate.localPrice.pricePerStay,
|
||||||
currency: availability.memberRate?.localPrice.currency,
|
currency: availability.memberRate.localPrice.currency,
|
||||||
},
|
},
|
||||||
euro: {
|
euro: {
|
||||||
price: availability.memberRate?.requestedPrice?.pricePerStay,
|
price: availability.memberRate.requestedPrice.pricePerStay,
|
||||||
currency: availability.memberRate?.requestedPrice?.currency,
|
currency: availability.memberRate.requestedPrice.currency,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
local: {
|
local: {
|
||||||
price: availability.publicRate?.localPrice.pricePerStay,
|
price: availability.publicRate.localPrice.pricePerStay,
|
||||||
currency: availability.publicRate?.localPrice.currency,
|
currency: availability.publicRate.localPrice.currency,
|
||||||
},
|
},
|
||||||
euro: {
|
euro: {
|
||||||
price: availability.publicRate?.requestedPrice?.pricePerStay,
|
price: availability.publicRate.requestedPrice.pricePerStay,
|
||||||
currency: availability.publicRate?.requestedPrice?.currency,
|
currency: availability.publicRate.requestedPrice.currency,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Summary
|
<>
|
||||||
showMemberPrice={!!(user && availability.memberRate)}
|
<div className={styles.mobileSummary}>
|
||||||
room={{
|
<SummaryBottomSheet>
|
||||||
roomType: availability.selectedRoom.roomType,
|
<div className={styles.summary}>
|
||||||
localPrice: prices.local,
|
<Summary
|
||||||
euroPrice: prices.euro,
|
showMemberPrice={!!(user && availability.memberRate)}
|
||||||
adults,
|
room={{
|
||||||
children,
|
roomType: availability.selectedRoom.roomType,
|
||||||
cancellationText: availability.cancellationText,
|
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);
|
background-color: var(--Scandic-Brand-Warm-White);
|
||||||
}
|
}
|
||||||
|
|
||||||
.enter-details-layout__content {
|
.enter-details-layout__container {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x3) var(--Spacing-x9);
|
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 */
|
/* 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 {
|
.enter-details-layout__summaryContainer {
|
||||||
grid-column: 2 / 3;
|
position: sticky;
|
||||||
grid-row: 1/-1;
|
bottom: 0;
|
||||||
}
|
left: 0;
|
||||||
|
right: 0;
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.enter-details-layout__summary {
|
.enter-details-layout__container {
|
||||||
top: calc(
|
grid-template-columns: 1fr 340px;
|
||||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
grid-template-rows: auto 1fr;
|
||||||
var(--Spacing-x-half)
|
margin: var(--Spacing-x5) auto 0;
|
||||||
|
width: min(
|
||||||
|
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||||
|
var(--max-width-navigation)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.enter-details-layout__hider {
|
.enter-details-layout__summaryContainer {
|
||||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
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}>
|
<EnterDetailsProvider step={params.step} isMember={!!user}>
|
||||||
<main className="enter-details-layout__layout">
|
<main className="enter-details-layout__layout">
|
||||||
{hotelHeader}
|
{hotelHeader}
|
||||||
<div className={"enter-details-layout__content"}>
|
<div className={"enter-details-layout__container"}>
|
||||||
{children}
|
<div className={"enter-details-layout__content"}>{children}</div>
|
||||||
<aside className="enter-details-layout__summaryContainer">
|
<aside className={"enter-details-layout__summaryContainer"}>
|
||||||
<div className="enter-details-layout__hider" />
|
{summary}
|
||||||
<div className="enter-details-layout__summary">{summary}</div>
|
|
||||||
<div className="enter-details-layout__shadow" />
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export default async function StepPage({
|
|||||||
toDate,
|
toDate,
|
||||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
} = 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)
|
const childrenAsString = children && generateChildrenString(children)
|
||||||
|
|
||||||
@@ -60,12 +66,9 @@ export default async function StepPage({
|
|||||||
roomStayEndDate: toDate,
|
roomStayEndDate: toDate,
|
||||||
rateCode,
|
rateCode,
|
||||||
roomTypeCode,
|
roomTypeCode,
|
||||||
|
packageCodes,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hotelData = await getHotelData({
|
|
||||||
hotelId,
|
|
||||||
language: lang,
|
|
||||||
})
|
|
||||||
const roomAvailability = await getSelectedRoomAvailability({
|
const roomAvailability = await getSelectedRoomAvailability({
|
||||||
hotelId,
|
hotelId,
|
||||||
adults,
|
adults,
|
||||||
@@ -74,6 +77,12 @@ export default async function StepPage({
|
|||||||
roomStayEndDate: toDate,
|
roomStayEndDate: toDate,
|
||||||
rateCode,
|
rateCode,
|
||||||
roomTypeCode,
|
roomTypeCode,
|
||||||
|
packageCodes,
|
||||||
|
})
|
||||||
|
const hotelData = await getHotelData({
|
||||||
|
hotelId,
|
||||||
|
language: lang,
|
||||||
|
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||||
})
|
})
|
||||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||||
const user = await getProfileSafely()
|
const user = await getProfileSafely()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
|
|||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
|
import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils"
|
||||||
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
||||||
import {
|
import {
|
||||||
generateChildrenString,
|
generateChildrenString,
|
||||||
@@ -11,11 +12,7 @@ import {
|
|||||||
import { MapModal } from "@/components/MapModal"
|
import { MapModal } from "@/components/MapModal"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import {
|
import { fetchAvailableHotels } from "../../utils"
|
||||||
fetchAvailableHotels,
|
|
||||||
getCentralCoordinates,
|
|
||||||
getHotelPins,
|
|
||||||
} from "../../utils"
|
|
||||||
|
|
||||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
@@ -61,16 +58,12 @@ export default async function SelectHotelMapPage({
|
|||||||
|
|
||||||
const hotelPins = getHotelPins(hotels)
|
const hotelPins = getHotelPins(hotels)
|
||||||
|
|
||||||
const centralCoordinates = getCentralCoordinates(hotelPins)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapModal>
|
<MapModal>
|
||||||
<SelectHotelMap
|
<SelectHotelMap
|
||||||
apiKey={googleMapsApiKey}
|
apiKey={googleMapsApiKey}
|
||||||
coordinates={centralCoordinates}
|
|
||||||
hotelPins={hotelPins}
|
hotelPins={hotelPins}
|
||||||
mapId={googleMapId}
|
mapId={googleMapId}
|
||||||
isModal={true}
|
|
||||||
hotels={hotels}
|
hotels={hotels}
|
||||||
/>
|
/>
|
||||||
</MapModal>
|
</MapModal>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
.main {
|
.main {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Spacing-x3);
|
padding: 0 var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2);
|
||||||
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
|
|
||||||
background-color: var(--Scandic-Brand-Warm-White);
|
background-color: var(--Scandic-Brand-Warm-White);
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: var(--max-width);
|
max-width: var(--max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0 auto;
|
flex-direction: column;
|
||||||
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
|
gap: var(--Spacing-x2);
|
||||||
var(--Spacing-x5);
|
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
|
||||||
justify-content: space-between;
|
}
|
||||||
max-width: var(--max-width);
|
|
||||||
|
.cityInformation {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sideBar {
|
.sideBar {
|
||||||
@@ -38,7 +40,31 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hotelList {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@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 {
|
.link {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-bottom: var(--Spacing-x6);
|
padding-bottom: var(--Spacing-x6);
|
||||||
@@ -50,13 +76,6 @@
|
|||||||
border-radius: var(--Corner-radius-Medium);
|
border-radius: var(--Corner-radius-Medium);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
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 {
|
.main {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x5);
|
gap: var(--Spacing-x5);
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
import { ChevronRightIcon } from "@/components/Icons"
|
import { ChevronRightIcon } from "@/components/Icons"
|
||||||
import StaticMap from "@/components/Maps/StaticMap"
|
import StaticMap from "@/components/Maps/StaticMap"
|
||||||
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
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 TrackingSDK from "@/components/TrackingSDK"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
@@ -32,6 +36,7 @@ import {
|
|||||||
TrackingSDKHotelInfo,
|
TrackingSDKHotelInfo,
|
||||||
TrackingSDKPageData,
|
TrackingSDKPageData,
|
||||||
} from "@/types/components/tracking"
|
} from "@/types/components/tracking"
|
||||||
|
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||||
import { LangParams, PageArgs } from "@/types/params"
|
import { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export default async function SelectHotelPage({
|
export default async function SelectHotelPage({
|
||||||
@@ -99,17 +104,44 @@ export default async function SelectHotelPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div>{city.name}</div>
|
<div className={styles.title}>
|
||||||
<HotelSorter />
|
<div className={styles.cityInformation}>
|
||||||
|
<Subtitle>{city.name}</Subtitle>
|
||||||
|
<Preamble>{hotels.length} hotels</Preamble>
|
||||||
|
</div>
|
||||||
|
<HotelSorter />
|
||||||
|
</div>
|
||||||
|
<MobileMapButtonContainer city={searchParams.city} />
|
||||||
</header>
|
</header>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.sideBar}>
|
<div className={styles.sideBar}>
|
||||||
<Link
|
{hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
|
||||||
className={styles.link}
|
<Link
|
||||||
color="burgundy"
|
className={styles.link}
|
||||||
href={selectHotelMap[params.lang]}
|
color="burgundy"
|
||||||
keepSearchParams
|
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}>
|
<div className={styles.mapContainer}>
|
||||||
<StaticMap
|
<StaticMap
|
||||||
city={searchParams.city}
|
city={searchParams.city}
|
||||||
@@ -119,16 +151,22 @@ export default async function SelectHotelPage({
|
|||||||
mapType="roadmap"
|
mapType="roadmap"
|
||||||
altText={`Map of ${searchParams.city} city center`}
|
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>
|
</div>
|
||||||
</Link>
|
)}
|
||||||
<MobileMapButtonContainer city={searchParams.city} />
|
|
||||||
<HotelFilter filters={filterList} />
|
<HotelFilter filters={filterList} />
|
||||||
</div>
|
</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
|
<TrackingSDK
|
||||||
pageData={pageTrackingData}
|
pageData={pageTrackingData}
|
||||||
hotelInfo={hotelsTrackingData}
|
hotelInfo={hotelsTrackingData}
|
||||||
|
|||||||
@@ -87,38 +87,3 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
|
|||||||
{ facilityFilters: [], surroundingsFilters: [] }
|
{ 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 { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import { LangParams, PageArgs } from "@/types/params"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export default async function SelectRatePage({
|
export default async function SelectRatePage({
|
||||||
params,
|
params,
|
||||||
@@ -49,11 +49,11 @@ export default async function SelectRatePage({
|
|||||||
searchParams.fromDate &&
|
searchParams.fromDate &&
|
||||||
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
|
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
|
||||||
? searchParams.fromDate
|
? searchParams.fromDate
|
||||||
: dt().utc().format("YYYY-MM-D")
|
: dt().utc().format("YYYY-MM-DD")
|
||||||
const validToDate =
|
const validToDate =
|
||||||
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
|
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
|
||||||
? searchParams.toDate
|
? 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 adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
|
||||||
const childrenCount = selectRoomParamsObject.room[0].child?.length
|
const childrenCount = selectRoomParamsObject.room[0].child?.length
|
||||||
const children = selectRoomParamsObject.room[0].child
|
const children = selectRoomParamsObject.room[0].child
|
||||||
@@ -94,9 +94,16 @@ export default async function SelectRatePage({
|
|||||||
|
|
||||||
const roomCategories = hotelData?.included
|
const roomCategories = hotelData?.included
|
||||||
|
|
||||||
|
const noRoomsAvailable = roomsAvailability.roomConfigurations.reduce(
|
||||||
|
(acc, room) => {
|
||||||
|
return acc && room.status === "NotAvailable"
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HotelInfoCard hotelData={hotelData} />
|
<HotelInfoCard hotelData={hotelData} noAvailability={noRoomsAvailable} />
|
||||||
<Rooms
|
<Rooms
|
||||||
roomsAvailability={roomsAvailability}
|
roomsAvailability={roomsAvailability}
|
||||||
roomCategories={roomCategories ?? []}
|
roomCategories={roomCategories ?? []}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <LoadingSpinner />
|
return <LoadingSpinner fullPage />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
|
||||||
|
|
||||||
import styles from "./loading.module.css"
|
|
||||||
|
|
||||||
export default function LoadingBookingWidget() {
|
export default function LoadingBookingWidget() {
|
||||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <BookingWidgetSkeleton />
|
||||||
<div className={styles.container}>
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import CurrentLoadingSpinner from "@/components/Current/LoadingSpinner"
|
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() {
|
export default function LoadingFooter() {
|
||||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||||
return <CurrentLoadingSpinner />
|
return <CurrentLoadingSpinner />
|
||||||
}
|
}
|
||||||
return <LoadingSpinner />
|
return (
|
||||||
|
<footer>
|
||||||
|
<FooterNavigationSkeleton />
|
||||||
|
<FooterDetailsSkeleton />
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <LoadingSpinner />
|
return <LoadingSpinner fullPage />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export default async function RootLayout({
|
|||||||
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
||||||
<RouterTracking />
|
<RouterTracking />
|
||||||
{header}
|
{header}
|
||||||
<BookingWidget />
|
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
<TokenRefresher />
|
<TokenRefresher />
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
--max-width-navigation: 89.5rem;
|
--max-width-navigation: 89.5rem;
|
||||||
|
|
||||||
--main-menu-mobile-height: 75px;
|
--main-menu-mobile-height: 75px;
|
||||||
--main-menu-desktop-height: 129px;
|
--main-menu-desktop-height: 125px;
|
||||||
--booking-widget-mobile-height: 75px;
|
--booking-widget-mobile-height: 75px;
|
||||||
--booking-widget-desktop-height: 77px;
|
--booking-widget-desktop-height: 77px;
|
||||||
--hotel-page-map-desktop-width: 23.75rem;
|
--hotel-page-map-desktop-width: 23.75rem;
|
||||||
@@ -114,6 +114,8 @@
|
|||||||
/* Z-INDEX */
|
/* Z-INDEX */
|
||||||
--header-z-index: 11;
|
--header-z-index: 11;
|
||||||
--menu-overlay-z-index: 11;
|
--menu-overlay-z-index: 11;
|
||||||
|
--booking-widget-z-index: 10;
|
||||||
|
--booking-widget-open-z-index: 100;
|
||||||
--dialog-z-index: 9;
|
--dialog-z-index: 9;
|
||||||
--sidepeek-z-index: 100;
|
--sidepeek-z-index: 100;
|
||||||
--lightbox-z-index: 150;
|
--lightbox-z-index: 150;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function AccordionSection({ accordion, title }: AccordionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContainer id={HotelHashValues.faq}>
|
<SectionContainer id={HotelHashValues.faq}>
|
||||||
{title && <SectionHeader textTransform="uppercase" title={title} />}
|
<SectionHeader textTransform="uppercase" title={title} />
|
||||||
<Accordion
|
<Accordion
|
||||||
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
|
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
|
||||||
theme="light"
|
theme="light"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { overview } from "@/constants/routes/myPages"
|
import { overview } from "@/constants/routes/myPages"
|
||||||
|
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import LoginButton from "@/components/LoginButton"
|
import LoginButton from "@/components/LoginButton"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import { getLang } from "@/i18n/serverContext"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
@@ -14,8 +14,8 @@ import type { SignUpVerificationProps } from "@/types/components/blocks/dynamicC
|
|||||||
export default async function SignUpVerification({
|
export default async function SignUpVerification({
|
||||||
dynamic_content,
|
dynamic_content,
|
||||||
}: SignUpVerificationProps) {
|
}: SignUpVerificationProps) {
|
||||||
const session = await auth()
|
const user = await getProfileSafely()
|
||||||
if (session) {
|
if (user) {
|
||||||
redirect(overview[getLang()])
|
redirect(overview[getLang()])
|
||||||
}
|
}
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { overview } from "@/constants/routes/myPages"
|
import { overview } from "@/constants/routes/myPages"
|
||||||
|
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import SignupForm from "@/components/Forms/Signup"
|
import SignupForm from "@/components/Forms/Signup"
|
||||||
import { getLang } from "@/i18n/serverContext"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent
|
|||||||
export default async function SignupFormWrapper({
|
export default async function SignupFormWrapper({
|
||||||
dynamic_content,
|
dynamic_content,
|
||||||
}: SignupFormWrapperProps) {
|
}: SignupFormWrapperProps) {
|
||||||
const session = await auth()
|
const user = await getProfileSafely()
|
||||||
if (session) {
|
if (user) {
|
||||||
// We don't want to allow users to access signup if they are already authenticated.
|
// We don't want to allow users to access signup if they are already authenticated.
|
||||||
redirect(overview[getLang()])
|
redirect(overview[getLang()])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function TableBlock({ data }: TableBlockProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContainer>
|
<SectionContainer>
|
||||||
{heading ? <SectionHeader preamble={preamble} title={heading} /> : null}
|
<SectionHeader preamble={preamble} title={heading} />
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper}>
|
||||||
<ScrollWrapper>
|
<ScrollWrapper>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import { FormProvider, useForm } from "react-hook-form"
|
|||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
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 { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
|
||||||
import { CloseLargeIcon } from "@/components/Icons"
|
import { CloseLargeIcon } from "@/components/Icons"
|
||||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||||
import { debounce } from "@/utils/debounce"
|
import { debounce } from "@/utils/debounce"
|
||||||
import { getFormattedUrlQueryParams } from "@/utils/url"
|
import { getFormattedUrlQueryParams } from "@/utils/url"
|
||||||
|
|
||||||
import MobileToggleButton from "./MobileToggleButton"
|
import MobileToggleButton, {
|
||||||
|
MobileToggleButtonSkeleton,
|
||||||
|
} from "./MobileToggleButton"
|
||||||
|
|
||||||
import styles from "./bookingWidget.module.css"
|
import styles from "./bookingWidget.module.css"
|
||||||
|
|
||||||
@@ -38,11 +42,11 @@ export default function BookingWidgetClient({
|
|||||||
|
|
||||||
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
|
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
|
||||||
searchParams
|
searchParams
|
||||||
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
|
? getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
|
||||||
adults: "number",
|
adults: "number",
|
||||||
age: "number",
|
age: "number",
|
||||||
bed: "number",
|
bed: "number",
|
||||||
}) as BookingWidgetSearchParams)
|
})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const getLocationObj = (destination: string): Location | undefined => {
|
const getLocationObj = (destination: string): Location | undefined => {
|
||||||
@@ -75,6 +79,16 @@ export default function BookingWidgetClient({
|
|||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const defaultRoomsData = bookingWidgetSearchData?.room?.map((room) => ({
|
||||||
|
adults: room.adults,
|
||||||
|
child: room.child ?? [],
|
||||||
|
})) ?? [
|
||||||
|
{
|
||||||
|
adults: 1,
|
||||||
|
child: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const methods = useForm<BookingWidgetSchema>({
|
const methods = useForm<BookingWidgetSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
search: selectedLocation?.name ?? "",
|
search: selectedLocation?.name ?? "",
|
||||||
@@ -92,12 +106,7 @@ export default function BookingWidgetClient({
|
|||||||
bookingCode: "",
|
bookingCode: "",
|
||||||
redemption: false,
|
redemption: false,
|
||||||
voucher: false,
|
voucher: false,
|
||||||
rooms: bookingWidgetSearchData?.room ?? [
|
rooms: defaultRoomsData,
|
||||||
{
|
|
||||||
adults: 1,
|
|
||||||
child: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
shouldFocusError: false,
|
shouldFocusError: false,
|
||||||
mode: "all",
|
mode: "all",
|
||||||
@@ -154,21 +163,37 @@ export default function BookingWidgetClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<section ref={bookingWidgetRef} className={styles.containerDesktop}>
|
<section
|
||||||
<Form locations={locations} type={type} />
|
ref={bookingWidgetRef}
|
||||||
</section>
|
className={styles.wrapper}
|
||||||
<section className={styles.containerMobile} data-open={isOpen}>
|
data-open={isOpen}
|
||||||
<button
|
>
|
||||||
className={styles.close}
|
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
||||||
onClick={closeMobileSearch}
|
<div className={styles.formContainer}>
|
||||||
type="button"
|
<button
|
||||||
>
|
className={styles.close}
|
||||||
<CloseLargeIcon />
|
onClick={closeMobileSearch}
|
||||||
</button>
|
type="button"
|
||||||
<Form locations={locations} type={type} />
|
>
|
||||||
|
<CloseLargeIcon />
|
||||||
|
</button>
|
||||||
|
<Form locations={locations} type={type} setIsOpen={setIsOpen} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
||||||
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
|
||||||
</FormProvider>
|
</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;
|
display: grid;
|
||||||
gap: var(--Spacing-x-one-and-half);
|
gap: var(--Spacing-x-one-and-half);
|
||||||
padding: var(--Spacing-x2);
|
padding: var(--Spacing-x2);
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { dt } from "@/lib/dt"
|
|||||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||||
|
|
||||||
import { EditIcon, SearchIcon } from "@/components/Icons"
|
import { EditIcon, SearchIcon } from "@/components/Icons"
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
@@ -24,19 +25,12 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|||||||
export default function MobileToggleButton({
|
export default function MobileToggleButton({
|
||||||
openMobileSearch,
|
openMobileSearch,
|
||||||
}: BookingWidgetToggleButtonProps) {
|
}: BookingWidgetToggleButtonProps) {
|
||||||
const [hasMounted, setHasMounted] = useState(false)
|
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const d = useWatch({ name: "date" })
|
const d = useWatch({ name: "date" })
|
||||||
const location = useWatch({ name: "location" })
|
const location = useWatch({ name: "location" })
|
||||||
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
|
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
|
||||||
|
|
||||||
const bookingWidgetMobileRef = useRef(null)
|
|
||||||
useStickyPosition({
|
|
||||||
ref: bookingWidgetMobileRef,
|
|
||||||
name: StickyElementNameEnum.BOOKING_WIDGET_MOBILE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const parsedLocation: Location | null = location
|
const parsedLocation: Location | null = location
|
||||||
? JSON.parse(decodeURIComponent(location))
|
? JSON.parse(decodeURIComponent(location))
|
||||||
: null
|
: null
|
||||||
@@ -46,14 +40,6 @@ export default function MobileToggleButton({
|
|||||||
const selectedFromDate = dt(d.fromDate).locale(lang).format("D MMM")
|
const selectedFromDate = dt(d.fromDate).locale(lang).format("D MMM")
|
||||||
const selectedToDate = dt(d.toDate).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 locationAndDateIsSet = parsedLocation && d
|
||||||
|
|
||||||
const totalRooms = rooms.length
|
const totalRooms = rooms.length
|
||||||
@@ -75,7 +61,6 @@ export default function MobileToggleButton({
|
|||||||
className={locationAndDateIsSet ? styles.complete : styles.partial}
|
className={locationAndDateIsSet ? styles.complete : styles.partial}
|
||||||
onClick={openMobileSearch}
|
onClick={openMobileSearch}
|
||||||
role="button"
|
role="button"
|
||||||
ref={bookingWidgetMobileRef}
|
|
||||||
>
|
>
|
||||||
{!locationAndDateIsSet && (
|
{!locationAndDateIsSet && (
|
||||||
<>
|
<>
|
||||||
@@ -133,3 +118,28 @@ export default function MobileToggleButton({
|
|||||||
</div>
|
</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,
|
.wrapper {
|
||||||
.containerMobile,
|
position: sticky;
|
||||||
.close {
|
z-index: var(--booking-widget-z-index);
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 767px) {
|
.formContainer {
|
||||||
.containerMobile {
|
display: grid;
|
||||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
grid-template-rows: auto 1fr;
|
||||||
bottom: -100%;
|
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||||
display: grid;
|
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
grid-template-rows: 36px 1fr;
|
height: calc(100dvh - 20px);
|
||||||
height: calc(100dvh - 20px);
|
width: 100%;
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
transition: bottom 300ms ease;
|
bottom: -100%;
|
||||||
width: 100%;
|
transition: bottom 300ms ease;
|
||||||
z-index: 10000;
|
}
|
||||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerMobile[data-open="true"] {
|
.wrapper[data-open="true"] {
|
||||||
bottom: 0;
|
z-index: var(--booking-widget-open-z-index);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.wrapper[data-open="true"] .formContainer {
|
||||||
background: none;
|
bottom: 0;
|
||||||
border: none;
|
}
|
||||||
cursor: pointer;
|
|
||||||
justify-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerMobile[data-open="true"] + .backdrop {
|
.close {
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background: none;
|
||||||
height: 100%;
|
border: none;
|
||||||
left: 0;
|
cursor: pointer;
|
||||||
position: absolute;
|
justify-self: flex-end;
|
||||||
top: 0;
|
padding: 0;
|
||||||
width: 100%;
|
}
|
||||||
z-index: 1000;
|
|
||||||
}
|
.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) {
|
@media screen and (min-width: 768px) {
|
||||||
.containerDesktop {
|
.wrapper {
|
||||||
display: block;
|
|
||||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
|
||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
.formContainer {
|
||||||
.container {
|
display: block;
|
||||||
z-index: 9;
|
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>
|
</div>
|
||||||
<Caption color="uiTextPlaceholder">
|
<Caption color="uiTextPlaceholder">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "Distance to city centre" },
|
{ id: "Distance in km to city centre" },
|
||||||
{ number: distanceToCentre }
|
{ number: distanceToCentre }
|
||||||
)}
|
)}
|
||||||
</Caption>
|
</Caption>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function IntroSection({
|
|||||||
const { streetAddress, city } = address
|
const { streetAddress, city } = address
|
||||||
const { distanceToCentre } = location
|
const { distanceToCentre } = location
|
||||||
const formattedDistanceText = intl.formatMessage(
|
const formattedDistanceText = intl.formatMessage(
|
||||||
{ id: "Distance to city centre" },
|
{ id: "Distance in km to city centre" },
|
||||||
{ number: distanceToCentre }
|
{ number: distanceToCentre }
|
||||||
)
|
)
|
||||||
const lang = getLang()
|
const lang = getLang()
|
||||||
|
|||||||
@@ -1,44 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import Image from "@/components/Image"
|
import Image from "@/components/Image"
|
||||||
import Lightbox from "@/components/Lightbox/"
|
import Lightbox from "@/components/Lightbox/"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
|
|
||||||
import styles from "./previewImages.module.css"
|
import styles from "./previewImages.module.css"
|
||||||
|
|
||||||
import type { PreviewImagesProps } from "@/types/components/hotelPage/previewImages"
|
import type { PreviewImagesProps } from "@/types/components/hotelPage/previewImages"
|
||||||
|
|
||||||
export default async function PreviewImages({
|
export default function PreviewImages({
|
||||||
images,
|
images,
|
||||||
hotelName,
|
hotelName,
|
||||||
}: PreviewImagesProps) {
|
}: PreviewImagesProps) {
|
||||||
const intl = await getIntl()
|
const intl = useIntl()
|
||||||
const imageGalleryText = intl.formatMessage({ id: "Image gallery" })
|
const [lightboxIsOpen, setLightboxIsOpen] = useState(false)
|
||||||
const dialogTitle = `${hotelName} - ${imageGalleryText}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Lightbox images={images} dialogTitle={dialogTitle}>
|
<div className={styles.imageWrapper}>
|
||||||
<div className={styles.imageWrapper}>
|
{images.slice(0, 3).map((image, index) => (
|
||||||
{images.slice(0, 3).map((image, index) => (
|
<Image
|
||||||
<Image
|
key={index}
|
||||||
key={index}
|
src={image.imageSizes.medium}
|
||||||
src={image.imageSizes.medium}
|
alt={image.metaData.altText}
|
||||||
alt={image.metaData.altText}
|
title={image.metaData.title}
|
||||||
title={image.metaData.title}
|
width={index === 0 ? 752 : 292}
|
||||||
width={index === 0 ? 752 : 292}
|
height={index === 0 ? 540 : 266}
|
||||||
height={index === 0 ? 540 : 266}
|
className={styles.image}
|
||||||
className={styles.image}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
<Button
|
||||||
<Button
|
theme="base"
|
||||||
theme="base"
|
intent="inverted"
|
||||||
intent="inverted"
|
size="small"
|
||||||
size="small"
|
onClick={() => setLightboxIsOpen(true)}
|
||||||
id="lightboxTrigger"
|
className={styles.seeAllButton}
|
||||||
className={styles.seeAllButton}
|
>
|
||||||
>
|
{intl.formatMessage({ id: "See all photos" })}
|
||||||
{intl.formatMessage({ id: "See all photos" })}
|
</Button>
|
||||||
</Button>
|
<Lightbox
|
||||||
</div>
|
images={images}
|
||||||
</Lightbox>
|
dialogTitle={intl.formatMessage(
|
||||||
|
{ id: "Image gallery" },
|
||||||
|
{ name: hotelName }
|
||||||
|
)}
|
||||||
|
isOpen={lightboxIsOpen}
|
||||||
|
onClose={() => setLightboxIsOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,62 +2,38 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { GalleryIcon } from "@/components/Icons"
|
import useSidePeekStore from "@/stores/sidepeek"
|
||||||
import Image from "@/components/Image"
|
|
||||||
|
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||||
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
|
||||||
import RoomDetailsButton from "../RoomDetailsButton"
|
|
||||||
|
|
||||||
import styles from "./roomCard.module.css"
|
import styles from "./roomCard.module.css"
|
||||||
|
|
||||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
||||||
|
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||||
|
|
||||||
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||||
const { images, name, roomSize, occupancy, id } = room
|
const { images, name, roomSize, occupancy } = room
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const mainImage = images[0]
|
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||||
|
|
||||||
const size =
|
const size =
|
||||||
roomSize?.min === roomSize?.max
|
roomSize?.min === roomSize?.max
|
||||||
? `${roomSize.min} m²`
|
? `${roomSize.min} m²`
|
||||||
: `${roomSize.min} - ${roomSize.max} 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 (
|
return (
|
||||||
<article className={styles.roomCard}>
|
<article className={styles.roomCard}>
|
||||||
<button className={styles.imageWrapper} onClick={handleImageClick}>
|
<div className={styles.imageContainer}>
|
||||||
{/* TODO: re-enable once we have support for badge text from API team. */}
|
<ImageGallery
|
||||||
{/* {badgeTextTransKey && ( */}
|
images={images}
|
||||||
{/* <span className={styles.badge}> */}
|
title={intl.formatMessage({ id: "Image gallery" }, { name })}
|
||||||
{/* {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}
|
|
||||||
height={200}
|
height={200}
|
||||||
width={300}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.innerContent}>
|
<div className={styles.innerContent}>
|
||||||
<Subtitle
|
<Subtitle
|
||||||
@@ -65,16 +41,32 @@ export function RoomCard({ hotelId, room }: RoomCardProps) {
|
|||||||
textAlign="center"
|
textAlign="center"
|
||||||
type="one"
|
type="one"
|
||||||
color="black"
|
color="black"
|
||||||
className={styles.title}
|
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
<Body color="grey">{subtitle}</Body>
|
<Body color="grey">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "hotelPages.rooms.roomCard.persons" },
|
||||||
|
{ size, totalOccupancy: occupancy.total }
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
<RoomDetailsButton
|
<Button
|
||||||
hotelId={hotelId}
|
intent="text"
|
||||||
roomTypeCode={room.roomTypes[0].code}
|
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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,34 +3,7 @@
|
|||||||
background-color: var(--UI-Opacity-White-100);
|
background-color: var(--UI-Opacity-White-100);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
overflow: hidden;
|
||||||
|
|
||||||
/*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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -46,32 +19,7 @@
|
|||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.imageContainer {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title:first-child {
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imageWrapper {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: transparent;
|
height: 200px;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.header {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
background-color: var(--Main-Grey-White);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 1366px) {
|
@media screen and (max-width: 1366px) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
--header-height: 68px;
|
--header-height: 72px;
|
||||||
--sticky-button-height: 120px;
|
--sticky-button-height: 120px;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -11,12 +11,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
align-self: flex-start;
|
align-self: flex-end;
|
||||||
background-color: var(--Main-Grey-White);
|
background-color: var(--Main-Grey-White);
|
||||||
display: grid;
|
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
grid-template-columns: 1fr 24px;
|
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x2);
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
.hideWrapper {
|
.hideWrapper {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
|||||||
if (!dt(selected).isBefore(dt(), "day")) {
|
if (!dt(selected).isBefore(dt(), "day")) {
|
||||||
if (isSelectingFrom) {
|
if (isSelectingFrom) {
|
||||||
setValue(name, {
|
setValue(name, {
|
||||||
fromDate: dt(selected).format("YYYY-MM-D"),
|
fromDate: dt(selected).format("YYYY-MM-DD"),
|
||||||
toDate: undefined,
|
toDate: undefined,
|
||||||
})
|
})
|
||||||
setIsSelectingFrom(false)
|
setIsSelectingFrom(false)
|
||||||
@@ -57,11 +57,11 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
|||||||
if (toDate.isAfter(fromDate)) {
|
if (toDate.isAfter(fromDate)) {
|
||||||
setValue(name, {
|
setValue(name, {
|
||||||
fromDate: selectedDate.fromDate,
|
fromDate: selectedDate.fromDate,
|
||||||
toDate: toDate.format("YYYY-MM-D"),
|
toDate: toDate.format("YYYY-MM-DD"),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setValue(name, {
|
setValue(name, {
|
||||||
fromDate: toDate.format("YYYY-MM-D"),
|
fromDate: toDate.format("YYYY-MM-DD"),
|
||||||
toDate: selectedDate.fromDate,
|
toDate: selectedDate.fromDate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
|||||||
if (!selectedDate.toDate) {
|
if (!selectedDate.toDate) {
|
||||||
setValue(name, {
|
setValue(name, {
|
||||||
fromDate: selectedDate.fromDate,
|
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)
|
setIsSelectingFrom(true)
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,12 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
|||||||
data-isopen={isOpen}
|
data-isopen={isOpen}
|
||||||
ref={ref}
|
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>
|
<Body className={styles.body} asChild>
|
||||||
<span>
|
<span>
|
||||||
{selectedFromDate} - {selectedToDate}
|
{selectedFromDate} - {selectedToDate}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getFooter, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
|||||||
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
|
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
|
||||||
import Image from "@/components/Image"
|
import Image from "@/components/Image"
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
@@ -92,3 +93,40 @@ export default async function FooterDetails() {
|
|||||||
</section>
|
</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 { ArrowRightIcon } from "@/components/Icons"
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
|
||||||
@@ -30,3 +31,24 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
|
|||||||
</nav>
|
</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 Image from "@/components/Image"
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import { getLang } from "@/i18n/serverContext"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
@@ -80,3 +80,46 @@ export default function FooterSecondaryNav({
|
|||||||
</div>
|
</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 { getFooter } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import FooterMainNav from "./MainNav"
|
import FooterMainNav, { FooterMainNavSkeleton } from "./MainNav"
|
||||||
import FooterSecondaryNav from "./SecondaryNav"
|
import FooterSecondaryNav, { FooterSecondaryNavSkeleton } from "./SecondaryNav"
|
||||||
|
|
||||||
import styles from "./navigation.module.css"
|
import styles from "./navigation.module.css"
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export default async function FooterNavigation() {
|
|||||||
if (!footer) {
|
if (!footer) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<div className={styles.maxWidth}>
|
<div className={styles.maxWidth}>
|
||||||
@@ -22,3 +23,14 @@ export default async function FooterNavigation() {
|
|||||||
</section>
|
</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 { useFormContext, useWatch } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
|
||||||
import Input from "../Input"
|
import Input from "../Input"
|
||||||
@@ -203,3 +204,18 @@ export default function Search({ locations }: SearchProps) {
|
|||||||
</Downshift>
|
</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);
|
border-radius: var(--Corner-radius-Small);
|
||||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container:hover,
|
.container:hover,
|
||||||
.container:has(input:active, input:focus, input:focus-within) {
|
.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) {
|
.container:has(input:active, input:focus, input:focus-within) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||||
@@ -78,3 +79,54 @@ export default function Voucher() {
|
|||||||
</div>
|
</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"
|
"use client"
|
||||||
import { useState } from "react"
|
|
||||||
import { useWatch } from "react-hook-form"
|
import { useWatch } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
@@ -7,13 +6,13 @@ import { dt } from "@/lib/dt"
|
|||||||
|
|
||||||
import DatePicker from "@/components/DatePicker"
|
import DatePicker from "@/components/DatePicker"
|
||||||
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
|
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
|
||||||
import GuestsRoomsProvider from "@/components/GuestsRoomsPicker/Provider/GuestsRoomsProvider"
|
|
||||||
import { SearchIcon } from "@/components/Icons"
|
import { SearchIcon } from "@/components/Icons"
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
|
||||||
import Search from "./Search"
|
import Search, { SearchSkeleton } from "./Search"
|
||||||
import Voucher from "./Voucher"
|
import Voucher, { VoucherSkeleton } from "./Voucher"
|
||||||
|
|
||||||
import styles from "./formContent.module.css"
|
import styles from "./formContent.module.css"
|
||||||
|
|
||||||
@@ -26,12 +25,10 @@ export default function FormContent({
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const selectedDate = useWatch({ name: "date" })
|
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 nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
|
||||||
|
|
||||||
const selectedGuests = useWatch({ name: "rooms" })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
@@ -51,12 +48,10 @@ export default function FormContent({
|
|||||||
<div className={styles.rooms}>
|
<div className={styles.rooms}>
|
||||||
<label>
|
<label>
|
||||||
<Caption color="red" type="bold" asChild>
|
<Caption color="red" type="bold" asChild>
|
||||||
<span>{rooms}</span>
|
<span>{roomsLabel}</span>
|
||||||
</Caption>
|
</Caption>
|
||||||
</label>
|
</label>
|
||||||
<GuestsRoomsProvider selectedGuests={selectedGuests}>
|
<GuestsRoomsPickerForm />
|
||||||
<GuestsRoomsPickerForm name="rooms" />
|
|
||||||
</GuestsRoomsProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.voucherContainer}>
|
<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 useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import FormContent from "./FormContent"
|
import FormContent, { BookingWidgetFormContentSkeleton } from "./FormContent"
|
||||||
import { bookingWidgetVariants } from "./variants"
|
import { bookingWidgetVariants } from "./variants"
|
||||||
|
|
||||||
import styles from "./form.module.css"
|
import styles from "./form.module.css"
|
||||||
@@ -17,7 +17,11 @@ import { Location } from "@/types/trpc/routers/hotel/locations"
|
|||||||
|
|
||||||
const formId = "booking-widget"
|
const formId = "booking-widget"
|
||||||
|
|
||||||
export default function Form({ locations, type }: BookingWidgetFormProps) {
|
export default function Form({
|
||||||
|
locations,
|
||||||
|
type,
|
||||||
|
setIsOpen,
|
||||||
|
}: BookingWidgetFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
setIsOpen(false)
|
||||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +73,17 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
|||||||
</section>
|
</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 { z } from "zod"
|
||||||
|
|
||||||
|
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||||
|
|
||||||
export const guestRoomSchema = z.object({
|
export const guestRoomSchema = z
|
||||||
adults: z.number().default(1),
|
.object({
|
||||||
child: z.array(
|
adults: z.number().default(1),
|
||||||
z.object({
|
child: z
|
||||||
age: z.number().nonnegative(),
|
.array(
|
||||||
bed: z.number(),
|
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)
|
export const guestRoomsSchema = z.array(guestRoomSchema)
|
||||||
|
|
||||||
|
|||||||
@@ -3,54 +3,32 @@
|
|||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
|
||||||
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
|
||||||
import Counter from "../Counter"
|
import Counter from "../Counter"
|
||||||
|
|
||||||
import styles from "./adult-selector.module.css"
|
import styles from "./adult-selector.module.css"
|
||||||
|
|
||||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||||
import {
|
|
||||||
AdultSelectorProps,
|
|
||||||
Child,
|
|
||||||
} 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 intl = useIntl()
|
||||||
const adultsLabel = intl.formatMessage({ id: "Adults" })
|
const adultsLabel = intl.formatMessage({ id: "Adults" })
|
||||||
const { setValue } = useFormContext()
|
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) {
|
function increaseAdultsCount() {
|
||||||
if (adults < 6) {
|
if (currentAdults < 6) {
|
||||||
increaseAdults(roomIndex)
|
setValue(name, currentAdults + 1)
|
||||||
setValue(`rooms.${roomIndex}.adults`, adults + 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decreaseAdultsCount(roomIndex: number) {
|
function decreaseAdultsCount() {
|
||||||
if (adults > 1) {
|
if (currentAdults > 1) {
|
||||||
decreaseAdults(roomIndex)
|
setValue(name, currentAdults - 1)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +38,11 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
|
|||||||
{adultsLabel}
|
{adultsLabel}
|
||||||
</Caption>
|
</Caption>
|
||||||
<Counter
|
<Counter
|
||||||
count={adults}
|
count={currentAdults}
|
||||||
handleOnDecrease={() => {
|
handleOnDecrease={decreaseAdultsCount}
|
||||||
decreaseAdultsCount(roomIndex)
|
handleOnIncrease={increaseAdultsCount}
|
||||||
}}
|
disableDecrease={currentAdults == 1}
|
||||||
handleOnIncrease={() => {
|
disableIncrease={currentAdults == 6}
|
||||||
increaseAdultsCount(roomIndex)
|
|
||||||
}}
|
|
||||||
disableDecrease={adults == 1}
|
|
||||||
disableIncrease={adults == 6}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
|
||||||
|
|
||||||
import { ErrorCircleIcon } from "@/components/Icons"
|
import { ErrorCircleIcon } from "@/components/Icons"
|
||||||
import Select from "@/components/TempDesignSystem/Select"
|
import Select from "@/components/TempDesignSystem/Select"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
@@ -17,55 +15,35 @@ import {
|
|||||||
ChildInfoSelectorProps,
|
ChildInfoSelectorProps,
|
||||||
} from "@/types/components/bookingWidget/guestsRoomsPicker"
|
} from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||||
|
|
||||||
|
const ageList = [...Array(13)].map((_, i) => ({
|
||||||
|
label: i.toString(),
|
||||||
|
value: i,
|
||||||
|
}))
|
||||||
|
|
||||||
export default function ChildInfoSelector({
|
export default function ChildInfoSelector({
|
||||||
child = { age: -1, bed: -1 },
|
child = { age: -1, bed: -1 },
|
||||||
|
childrenInAdultsBed,
|
||||||
|
adults,
|
||||||
index = 0,
|
index = 0,
|
||||||
roomIndex = 0,
|
roomIndex = 0,
|
||||||
}: ChildInfoSelectorProps) {
|
}: ChildInfoSelectorProps) {
|
||||||
|
const ageFieldName = `rooms.${roomIndex}.child.${index}.age`
|
||||||
|
const bedFieldName = `rooms.${roomIndex}.child.${index}.bed`
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const ageLabel = intl.formatMessage({ id: "Age" })
|
const ageLabel = intl.formatMessage({ id: "Age" })
|
||||||
const ageReqdErrMsg = intl.formatMessage({ id: "Child age is required" })
|
|
||||||
const bedLabel = intl.formatMessage({ id: "Bed" })
|
const bedLabel = intl.formatMessage({ id: "Bed" })
|
||||||
const { setValue } = useFormContext()
|
const errorMessage = intl.formatMessage({ id: "Child age is required" })
|
||||||
const { adults, childrenInAdultsBed } = useGuestsRoomsStore(
|
const { setValue, formState, register, trigger } = useFormContext()
|
||||||
(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedBed(bed: number) {
|
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)
|
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[] = [
|
const allBedTypes: ChildBed[] = [
|
||||||
@@ -97,6 +75,12 @@ export default function ChildInfoSelector({
|
|||||||
return availableBedTypes
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div key={index} className={styles.childInfoContainer}>
|
<div key={index} className={styles.childInfoContainer}>
|
||||||
@@ -110,13 +94,15 @@ export default function ChildInfoSelector({
|
|||||||
onSelect={(key) => {
|
onSelect={(key) => {
|
||||||
updateSelectedAge(key as number)
|
updateSelectedAge(key as number)
|
||||||
}}
|
}}
|
||||||
name={`rooms.${roomIndex}.child.${index}.age`}
|
|
||||||
placeholder={ageLabel}
|
placeholder={ageLabel}
|
||||||
maxHeight={150}
|
maxHeight={150}
|
||||||
|
{...register(ageFieldName, {
|
||||||
|
required: true,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{child.age !== -1 ? (
|
{child.age >= 0 ? (
|
||||||
<Select
|
<Select
|
||||||
items={getAvailableBeds(child.age)}
|
items={getAvailableBeds(child.age)}
|
||||||
label={bedLabel}
|
label={bedLabel}
|
||||||
@@ -125,16 +111,26 @@ export default function ChildInfoSelector({
|
|||||||
onSelect={(key) => {
|
onSelect={(key) => {
|
||||||
updateSelectedBed(key as number)
|
updateSelectedBed(key as number)
|
||||||
}}
|
}}
|
||||||
name={`rooms.${roomIndex}.child.${index}.age`}
|
|
||||||
placeholder={bedLabel}
|
placeholder={bedLabel}
|
||||||
|
{...register(bedFieldName, {
|
||||||
|
required: true,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isValidated && child.age < 0 ? (
|
|
||||||
|
{roomErrors && roomErrors.message ? (
|
||||||
<Caption color="red" className={styles.error}>
|
<Caption color="red" className={styles.error}>
|
||||||
<ErrorCircleIcon color="red" />
|
<ErrorCircleIcon color="red" />
|
||||||
{ageReqdErrMsg}
|
{roomErrors.message}
|
||||||
|
</Caption>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ageError || bedError ? (
|
||||||
|
<Caption color="red" className={styles.error}>
|
||||||
|
<ErrorCircleIcon color="red" />
|
||||||
|
{errorMessage}
|
||||||
</Caption>
|
</Caption>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
|
||||||
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
|
||||||
import Counter from "../Counter"
|
import Counter from "../Counter"
|
||||||
@@ -12,40 +10,30 @@ import ChildInfoSelector from "./ChildInfoSelector"
|
|||||||
|
|
||||||
import styles from "./child-selector.module.css"
|
import styles from "./child-selector.module.css"
|
||||||
|
|
||||||
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||||
import { ChildSelectorProps } 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 intl = useIntl()
|
||||||
const childrenLabel = intl.formatMessage({ id: "Children" })
|
const childrenLabel = intl.formatMessage({ id: "Children" })
|
||||||
const { setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
const { setValue } = useFormContext()
|
||||||
const children = useGuestsRoomsStore((state) => state.rooms[roomIndex].child)
|
|
||||||
const increaseChildren = useGuestsRoomsStore(
|
|
||||||
(state) => state.increaseChildren
|
|
||||||
)
|
|
||||||
const decreaseChildren = useGuestsRoomsStore(
|
|
||||||
(state) => state.decreaseChildren
|
|
||||||
)
|
|
||||||
|
|
||||||
function increaseChildrenCount(roomIndex: number) {
|
function increaseChildrenCount(roomIndex: number) {
|
||||||
if (children.length < 5) {
|
if (currentChildren.length < 5) {
|
||||||
increaseChildren(roomIndex)
|
setValue(`rooms.${roomIndex}.child.${currentChildren.length}`, {
|
||||||
setValue(
|
age: undefined,
|
||||||
`rooms.${roomIndex}.child.${children.length}`,
|
bed: undefined,
|
||||||
{
|
})
|
||||||
age: -1,
|
|
||||||
bed: -1,
|
|
||||||
},
|
|
||||||
{ shouldValidate: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function decreaseChildrenCount(roomIndex: number) {
|
function decreaseChildrenCount(roomIndex: number) {
|
||||||
if (children.length > 0) {
|
if (currentChildren.length > 0) {
|
||||||
const newChildrenList = decreaseChildren(roomIndex)
|
currentChildren.pop()
|
||||||
setValue(`rooms.${roomIndex}.child`, newChildrenList, {
|
setValue(`rooms.${roomIndex}.child`, currentChildren)
|
||||||
shouldValidate: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,23 +44,25 @@ export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
|
|||||||
{childrenLabel}
|
{childrenLabel}
|
||||||
</Caption>
|
</Caption>
|
||||||
<Counter
|
<Counter
|
||||||
count={children.length}
|
count={currentChildren.length}
|
||||||
handleOnDecrease={() => {
|
handleOnDecrease={() => {
|
||||||
decreaseChildrenCount(roomIndex)
|
decreaseChildrenCount(roomIndex)
|
||||||
}}
|
}}
|
||||||
handleOnIncrease={() => {
|
handleOnIncrease={() => {
|
||||||
increaseChildrenCount(roomIndex)
|
increaseChildrenCount(roomIndex)
|
||||||
}}
|
}}
|
||||||
disableDecrease={children.length == 0}
|
disableDecrease={currentChildren.length == 0}
|
||||||
disableIncrease={children.length == 5}
|
disableIncrease={currentChildren.length == 5}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{children.map((child, index) => (
|
{currentChildren.map((child, index) => (
|
||||||
<ChildInfoSelector
|
<ChildInfoSelector
|
||||||
roomIndex={roomIndex}
|
roomIndex={roomIndex}
|
||||||
index={index}
|
index={index}
|
||||||
child={child}
|
child={child}
|
||||||
|
adults={currentAdults}
|
||||||
key={"child_" + index}
|
key={"child_" + index}
|
||||||
|
childrenInAdultsBed={childrenInAdultsBed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function Counter({
|
|||||||
>
|
>
|
||||||
<MinusIcon color="burgundy" />
|
<MinusIcon color="burgundy" />
|
||||||
</Button>
|
</Button>
|
||||||
<Body color="textHighContrast" textAlign="center">
|
<Body color="baseTextHighContrast" textAlign="center">
|
||||||
{count}
|
{count}
|
||||||
</Body>
|
</Body>
|
||||||
<Button
|
<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 {
|
.triggerDesktop {
|
||||||
overflow: hidden;
|
display: none;
|
||||||
position: relative;
|
|
||||||
&[data-isopen="true"] {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.roomContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
@@ -14,9 +36,6 @@
|
|||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
padding-bottom: var(--Spacing-x1);
|
padding-bottom: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
.hideWrapper {
|
|
||||||
background-color: var(--Main-Grey-White);
|
|
||||||
}
|
|
||||||
.roomHeading {
|
.roomHeading {
|
||||||
margin-bottom: var(--Spacing-x1);
|
margin-bottom: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
@@ -29,43 +48,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.body {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.footer {
|
.footer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
grid-template-columns: auto;
|
grid-template-columns: auto;
|
||||||
margin-top: var(--Spacing-x2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 1366px) {
|
@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 {
|
.contentContainer {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
@@ -73,7 +63,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background-color: var(--Main-Grey-White);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||||
@@ -101,11 +90,10 @@
|
|||||||
rgba(255, 255, 255, 0) 7.5%,
|
rgba(255, 255, 255, 0) 7.5%,
|
||||||
#ffffff 82.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;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer .hideOnMobile {
|
.footer .hideOnMobile {
|
||||||
@@ -121,17 +109,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@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);
|
border-radius: var(--Corner-radius-Large);
|
||||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
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);
|
max-width: calc(100vw - 20px);
|
||||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
|
|
||||||
width: 360px;
|
width: 360px;
|
||||||
max-height: calc(100dvh - 77px - var(--Spacing-x6));
|
}
|
||||||
overflow-y: auto;
|
|
||||||
|
.pickerContainerDesktop:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -140,6 +151,7 @@
|
|||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
|
padding-top: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer .hideOnDesktop,
|
.footer .hideOnDesktop,
|
||||||
|
|||||||
@@ -1,67 +1,86 @@
|
|||||||
"use client"
|
"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 { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
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 Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
|
||||||
import GuestsRoomsPicker from "./GuestsRoomsPicker"
|
import PickerForm from "./Form"
|
||||||
|
|
||||||
import styles from "./guests-rooms-picker.module.css"
|
import styles from "./guests-rooms-picker.module.css"
|
||||||
|
|
||||||
export default function GuestsRoomsPickerForm({
|
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
export default function GuestsRoomsPickerForm() {
|
||||||
function handleClickOutside(evt: Event) {
|
const { watch } = useFormContext()
|
||||||
const target = evt.target as HTMLElement
|
const rooms = watch("rooms") as GuestsRoom[]
|
||||||
if (ref.current && target && !ref.current.contains(target)) {
|
|
||||||
closePicker()
|
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)
|
useEffect(() => {
|
||||||
}
|
setIsDesktop(checkIsDesktop)
|
||||||
}, [closePicker])
|
}, [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 (
|
return (
|
||||||
<div className={styles.container} data-isopen={isOpen} ref={ref}>
|
<Button className={`${className} ${styles.btn}`} type="button">
|
||||||
<button className={styles.btn} onClick={handleOnClick} type="button">
|
<Body>
|
||||||
<Body className={styles.body} asChild>
|
{rooms.map((room, i) => (
|
||||||
<span>
|
<span key={i}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "booking.rooms" },
|
{ id: "booking.rooms" },
|
||||||
{ totalRooms: rooms.length }
|
{ totalRooms: rooms.length }
|
||||||
@@ -69,21 +88,18 @@ export default function GuestsRoomsPickerForm({
|
|||||||
{", "}
|
{", "}
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "booking.adults" },
|
{ id: "booking.adults" },
|
||||||
{ totalAdults: adultCount }
|
{ totalAdults: room.adults }
|
||||||
)}
|
)}
|
||||||
{childCount > 0
|
{room.child.length > 0
|
||||||
? ", " +
|
? ", " +
|
||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
{ id: "booking.children" },
|
{ id: "booking.children" },
|
||||||
{ totalChildren: childCount }
|
{ totalChildren: room.child.length }
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</span>
|
</span>
|
||||||
</Body>
|
))}
|
||||||
</button>
|
</Body>
|
||||||
<div aria-modal className={styles.hideWrapper} role="dialog">
|
</Button>
|
||||||
<GuestsRoomsPicker closePicker={closePicker} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--Spacing-x1);
|
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"
|
import styles from "./headerLink.module.css"
|
||||||
|
|
||||||
@@ -6,16 +9,19 @@ import type { HeaderLinkProps } from "@/types/components/header/headerLink"
|
|||||||
|
|
||||||
export default function HeaderLink({
|
export default function HeaderLink({
|
||||||
children,
|
children,
|
||||||
className,
|
href,
|
||||||
...props
|
iconName,
|
||||||
|
iconSize = 20,
|
||||||
}: HeaderLinkProps) {
|
}: HeaderLinkProps) {
|
||||||
|
const Icon = getIconByIconName(iconName)
|
||||||
return (
|
return (
|
||||||
<Link
|
<Caption type="regular" color="textMediumContrast" asChild>
|
||||||
color="burgundy"
|
<Link href={href} className={styles.headerLink}>
|
||||||
className={`${styles.headerLink} ${className}`}
|
{Icon ? (
|
||||||
{...props}
|
<Icon className={styles.icon} width={iconSize} height={iconSize} />
|
||||||
>
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
|
</Caption>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,27 @@
|
|||||||
import { Suspense, useEffect } from "react"
|
import { Suspense, useEffect } from "react"
|
||||||
import { Dialog, Modal } from "react-aria-components"
|
import { Dialog, Modal } from "react-aria-components"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
import { useMediaQuery } from "usehooks-ts"
|
||||||
|
|
||||||
import useDropdownStore from "@/stores/main-menu"
|
import useDropdownStore from "@/stores/main-menu"
|
||||||
|
|
||||||
import { GiftIcon, SearchIcon, ServiceIcon } from "@/components/Icons"
|
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||||
import useMediaQuery from "@/hooks/useMediaQuery"
|
|
||||||
|
|
||||||
import HeaderLink from "../../HeaderLink"
|
import HeaderLink from "../../HeaderLink"
|
||||||
|
import TopLink from "../../TopLink"
|
||||||
|
|
||||||
import styles from "./mobileMenu.module.css"
|
import styles from "./mobileMenu.module.css"
|
||||||
|
|
||||||
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
|
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
|
||||||
import type { MobileMenuProps } from "@/types/components/header/mobileMenu"
|
import type { MobileMenuProps } from "@/types/components/header/mobileMenu"
|
||||||
|
import { IconName } from "@/types/components/icon"
|
||||||
|
|
||||||
export default function MobileMenu({
|
export default function MobileMenu({
|
||||||
children,
|
children,
|
||||||
languageUrls,
|
languageUrls,
|
||||||
topLink,
|
topLink,
|
||||||
|
isLoggedIn,
|
||||||
}: React.PropsWithChildren<MobileMenuProps>) {
|
}: React.PropsWithChildren<MobileMenuProps>) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const {
|
const {
|
||||||
@@ -75,20 +77,13 @@ export default function MobileMenu({
|
|||||||
className={styles.dialog}
|
className={styles.dialog}
|
||||||
aria-label={intl.formatMessage({ id: "Menu" })}
|
aria-label={intl.formatMessage({ id: "Menu" })}
|
||||||
>
|
>
|
||||||
<Suspense fallback={"Loading nav"}>{children}</Suspense>
|
{children}
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<HeaderLink href="#">
|
<HeaderLink href="#" iconName={IconName.Search}>
|
||||||
<SearchIcon width={20} height={20} color="burgundy" />
|
|
||||||
{intl.formatMessage({ id: "Find booking" })}
|
{intl.formatMessage({ id: "Find booking" })}
|
||||||
</HeaderLink>
|
</HeaderLink>
|
||||||
{topLink.link ? (
|
<TopLink isLoggedIn={isLoggedIn} topLink={topLink} iconSize={20} />
|
||||||
<HeaderLink href={topLink.link.url}>
|
<HeaderLink href="#" iconName={IconName.Service}>
|
||||||
<GiftIcon width={20} height={20} color="burgundy" />
|
|
||||||
{topLink.title}
|
|
||||||
</HeaderLink>
|
|
||||||
) : null}
|
|
||||||
<HeaderLink href="#">
|
|
||||||
<ServiceIcon width={20} height={20} color="burgundy" />
|
|
||||||
{intl.formatMessage({ id: "Customer service" })}
|
{intl.formatMessage({ id: "Customer service" })}
|
||||||
</HeaderLink>
|
</HeaderLink>
|
||||||
<LanguageSwitcher type="mobileHeader" urls={languageUrls} />
|
<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"
|
import MobileMenu from "../MobileMenu"
|
||||||
|
|
||||||
@@ -8,13 +12,18 @@ export default async function MobileMenuWrapper({
|
|||||||
// preloaded
|
// preloaded
|
||||||
const languages = await getLanguageSwitcher()
|
const languages = await getLanguageSwitcher()
|
||||||
const header = await getHeader()
|
const header = await getHeader()
|
||||||
|
const user = await getName()
|
||||||
|
|
||||||
if (!languages || !header) {
|
if (!languages || !header) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileMenu languageUrls={languages.urls} topLink={header.data.topLink}>
|
<MobileMenu
|
||||||
|
languageUrls={languages.urls}
|
||||||
|
topLink={header.data.topLink}
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MobileMenu>
|
</MobileMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
|
|||||||
import useDropdownStore from "@/stores/main-menu"
|
import useDropdownStore from "@/stores/main-menu"
|
||||||
|
|
||||||
import { ChevronDownSmallIcon } from "@/components/Icons"
|
import { ChevronDownSmallIcon } from "@/components/Icons"
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import useClickOutside from "@/hooks/useClickOutside"
|
import useClickOutside from "@/hooks/useClickOutside"
|
||||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||||
@@ -47,7 +48,7 @@ export default function MyPagesMenu({
|
|||||||
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
|
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
|
||||||
>
|
>
|
||||||
<Avatar initials={getInitials(user.firstName, user.lastName)} />
|
<Avatar initials={getInitials(user.firstName, user.lastName)} />
|
||||||
<Body textTransform="bold" color="textHighContrast" asChild>
|
<Body textTransform="bold" color="baseTextHighContrast" asChild>
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage({ id: "Hi" })} {user.firstName}!
|
{intl.formatMessage({ id: "Hi" })} {user.firstName}!
|
||||||
</span>
|
</span>
|
||||||
@@ -73,3 +74,15 @@ export default function MyPagesMenu({
|
|||||||
</div>
|
</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 { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import Avatar from "../Avatar"
|
import Avatar from "../Avatar"
|
||||||
import MyPagesMenu from "../MyPagesMenu"
|
import MyPagesMenu, { MyPagesMenuSkeleton } from "../MyPagesMenu"
|
||||||
import MyPagesMobileMenu from "../MyPagesMobileMenu"
|
import MyPagesMobileMenu, {
|
||||||
|
MyPagesMobileMenuSkeleton,
|
||||||
|
} from "../MyPagesMobileMenu"
|
||||||
|
|
||||||
import styles from "./myPagesMenuWrapper.module.css"
|
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 { useEffect } from "react"
|
||||||
import { Dialog, Modal } from "react-aria-components"
|
import { Dialog, Modal } from "react-aria-components"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
import { useMediaQuery } from "usehooks-ts"
|
||||||
|
|
||||||
import useDropdownStore from "@/stores/main-menu"
|
import useDropdownStore from "@/stores/main-menu"
|
||||||
|
|
||||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||||
import useMediaQuery from "@/hooks/useMediaQuery"
|
|
||||||
import { getInitials } from "@/utils/user"
|
import { getInitials } from "@/utils/user"
|
||||||
|
|
||||||
import Avatar from "../Avatar"
|
import Avatar from "../Avatar"
|
||||||
@@ -76,3 +76,13 @@ export default function MyPagesMobileMenu({
|
|||||||
</div>
|
</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 NavigationMenuItem from "../NavigationMenuItem"
|
||||||
|
|
||||||
import styles from "./navigationMenuList.module.css"
|
import styles from "./navigationMenuList.module.css"
|
||||||
@@ -20,3 +24,13 @@ export default function NavigationMenuList({
|
|||||||
</ul>
|
</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 { getIntl } from "@/i18n"
|
||||||
import { getLang } from "@/i18n/serverContext"
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
|
import { NavigationMenuListSkeleton } from "./NavigationMenu/NavigationMenuList"
|
||||||
|
import { MobileMenuSkeleton } from "./MobileMenu"
|
||||||
import MobileMenuWrapper from "./MobileMenuWrapper"
|
import MobileMenuWrapper from "./MobileMenuWrapper"
|
||||||
import MyPagesMenuWrapper from "./MyPagesMenuWrapper"
|
import MyPagesMenuWrapper, {
|
||||||
|
MyPagesMenuWrapperSkeleton,
|
||||||
|
} from "./MyPagesMenuWrapper"
|
||||||
import NavigationMenu from "./NavigationMenu"
|
import NavigationMenu from "./NavigationMenu"
|
||||||
|
|
||||||
import styles from "./mainMenu.module.css"
|
import styles from "./mainMenu.module.css"
|
||||||
|
|
||||||
export default async function MainMenu() {
|
export default function MainMenu() {
|
||||||
const lang = getLang()
|
|
||||||
|
|
||||||
const intl = await getIntl()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mainMenu}>
|
<div className={styles.mainMenu}>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<NextLink className={styles.logoLink} href={`/${lang}`}>
|
<Suspense fallback={<Logo alt="..." />}>
|
||||||
<Image
|
<MainMenuLogo />
|
||||||
alt={intl.formatMessage({ id: "Back to scandichotels.com" })}
|
</Suspense>
|
||||||
className={styles.logo}
|
|
||||||
height={22}
|
|
||||||
src="/_static/img/scandic-logotype.svg"
|
|
||||||
width={103}
|
|
||||||
/>
|
|
||||||
</NextLink>
|
|
||||||
<div className={styles.menus}>
|
<div className={styles.menus}>
|
||||||
<Suspense fallback={"Loading nav"}>
|
<Suspense fallback={<NavigationMenuListSkeleton />}>
|
||||||
<NavigationMenu isMobile={false} />
|
<NavigationMenu isMobile={false} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={"Loading profile"}>
|
<Suspense fallback={<MyPagesMenuWrapperSkeleton />}>
|
||||||
<MyPagesMenuWrapper />
|
<MyPagesMenuWrapper />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={"Loading menu"}>
|
<Suspense fallback={<MobileMenuSkeleton />}>
|
||||||
<MobileMenuWrapper>
|
<MobileMenuWrapper>
|
||||||
<NavigationMenu isMobile={true} />
|
<NavigationMenu isMobile={true} />
|
||||||
</MobileMenuWrapper>
|
</MobileMenuWrapper>
|
||||||
@@ -45,3 +39,25 @@ export default async function MainMenu() {
|
|||||||
</div>
|
</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 {
|
.mainMenu {
|
||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
padding: var(--Spacing-x2);
|
padding: var(--Spacing-x2);
|
||||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.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 LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import HeaderLink from "../HeaderLink"
|
import HeaderLink from "../HeaderLink"
|
||||||
|
import TopLink from "../TopLink"
|
||||||
|
|
||||||
import styles from "./topMenu.module.css"
|
import styles from "./topMenu.module.css"
|
||||||
|
|
||||||
|
import { IconName } from "@/types/components/icon"
|
||||||
|
|
||||||
export default async function TopMenu() {
|
export default async function TopMenu() {
|
||||||
// cached
|
// cached
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
// both preloaded
|
// both preloaded
|
||||||
const languages = await getLanguageSwitcher()
|
const languages = await getLanguageSwitcher()
|
||||||
const header = await getHeader()
|
const header = await getHeader()
|
||||||
|
const user = await getName()
|
||||||
|
|
||||||
if (!languages || !header) {
|
if (!languages || !header) {
|
||||||
return null
|
return null
|
||||||
@@ -24,28 +31,27 @@ export default async function TopMenu() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.topMenu}>
|
<div className={styles.topMenu}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{header.data.topLink.link ? (
|
<TopLink isLoggedIn={!!user} topLink={header.data.topLink} />
|
||||||
<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}
|
|
||||||
<div className={styles.options}>
|
<div className={styles.options}>
|
||||||
<LanguageSwitcher type="desktopHeader" urls={languages.urls} />
|
<LanguageSwitcher type="desktopHeader" urls={languages.urls} />
|
||||||
|
|
||||||
<Caption type="regular" color="textMediumContrast" asChild>
|
<Caption type="regular" color="textMediumContrast" asChild>
|
||||||
<Link href="#" color="peach80" variant="icon">
|
<HeaderLink href="#" iconName={IconName.Search}>
|
||||||
<SearchIcon width={20} height={20} />
|
|
||||||
{intl.formatMessage({ id: "Find booking" })}
|
{intl.formatMessage({ id: "Find booking" })}
|
||||||
</Link>
|
</HeaderLink>
|
||||||
</Caption>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
|
import {
|
||||||
|
getHeader,
|
||||||
|
getLanguageSwitcher,
|
||||||
|
getName,
|
||||||
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import MainMenu from "./MainMenu"
|
import MainMenu from "./MainMenu"
|
||||||
import TopMenu from "./TopMenu"
|
import TopMenu, { TopMenuSkeleton } from "./TopMenu"
|
||||||
|
|
||||||
import styles from "./header.module.css"
|
import styles from "./header.module.css"
|
||||||
|
|
||||||
export default function Header() {
|
export default async function Header() {
|
||||||
void getHeader()
|
void getHeader()
|
||||||
void getLanguageSwitcher()
|
void getLanguageSwitcher()
|
||||||
|
void getName()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Suspense fallback="Loading top menu">
|
<Suspense fallback={<TopMenuSkeleton />}>
|
||||||
<TopMenu />
|
<TopMenu />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback="Loading main menu">
|
<MainMenu />
|
||||||
<MainMenu />
|
|
||||||
</Suspense>
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
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}>
|
<span className={styles.heading}>
|
||||||
{intl.formatMessage({ id: "Driving directions" })}
|
{intl.formatMessage({ id: "Driving directions" })}
|
||||||
</span>
|
</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
|
Google Maps
|
||||||
</Link>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className={styles.heading}>
|
<span className={styles.heading}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Label as AriaLabel } from "react-aria-components"
|
import { Label as AriaLabel } from "react-aria-components"
|
||||||
@@ -45,6 +44,8 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|||||||
const maxRetries = 40
|
const maxRetries = 40
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
|
|
||||||
|
export const formId = "submit-booking"
|
||||||
|
|
||||||
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||||
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
||||||
}
|
}
|
||||||
@@ -59,10 +60,13 @@ export default function Payment({
|
|||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const queryParams = useSearchParams()
|
const queryParams = useSearchParams()
|
||||||
const { userData, roomData } = useEnterDetailsStore((state) => ({
|
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
|
||||||
userData: state.userData,
|
(state) => ({
|
||||||
roomData: state.roomData,
|
userData: state.userData,
|
||||||
}))
|
roomData: state.roomData,
|
||||||
|
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
firstName,
|
firstName,
|
||||||
@@ -72,6 +76,7 @@ export default function Payment({
|
|||||||
countryCode,
|
countryCode,
|
||||||
breakfast,
|
breakfast,
|
||||||
bedType,
|
bedType,
|
||||||
|
membershipNo,
|
||||||
} = userData
|
} = userData
|
||||||
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
||||||
|
|
||||||
@@ -119,6 +124,16 @@ export default function Payment({
|
|||||||
}
|
}
|
||||||
}, [bookingStatus, router])
|
}, [bookingStatus, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSubmittingDisabled(
|
||||||
|
!methods.formState.isValid || methods.formState.isSubmitting
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
methods.formState.isValid,
|
||||||
|
methods.formState.isSubmitting,
|
||||||
|
setIsSubmittingDisabled,
|
||||||
|
])
|
||||||
|
|
||||||
function handleSubmit(data: PaymentFormData) {
|
function handleSubmit(data: PaymentFormData) {
|
||||||
const allQueryParams =
|
const allQueryParams =
|
||||||
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
|
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
|
||||||
@@ -132,17 +147,6 @@ export default function Payment({
|
|||||||
(card) => card.id === data.paymentMethod
|
(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({
|
initiateBooking.mutate({
|
||||||
hotelId: hotel,
|
hotelId: hotel,
|
||||||
checkInDate: fromDate,
|
checkInDate: fromDate,
|
||||||
@@ -160,9 +164,9 @@ export default function Payment({
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
phoneCountryCodePrefix,
|
phoneNumber,
|
||||||
phoneNumber: phone,
|
|
||||||
countryCode,
|
countryCode,
|
||||||
|
membershipNumber: membershipNo,
|
||||||
},
|
},
|
||||||
packages: {
|
packages: {
|
||||||
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
||||||
@@ -171,7 +175,8 @@ export default function Payment({
|
|||||||
petFriendly:
|
petFriendly:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
||||||
accessibility:
|
accessibility:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
smsConfirmationRequested: data.smsConfirmation,
|
smsConfirmationRequested: data.smsConfirmation,
|
||||||
roomPrice,
|
roomPrice,
|
||||||
@@ -209,6 +214,7 @@ export default function Payment({
|
|||||||
<form
|
<form
|
||||||
className={styles.paymentContainer}
|
className={styles.paymentContainer}
|
||||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||||
|
id={formId}
|
||||||
>
|
>
|
||||||
{mustBeGuaranteed ? (
|
{mustBeGuaranteed ? (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
@@ -309,15 +315,16 @@ export default function Payment({
|
|||||||
</Caption>
|
</Caption>
|
||||||
</AriaLabel>
|
</AriaLabel>
|
||||||
</section>
|
</section>
|
||||||
<Button
|
<div className={styles.submitButton}>
|
||||||
type="submit"
|
<Button
|
||||||
className={styles.submitButton}
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
!methods.formState.isValid || methods.formState.isSubmitting
|
!methods.formState.isValid || methods.formState.isSubmitting
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
{intl.formatMessage({ id: "Complete booking" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.submitButton {
|
.submitButton {
|
||||||
align-self: flex-start;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paymentContainer .link {
|
.paymentContainer .link {
|
||||||
@@ -31,3 +31,10 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x-one-and-half);
|
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 { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
|
||||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
|
||||||
@@ -62,6 +61,9 @@ export default function SectionAccordion({
|
|||||||
function onModify() {
|
function onModify() {
|
||||||
navigate(step)
|
navigate(step)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textColor =
|
||||||
|
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
|
||||||
return (
|
return (
|
||||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
||||||
<div className={styles.iconWrapper}>
|
<div className={styles.iconWrapper}>
|
||||||
@@ -72,37 +74,25 @@ export default function SectionAccordion({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
<header className={styles.headerContainer}>
|
<header>
|
||||||
<div>
|
<button onClick={onModify} className={styles.modifyButton}>
|
||||||
<Footnote
|
<Footnote
|
||||||
|
className={styles.title}
|
||||||
asChild
|
asChild
|
||||||
textTransform="uppercase"
|
textTransform="uppercase"
|
||||||
type="label"
|
type="label"
|
||||||
color="uiTextHighContrast"
|
color={textColor}
|
||||||
>
|
>
|
||||||
<h2>{header}</h2>
|
<h2>{header}</h2>
|
||||||
</Footnote>
|
</Footnote>
|
||||||
<Subtitle
|
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||||
type="two"
|
|
||||||
className={styles.selection}
|
|
||||||
color="uiTextHighContrast"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
</div>
|
|
||||||
{isComplete && !isOpen && (
|
{isComplete && !isOpen && (
|
||||||
<Button
|
<ChevronDownIcon className={styles.button} color="burgundy" />
|
||||||
onClick={onModify}
|
)}
|
||||||
theme="base"
|
</button>
|
||||||
size="small"
|
|
||||||
variant="icon"
|
|
||||||
intent="text"
|
|
||||||
wrapping
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Modify" })}{" "}
|
|
||||||
<ChevronDownIcon color="burgundy" width={20} height={20} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.content}>{children}</div>
|
<div className={styles.content}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,25 +2,33 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
|
||||||
padding-top: var(--Spacing-x3);
|
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 {
|
.wrapper:last-child .main {
|
||||||
border-bottom: none;
|
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 {
|
.main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
@@ -31,21 +39,14 @@
|
|||||||
grid-template-rows: 2em 0fr;
|
grid-template-rows: 2em 0fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerContainer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
font-size: var(--typography-Title-4-fontSize);
|
font-size: var(--typography-Title-4-fontSize);
|
||||||
|
grid-area: selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: var(--Spacing-x1);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
@@ -78,3 +79,23 @@
|
|||||||
.content {
|
.content {
|
||||||
overflow: hidden;
|
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 useSidePeekStore from "@/stores/sidepeek"
|
||||||
|
|
||||||
|
import ChevronRight from "@/components/Icons/ChevronRight"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
|
||||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||||
@@ -28,6 +29,7 @@ export default function ToggleSidePeek({
|
|||||||
wrapping
|
wrapping
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||||
|
<ChevronRight height="14" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,26 +33,25 @@ export default function SelectedRoom({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
<div className={styles.headerContainer}>
|
<div className={styles.headerContainer}>
|
||||||
<div>
|
<Footnote
|
||||||
<Footnote
|
className={styles.title}
|
||||||
asChild
|
asChild
|
||||||
textTransform="uppercase"
|
textTransform="uppercase"
|
||||||
type="label"
|
type="label"
|
||||||
color="uiTextHighContrast"
|
color="uiTextHighContrast"
|
||||||
>
|
>
|
||||||
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
|
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
|
||||||
</Footnote>
|
</Footnote>
|
||||||
<Subtitle
|
<Subtitle
|
||||||
type="two"
|
type="two"
|
||||||
className={styles.selection}
|
className={styles.description}
|
||||||
color="uiTextHighContrast"
|
color="uiTextHighContrast"
|
||||||
>
|
>
|
||||||
{room.roomType}{" "}
|
{room.roomType}{" "}
|
||||||
<span className={styles.rate}>{`(${rateDescription})`}</span>
|
<span className={styles.rate}>{rateDescription}</span>
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
className={styles.button}
|
||||||
color="burgundy"
|
color="burgundy"
|
||||||
href={selectRateUrl}
|
href={selectRateUrl}
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -2,43 +2,41 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x-one-and-half);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
padding-bottom: var(--Spacing-x3);
|
padding-bottom: var(--Spacing-x3);
|
||||||
grid-template-rows: 2em 0fr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerContainer {
|
.headerContainer {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
grid-template-areas:
|
||||||
|
"title button"
|
||||||
|
"description button";
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection {
|
.title {
|
||||||
|
grid-area: title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
font-size: var(--typography-Title-4-fontSize);
|
font-size: var(--typography-Title-4-fontSize);
|
||||||
|
grid-area: description;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
grid-area: button;
|
||||||
|
justify-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: var(--Spacing-x1);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
@@ -57,9 +55,42 @@
|
|||||||
|
|
||||||
.rate {
|
.rate {
|
||||||
color: var(--UI-Text-Placeholder);
|
color: var(--UI-Text-Placeholder);
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details {
|
.details {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
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"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { ChevronDown } from "react-feather"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
|
||||||
import { ArrowRightIcon } from "@/components/Icons"
|
import { ArrowRightIcon } from "@/components/Icons"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
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 { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||||
|
|
||||||
function parsePrice(price: string | undefined) {
|
function storeSelector(state: EnterDetailsState) {
|
||||||
return price ? parseInt(price) : 0
|
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({
|
export default function Summary({
|
||||||
@@ -36,20 +46,17 @@ export default function Summary({
|
|||||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
||||||
>()
|
>()
|
||||||
const [totalPrice, setTotalPrice] = useState({
|
|
||||||
local: parsePrice(room.localPrice.price),
|
|
||||||
euro: parsePrice(room.euroPrice.price),
|
|
||||||
})
|
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
|
const {
|
||||||
(state) => ({
|
fromDate,
|
||||||
fromDate: state.roomData.fromDate,
|
toDate,
|
||||||
toDate: state.roomData.toDate,
|
bedType,
|
||||||
bedType: state.userData.bedType,
|
breakfast,
|
||||||
breakfast: state.userData.breakfast,
|
setTotalPrice,
|
||||||
})
|
totalPrice,
|
||||||
)
|
toggleSummaryOpen,
|
||||||
|
} = useEnterDetailsStore(storeSelector)
|
||||||
|
|
||||||
const diff = dt(toDate).diff(fromDate, "days")
|
const diff = dt(toDate).diff(fromDate, "days")
|
||||||
|
|
||||||
@@ -63,51 +70,85 @@ export default function Summary({
|
|||||||
color = "red"
|
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(() => {
|
useEffect(() => {
|
||||||
setChosenBed(bedType)
|
setChosenBed(bedType)
|
||||||
|
setChosenBreakfast(breakfast)
|
||||||
|
|
||||||
if (breakfast) {
|
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
|
||||||
setChosenBreakfast(breakfast)
|
setTotalPrice({
|
||||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
local: {
|
||||||
setTotalPrice({
|
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||||
local: parsePrice(room.localPrice.price),
|
currency: room.localPrice.currency,
|
||||||
euro: parsePrice(room.euroPrice.price),
|
},
|
||||||
})
|
euro: {
|
||||||
} else {
|
price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice),
|
||||||
setTotalPrice({
|
currency: room.euroPrice.currency,
|
||||||
local:
|
},
|
||||||
parsePrice(room.localPrice.price) +
|
})
|
||||||
parsePrice(breakfast.localPrice.totalPrice),
|
} else {
|
||||||
euro:
|
setTotalPrice({
|
||||||
parsePrice(room.euroPrice.price) +
|
local: {
|
||||||
parsePrice(breakfast.requestedPrice.totalPrice),
|
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 (
|
return (
|
||||||
<section className={styles.summary}>
|
<section className={styles.summary}>
|
||||||
<header>
|
<header className={styles.header}>
|
||||||
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
|
<Subtitle className={styles.title} type="two">
|
||||||
|
{intl.formatMessage({ id: "Summary" })}
|
||||||
|
</Subtitle>
|
||||||
<Body className={styles.date} color="baseTextMediumContrast">
|
<Body className={styles.date} color="baseTextMediumContrast">
|
||||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||||
</Body>
|
</Body>
|
||||||
|
<Button
|
||||||
|
intent="text"
|
||||||
|
size="small"
|
||||||
|
className={styles.chevronButton}
|
||||||
|
onClick={toggleSummaryOpen}
|
||||||
|
>
|
||||||
|
<ChevronDown height="20" width="20" />
|
||||||
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<Divider color="primaryLightSubtle" />
|
<Divider color="primaryLightSubtle" />
|
||||||
<div className={styles.addOns}>
|
<div className={styles.addOns}>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.entry}>
|
<div className={styles.entry}>
|
||||||
<Body color="textHighContrast">{room.roomType}</Body>
|
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||||
<Caption color={color}>
|
<Caption color={color}>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
{
|
{
|
||||||
amount: intl.formatNumber(
|
amount: intl.formatNumber(room.localPrice.price),
|
||||||
parseInt(room.localPrice.price ?? "0")
|
|
||||||
),
|
|
||||||
currency: room.localPrice.currency,
|
currency: room.localPrice.currency,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
@@ -134,17 +175,37 @@ export default function Summary({
|
|||||||
{intl.formatMessage({ id: "Rate details" })}
|
{intl.formatMessage({ id: "Rate details" })}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 ? (
|
{chosenBed ? (
|
||||||
<div className={styles.entry}>
|
<div className={styles.entry}>
|
||||||
<div>
|
<div>
|
||||||
<Body color="textHighContrast">{chosenBed.description}</Body>
|
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
|
||||||
<Caption color="uiTextMediumContrast">
|
<Caption color="uiTextMediumContrast">
|
||||||
{intl.formatMessage({ id: "Based on availability" })}
|
{intl.formatMessage({ id: "Based on availability" })}
|
||||||
</Caption>
|
</Caption>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Caption color="uiTextMediumContrast">
|
<Caption color="uiTextHighContrast">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
{ amount: "0", currency: room.localPrice.currency }
|
{ amount: "0", currency: room.localPrice.currency }
|
||||||
@@ -156,10 +217,10 @@ export default function Summary({
|
|||||||
{chosenBreakfast ? (
|
{chosenBreakfast ? (
|
||||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
||||||
<div className={styles.entry}>
|
<div className={styles.entry}>
|
||||||
<Body color="textHighContrast">
|
<Body color="uiTextHighContrast">
|
||||||
{intl.formatMessage({ id: "No breakfast" })}
|
{intl.formatMessage({ id: "No breakfast" })}
|
||||||
</Body>
|
</Body>
|
||||||
<Caption color="uiTextMediumContrast">
|
<Caption color="uiTextHighContrast">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
{ amount: "0", currency: room.localPrice.currency }
|
{ amount: "0", currency: room.localPrice.currency }
|
||||||
@@ -168,10 +229,10 @@ export default function Summary({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.entry}>
|
<div className={styles.entry}>
|
||||||
<Body color="textHighContrast">
|
<Body color="uiTextHighContrast">
|
||||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||||
</Body>
|
</Body>
|
||||||
<Caption color="uiTextMediumContrast">
|
<Caption color="uiTextHighContrast">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
{
|
{
|
||||||
@@ -203,8 +264,8 @@ export default function Summary({
|
|||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
{
|
{
|
||||||
amount: intl.formatNumber(totalPrice.local),
|
amount: intl.formatNumber(totalPrice.local.price),
|
||||||
currency: room.localPrice.currency,
|
currency: totalPrice.local.currency,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
@@ -213,14 +274,14 @@ export default function Summary({
|
|||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
{
|
{
|
||||||
amount: intl.formatNumber(totalPrice.euro),
|
amount: intl.formatNumber(totalPrice.euro.price),
|
||||||
currency: room.euroPrice.currency,
|
currency: totalPrice.euro.currency,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Caption>
|
</Caption>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider color="primaryLightSubtle" />
|
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,11 +7,28 @@
|
|||||||
height: 100%;
|
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 {
|
.date {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
grid-area: date;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@@ -38,3 +55,21 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x2);
|
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 {
|
.card {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-areas:
|
flex-direction: column;
|
||||||
"image header"
|
|
||||||
"hotel hotel"
|
|
||||||
"prices prices";
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
padding: var(--Spacing-x2);
|
|
||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
border-radius: var(--Corner-radius-Medium);
|
border-radius: var(--Corner-radius-Medium);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.active {
|
||||||
|
border: 1px solid var(--Base-Border-Hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.active {
|
.card.active {
|
||||||
@@ -17,14 +17,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.imageContainer {
|
.imageContainer {
|
||||||
grid-area: image;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 200px;
|
||||||
width: 116px;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.tripAdvisor {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.imageContainer img {
|
.imageContainer img {
|
||||||
@@ -32,19 +27,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hotelInformation {
|
.hotelInformation {
|
||||||
grid-area: header;
|
margin-bottom: var(--Spacing-x-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotel {
|
.hotelContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.facilities {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
|
margin-top: var(--Spacing-x-one-and-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
.facilitiesItem {
|
.facilitiesItem {
|
||||||
@@ -56,66 +73,76 @@
|
|||||||
.prices {
|
.prices {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x-one-and-half);
|
||||||
grid-area: prices;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public,
|
.detailsButton {
|
||||||
.member {
|
border-bottom: none;
|
||||||
max-width: fit-content;
|
|
||||||
margin-bottom: var(--Spacing-x-half);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
justify-content: center;
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialAlerts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.card.pageListing {
|
.card.pageListing {
|
||||||
grid-template-areas:
|
flex-direction: row;
|
||||||
"image header"
|
|
||||||
"image hotel"
|
|
||||||
"image prices";
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageListing .imageContainer {
|
.pageListing .hotelDescription {
|
||||||
position: relative;
|
display: block;
|
||||||
min-height: 200px;
|
|
||||||
width: 518px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageListing .tripAdvisor {
|
.pageListing .imageContainer {
|
||||||
position: absolute;
|
position: relative;
|
||||||
display: block;
|
height: 100%;
|
||||||
left: 7px;
|
width: 314px;
|
||||||
top: 7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageListing .hotelInformation {
|
.pageListing .hotelInformation {
|
||||||
padding-top: var(--Spacing-x2);
|
width: min(422px, 100%);
|
||||||
padding-right: var(--Spacing-x2);
|
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);
|
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 {
|
.pageListing .prices {
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
width: 260px;
|
||||||
padding-right: var(--Spacing-x2);
|
|
||||||
padding-bottom: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageListing .detailsButton {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageListing .button {
|
.pageListing .button {
|
||||||
width: 160px;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageListing .addressMobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageListing .address {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { useParams } from "next/dist/client/components/navigation"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Lang } from "@/constants/languages"
|
||||||
|
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
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 Link from "@/components/TempDesignSystem/Link"
|
||||||
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
|
||||||
|
|
||||||
import ReadMore from "../ReadMore"
|
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 { hotelCardVariants } from "./variants"
|
||||||
|
|
||||||
import styles from "./hotelCard.module.css"
|
import styles from "./hotelCard.module.css"
|
||||||
@@ -26,6 +32,8 @@ export default function HotelCard({
|
|||||||
state = "default",
|
state = "default",
|
||||||
onHotelCardHover,
|
onHotelCardHover,
|
||||||
}: HotelCardProps) {
|
}: HotelCardProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const lang = params.lang as Lang
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
const { hotelData } = hotel
|
const { hotelData } = hotel
|
||||||
@@ -55,93 +63,104 @@ export default function HotelCard({
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<section className={styles.imageContainer}>
|
<div>
|
||||||
{hotelData.gallery && (
|
<div className={styles.imageContainer}>
|
||||||
<ImageGallery
|
<ImageGallery
|
||||||
title={hotelData.name}
|
title={hotelData.name}
|
||||||
images={[
|
images={hotelData.galleryImages}
|
||||||
hotelData.hotelContent.images,
|
fill
|
||||||
...hotelData.gallery.heroImages,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{hotelData.ratings?.tripAdvisor && (
|
||||||
<div className={styles.tripAdvisor}>
|
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
|
||||||
<Chip intent="primary" className={styles.tripAdvisor}>
|
)}
|
||||||
<TripAdvisorIcon color="white" />
|
|
||||||
{hotelData.ratings?.tripAdvisor.rating}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
<section className={styles.hotelInformation}>
|
<div className={styles.hotelContent}>
|
||||||
<ScandicLogoIcon color="red" />
|
<section className={styles.hotelInformation}>
|
||||||
<Title as="h4" textTransform="capitalize">
|
<div className={styles.titleContainer}>
|
||||||
{hotelData.name}
|
<HotelLogo
|
||||||
</Title>
|
hotelId={hotel.hotelData.operaId}
|
||||||
<Footnote color="uiTextMediumContrast">
|
hotelType={hotel.hotelData.hotelType}
|
||||||
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
|
/>
|
||||||
</Footnote>
|
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
|
||||||
<Footnote color="uiTextMediumContrast">
|
{hotelData.name}
|
||||||
{`${hotelData.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
</Subtitle>
|
||||||
</Footnote>
|
<div className={styles.addressContainer}>
|
||||||
</section>
|
<address className={styles.address}>
|
||||||
<section className={styles.hotel}>
|
<Caption color="uiTextPlaceholder">
|
||||||
<div className={styles.facilities}>
|
{hotelData.address.streetAddress}, {hotelData.address.city}
|
||||||
{amenities.map((facility) => {
|
</Caption>
|
||||||
const IconComponent = mapFacilityToIcon(facility.id)
|
</address>
|
||||||
return (
|
<Link
|
||||||
<div className={styles.facilitiesItem} key={facility.id}>
|
className={styles.addressMobile}
|
||||||
{IconComponent && <IconComponent color="grey80" />}
|
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
|
||||||
<Caption color="textMediumContrast">{facility.name}</Caption>
|
keepSearchParams
|
||||||
|
>
|
||||||
|
<Caption color="baseTextMediumContrast" type="underline">
|
||||||
|
{hotelData.address.streetAddress}, {hotelData.address.city}
|
||||||
|
</Caption>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<Divider variant="vertical" color="subtle" />
|
||||||
</div>
|
</div>
|
||||||
)
|
<Caption color="uiTextPlaceholder">
|
||||||
})}
|
{intl.formatMessage(
|
||||||
</div>
|
{ id: "Distance in km to city centre" },
|
||||||
<ReadMore
|
{ number: hotelData.location.distanceToCentre }
|
||||||
label={intl.formatMessage({ id: "See hotel details" })}
|
)}
|
||||||
hotelId={hotelData.operaId}
|
</Caption>
|
||||||
hotel={hotelData}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</section>
|
<Body className={styles.hotelDescription}>
|
||||||
<section className={styles.prices}>
|
{hotelData.hotelContent.texts.descriptions.short}
|
||||||
<div>
|
</Body>
|
||||||
<Chip intent="primary" className={styles.public}>
|
<div className={styles.facilities}>
|
||||||
<PriceTagIcon color="white" width={15} height={15} />
|
{amenities.map((facility) => {
|
||||||
{intl.formatMessage({ id: "Public price from" })}
|
const IconComponent = mapFacilityToIcon(facility.id)
|
||||||
</Chip>
|
return (
|
||||||
<Caption color="textMediumContrast">
|
<div className={styles.facilitiesItem} key={facility.id}>
|
||||||
{price?.regularAmount} {price?.currency} /
|
{IconComponent && <IconComponent color="grey80" />}
|
||||||
{intl.formatMessage({ id: "night" })}
|
<Caption color="uiTextMediumContrast">
|
||||||
</Caption>
|
{facility.name}
|
||||||
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
|
</Caption>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)
|
||||||
<Chip intent="primary" className={styles.member}>
|
})}
|
||||||
<PriceTagIcon color="white" width={15} height={15} />
|
</div>
|
||||||
{intl.formatMessage({ id: "Member price from" })}
|
<ReadMore
|
||||||
</Chip>
|
label={intl.formatMessage({ id: "See hotel details" })}
|
||||||
<Caption color="textMediumContrast">
|
hotelId={hotelData.operaId}
|
||||||
{price?.memberAmount} {price?.currency} /
|
hotel={hotelData}
|
||||||
{intl.formatMessage({ id: "night" })}
|
showCTA={true}
|
||||||
</Caption>
|
/>
|
||||||
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
|
{hotelData.specialAlerts.length > 0 && (
|
||||||
</div>
|
<div className={styles.specialAlerts}>
|
||||||
<Button
|
{hotelData.specialAlerts.map((alert) => (
|
||||||
asChild
|
<Alert key={alert.id} type={alert.type} text={alert.text} />
|
||||||
theme="base"
|
))}
|
||||||
intent="tertiary"
|
</div>
|
||||||
size="small"
|
)}
|
||||||
className={styles.button}
|
</section>
|
||||||
>
|
<div className={styles.prices}>
|
||||||
{/* TODO: Localize link and also use correct search params */}
|
<HotelPriceList price={price} />
|
||||||
<Link
|
<Button
|
||||||
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
asChild
|
||||||
color="none"
|
theme="base"
|
||||||
keepSearchParams
|
intent="primary"
|
||||||
|
size="small"
|
||||||
|
className={styles.button}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "See rooms" })}
|
{/* TODO: Localize link and also use correct search params */}
|
||||||
</Link>
|
<Link
|
||||||
</Button>
|
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
||||||
</section>
|
color="none"
|
||||||
|
keepSearchParams
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "See rooms" })}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.closeIcon {
|
.closeIcon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 7px;
|
top: 7px;
|
||||||
@@ -52,7 +58,7 @@
|
|||||||
.facilities {
|
.facilities {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--Spacing-x1);
|
gap: 0 var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.facilitiesItem {
|
.facilitiesItem {
|
||||||
@@ -67,7 +73,6 @@
|
|||||||
background: var(--Base-Surface-Secondary-light-Normal);
|
background: var(--Base-Surface-Secondary-light-Normal);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x-half);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.perNight {
|
.perNight {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Lang } from "@/constants/languages"
|
||||||
|
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
import { CloseLargeIcon } from "@/components/Icons"
|
import { CloseLargeIcon } from "@/components/Icons"
|
||||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||||
import Image from "@/components/Image"
|
import Image from "@/components/Image"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Chip from "@/components/TempDesignSystem/Chip"
|
import Chip from "@/components/TempDesignSystem/Chip"
|
||||||
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
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"
|
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||||
|
|
||||||
export default function HotelCardDialog({
|
export default function HotelCardDialog({
|
||||||
pin,
|
data,
|
||||||
isOpen,
|
isOpen,
|
||||||
handleClose,
|
handleClose,
|
||||||
}: HotelCardDialogProps) {
|
}: HotelCardDialogProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const lang = params.lang as Lang
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
if (!pin) {
|
if (!data) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +42,7 @@ export default function HotelCardDialog({
|
|||||||
amenities,
|
amenities,
|
||||||
images,
|
images,
|
||||||
ratings,
|
ratings,
|
||||||
} = pin
|
} = data
|
||||||
|
|
||||||
const firstImage = images[0]?.imageSizes?.small
|
const firstImage = images[0]?.imageSizes?.small
|
||||||
const altText = images[0]?.metaData?.altText
|
const altText = images[0]?.metaData?.altText
|
||||||
@@ -52,20 +59,24 @@ export default function HotelCardDialog({
|
|||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
<Image src={firstImage} alt={altText} fill />
|
<Image src={firstImage} alt={altText} fill />
|
||||||
<div className={styles.tripAdvisor}>
|
<div className={styles.tripAdvisor}>
|
||||||
<Chip intent="primary" className={styles.tripAdvisor}>
|
<Chip intent="secondary" className={styles.tripAdvisor}>
|
||||||
<TripAdvisorIcon color="white" />
|
<TripAdvisorIcon color="burgundy" />
|
||||||
{ratings}
|
{ratings}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Body textTransform="bold">{name}</Body>
|
<div className={styles.name}>
|
||||||
|
<Body textTransform="bold">{name}</Body>
|
||||||
|
</div>
|
||||||
<div className={styles.facilities}>
|
<div className={styles.facilities}>
|
||||||
{amenities.map((facility) => {
|
{amenities.map((facility) => {
|
||||||
const IconComponent = mapFacilityToIcon(facility.id)
|
const IconComponent = mapFacilityToIcon(facility.id)
|
||||||
return (
|
return (
|
||||||
<div className={styles.facilitiesItem} key={facility.id}>
|
<div className={styles.facilitiesItem} key={facility.id}>
|
||||||
{IconComponent && <IconComponent color="grey80" />}
|
{IconComponent && (
|
||||||
|
<IconComponent width={16} height={16} color="grey80" />
|
||||||
|
)}
|
||||||
<Caption color="uiTextMediumContrast">
|
<Caption color="uiTextMediumContrast">
|
||||||
{facility.name}
|
{facility.name}
|
||||||
</Caption>
|
</Caption>
|
||||||
@@ -90,8 +101,15 @@ export default function HotelCardDialog({
|
|||||||
</Subtitle>
|
</Subtitle>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
.hotelCards {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 (
|
return (
|
||||||
<section className={styles.hotelCards}>
|
<section className={styles.hotelCards}>
|
||||||
{hotels?.length ? (
|
{hotels?.length
|
||||||
hotels.map((hotel) => (
|
? hotels.map((hotel) => (
|
||||||
<HotelCard
|
<HotelCard
|
||||||
key={hotel.hotelData.operaId}
|
key={hotel.hotelData.operaId}
|
||||||
hotel={hotel}
|
hotel={hotel}
|
||||||
type={type}
|
type={type}
|
||||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||||
onHotelCardHover={onHotelCardHover}
|
onHotelCardHover={onHotelCardHover}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
: null}
|
||||||
<Title>No hotels found</Title>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function HotelSelectionHeader({
|
|||||||
</div>
|
</div>
|
||||||
<Caption color="textMediumContrast">
|
<Caption color="textMediumContrast">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "Distance to city centre" },
|
{ id: "Distance in km to city centre" },
|
||||||
{ number: hotel.location.distanceToCentre }
|
{ number: hotel.location.distanceToCentre }
|
||||||
)}
|
)}
|
||||||
</Caption>
|
</Caption>
|
||||||
@@ -41,7 +41,7 @@ export default function HotelSelectionHeader({
|
|||||||
<Divider variant="vertical" color="subtle" />
|
<Divider variant="vertical" color="subtle" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.descriptionContainer}>
|
<div className={styles.descriptionContainer}>
|
||||||
<Body color="textHighContrast">
|
<Body color="baseTextHighContrast">
|
||||||
{hotel.hotelContent.texts.descriptions.short}
|
{hotel.hotelContent.texts.descriptions.short}
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import styles from "./readMore.module.css"
|
|||||||
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
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)
|
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })
|
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId, showCTA })
|
||||||
}}
|
}}
|
||||||
intent="text"
|
intent="text"
|
||||||
theme="base"
|
theme="base"
|
||||||
@@ -23,7 +23,11 @@ export default function ReadMore({ label, hotelId }: ReadMoreProps) {
|
|||||||
className={styles.detailsButton}
|
className={styles.detailsButton}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<ChevronRightIcon color="burgundy" height={20} width={20} />
|
<ChevronRightIcon
|
||||||
|
color="baseButtonTextOnFillNormal"
|
||||||
|
height={20}
|
||||||
|
width={20}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,30 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
.facilities {
|
.facilities {
|
||||||
font-family: var(--typography-Body-Bold-fontFamily);
|
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 {
|
.filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(min-content, max-content));
|
grid-template-columns: repeat(2, minmax(min-content, max-content));
|
||||||
gap: var(--Spacing-x-one-and-half);
|
gap: var(--Spacing-x-one-and-half);
|
||||||
margin-bottom: var(--Spacing-x-one-and-half);
|
margin-bottom: var(--Spacing-x1);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,29 @@
|
|||||||
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation"
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
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 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) {
|
export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const { watch, handleSubmit, getValues, register } = useForm<
|
const methods = useForm<Record<string, boolean | undefined>>({
|
||||||
Record<string, boolean | undefined>
|
|
||||||
>({
|
|
||||||
defaultValues: searchParams
|
defaultValues: searchParams
|
||||||
?.get("filters")
|
?.get("filters")
|
||||||
?.split(",")
|
?.split(",")
|
||||||
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
|
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
|
||||||
})
|
})
|
||||||
|
const { watch, handleSubmit, getValues, register } = methods
|
||||||
|
|
||||||
const submitFilter = useCallback(() => {
|
const submitFilter = useCallback(() => {
|
||||||
const newSearchParams = new URLSearchParams(searchParams)
|
const newSearchParams = new URLSearchParams(searchParams)
|
||||||
@@ -50,43 +53,42 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
|
|||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}, [handleSubmit, watch, submitFilter])
|
}, [handleSubmit, watch, submitFilter])
|
||||||
|
|
||||||
|
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.container}>
|
<aside className={styles.container}>
|
||||||
<div className={styles.facilities}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={handleSubmit(submitFilter)}>
|
<form onSubmit={handleSubmit(submitFilter)}>
|
||||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||||
<ul>
|
<div className={styles.facilities}>
|
||||||
{filters.facilityFilters.map((filter) => (
|
<Subtitle>
|
||||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
{intl.formatMessage({ id: "Hotel facilities" })}
|
||||||
<input
|
</Subtitle>
|
||||||
type="checkbox"
|
<ul>
|
||||||
id={`checkbox-${filter.id.toString()}`}
|
{filters.facilityFilters.map((filter) => (
|
||||||
{...register(filter.id.toString())}
|
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||||
/>
|
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
||||||
<label htmlFor={`checkbox-${filter.id.toString()}`}>
|
</li>
|
||||||
{filter.name}
|
))}
|
||||||
</label>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
<div className={styles.facilities}>
|
||||||
<ul>
|
<Subtitle>
|
||||||
{filters.surroundingsFilters.map((filter) => (
|
{intl.formatMessage({ id: "Hotel surroundings" })}
|
||||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
</Subtitle>
|
||||||
<input
|
<ul>
|
||||||
type="checkbox"
|
{filters.surroundingsFilters.map((filter) => (
|
||||||
id={`checkbox-${filter.id.toString()}`}
|
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||||
{...register(filter.id.toString())}
|
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
||||||
/>
|
</li>
|
||||||
<label htmlFor={`checkbox-${filter.id.toString()}`}>
|
))}
|
||||||
{filter.name}
|
</ul>
|
||||||
</label>
|
</div>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</FormProvider>
|
||||||
</aside>
|
</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