Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-11-18 12:20:13 +01:00
240 changed files with 5429 additions and 2717 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? []}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}` ? `${roomSize.min}`
: `${roomSize.min} - ${roomSize.max}` : `${roomSize.min} - ${roomSize.max}`
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>
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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" />
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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