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_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"
HIDE_FOR_NEXT_RELEASE="true"
SALESFORCE_PREFERENCE_BASE_URL="test"
USE_NEW_REWARDS_ENDPOINT="true"

View File

@@ -1,5 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
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 { isSignupPage } from "@/constants/routes/signup"
import { env } from "@/env/server"
import HotelPage from "@/components/ContentType/HotelPage"
@@ -22,17 +24,33 @@ export default function ContentTypePage({
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
setLang(params.lang)
const pathname = headers().get("x-pathname") || ""
switch (params.contentType) {
case "collection-page":
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <CollectionPage />
case "content-page":
case "content-page": {
const isSignupRoute = isSignupPage(pathname)
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
// Hide content pages for next release for non-signup routes.
if (!isSignupRoute) {
return notFound()
}
}
if (!env.SHOW_SIGNUP_FLOW) {
// Hide content pages for signup routes when signup flow is disabled.
if (isSignupRoute) {
return notFound()
}
}
return <ContentPage />
}
case "loyalty-page":
return <LoyaltyPage />
case "hotel-page":

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

View File

@@ -8,94 +8,38 @@
background-color: var(--Scandic-Brand-Warm-White);
}
.enter-details-layout__content {
.enter-details-layout__container {
display: grid;
gap: var(--Spacing-x3) var(--Spacing-x9);
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
margin: var(--Spacing-x5) auto 0;
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.enter-details-layout__content {
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.enter-details-layout__summaryContainer {
grid-column: 2 / 3;
grid-row: 1/-1;
}
.enter-details-layout__summary {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Large);
z-index: 1;
}
.enter-details-layout__hider {
display: none;
}
.enter-details-layout__shadow {
display: none;
}
@media screen and (min-width: 950px) {
.enter-details-layout__summaryContainer {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.enter-details-layout__summary {
position: sticky;
top: calc(
var(--booking-widget-desktop-height) +
var(--booking-widget-desktop-height) + var(--Spacing-x-one-and-half)
);
margin-top: calc(0px - var(--Spacing-x9));
border-bottom: none;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
}
.enter-details-layout__hider {
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
margin-top: var(--Spacing-x4);
top: calc(
var(--booking-widget-desktop-height) +
var(--booking-widget-desktop-height) - 6px
);
height: 40px;
}
.enter-details-layout__shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
border-top: none;
border-bottom: none;
}
position: sticky;
bottom: 0;
left: 0;
right: 0;
}
@media screen and (min-width: 1367px) {
.enter-details-layout__summary {
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
.enter-details-layout__container {
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
margin: var(--Spacing-x5) auto 0;
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.enter-details-layout__hider {
top: calc(var(--booking-widget-desktop-height) - 6px);
.enter-details-layout__summaryContainer {
position: static;
display: grid;
grid-column: 2/3;
grid-row: 1/-1;
}
}

View File

@@ -28,12 +28,10 @@ export default async function StepLayout({
<EnterDetailsProvider step={params.step} isMember={!!user}>
<main className="enter-details-layout__layout">
{hotelHeader}
<div className={"enter-details-layout__content"}>
{children}
<aside className="enter-details-layout__summaryContainer">
<div className="enter-details-layout__hider" />
<div className="enter-details-layout__summary">{summary}</div>
<div className="enter-details-layout__shadow" />
<div className={"enter-details-layout__container"}>
<div className={"enter-details-layout__content"}>{children}</div>
<aside className={"enter-details-layout__summaryContainer"}>
{summary}
</aside>
</div>
</main>

View File

@@ -46,7 +46,13 @@ export default async function StepPage({
toDate,
} = getQueryParamsForEnterDetails(selectRoomParams)
const { adults, children, roomTypeCode, rateCode } = rooms[0] // TODO: Handle multiple rooms
const {
adults,
children,
roomTypeCode,
rateCode,
packages: packageCodes,
} = rooms[0] // TODO: Handle multiple rooms
const childrenAsString = children && generateChildrenString(children)
@@ -60,12 +66,9 @@ export default async function StepPage({
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
packageCodes,
})
const hotelData = await getHotelData({
hotelId,
language: lang,
})
const roomAvailability = await getSelectedRoomAvailability({
hotelId,
adults,
@@ -74,6 +77,12 @@ export default async function StepPage({
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
packageCodes,
})
const hotelData = await getHotelData({
hotelId,
language: lang,
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
})
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const user = await getProfileSafely()

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
import { env } from "@/env/server"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils"
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
import {
generateChildrenString,
@@ -11,11 +12,7 @@ import {
import { MapModal } from "@/components/MapModal"
import { setLang } from "@/i18n/serverContext"
import {
fetchAvailableHotels,
getCentralCoordinates,
getHotelPins,
} from "../../utils"
import { fetchAvailableHotels } from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
@@ -61,16 +58,12 @@ export default async function SelectHotelMapPage({
const hotelPins = getHotelPins(hotels)
const centralCoordinates = getCentralCoordinates(hotelPins)
return (
<MapModal>
<SelectHotelMap
apiKey={googleMapsApiKey}
coordinates={centralCoordinates}
hotelPins={hotelPins}
mapId={googleMapId}
isModal={true}
hotels={hotels}
/>
</MapModal>

View File

@@ -1,21 +1,23 @@
.main {
display: flex;
gap: var(--Spacing-x3);
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
padding: 0 var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2);
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
flex-direction: column;
max-width: var(--max-width);
margin: 0 auto;
}
.header {
display: flex;
margin: 0 auto;
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
var(--Spacing-x5);
justify-content: space-between;
max-width: var(--max-width);
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
}
.cityInformation {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
}
.sideBar {
@@ -38,7 +40,31 @@
flex: 1;
}
.hotelList {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
@media (min-width: 768px) {
.main {
padding: var(--Spacing-x5);
}
.header {
display: block;
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
var(--Spacing-x5);
}
.title {
margin: 0 auto;
display: flex;
max-width: var(--max-width-navigation);
align-items: center;
justify-content: space-between;
}
.link {
display: flex;
padding-bottom: var(--Spacing-x6);
@@ -50,13 +76,6 @@
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
}
.mapLinkText {
display: flex;
align-items: center;
justify-content: center;
gap: var(--Spacing-x-half);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x0);
}
.main {
flex-direction: row;
gap: var(--Spacing-x5);

View File

@@ -19,7 +19,11 @@ import {
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext"
@@ -32,6 +36,7 @@ import {
TrackingSDKHotelInfo,
TrackingSDKPageData,
} from "@/types/components/tracking"
import { AlertTypeEnum } from "@/types/enums/alert"
import { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelPage({
@@ -99,17 +104,44 @@ export default async function SelectHotelPage({
return (
<>
<header className={styles.header}>
<div>{city.name}</div>
<HotelSorter />
<div className={styles.title}>
<div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle>
<Preamble>{hotels.length} hotels</Preamble>
</div>
<HotelSorter />
</div>
<MobileMapButtonContainer city={searchParams.city} />
</header>
<main className={styles.main}>
<div className={styles.sideBar}>
<Link
className={styles.link}
color="burgundy"
href={selectHotelMap[params.lang]}
keepSearchParams
>
{hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
<Link
className={styles.link}
color="burgundy"
href={selectHotelMap[params.lang]}
keepSearchParams
>
<div className={styles.mapContainer}>
<StaticMap
city={searchParams.city}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${searchParams.city} city center`}
/>
<Button wrapping size="medium" intent="text" theme="base">
{intl.formatMessage({ id: "See map" })}
<ChevronRightIcon
color="baseButtonTextOnFillNormal"
width={20}
height={20}
/>
</Button>
</div>
</Link>
) : (
<div className={styles.mapContainer}>
<StaticMap
city={searchParams.city}
@@ -119,16 +151,22 @@ export default async function SelectHotelPage({
mapType="roadmap"
altText={`Map of ${searchParams.city} city center`}
/>
<div className={styles.mapLinkText}>
{intl.formatMessage({ id: "Show map" })}
<ChevronRightIcon color="burgundy" width={20} height={20} />
</div>
</div>
</Link>
<MobileMapButtonContainer city={searchParams.city} />
)}
<HotelFilter filters={filterList} />
</div>
<HotelCardListing hotelData={hotels} />
<div className={styles.hotelList}>
{!hotels.length && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
/>
)}
<HotelCardListing hotelData={hotels} />
</div>
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={hotelsTrackingData}

View File

@@ -87,38 +87,3 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
{ facilityFilters: [], surroundingsFilters: [] }
)
}
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
return hotels.map((hotel) => ({
coordinates: {
lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
publicPrice: hotel.price?.regularAmount ?? null,
memberPrice: hotel.price?.memberAmount ?? null,
currency: hotel.price?.currency || null,
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
}))
}
export function getCentralCoordinates(hotels: HotelPin[]) {
const centralCoordinates = hotels.reduce(
(acc, pin) => {
acc.lat += pin.coordinates.lat
acc.lng += pin.coordinates.lng
return acc
},
{ lat: 0, lng: 0 }
)
centralCoordinates.lat /= hotels.length
centralCoordinates.lng /= hotels.length
return centralCoordinates
}

View File

@@ -18,7 +18,7 @@ import { setLang } from "@/i18n/serverContext"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { LangParams, PageArgs } from "@/types/params"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectRatePage({
params,
@@ -49,11 +49,11 @@ export default async function SelectRatePage({
searchParams.fromDate &&
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
? searchParams.fromDate
: dt().utc().format("YYYY-MM-D")
: dt().utc().format("YYYY-MM-DD")
const validToDate =
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
? searchParams.toDate
: dt().utc().add(1, "day").format("YYYY-MM-D")
: dt().utc().add(1, "day").format("YYYY-MM-DD")
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
const childrenCount = selectRoomParamsObject.room[0].child?.length
const children = selectRoomParamsObject.room[0].child
@@ -94,9 +94,16 @@ export default async function SelectRatePage({
const roomCategories = hotelData?.included
const noRoomsAvailable = roomsAvailability.roomConfigurations.reduce(
(acc, room) => {
return acc && room.status === "NotAvailable"
},
true
)
return (
<>
<HotelInfoCard hotelData={hotelData} />
<HotelInfoCard hotelData={hotelData} noAvailability={noRoomsAvailable} />
<Rooms
roomsAvailability={roomsAvailability}
roomCategories={roomCategories ?? []}

View File

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

View File

@@ -1,17 +1,11 @@
import { env } from "@/env/server"
import LoadingSpinner from "@/components/LoadingSpinner"
import styles from "./loading.module.css"
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
export default function LoadingBookingWidget() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
return (
<div className={styles.container}>
<LoadingSpinner />
</div>
)
return <BookingWidgetSkeleton />
}

View File

@@ -1,11 +1,17 @@
import { env } from "@/env/server"
import CurrentLoadingSpinner from "@/components/Current/LoadingSpinner"
import LoadingSpinner from "@/components/LoadingSpinner"
import { FooterDetailsSkeleton } from "@/components/Footer/Details"
import { FooterNavigationSkeleton } from "@/components/Footer/Navigation"
export default function LoadingFooter() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return <CurrentLoadingSpinner />
}
return <LoadingSpinner />
return (
<footer>
<FooterNavigationSkeleton />
<FooterDetailsSkeleton />
</footer>
)
}

View File

@@ -1,5 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
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 }}>
<RouterTracking />
{header}
<BookingWidget />
{children}
<Footer />
<TokenRefresher />

View File

@@ -106,7 +106,7 @@
--max-width-navigation: 89.5rem;
--main-menu-mobile-height: 75px;
--main-menu-desktop-height: 129px;
--main-menu-desktop-height: 125px;
--booking-widget-mobile-height: 75px;
--booking-widget-desktop-height: 77px;
--hotel-page-map-desktop-width: 23.75rem;
@@ -114,6 +114,8 @@
/* Z-INDEX */
--header-z-index: 11;
--menu-overlay-z-index: 11;
--booking-widget-z-index: 10;
--booking-widget-open-z-index: 100;
--dialog-z-index: 9;
--sidepeek-z-index: 100;
--lightbox-z-index: 150;

View File

@@ -24,7 +24,7 @@ export default function AccordionSection({ accordion, title }: AccordionProps) {
return (
<SectionContainer id={HotelHashValues.faq}>
{title && <SectionHeader textTransform="uppercase" title={title} />}
<SectionHeader textTransform="uppercase" title={title} />
<Accordion
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
theme="light"

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import LoginButton from "@/components/LoginButton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
@@ -14,8 +14,8 @@ import type { SignUpVerificationProps } from "@/types/components/blocks/dynamicC
export default async function SignUpVerification({
dynamic_content,
}: SignUpVerificationProps) {
const session = await auth()
if (session) {
const user = await getProfileSafely()
if (user) {
redirect(overview[getLang()])
}
const intl = await getIntl()

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import SignupForm from "@/components/Forms/Signup"
import { getLang } from "@/i18n/serverContext"
@@ -11,8 +11,8 @@ import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
const session = await auth()
if (session) {
const user = await getProfileSafely()
if (user) {
// We don't want to allow users to access signup if they are already authenticated.
redirect(overview[getLang()])
}

View File

@@ -50,7 +50,7 @@ export default function TableBlock({ data }: TableBlockProps) {
return (
<SectionContainer>
{heading ? <SectionHeader preamble={preamble} title={heading} /> : null}
<SectionHeader preamble={preamble} title={heading} />
<div className={styles.tableWrapper}>
<ScrollWrapper>
<Table

View File

@@ -6,14 +6,18 @@ import { FormProvider, useForm } from "react-hook-form"
import { dt } from "@/lib/dt"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import Form from "@/components/Forms/BookingWidget"
import Form, {
BookingWidgetFormSkeleton,
} from "@/components/Forms/BookingWidget"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import { CloseLargeIcon } from "@/components/Icons"
import useStickyPosition from "@/hooks/useStickyPosition"
import { debounce } from "@/utils/debounce"
import { getFormattedUrlQueryParams } from "@/utils/url"
import MobileToggleButton from "./MobileToggleButton"
import MobileToggleButton, {
MobileToggleButtonSkeleton,
} from "./MobileToggleButton"
import styles from "./bookingWidget.module.css"
@@ -38,11 +42,11 @@ export default function BookingWidgetClient({
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
searchParams
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
? getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
adults: "number",
age: "number",
bed: "number",
}) as BookingWidgetSearchParams)
})
: undefined
const getLocationObj = (destination: string): Location | undefined => {
@@ -75,6 +79,16 @@ export default function BookingWidgetClient({
)
: undefined
const defaultRoomsData = bookingWidgetSearchData?.room?.map((room) => ({
adults: room.adults,
child: room.child ?? [],
})) ?? [
{
adults: 1,
child: [],
},
]
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: selectedLocation?.name ?? "",
@@ -92,12 +106,7 @@ export default function BookingWidgetClient({
bookingCode: "",
redemption: false,
voucher: false,
rooms: bookingWidgetSearchData?.room ?? [
{
adults: 1,
child: [],
},
],
rooms: defaultRoomsData,
},
shouldFocusError: false,
mode: "all",
@@ -154,21 +163,37 @@ export default function BookingWidgetClient({
return (
<FormProvider {...methods}>
<section ref={bookingWidgetRef} className={styles.containerDesktop}>
<Form locations={locations} type={type} />
</section>
<section className={styles.containerMobile} data-open={isOpen}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} />
<section
ref={bookingWidgetRef}
className={styles.wrapper}
data-open={isOpen}
>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={styles.formContainer}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} setIsOpen={setIsOpen} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
<MobileToggleButton openMobileSearch={openMobileSearch} />
</FormProvider>
)
}
export function BookingWidgetSkeleton() {
return (
<>
<section className={styles.wrapper} style={{ top: 0 }}>
<MobileToggleButtonSkeleton />
<div className={styles.formContainer}>
<BookingWidgetFormSkeleton />
</div>
</section>
</>
)
}

View File

@@ -6,8 +6,6 @@
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 1;
background-color: var(--Base-Surface-Primary-light-Normal);
}

View File

@@ -7,6 +7,7 @@ import { dt } from "@/lib/dt"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import { EditIcon, SearchIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -24,19 +25,12 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
export default function MobileToggleButton({
openMobileSearch,
}: BookingWidgetToggleButtonProps) {
const [hasMounted, setHasMounted] = useState(false)
const intl = useIntl()
const lang = useLang()
const d = useWatch({ name: "date" })
const location = useWatch({ name: "location" })
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
const bookingWidgetMobileRef = useRef(null)
useStickyPosition({
ref: bookingWidgetMobileRef,
name: StickyElementNameEnum.BOOKING_WIDGET_MOBILE,
})
const parsedLocation: Location | null = location
? JSON.parse(decodeURIComponent(location))
: null
@@ -46,14 +40,6 @@ export default function MobileToggleButton({
const selectedFromDate = dt(d.fromDate).locale(lang).format("D MMM")
const selectedToDate = dt(d.toDate).locale(lang).format("D MMM")
useEffect(() => {
setHasMounted(true)
}, [])
if (!hasMounted) {
return null
}
const locationAndDateIsSet = parsedLocation && d
const totalRooms = rooms.length
@@ -75,7 +61,6 @@ export default function MobileToggleButton({
className={locationAndDateIsSet ? styles.complete : styles.partial}
onClick={openMobileSearch}
role="button"
ref={bookingWidgetMobileRef}
>
{!locationAndDateIsSet && (
<>
@@ -133,3 +118,28 @@ export default function MobileToggleButton({
</div>
)
}
export function MobileToggleButtonSkeleton() {
const intl = useIntl()
return (
<div className={styles.partial}>
<div>
<Caption type="bold" color="red">
{intl.formatMessage({ id: "Where to" })}
</Caption>
<SkeletonShimmer height="24px" />
</div>
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
<div>
<Caption type="bold" color="red">
{intl.formatMessage({ id: "booking.nights" }, { totalNights: 0 })}
</Caption>
<SkeletonShimmer height="24px" />
</div>
<div className={styles.icon}>
<SearchIcon color="white" />
</div>
</div>
)
}

View File

@@ -1,60 +1,63 @@
.containerDesktop,
.containerMobile,
.close {
display: none;
.wrapper {
position: sticky;
z-index: var(--booking-widget-z-index);
}
@media screen and (max-width: 767px) {
.containerMobile {
background-color: var(--UI-Input-Controls-Surface-Normal);
bottom: -100%;
display: grid;
gap: var(--Spacing-x3);
grid-template-rows: 36px 1fr;
height: calc(100dvh - 20px);
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
transition: bottom 300ms ease;
width: 100%;
z-index: 10000;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
}
.formContainer {
display: grid;
grid-template-rows: auto 1fr;
background-color: var(--UI-Input-Controls-Surface-Normal);
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
gap: var(--Spacing-x3);
height: calc(100dvh - 20px);
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
bottom: -100%;
transition: bottom 300ms ease;
}
.containerMobile[data-open="true"] {
bottom: 0;
}
.wrapper[data-open="true"] {
z-index: var(--booking-widget-open-z-index);
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
}
.wrapper[data-open="true"] .formContainer {
bottom: 0;
}
.containerMobile[data-open="true"] + .backdrop {
background-color: rgba(0, 0, 0, 0.4);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: 1000;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
padding: 0;
}
.wrapper[data-open="true"] + .backdrop {
background-color: rgba(0, 0, 0, 0.4);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: calc(var(--booking-widget-open-z-index) - 1);
}
@media screen and (min-width: 768px) {
.containerDesktop {
display: block;
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
position: sticky;
.wrapper {
top: 0;
z-index: 10;
background-color: var(--Base-Surface-Primary-light-Normal);
}
}
@media screen and (min-width: 1367px) {
.container {
z-index: 9;
.formContainer {
display: block;
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
height: auto;
position: static;
padding: 0;
}
.close {
display: none;
}
}

View File

@@ -46,7 +46,7 @@ export default async function HotelListingItem({
</div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "Distance to city centre" },
{ id: "Distance in km to city centre" },
{ number: distanceToCentre }
)}
</Caption>

View File

@@ -25,7 +25,7 @@ export default async function IntroSection({
const { streetAddress, city } = address
const { distanceToCentre } = location
const formattedDistanceText = intl.formatMessage(
{ id: "Distance to city centre" },
{ id: "Distance in km to city centre" },
{ number: distanceToCentre }
)
const lang = getLang()

View File

@@ -1,44 +1,54 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox/"
import Button from "@/components/TempDesignSystem/Button"
import { getIntl } from "@/i18n"
import styles from "./previewImages.module.css"
import type { PreviewImagesProps } from "@/types/components/hotelPage/previewImages"
export default async function PreviewImages({
export default function PreviewImages({
images,
hotelName,
}: PreviewImagesProps) {
const intl = await getIntl()
const imageGalleryText = intl.formatMessage({ id: "Image gallery" })
const dialogTitle = `${hotelName} - ${imageGalleryText}`
const intl = useIntl()
const [lightboxIsOpen, setLightboxIsOpen] = useState(false)
return (
<Lightbox images={images} dialogTitle={dialogTitle}>
<div className={styles.imageWrapper}>
{images.slice(0, 3).map((image, index) => (
<Image
key={index}
src={image.imageSizes.medium}
alt={image.metaData.altText}
title={image.metaData.title}
width={index === 0 ? 752 : 292}
height={index === 0 ? 540 : 266}
className={styles.image}
/>
))}
<Button
theme="base"
intent="inverted"
size="small"
id="lightboxTrigger"
className={styles.seeAllButton}
>
{intl.formatMessage({ id: "See all photos" })}
</Button>
</div>
</Lightbox>
<div className={styles.imageWrapper}>
{images.slice(0, 3).map((image, index) => (
<Image
key={index}
src={image.imageSizes.medium}
alt={image.metaData.altText}
title={image.metaData.title}
width={index === 0 ? 752 : 292}
height={index === 0 ? 540 : 266}
className={styles.image}
/>
))}
<Button
theme="base"
intent="inverted"
size="small"
onClick={() => setLightboxIsOpen(true)}
className={styles.seeAllButton}
>
{intl.formatMessage({ id: "See all photos" })}
</Button>
<Lightbox
images={images}
dialogTitle={intl.formatMessage(
{ id: "Image gallery" },
{ name: hotelName }
)}
isOpen={lightboxIsOpen}
onClose={() => setLightboxIsOpen(false)}
/>
</div>
)
}

View File

@@ -2,62 +2,38 @@
import { useIntl } from "react-intl"
import { GalleryIcon } from "@/components/Icons"
import Image from "@/components/Image"
import useSidePeekStore from "@/stores/sidepeek"
import { ChevronRightSmallIcon } from "@/components/Icons"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import RoomDetailsButton from "../RoomDetailsButton"
import styles from "./roomCard.module.css"
import type { RoomCardProps } from "@/types/components/hotelPage/room"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export function RoomCard({ hotelId, room }: RoomCardProps) {
const { images, name, roomSize, occupancy, id } = room
const { images, name, roomSize, occupancy } = room
const intl = useIntl()
const mainImage = images[0]
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
const size =
roomSize?.min === roomSize?.max
? `${roomSize.min}`
: `${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 (
<article className={styles.roomCard}>
<button className={styles.imageWrapper} onClick={handleImageClick}>
{/* TODO: re-enable once we have support for badge text from API team. */}
{/* {badgeTextTransKey && ( */}
{/* <span className={styles.badge}> */}
{/* {intl.formatMessage({ id: badgeTextTransKey })} */}
{/* </span> */}
{/* )} */}
<span className={styles.imageCount}>
<GalleryIcon color="white" />
{images.length}
</span>
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
<Image
className={styles.image}
src={mainImage.imageSizes.large}
alt={mainImage.metaData.altText}
<div className={styles.imageContainer}>
<ImageGallery
images={images}
title={intl.formatMessage({ id: "Image gallery" }, { name })}
height={200}
width={300}
/>
</button>
</div>
<div className={styles.content}>
<div className={styles.innerContent}>
<Subtitle
@@ -65,16 +41,32 @@ export function RoomCard({ hotelId, room }: RoomCardProps) {
textAlign="center"
type="one"
color="black"
className={styles.title}
>
{name}
</Subtitle>
<Body color="grey">{subtitle}</Body>
<Body color="grey">
{intl.formatMessage(
{ id: "hotelPages.rooms.roomCard.persons" },
{ size, totalOccupancy: occupancy.total }
)}
</Body>
</div>
<RoomDetailsButton
hotelId={hotelId}
roomTypeCode={room.roomTypes[0].code}
/>
<Button
intent="text"
type="button"
size="medium"
theme="base"
onClick={() =>
openSidePeek({
key: SidePeekEnum.roomDetails,
hotelId,
roomTypeCode: room.roomTypes[0].code,
})
}
>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
</Button>
</div>
</article>
)

View File

@@ -3,34 +3,7 @@
background-color: var(--UI-Opacity-White-100);
border: 1px solid var(--Base-Border-Subtle);
display: grid;
}
/*TODO: Build Chip/Badge component. */
.badge {
position: absolute;
top: var(--Spacing-x1);
left: var(--Spacing-x1);
background-color: var(--Tertiary-Dark-Surface-Hover);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Medium);
color: var(--Tertiary-Dark-On-Surface-Text);
text-transform: uppercase;
font-size: var(--typography-Chip-fontSize-Placeholder);
font-weight: 400;
}
.imageCount {
position: absolute;
right: var(--Spacing-x1);
bottom: var(--Spacing-x1);
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
background-color: var(--UI-Grey-90);
opacity: 90%;
color: var(--UI-Input-Controls-Fill-Normal);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
overflow: hidden;
}
.content {
@@ -46,32 +19,7 @@
gap: var(--Spacing-x1);
}
.title {
display: flex;
align-items: center;
}
.title:first-child {
height: 2em;
}
.imageWrapper {
.imageContainer {
position: relative;
background-color: transparent;
border-width: 0;
cursor: pointer;
margin: 0;
padding: 0;
display: flex;
}
.image {
width: 100%;
object-fit: cover;
border-top-left-radius: var(--Corner-radius-Medium);
border-top-right-radius: var(--Corner-radius-Medium);
}
.subtitle {
color: var(--UI-Text-Placeholder);
height: 200px;
}

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 {
display: grid;
background-color: var(--Main-Grey-White);
}
@media screen and (max-width: 1366px) {

View File

@@ -1,5 +1,5 @@
.container {
--header-height: 68px;
--header-height: 72px;
--sticky-button-height: 120px;
display: grid;
@@ -11,12 +11,10 @@
}
.header {
align-self: flex-start;
align-self: flex-end;
background-color: var(--Main-Grey-White);
display: grid;
grid-area: header;
grid-template-columns: 1fr 24px;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x2);
padding: var(--Spacing-x3) var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 10;

View File

@@ -38,7 +38,7 @@
.hideWrapper {
bottom: 0;
left: 0;
overflow: auto;
overflow: hidden;
position: fixed;
right: 0;
top: 100%;

View File

@@ -47,7 +47,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
if (!dt(selected).isBefore(dt(), "day")) {
if (isSelectingFrom) {
setValue(name, {
fromDate: dt(selected).format("YYYY-MM-D"),
fromDate: dt(selected).format("YYYY-MM-DD"),
toDate: undefined,
})
setIsSelectingFrom(false)
@@ -57,11 +57,11 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
if (toDate.isAfter(fromDate)) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: toDate.format("YYYY-MM-D"),
toDate: toDate.format("YYYY-MM-DD"),
})
} else {
setValue(name, {
fromDate: toDate.format("YYYY-MM-D"),
fromDate: toDate.format("YYYY-MM-DD"),
toDate: selectedDate.fromDate,
})
}
@@ -75,7 +75,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
if (!selectedDate.toDate) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-D"),
toDate: dt(selectedDate.fromDate)
.add(1, "day")
.format("YYYY-MM-DD"),
})
setIsSelectingFrom(true)
}
@@ -121,7 +123,12 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
data-isopen={isOpen}
ref={ref}
>
<button className={styles.btn} onFocus={showOnFocus} type="button">
<button
className={styles.btn}
onFocus={showOnFocus}
onClick={() => setIsOpen(true)}
type="button"
>
<Body className={styles.body} asChild>
<span>
{selectedFromDate} - {selectedToDate}

View File

@@ -3,6 +3,7 @@ import { getFooter, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import Image from "@/components/Image"
import LanguageSwitcher from "@/components/LanguageSwitcher"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { getIntl } from "@/i18n"
@@ -92,3 +93,40 @@ export default async function FooterDetails() {
</section>
)
}
export async function FooterDetailsSkeleton() {
const lang = getLang()
const intl = await getIntl()
const currentYear = new Date().getFullYear()
return (
<section className={styles.details}>
<div className={styles.topContainer}>
<Link href={`/${lang}`}>
<Image
alt="Scandic Hotels logo"
height={22}
src="/_static/img/scandic-logotype-white.svg"
width={103}
/>
</Link>
<nav className={styles.socialNav}>
<SkeletonShimmer width="10ch" height="20px" contrast="dark" />
</nav>
</div>
<div className={styles.bottomContainer}>
<div className={styles.copyrightContainer}>
<Footnote type="label" textTransform="uppercase">
© {currentYear}{" "}
{intl.formatMessage({ id: "Copyright all rights reserved" })}
</Footnote>
</div>
<div className={styles.navigationContainer}>
<nav className={styles.navigation}>
<SkeletonShimmer width="40ch" height="20px" contrast="dark" />
</nav>
</div>
</div>
</section>
)
}

View File

@@ -1,4 +1,5 @@
import { ArrowRightIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -30,3 +31,24 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
</nav>
)
}
export function FooterMainNavSkeleton() {
const items = Array.from({ length: 4 }).map((_, i) => i)
return (
<nav className={styles.mainNavigation}>
<ul className={styles.mainNavigationList}>
{items.map((x) => (
<li key={x} className={styles.mainNavigationItem}>
<Subtitle color="baseTextMediumContrast" type="two" asChild>
<span className={styles.mainNavigationLink}>
<SkeletonShimmer width="80%" />
<ArrowRightIcon color="peach80" />
</span>
</Subtitle>
</li>
))}
</ul>
</nav>
)
}

View File

@@ -1,6 +1,6 @@
import Image from "@/components/Image"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getLang } from "@/i18n/serverContext"
@@ -80,3 +80,46 @@ export default function FooterSecondaryNav({
</div>
)
}
export function FooterSecondaryNavSkeleton() {
return (
<div className={styles.secondaryNavigation}>
<nav className={styles.secondaryNavigationGroup}>
<SkeletonShimmer width="10ch" />
<ul className={styles.secondaryNavigationList}>
<li className={styles.appDownloadItem}>
<SkeletonShimmer width="16ch" />
</li>
<li className={styles.appDownloadItem}>
<SkeletonShimmer width="16ch" />
</li>
</ul>
</nav>
<nav className={styles.secondaryNavigationGroup}>
<SkeletonShimmer width="20ch" />
<ul className={styles.secondaryNavigationList}>
<li className={styles.secondaryNavigationItem}>
<SkeletonShimmer width="25ch" />
</li>
</ul>
</nav>
<nav className={styles.secondaryNavigationGroup}>
<SkeletonShimmer width="15ch" />
<ul className={styles.secondaryNavigationList}>
<li className={styles.secondaryNavigationItem}>
<SkeletonShimmer width="30ch" />
</li>
<li className={styles.secondaryNavigationItem}>
<SkeletonShimmer width="36ch" />
</li>
<li className={styles.secondaryNavigationItem}>
<SkeletonShimmer width="12ch" />
</li>
<li className={styles.secondaryNavigationItem}>
<SkeletonShimmer width="20ch" />
</li>
</ul>
</nav>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { getFooter } from "@/lib/trpc/memoizedRequests"
import FooterMainNav from "./MainNav"
import FooterSecondaryNav from "./SecondaryNav"
import FooterMainNav, { FooterMainNavSkeleton } from "./MainNav"
import FooterSecondaryNav, { FooterSecondaryNavSkeleton } from "./SecondaryNav"
import styles from "./navigation.module.css"
@@ -10,6 +10,7 @@ export default async function FooterNavigation() {
if (!footer) {
return null
}
return (
<section className={styles.section}>
<div className={styles.maxWidth}>
@@ -22,3 +23,14 @@ export default async function FooterNavigation() {
</section>
)
}
export function FooterNavigationSkeleton() {
return (
<section className={styles.section}>
<div className={styles.maxWidth}>
<FooterMainNavSkeleton />
<FooterSecondaryNavSkeleton />
</div>
</section>
)
}

View File

@@ -11,6 +11,7 @@ import {
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Input from "../Input"
@@ -203,3 +204,18 @@ export default function Search({ locations }: SearchProps) {
</Downshift>
)
}
export function SearchSkeleton() {
return (
<div className={styles.container}>
<div className={styles.label}>
<Caption type="bold" color="red" asChild>
<span>Where to</span>
</Caption>
</div>
<div className={styles.input}>
<SkeletonShimmer />
</div>
</div>
)
}

View File

@@ -5,11 +5,12 @@
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
position: relative;
height: 60px;
}
.container:hover,
.container:has(input:active, input:focus, input:focus-within) {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
background-color: var(--Base-Background-Primary-Normal);
}
.container:has(input:active, input:focus, input:focus-within) {

View File

@@ -1,4 +1,5 @@
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
@@ -78,3 +79,54 @@ export default function Voucher() {
</div>
)
}
export function VoucherSkeleton() {
const intl = useIntl()
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
const useVouchers = intl.formatMessage({ id: "Use code/voucher" })
const addVouchers = intl.formatMessage({ id: "Add code" })
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
const reward = intl.formatMessage({ id: "Book reward night" })
const form = useForm()
return (
<FormProvider {...form}>
<div className={styles.optionsContainer}>
<div className={styles.vouchers}>
<label>
<Caption color="disabled" type="bold" asChild>
<span>{vouchers}</span>
</Caption>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</label>
<Input type="text" placeholder={addVouchers} disabled />
</div>
<div className={styles.options}>
<div className={`${styles.option} ${styles.checkboxVoucher}`}>
<Checkbox name="useVouchers" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{useVouchers}</span>
</Caption>
</Checkbox>
</div>
<div className={styles.option}>
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{bonus}</span>
</Caption>
</Checkbox>
</div>
<div className={styles.option}>
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{reward}</span>
</Caption>
</Checkbox>
</div>
</div>
</div>
</FormProvider>
)
}

View File

@@ -1,5 +1,4 @@
"use client"
import { useState } from "react"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
@@ -7,13 +6,13 @@ import { dt } from "@/lib/dt"
import DatePicker from "@/components/DatePicker"
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
import GuestsRoomsProvider from "@/components/GuestsRoomsPicker/Provider/GuestsRoomsProvider"
import { SearchIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Search from "./Search"
import Voucher from "./Voucher"
import Search, { SearchSkeleton } from "./Search"
import Voucher, { VoucherSkeleton } from "./Voucher"
import styles from "./formContent.module.css"
@@ -26,12 +25,10 @@ export default function FormContent({
const intl = useIntl()
const selectedDate = useWatch({ name: "date" })
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
const roomsLabel = intl.formatMessage({ id: "Guests & Rooms" })
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
const selectedGuests = useWatch({ name: "rooms" })
return (
<>
<div className={styles.input}>
@@ -51,12 +48,10 @@ export default function FormContent({
<div className={styles.rooms}>
<label>
<Caption color="red" type="bold" asChild>
<span>{rooms}</span>
<span>{roomsLabel}</span>
</Caption>
</label>
<GuestsRoomsProvider selectedGuests={selectedGuests}>
<GuestsRoomsPickerForm name="rooms" />
</GuestsRoomsProvider>
<GuestsRoomsPickerForm />
</div>
</div>
<div className={styles.voucherContainer}>
@@ -90,3 +85,53 @@ export default function FormContent({
</>
)
}
export function BookingWidgetFormContentSkeleton() {
const intl = useIntl()
return (
<div className={styles.input}>
<div className={styles.inputContainer}>
<div className={styles.where}>
<SearchSkeleton />
</div>
<div className={styles.when}>
<Caption color="red" type="bold">
{intl.formatMessage({ id: "booking.nights" }, { totalNights: 0 })}
</Caption>
<SkeletonShimmer />
</div>
<div className={styles.rooms}>
<Caption color="red" type="bold" asChild>
<span>{intl.formatMessage({ id: "Guests & Rooms" })}</span>
</Caption>
<SkeletonShimmer />
</div>
</div>
<div className={styles.voucherContainer}>
<VoucherSkeleton />
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
intent="primary"
theme="base"
type="submit"
disabled
>
<Caption
color="white"
type="bold"
className={styles.buttonText}
asChild
>
<span>{intl.formatMessage({ id: "Search" })}</span>
</Caption>
<span className={styles.icon}>
<SearchIcon color="white" width={28} height={28} />
</span>
</Button>
</div>
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { selectHotel, selectRate } from "@/constants/routes/hotelReservation"
import useLang from "@/hooks/useLang"
import FormContent from "./FormContent"
import FormContent, { BookingWidgetFormContentSkeleton } from "./FormContent"
import { bookingWidgetVariants } from "./variants"
import styles from "./form.module.css"
@@ -17,7 +17,11 @@ import { Location } from "@/types/trpc/routers/hotel/locations"
const formId = "booking-widget"
export default function Form({ locations, type }: BookingWidgetFormProps) {
export default function Form({
locations,
type,
setIsOpen,
}: BookingWidgetFormProps) {
const router = useRouter()
const lang = useLang()
@@ -52,7 +56,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
)
})
})
setIsOpen(false)
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
}
@@ -69,3 +73,17 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
</section>
)
}
export function BookingWidgetFormSkeleton() {
const classNames = bookingWidgetVariants({
type: "full",
})
return (
<section className={classNames}>
<form className={styles.form}>
<BookingWidgetFormContentSkeleton />
</form>
</section>
)
}

View File

@@ -1,16 +1,37 @@
import { z } from "zod"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export const guestRoomSchema = z.object({
adults: z.number().default(1),
child: z.array(
z.object({
age: z.number().nonnegative(),
bed: z.number(),
})
),
})
export const guestRoomSchema = z
.object({
adults: z.number().default(1),
child: z
.array(
z.object({
age: z.number().min(0, "Age is required"),
bed: z.number().min(0, "Bed choice is required"),
})
)
.default([]),
})
.superRefine((value, ctx) => {
const childrenInAdultsBed = value.child.filter(
(c) => c.bed === ChildBedMapEnum.IN_ADULTS_BED
)
if (value.adults < childrenInAdultsBed.length) {
const lastAdultBedIndex = value.child
.map((c) => c.bed)
.lastIndexOf(ChildBedMapEnum.IN_ADULTS_BED)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"You cannot have more children in adults bed than adults in the room",
path: ["child", lastAdultBedIndex],
})
}
})
export const guestRoomsSchema = z.array(guestRoomSchema)

View File

@@ -3,54 +3,32 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Counter from "../Counter"
import styles from "./adult-selector.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
AdultSelectorProps,
Child,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
export default function AdultSelector({
roomIndex = 0,
currentAdults,
}: SelectorProps) {
const name = `rooms.${roomIndex}.adults`
const intl = useIntl()
const adultsLabel = intl.formatMessage({ id: "Adults" })
const { setValue } = useFormContext()
const { adults, child, childrenInAdultsBed } = useGuestsRoomsStore(
(state) => state.rooms[roomIndex]
)
const increaseAdults = useGuestsRoomsStore((state) => state.increaseAdults)
const decreaseAdults = useGuestsRoomsStore((state) => state.decreaseAdults)
function increaseAdultsCount(roomIndex: number) {
if (adults < 6) {
increaseAdults(roomIndex)
setValue(`rooms.${roomIndex}.adults`, adults + 1)
function increaseAdultsCount() {
if (currentAdults < 6) {
setValue(name, currentAdults + 1)
}
}
function decreaseAdultsCount(roomIndex: number) {
if (adults > 1) {
decreaseAdults(roomIndex)
setValue(`rooms.${roomIndex}.adults`, adults - 1)
if (childrenInAdultsBed > adults) {
const toUpdateIndex = child.findIndex(
(child: Child) => child.bed == ChildBedMapEnum.IN_ADULTS_BED
)
if (toUpdateIndex != -1) {
setValue(
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
child[toUpdateIndex].age < 3
? ChildBedMapEnum.IN_CRIB
: ChildBedMapEnum.IN_EXTRA_BED
)
}
}
function decreaseAdultsCount() {
if (currentAdults > 1) {
setValue(name, currentAdults - 1)
}
}
@@ -60,15 +38,11 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
{adultsLabel}
</Caption>
<Counter
count={adults}
handleOnDecrease={() => {
decreaseAdultsCount(roomIndex)
}}
handleOnIncrease={() => {
increaseAdultsCount(roomIndex)
}}
disableDecrease={adults == 1}
disableIncrease={adults == 6}
count={currentAdults}
handleOnDecrease={decreaseAdultsCount}
handleOnIncrease={increaseAdultsCount}
disableDecrease={currentAdults == 1}
disableIncrease={currentAdults == 6}
/>
</section>
)

View File

@@ -3,8 +3,6 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { ErrorCircleIcon } from "@/components/Icons"
import Select from "@/components/TempDesignSystem/Select"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -17,55 +15,35 @@ import {
ChildInfoSelectorProps,
} from "@/types/components/bookingWidget/guestsRoomsPicker"
const ageList = [...Array(13)].map((_, i) => ({
label: i.toString(),
value: i,
}))
export default function ChildInfoSelector({
child = { age: -1, bed: -1 },
childrenInAdultsBed,
adults,
index = 0,
roomIndex = 0,
}: ChildInfoSelectorProps) {
const ageFieldName = `rooms.${roomIndex}.child.${index}.age`
const bedFieldName = `rooms.${roomIndex}.child.${index}.bed`
const intl = useIntl()
const ageLabel = intl.formatMessage({ id: "Age" })
const ageReqdErrMsg = intl.formatMessage({ id: "Child age is required" })
const bedLabel = intl.formatMessage({ id: "Bed" })
const { setValue } = useFormContext()
const { adults, childrenInAdultsBed } = useGuestsRoomsStore(
(state) => state.rooms[roomIndex]
)
const {
isValidated,
updateChildAge,
updateChildBed,
increaseChildInAdultsBed,
decreaseChildInAdultsBed,
} = useGuestsRoomsStore((state) => ({
isValidated: state.isValidated,
updateChildAge: state.updateChildAge,
updateChildBed: state.updateChildBed,
increaseChildInAdultsBed: state.increaseChildInAdultsBed,
decreaseChildInAdultsBed: state.decreaseChildInAdultsBed,
}))
const ageList = Array.from(Array(13).keys()).map((age) => ({
label: `${age}`,
value: age,
}))
function updateSelectedAge(age: number) {
updateChildAge(age, roomIndex, index)
setValue(`rooms.${roomIndex}.child.${index}.age`, age, {
shouldValidate: true,
})
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
}
const errorMessage = intl.formatMessage({ id: "Child age is required" })
const { setValue, formState, register, trigger } = useFormContext()
function updateSelectedBed(bed: number) {
if (bed == ChildBedMapEnum.IN_ADULTS_BED) {
increaseChildInAdultsBed(roomIndex)
} else if (child.bed == ChildBedMapEnum.IN_ADULTS_BED) {
decreaseChildInAdultsBed(roomIndex)
}
updateChildBed(bed, roomIndex, index)
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
trigger()
}
function updateSelectedAge(age: number) {
setValue(`rooms.${roomIndex}.child.${index}.age`, age)
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
}
const allBedTypes: ChildBed[] = [
@@ -97,6 +75,12 @@ export default function ChildInfoSelector({
return availableBedTypes
}
//@ts-expect-error: formState is typed with FormValues
const roomErrors = formState.errors.rooms?.[roomIndex]?.child?.[index]
const ageError = roomErrors?.age
const bedError = roomErrors?.bed
return (
<>
<div key={index} className={styles.childInfoContainer}>
@@ -110,13 +94,15 @@ export default function ChildInfoSelector({
onSelect={(key) => {
updateSelectedAge(key as number)
}}
name={`rooms.${roomIndex}.child.${index}.age`}
placeholder={ageLabel}
maxHeight={150}
{...register(ageFieldName, {
required: true,
})}
/>
</div>
<div>
{child.age !== -1 ? (
{child.age >= 0 ? (
<Select
items={getAvailableBeds(child.age)}
label={bedLabel}
@@ -125,16 +111,26 @@ export default function ChildInfoSelector({
onSelect={(key) => {
updateSelectedBed(key as number)
}}
name={`rooms.${roomIndex}.child.${index}.age`}
placeholder={bedLabel}
{...register(bedFieldName, {
required: true,
})}
/>
) : null}
</div>
</div>
{isValidated && child.age < 0 ? (
{roomErrors && roomErrors.message ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" />
{ageReqdErrMsg}
{roomErrors.message}
</Caption>
) : null}
{ageError || bedError ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" />
{errorMessage}
</Caption>
) : null}
</>

View File

@@ -3,8 +3,6 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Counter from "../Counter"
@@ -12,40 +10,30 @@ import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css"
import { BookingWidgetSchema } from "@/types/components/bookingWidget"
import { ChildSelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
import { SelectorProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
export default function ChildSelector({
roomIndex = 0,
currentAdults,
childrenInAdultsBed,
currentChildren,
}: SelectorProps) {
const intl = useIntl()
const childrenLabel = intl.formatMessage({ id: "Children" })
const { setValue, trigger } = useFormContext<BookingWidgetSchema>()
const children = useGuestsRoomsStore((state) => state.rooms[roomIndex].child)
const increaseChildren = useGuestsRoomsStore(
(state) => state.increaseChildren
)
const decreaseChildren = useGuestsRoomsStore(
(state) => state.decreaseChildren
)
const { setValue } = useFormContext()
function increaseChildrenCount(roomIndex: number) {
if (children.length < 5) {
increaseChildren(roomIndex)
setValue(
`rooms.${roomIndex}.child.${children.length}`,
{
age: -1,
bed: -1,
},
{ shouldValidate: true }
)
if (currentChildren.length < 5) {
setValue(`rooms.${roomIndex}.child.${currentChildren.length}`, {
age: undefined,
bed: undefined,
})
}
}
function decreaseChildrenCount(roomIndex: number) {
if (children.length > 0) {
const newChildrenList = decreaseChildren(roomIndex)
setValue(`rooms.${roomIndex}.child`, newChildrenList, {
shouldValidate: true,
})
if (currentChildren.length > 0) {
currentChildren.pop()
setValue(`rooms.${roomIndex}.child`, currentChildren)
}
}
@@ -56,23 +44,25 @@ export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
{childrenLabel}
</Caption>
<Counter
count={children.length}
count={currentChildren.length}
handleOnDecrease={() => {
decreaseChildrenCount(roomIndex)
}}
handleOnIncrease={() => {
increaseChildrenCount(roomIndex)
}}
disableDecrease={children.length == 0}
disableIncrease={children.length == 5}
disableDecrease={currentChildren.length == 0}
disableIncrease={currentChildren.length == 5}
/>
</section>
{children.map((child, index) => (
{currentChildren.map((child, index) => (
<ChildInfoSelector
roomIndex={roomIndex}
index={index}
child={child}
adults={currentAdults}
key={"child_" + index}
childrenInAdultsBed={childrenInAdultsBed}
/>
))}
</>

View File

@@ -29,7 +29,7 @@ export default function Counter({
>
<MinusIcon color="burgundy" />
</Button>
<Body color="textHighContrast" textAlign="center">
<Body color="baseTextHighContrast" textAlign="center">
{count}
</Body>
<Button

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 {
overflow: hidden;
position: relative;
&[data-isopen="true"] {
overflow: visible;
}
.triggerDesktop {
display: none;
}
.pickerContainerMobile {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 20px;
transition: top 300ms ease;
z-index: 100;
}
.contentWrapper {
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
}
.pickerContainerDesktop {
display: none;
}
.roomContainer {
display: grid;
gap: var(--Spacing-x2);
@@ -14,9 +36,6 @@
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x1);
}
.hideWrapper {
background-color: var(--Main-Grey-White);
}
.roomHeading {
margin-bottom: var(--Spacing-x1);
}
@@ -29,43 +48,14 @@
width: 100%;
text-align: left;
}
.body {
opacity: 0.8;
}
.footer {
display: grid;
gap: var(--Spacing-x1);
grid-template-columns: auto;
margin-top: var(--Spacing-x2);
}
@media screen and (max-width: 1366px) {
.hideWrapper {
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 100%;
transition: top 300ms ease;
z-index: 10002;
overflow: hidden;
}
.container[data-isopen="true"] .hideWrapper {
top: 20px;
}
.pickerContainer {
--header-height: 72px;
--sticky-button-height: 140px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.contentContainer {
grid-area: content;
overflow-y: scroll;
@@ -73,7 +63,6 @@
}
.header {
background-color: var(--Main-Grey-White);
display: grid;
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
@@ -101,11 +90,10 @@
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
bottom: 0;
width: 100%;
z-index: 10;
}
.footer .hideOnMobile {
@@ -121,17 +109,40 @@
}
@media screen and (min-width: 1367px) {
.hideWrapper {
.pickerContainerMobile {
display: none;
}
.contentWrapper {
grid-template-rows: auto;
}
.contentContainer {
overflow-y: visible;
}
.triggerMobile {
display: none;
}
.triggerDesktop {
display: block;
}
.pickerContainerDesktop {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
display: grid;
border-radius: var(--Corner-radius-Large);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
left: calc((var(--Spacing-x1) + var(--Spacing-x2)) * -1);
max-width: calc(100vw - 20px);
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
width: 360px;
max-height: calc(100dvh - 77px - var(--Spacing-x6));
overflow-y: auto;
}
.pickerContainerDesktop:focus-visible {
outline: none;
}
.header {
@@ -140,6 +151,7 @@
.footer {
grid-template-columns: auto auto;
padding-top: var(--Spacing-x2);
}
.footer .hideOnDesktop,

View File

@@ -1,67 +1,86 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useState } from "react"
import {
Button,
Dialog,
DialogTrigger,
Modal,
Popover,
} from "react-aria-components"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
import { guestRoomsSchema } from "@/components/Forms/BookingWidget/schema"
import Body from "@/components/TempDesignSystem/Text/Body"
import GuestsRoomsPicker from "./GuestsRoomsPicker"
import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
export default function GuestsRoomsPickerForm({
name = "rooms",
}: {
name: string
}) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const { setValue } = useFormContext()
const { rooms, adultCount, childCount, setIsValidated } = useGuestsRoomsStore(
(state) => ({
rooms: state.rooms,
adultCount: state.adultCount,
childCount: state.childCount,
setIsValidated: state.setIsValidated,
})
)
const ref = useRef<HTMLDivElement | null>(null)
function handleOnClick() {
setIsOpen((prevIsOpen) => !prevIsOpen)
}
const closePicker = useCallback(() => {
const guestRoomsValidData = guestRoomsSchema.safeParse(rooms)
if (guestRoomsValidData.success) {
setIsOpen(false)
setIsValidated(false)
setValue(name, guestRoomsValidData.data, { shouldValidate: true })
} else {
setIsValidated(true)
}
}, [rooms, name, setValue, setIsValidated, setIsOpen])
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
useEffect(() => {
function handleClickOutside(evt: Event) {
const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
closePicker()
export default function GuestsRoomsPickerForm() {
const { watch } = useFormContext()
const rooms = watch("rooms") as GuestsRoom[]
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const htmlElement =
typeof window !== "undefined" ? document.querySelector("body") : null
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
function setOverflowClip(isOpen: boolean) {
if (htmlElement) {
if (isOpen) {
htmlElement.style.overflow = "visible"
} else {
// !important needed to override 'overflow: hidden' set by react-aria.
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
htmlElement.style.overflow = "clip !important"
}
}
document.addEventListener("click", handleClickOutside)
return () => {
document.removeEventListener("click", handleClickOutside)
}
}, [closePicker])
}
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip}>
<Trigger rooms={rooms} className={styles.triggerDesktop} />
<Popover placement="bottom start" offset={36}>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Popover>
</DialogTrigger>
) : (
<DialogTrigger>
<Trigger rooms={rooms} className={styles.triggerMobile} />
<Modal>
<Dialog className={styles.pickerContainerMobile}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
)
}
function Trigger({
rooms,
className,
}: {
rooms: GuestsRoom[]
className: string
}) {
const intl = useIntl()
return (
<div className={styles.container} data-isopen={isOpen} ref={ref}>
<button className={styles.btn} onClick={handleOnClick} type="button">
<Body className={styles.body} asChild>
<span>
<Button className={`${className} ${styles.btn}`} type="button">
<Body>
{rooms.map((room, i) => (
<span key={i}>
{intl.formatMessage(
{ id: "booking.rooms" },
{ totalRooms: rooms.length }
@@ -69,21 +88,18 @@ export default function GuestsRoomsPickerForm({
{", "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: adultCount }
{ totalAdults: room.adults }
)}
{childCount > 0
{room.child.length > 0
? ", " +
intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: childCount }
{ totalChildren: room.child.length }
)
: null}
</span>
</Body>
</button>
<div aria-modal className={styles.hideWrapper} role="dialog">
<GuestsRoomsPicker closePicker={closePicker} />
</div>
</div>
))}
</Body>
</Button>
)
}

View File

@@ -2,5 +2,16 @@
display: flex;
align-items: center;
gap: var(--Spacing-x1);
font-size: var(--typography-Caption-Regular-fontSize);
}
.headerLink:hover {
color: var(--Base-Text-High-contrast);
}
.headerLink .icon * {
fill: var(--Base-Text-Medium-contrast);
}
.headerLink:hover .icon * {
fill: var(--Base-Text-High-contrast);
}

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"
@@ -6,16 +9,19 @@ import type { HeaderLinkProps } from "@/types/components/header/headerLink"
export default function HeaderLink({
children,
className,
...props
href,
iconName,
iconSize = 20,
}: HeaderLinkProps) {
const Icon = getIconByIconName(iconName)
return (
<Link
color="burgundy"
className={`${styles.headerLink} ${className}`}
{...props}
>
{children}
</Link>
<Caption type="regular" color="textMediumContrast" asChild>
<Link href={href} className={styles.headerLink}>
{Icon ? (
<Icon className={styles.icon} width={iconSize} height={iconSize} />
) : null}
{children}
</Link>
</Caption>
)
}

View File

@@ -3,25 +3,27 @@
import { Suspense, useEffect } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import useDropdownStore from "@/stores/main-menu"
import { GiftIcon, SearchIcon, ServiceIcon } from "@/components/Icons"
import LanguageSwitcher from "@/components/LanguageSwitcher"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import useMediaQuery from "@/hooks/useMediaQuery"
import HeaderLink from "../../HeaderLink"
import TopLink from "../../TopLink"
import styles from "./mobileMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { MobileMenuProps } from "@/types/components/header/mobileMenu"
import { IconName } from "@/types/components/icon"
export default function MobileMenu({
children,
languageUrls,
topLink,
isLoggedIn,
}: React.PropsWithChildren<MobileMenuProps>) {
const intl = useIntl()
const {
@@ -75,20 +77,13 @@ export default function MobileMenu({
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Menu" })}
>
<Suspense fallback={"Loading nav"}>{children}</Suspense>
{children}
<footer className={styles.footer}>
<HeaderLink href="#">
<SearchIcon width={20} height={20} color="burgundy" />
<HeaderLink href="#" iconName={IconName.Search}>
{intl.formatMessage({ id: "Find booking" })}
</HeaderLink>
{topLink.link ? (
<HeaderLink href={topLink.link.url}>
<GiftIcon width={20} height={20} color="burgundy" />
{topLink.title}
</HeaderLink>
) : null}
<HeaderLink href="#">
<ServiceIcon width={20} height={20} color="burgundy" />
<TopLink isLoggedIn={isLoggedIn} topLink={topLink} iconSize={20} />
<HeaderLink href="#" iconName={IconName.Service}>
{intl.formatMessage({ id: "Customer service" })}
</HeaderLink>
<LanguageSwitcher type="mobileHeader" urls={languageUrls} />
@@ -98,3 +93,20 @@ export default function MobileMenu({
</>
)
}
export function MobileMenuSkeleton() {
const intl = useIntl()
return (
<button
type="button"
disabled
className={styles.hamburger}
aria-label={intl.formatMessage({
id: "Open menu",
})}
>
<span className={styles.bar} />
</button>
)
}

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"
@@ -8,13 +12,18 @@ export default async function MobileMenuWrapper({
// preloaded
const languages = await getLanguageSwitcher()
const header = await getHeader()
const user = await getName()
if (!languages || !header) {
return null
}
return (
<MobileMenu languageUrls={languages.urls} topLink={header.data.topLink}>
<MobileMenu
languageUrls={languages.urls}
topLink={header.data.topLink}
isLoggedIn={!!user}
>
{children}
</MobileMenu>
)

View File

@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
import useDropdownStore from "@/stores/main-menu"
import { ChevronDownSmallIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Body from "@/components/TempDesignSystem/Text/Body"
import useClickOutside from "@/hooks/useClickOutside"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
@@ -47,7 +48,7 @@ export default function MyPagesMenu({
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
>
<Avatar initials={getInitials(user.firstName, user.lastName)} />
<Body textTransform="bold" color="textHighContrast" asChild>
<Body textTransform="bold" color="baseTextHighContrast" asChild>
<span>
{intl.formatMessage({ id: "Hi" })} {user.firstName}!
</span>
@@ -73,3 +74,15 @@ export default function MyPagesMenu({
</div>
)
}
export function MyPagesMenuSkeleton() {
return (
<div className={styles.myPagesMenu}>
<MainMenuButton>
<Avatar />
<SkeletonShimmer width="10ch" />
<ChevronDownSmallIcon className={`${styles.chevron}`} color="red" />
</MainMenuButton>
</div>
)
}

View File

@@ -10,8 +10,10 @@ import LoginButton from "@/components/LoginButton"
import { getIntl } from "@/i18n"
import Avatar from "../Avatar"
import MyPagesMenu from "../MyPagesMenu"
import MyPagesMobileMenu from "../MyPagesMobileMenu"
import MyPagesMenu, { MyPagesMenuSkeleton } from "../MyPagesMenu"
import MyPagesMobileMenu, {
MyPagesMobileMenuSkeleton,
} from "../MyPagesMobileMenu"
import styles from "./myPagesMenuWrapper.module.css"
@@ -62,3 +64,12 @@ export default async function MyPagesMenuWrapper() {
</>
)
}
export function MyPagesMenuWrapperSkeleton() {
return (
<div>
<MyPagesMenuSkeleton />
<MyPagesMobileMenuSkeleton />
</div>
)
}

View File

@@ -3,11 +3,11 @@
import { useEffect } from "react"
import { Dialog, Modal } from "react-aria-components"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import useDropdownStore from "@/stores/main-menu"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import useMediaQuery from "@/hooks/useMediaQuery"
import { getInitials } from "@/utils/user"
import Avatar from "../Avatar"
@@ -76,3 +76,13 @@ export default function MyPagesMobileMenu({
</div>
)
}
export function MyPagesMobileMenuSkeleton() {
return (
<div className={styles.myPagesMobileMenu}>
<MainMenuButton className={styles.button}>
<Avatar />
</MainMenuButton>
</div>
)
}

View File

@@ -1,3 +1,7 @@
import { cx } from "class-variance-authority"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import NavigationMenuItem from "../NavigationMenuItem"
import styles from "./navigationMenuList.module.css"
@@ -20,3 +24,13 @@ export default function NavigationMenuList({
</ul>
)
}
export function NavigationMenuListSkeleton() {
return (
<ul className={cx(styles.navigationMenu, styles.desktop)}>
<li className={styles.item}>
<SkeletonShimmer width="30ch" />
</li>
</ul>
)
}

View File

@@ -5,37 +5,31 @@ import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { NavigationMenuListSkeleton } from "./NavigationMenu/NavigationMenuList"
import { MobileMenuSkeleton } from "./MobileMenu"
import MobileMenuWrapper from "./MobileMenuWrapper"
import MyPagesMenuWrapper from "./MyPagesMenuWrapper"
import MyPagesMenuWrapper, {
MyPagesMenuWrapperSkeleton,
} from "./MyPagesMenuWrapper"
import NavigationMenu from "./NavigationMenu"
import styles from "./mainMenu.module.css"
export default async function MainMenu() {
const lang = getLang()
const intl = await getIntl()
export default function MainMenu() {
return (
<div className={styles.mainMenu}>
<nav className={styles.nav}>
<NextLink className={styles.logoLink} href={`/${lang}`}>
<Image
alt={intl.formatMessage({ id: "Back to scandichotels.com" })}
className={styles.logo}
height={22}
src="/_static/img/scandic-logotype.svg"
width={103}
/>
</NextLink>
<Suspense fallback={<Logo alt="..." />}>
<MainMenuLogo />
</Suspense>
<div className={styles.menus}>
<Suspense fallback={"Loading nav"}>
<Suspense fallback={<NavigationMenuListSkeleton />}>
<NavigationMenu isMobile={false} />
</Suspense>
<Suspense fallback={"Loading profile"}>
<Suspense fallback={<MyPagesMenuWrapperSkeleton />}>
<MyPagesMenuWrapper />
</Suspense>
<Suspense fallback={"Loading menu"}>
<Suspense fallback={<MobileMenuSkeleton />}>
<MobileMenuWrapper>
<NavigationMenu isMobile={true} />
</MobileMenuWrapper>
@@ -45,3 +39,25 @@ export default async function MainMenu() {
</div>
)
}
async function MainMenuLogo() {
const intl = await getIntl()
return <Logo alt={intl.formatMessage({ id: "Back to scandichotels.com" })} />
}
function Logo({ alt }: { alt: string }) {
const lang = getLang()
return (
<NextLink className={styles.logoLink} href={`/${lang}`}>
<Image
alt={alt}
className={styles.logo}
height={22}
src="/_static/img/scandic-logotype.svg"
width={103}
/>
</NextLink>
)
}

View File

@@ -1,7 +1,7 @@
.mainMenu {
background-color: var(--Base-Surface-Primary-light-Normal);
padding: var(--Spacing-x2);
border-bottom: 1px solid var(--Base-Border-Subtle);
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.nav {

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 Link from "@/components/TempDesignSystem/Link"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import HeaderLink from "../HeaderLink"
import TopLink from "../TopLink"
import styles from "./topMenu.module.css"
import { IconName } from "@/types/components/icon"
export default async function TopMenu() {
// cached
const intl = await getIntl()
// both preloaded
const languages = await getLanguageSwitcher()
const header = await getHeader()
const user = await getName()
if (!languages || !header) {
return null
@@ -24,28 +31,27 @@ export default async function TopMenu() {
return (
<div className={styles.topMenu}>
<div className={styles.content}>
{header.data.topLink.link ? (
<Caption type="regular" color="textMediumContrast" asChild>
<Link
href={header.data.topLink.link.url}
color="peach80"
variant="icon"
>
<GiftIcon width={20} height={20} />
{header.data.topLink.title}
</Link>
</Caption>
) : null}
<TopLink isLoggedIn={!!user} topLink={header.data.topLink} />
<div className={styles.options}>
<LanguageSwitcher type="desktopHeader" urls={languages.urls} />
<Caption type="regular" color="textMediumContrast" asChild>
<Link href="#" color="peach80" variant="icon">
<SearchIcon width={20} height={20} />
<HeaderLink href="#" iconName={IconName.Search}>
{intl.formatMessage({ id: "Find booking" })}
</Link>
</HeaderLink>
</Caption>
<HeaderLink href="#"></HeaderLink>
</div>
</div>
</div>
)
}
export function TopMenuSkeleton() {
return (
<div className={styles.topMenu}>
<div className={styles.content}>
<div className={styles.options}>
<SkeletonShimmer width="25ch" height="1.2em" />
</div>
</div>
</div>

View File

@@ -1,23 +1,27 @@
import { Suspense } from "react"
import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
import {
getHeader,
getLanguageSwitcher,
getName,
} from "@/lib/trpc/memoizedRequests"
import MainMenu from "./MainMenu"
import TopMenu from "./TopMenu"
import TopMenu, { TopMenuSkeleton } from "./TopMenu"
import styles from "./header.module.css"
export default function Header() {
export default async function Header() {
void getHeader()
void getLanguageSwitcher()
void getName()
return (
<header className={styles.header}>
<Suspense fallback="Loading top menu">
<Suspense fallback={<TopMenuSkeleton />}>
<TopMenu />
</Suspense>
<Suspense fallback="Loading main menu">
<MainMenu />
</Suspense>
<MainMenu />
</header>
)
}

View File

@@ -46,3 +46,9 @@
flex-direction: column;
justify-content: center;
}
.googleMaps {
text-decoration: none;
font-family: var(--typography-Body-Regular-fontFamily);
color: var(--Base-Text-Medium-contrast);
}

View File

@@ -32,9 +32,13 @@ export default function Contact({ hotel }: ContactProps) {
<span className={styles.heading}>
{intl.formatMessage({ id: "Driving directions" })}
</span>
<Link href="#" color="peach80">
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`}
className={styles.googleMaps}
target="_blank"
>
Google Maps
</Link>
</a>
</li>
<li>
<span className={styles.heading}>

View File

@@ -1,7 +1,6 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { Label as AriaLabel } from "react-aria-components"
@@ -45,6 +44,8 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
const maxRetries = 40
const retryInterval = 2000
export const formId = "submit-booking"
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
}
@@ -59,10 +60,13 @@ export default function Payment({
const lang = useLang()
const intl = useIntl()
const queryParams = useSearchParams()
const { userData, roomData } = useEnterDetailsStore((state) => ({
userData: state.userData,
roomData: state.roomData,
}))
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
(state) => ({
userData: state.userData,
roomData: state.roomData,
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
})
)
const {
firstName,
@@ -72,6 +76,7 @@ export default function Payment({
countryCode,
breakfast,
bedType,
membershipNo,
} = userData
const { toDate, fromDate, rooms: rooms, hotel } = roomData
@@ -119,6 +124,16 @@ export default function Payment({
}
}, [bookingStatus, router])
useEffect(() => {
setIsSubmittingDisabled(
!methods.formState.isValid || methods.formState.isSubmitting
)
}, [
methods.formState.isValid,
methods.formState.isSubmitting,
setIsSubmittingDisabled,
])
function handleSubmit(data: PaymentFormData) {
const allQueryParams =
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
@@ -132,17 +147,6 @@ export default function Payment({
(card) => card.id === data.paymentMethod
)
let phone: string
let phoneCountryCodePrefix: string | null = null
if (isValidPhoneNumber(phoneNumber)) {
const parsedPhone = parsePhoneNumber(phoneNumber)
phone = parsedPhone.nationalNumber
phoneCountryCodePrefix = parsedPhone.countryCallingCode
} else {
phone = phoneNumber
}
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
@@ -160,9 +164,9 @@ export default function Payment({
firstName,
lastName,
email,
phoneCountryCodePrefix,
phoneNumber: phone,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
},
packages: {
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
@@ -171,7 +175,8 @@ export default function Payment({
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
@@ -209,6 +214,7 @@ export default function Payment({
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<section className={styles.section}>
@@ -309,15 +315,16 @@ export default function Payment({
</Caption>
</AriaLabel>
</section>
<Button
type="submit"
className={styles.submitButton}
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking & go to payment" })}
</Button>
<div className={styles.submitButton}>
<Button
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
)

View File

@@ -18,7 +18,7 @@
}
.submitButton {
align-self: flex-start;
display: none;
}
.paymentContainer .link {
@@ -31,3 +31,10 @@
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
}
@media screen and (min-width: 1367px) {
.submitButton {
display: flex;
align-self: flex-start;
}
}

View File

@@ -5,7 +5,6 @@ import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -62,6 +61,9 @@ export default function SectionAccordion({
function onModify() {
navigate(step)
}
const textColor =
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
return (
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
<div className={styles.iconWrapper}>
@@ -72,37 +74,25 @@ export default function SectionAccordion({
</div>
</div>
<div className={styles.main}>
<header className={styles.headerContainer}>
<div>
<header>
<button onClick={onModify} className={styles.modifyButton}>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color="uiTextHighContrast"
color={textColor}
>
<h2>{header}</h2>
</Footnote>
<Subtitle
type="two"
className={styles.selection}
color="uiTextHighContrast"
>
<Subtitle className={styles.selection} type="two" color={textColor}>
{title}
</Subtitle>
</div>
{isComplete && !isOpen && (
<Button
onClick={onModify}
theme="base"
size="small"
variant="icon"
intent="text"
wrapping
>
{intl.formatMessage({ id: "Modify" })}{" "}
<ChevronDownIcon color="burgundy" width={20} height={20} />
</Button>
)}
{isComplete && !isOpen && (
<ChevronDownIcon className={styles.button} color="burgundy" />
)}
</button>
</header>
<div className={styles.content}>{children}</div>
</div>

View File

@@ -2,25 +2,33 @@
position: relative;
display: flex;
flex-direction: row;
gap: var(--Spacing-x3);
gap: var(--Spacing-x-one-and-half);
padding-top: var(--Spacing-x3);
}
.wrapper:not(:last-child)::after {
position: absolute;
left: 12px;
bottom: 0;
top: var(--Spacing-x5);
height: 100%;
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.wrapper:last-child .main {
border-bottom: none;
}
.modifyButton {
display: grid;
grid-template-areas: "title button" "selection button";
cursor: pointer;
background-color: transparent;
border: none;
width: 100%;
}
.title {
grid-area: title;
text-align: start;
}
.button {
grid-area: button;
justify-self: flex-end;
}
.main {
display: grid;
gap: var(--Spacing-x3);
@@ -31,21 +39,14 @@
grid-template-rows: 2em 0fr;
}
.headerContainer {
display: flex;
justify-content: space-between;
align-items: center;
}
.selection {
font-weight: 450;
font-size: var(--typography-Title-4-fontSize);
grid-area: selection;
}
.iconWrapper {
position: relative;
top: var(--Spacing-x1);
z-index: 2;
}
.circle {
@@ -78,3 +79,23 @@
.content {
overflow: hidden;
}
@media screen and (min-width: 1367px) {
.wrapper {
gap: var(--Spacing-x3);
}
.iconWrapper {
top: var(--Spacing-x1);
}
.wrapper:not(:last-child)::after {
position: absolute;
left: 12px;
bottom: 0;
top: var(--Spacing-x7);
height: 100%;
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
}

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import useSidePeekStore from "@/stores/sidepeek"
import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
@@ -28,6 +29,7 @@ export default function ToggleSidePeek({
wrapping
>
{intl.formatMessage({ id: "See room details" })}{" "}
<ChevronRight height="14" />
</Button>
)
}

View File

@@ -33,26 +33,25 @@ export default function SelectedRoom({
</div>
<div className={styles.main}>
<div className={styles.headerContainer}>
<div>
<Footnote
asChild
textTransform="uppercase"
type="label"
color="uiTextHighContrast"
>
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
</Footnote>
<Subtitle
type="two"
className={styles.selection}
color="uiTextHighContrast"
>
{room.roomType}{" "}
<span className={styles.rate}>{`(${rateDescription})`}</span>
</Subtitle>
</div>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color="uiTextHighContrast"
>
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
</Footnote>
<Subtitle
type="two"
className={styles.description}
color="uiTextHighContrast"
>
{room.roomType}{" "}
<span className={styles.rate}>{rateDescription}</span>
</Subtitle>
<Link
className={styles.button}
color="burgundy"
href={selectRateUrl}
size="small"

View File

@@ -2,43 +2,41 @@
position: relative;
display: flex;
flex-direction: row;
gap: var(--Spacing-x3);
padding-top: var(--Spacing-x3);
}
.wrapper::after {
position: absolute;
left: 12px;
bottom: 0;
top: var(--Spacing-x5);
height: 100%;
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
gap: var(--Spacing-x-one-and-half);
}
.main {
display: grid;
width: 100%;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-bottom: var(--Spacing-x3);
grid-template-rows: 2em 0fr;
}
.headerContainer {
display: flex;
display: grid;
justify-content: space-between;
align-items: center;
grid-template-areas:
"title button"
"description button";
}
.selection {
.title {
grid-area: title;
}
.description {
font-weight: 450;
font-size: var(--typography-Title-4-fontSize);
grid-area: description;
}
.button {
grid-area: button;
justify-self: flex-end;
}
.iconWrapper {
position: relative;
top: var(--Spacing-x1);
z-index: 2;
}
.circle {
@@ -57,9 +55,42 @@
.rate {
color: var(--UI-Text-Placeholder);
display: block;
}
.details {
display: flex;
justify-content: flex-start;
}
@media screen and (min-width: 1367px) {
.wrapper {
gap: var(--Spacing-x3);
padding-top: var(--Spacing-x3);
}
.iconWrapper {
top: var(--Spacing-x1);
}
.rate {
display: inline;
}
.rate::before {
content: "(";
}
.rate::after {
content: ")";
}
.wrapper:not(:last-child)::after {
position: absolute;
left: 12px;
bottom: 0;
top: var(--Spacing-x7);
height: 100%;
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
}

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"
import { useEffect, useState } from "react"
import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
import { ArrowRightIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
@@ -21,8 +23,16 @@ import { RoomsData } from "@/types/components/hotelReservation/enterDetails/book
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
function parsePrice(price: string | undefined) {
return price ? parseInt(price) : 0
function storeSelector(state: EnterDetailsState) {
return {
fromDate: state.roomData.fromDate,
toDate: state.roomData.toDate,
bedType: state.userData.bedType,
breakfast: state.userData.breakfast,
toggleSummaryOpen: state.toggleSummaryOpen,
setTotalPrice: state.setTotalPrice,
totalPrice: state.totalPrice,
}
}
export default function Summary({
@@ -36,20 +46,17 @@ export default function Summary({
const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
>()
const [totalPrice, setTotalPrice] = useState({
local: parsePrice(room.localPrice.price),
euro: parsePrice(room.euroPrice.price),
})
const intl = useIntl()
const lang = useLang()
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
(state) => ({
fromDate: state.roomData.fromDate,
toDate: state.roomData.toDate,
bedType: state.userData.bedType,
breakfast: state.userData.breakfast,
})
)
const {
fromDate,
toDate,
bedType,
breakfast,
setTotalPrice,
totalPrice,
toggleSummaryOpen,
} = useEnterDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
@@ -63,51 +70,85 @@ export default function Summary({
color = "red"
}
const additionalPackageCost = room.packages?.reduce(
(acc, curr) => {
acc.local = acc.local + parseInt(curr.localPrice.totalPrice)
acc.euro = acc.euro + parseInt(curr.requestedPrice.totalPrice)
return acc
},
{ local: 0, euro: 0 }
) || { local: 0, euro: 0 }
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
const roomsPriceEuro = room.euroPrice.price + additionalPackageCost.euro
useEffect(() => {
setChosenBed(bedType)
setChosenBreakfast(breakfast)
if (breakfast) {
setChosenBreakfast(breakfast)
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
setTotalPrice({
local: parsePrice(room.localPrice.price),
euro: parsePrice(room.euroPrice.price),
})
} else {
setTotalPrice({
local:
parsePrice(room.localPrice.price) +
parsePrice(breakfast.localPrice.totalPrice),
euro:
parsePrice(room.euroPrice.price) +
parsePrice(breakfast.requestedPrice.totalPrice),
})
}
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
setTotalPrice({
local: {
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: room.localPrice.currency,
},
euro: {
price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency,
},
})
} else {
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
},
euro: {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
},
})
}
}, [bedType, breakfast, room.localPrice, room.euroPrice])
}, [
bedType,
breakfast,
roomsPriceLocal,
room.localPrice.currency,
room.euroPrice.currency,
roomsPriceEuro,
setTotalPrice,
])
return (
<section className={styles.summary}>
<header>
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
<header className={styles.header}>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({ id: "Summary" })}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Button
intent="text"
size="small"
className={styles.chevronButton}
onClick={toggleSummaryOpen}
>
<ChevronDown height="20" width="20" />
</Button>
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div>
<div className={styles.entry}>
<Body color="textHighContrast">{room.roomType}</Body>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Caption color={color}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(
parseInt(room.localPrice.price ?? "0")
),
amount: intl.formatNumber(room.localPrice.price),
currency: room.localPrice.currency,
}
)}
@@ -134,17 +175,37 @@ export default function Summary({
{intl.formatMessage({ id: "Rate details" })}
</Link>
</div>
{room.packages
? room.packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
<div>
<Body color="uiTextHighContrast">
{roomPackage.description}
</Body>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency,
}
)}
</Caption>
</div>
))
: null}
{chosenBed ? (
<div className={styles.entry}>
<div>
<Body color="textHighContrast">{chosenBed.description}</Body>
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Caption color="uiTextMediumContrast">
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
@@ -156,10 +217,10 @@ export default function Summary({
{chosenBreakfast ? (
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
<div className={styles.entry}>
<Body color="textHighContrast">
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
@@ -168,10 +229,10 @@ export default function Summary({
</div>
) : (
<div className={styles.entry}>
<Body color="textHighContrast">
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
@@ -203,8 +264,8 @@ export default function Summary({
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local),
currency: room.localPrice.currency,
amount: intl.formatNumber(totalPrice.local.price),
currency: totalPrice.local.currency,
}
)}
</Body>
@@ -213,14 +274,14 @@ export default function Summary({
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro),
currency: room.euroPrice.currency,
amount: intl.formatNumber(totalPrice.euro.price),
currency: totalPrice.euro.currency,
}
)}
</Caption>
</div>
</div>
<Divider color="primaryLightSubtle" />
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
</section>
)

View File

@@ -7,11 +7,28 @@
height: 100%;
}
.header {
display: grid;
grid-template-areas: "title button" "date button";
}
.title {
grid-area: title;
}
.chevronButton {
grid-area: button;
justify-self: end;
align-items: center;
margin-right: calc(0px - var(--Spacing-x2));
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
@@ -38,3 +55,21 @@
flex-direction: column;
gap: var(--Spacing-x2);
}
.bottomDivider {
display: none;
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.chevronButton {
display: none;
}
}

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 {
display: grid;
grid-template-areas:
"image header"
"hotel hotel"
"prices prices";
gap: var(--Spacing-x2);
padding: var(--Spacing-x2);
display: flex;
flex-direction: column;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
width: 100%;
overflow: hidden;
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.card.active {
@@ -17,14 +17,9 @@
}
.imageContainer {
grid-area: image;
position: relative;
height: 100%;
width: 116px;
}
.tripAdvisor {
display: none;
height: 200px;
width: 100%;
}
.imageContainer img {
@@ -32,19 +27,41 @@
}
.hotelInformation {
grid-area: header;
margin-bottom: var(--Spacing-x-half);
}
.hotel {
.hotelContent {
display: flex;
flex-direction: column;
grid-area: hotel;
padding: var(--Spacing-x2);
}
.hotelDescription {
display: none;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
margin-top: var(--Spacing-x-half);
}
.addressContainer {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
}
.address {
display: none;
font-style: normal;
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
margin-top: var(--Spacing-x-one-and-half);
}
.facilitiesItem {
@@ -56,66 +73,76 @@
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
grid-area: prices;
gap: var(--Spacing-x-one-and-half);
width: 100%;
}
.public,
.member {
max-width: fit-content;
margin-bottom: var(--Spacing-x-half);
.detailsButton {
border-bottom: none;
}
.button {
justify-content: center;
min-width: 160px;
}
.specialAlerts {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.card.pageListing {
grid-template-areas:
"image header"
"image hotel"
"image prices";
flex-direction: row;
overflow: hidden;
padding: 0;
}
.pageListing .imageContainer {
position: relative;
min-height: 200px;
width: 518px;
.pageListing .hotelDescription {
display: block;
}
.pageListing .tripAdvisor {
position: absolute;
display: block;
left: 7px;
top: 7px;
.pageListing .imageContainer {
position: relative;
height: 100%;
width: 314px;
}
.pageListing .hotelInformation {
padding-top: var(--Spacing-x2);
width: min(422px, 100%);
padding-right: var(--Spacing-x2);
margin: 0;
}
.pageListing .hotel {
.pageListing .facilities {
margin: var(--Spacing-x1) 0;
}
.pageListing .hotelContent {
flex-direction: row;
align-items: center;
gap: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
padding-left: var(--Spacing-x3);
}
.pageListing .titleContainer {
margin-bottom: var(--Spacing-x-one-and-half);
}
.pageListing .prices {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-right: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
}
.pageListing .detailsButton {
border-bottom: none;
width: 260px;
}
.pageListing .button {
width: 160px;
width: 100%;
}
.pageListing .addressMobile {
display: none;
}
.pageListing .address {
display: inline;
}
}

View File

@@ -1,18 +1,24 @@
"use client"
import { useParams } from "next/dist/client/components/navigation"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import ImageGallery from "@/components/ImageGallery"
import Alert from "@/components/TempDesignSystem/Alert"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Title from "@/components/TempDesignSystem/Text/Title"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import ReadMore from "../ReadMore"
import ImageGallery from "../SelectRate/ImageGallery"
import TripAdvisorChip from "../TripAdvisorChip"
import HotelLogo from "./HotelLogo"
import HotelPriceList from "./HotelPriceList"
import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
@@ -26,6 +32,8 @@ export default function HotelCard({
state = "default",
onHotelCardHover,
}: HotelCardProps) {
const params = useParams()
const lang = params.lang as Lang
const intl = useIntl()
const { hotelData } = hotel
@@ -55,93 +63,104 @@ export default function HotelCard({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<section className={styles.imageContainer}>
{hotelData.gallery && (
<div>
<div className={styles.imageContainer}>
<ImageGallery
title={hotelData.name}
images={[
hotelData.hotelContent.images,
...hotelData.gallery.heroImages,
]}
images={hotelData.galleryImages}
fill
/>
)}
<div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" />
{hotelData.ratings?.tripAdvisor.rating}
</Chip>
{hotelData.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
)}
</div>
</section>
<section className={styles.hotelInformation}>
<ScandicLogoIcon color="red" />
<Title as="h4" textTransform="capitalize">
{hotelData.name}
</Title>
<Footnote color="uiTextMediumContrast">
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
</Footnote>
<Footnote color="uiTextMediumContrast">
{`${hotelData.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
</Footnote>
</section>
<section className={styles.hotel}>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
<Caption color="textMediumContrast">{facility.name}</Caption>
</div>
<div className={styles.hotelContent}>
<section className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogo
hotelId={hotel.hotelData.operaId}
hotelType={hotel.hotelData.hotelType}
/>
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
{hotelData.name}
</Subtitle>
<div className={styles.addressContainer}>
<address className={styles.address}>
<Caption color="uiTextPlaceholder">
{hotelData.address.streetAddress}, {hotelData.address.city}
</Caption>
</address>
<Link
className={styles.addressMobile}
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
keepSearchParams
>
<Caption color="baseTextMediumContrast" type="underline">
{hotelData.address.streetAddress}, {hotelData.address.city}
</Caption>
</Link>
<div>
<Divider variant="vertical" color="subtle" />
</div>
)
})}
</div>
<ReadMore
label={intl.formatMessage({ id: "See hotel details" })}
hotelId={hotelData.operaId}
hotel={hotelData}
/>
</section>
<section className={styles.prices}>
<div>
<Chip intent="primary" className={styles.public}>
<PriceTagIcon color="white" width={15} height={15} />
{intl.formatMessage({ id: "Public price from" })}
</Chip>
<Caption color="textMediumContrast">
{price?.regularAmount} {price?.currency} /
{intl.formatMessage({ id: "night" })}
</Caption>
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
</div>
<div>
<Chip intent="primary" className={styles.member}>
<PriceTagIcon color="white" width={15} height={15} />
{intl.formatMessage({ id: "Member price from" })}
</Chip>
<Caption color="textMediumContrast">
{price?.memberAmount} {price?.currency} /
{intl.formatMessage({ id: "night" })}
</Caption>
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
</div>
<Button
asChild
theme="base"
intent="tertiary"
size="small"
className={styles.button}
>
{/* TODO: Localize link and also use correct search params */}
<Link
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
color="none"
keepSearchParams
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{ number: hotelData.location.distanceToCentre }
)}
</Caption>
</div>
</div>
<Body className={styles.hotelDescription}>
{hotelData.hotelContent.texts.descriptions.short}
</Body>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
</div>
)
})}
</div>
<ReadMore
label={intl.formatMessage({ id: "See hotel details" })}
hotelId={hotelData.operaId}
hotel={hotelData}
showCTA={true}
/>
{hotelData.specialAlerts.length > 0 && (
<div className={styles.specialAlerts}>
{hotelData.specialAlerts.map((alert) => (
<Alert key={alert.id} type={alert.type} text={alert.text} />
))}
</div>
)}
</section>
<div className={styles.prices}>
<HotelPriceList price={price} />
<Button
asChild
theme="base"
intent="primary"
size="small"
className={styles.button}
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</section>
{/* TODO: Localize link and also use correct search params */}
<Link
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</div>
</div>
</article>
)
}

View File

@@ -18,6 +18,12 @@
position: relative;
}
.name {
height: 48px;
display: flex;
align-items: center;
}
.closeIcon {
position: absolute;
top: 7px;
@@ -52,7 +58,7 @@
.facilities {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
gap: 0 var(--Spacing-x1);
}
.facilitiesItem {
@@ -67,7 +73,6 @@
background: var(--Base-Surface-Secondary-light-Normal);
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.perNight {

View File

@@ -1,13 +1,18 @@
"use client"
import { useParams } from "next/navigation"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { CloseLargeIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -17,13 +22,15 @@ import styles from "./hotelCardDialog.module.css"
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialog({
pin,
data,
isOpen,
handleClose,
}: HotelCardDialogProps) {
const params = useParams()
const lang = params.lang as Lang
const intl = useIntl()
if (!pin) {
if (!data) {
return null
}
@@ -35,7 +42,7 @@ export default function HotelCardDialog({
amenities,
images,
ratings,
} = pin
} = data
const firstImage = images[0]?.imageSizes?.small
const altText = images[0]?.metaData?.altText
@@ -52,20 +59,24 @@ export default function HotelCardDialog({
<div className={styles.imageContainer}>
<Image src={firstImage} alt={altText} fill />
<div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" />
<Chip intent="secondary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
{ratings}
</Chip>
</div>
</div>
<div className={styles.content}>
<Body textTransform="bold">{name}</Body>
<div className={styles.name}>
<Body textTransform="bold">{name}</Body>
</div>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
{IconComponent && (
<IconComponent width={16} height={16} color="grey80" />
)}
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
@@ -90,8 +101,15 @@ export default function HotelCardDialog({
</Subtitle>
)}
</div>
<Button size="small" theme="base" className={styles.button}>
{intl.formatMessage({ id: "See rooms" })}
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate[lang]}?hotel=${data.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</div>
</div>

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 {
display: flex;
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 (
<section className={styles.hotelCards}>
{hotels?.length ? (
hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
) : (
<Title>No hotels found</Title>
)}
{hotels?.length
? hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
: null}
</section>
)
}

View File

@@ -31,7 +31,7 @@ export default function HotelSelectionHeader({
</div>
<Caption color="textMediumContrast">
{intl.formatMessage(
{ id: "Distance to city centre" },
{ id: "Distance in km to city centre" },
{ number: hotel.location.distanceToCentre }
)}
</Caption>
@@ -41,7 +41,7 @@ export default function HotelSelectionHeader({
<Divider variant="vertical" color="subtle" />
</div>
<div className={styles.descriptionContainer}>
<Body color="textHighContrast">
<Body color="baseTextHighContrast">
{hotel.hotelContent.texts.descriptions.short}
</Body>
</div>

View File

@@ -10,12 +10,12 @@ import styles from "./readMore.module.css"
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export default function ReadMore({ label, hotelId }: ReadMoreProps) {
export default function ReadMore({ label, hotelId, showCTA }: ReadMoreProps) {
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
return (
<Button
onPress={() => {
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId, showCTA })
}}
intent="text"
theme="base"
@@ -23,7 +23,11 @@ export default function ReadMore({ label, hotelId }: ReadMoreProps) {
className={styles.detailsButton}
>
{label}
<ChevronRightIcon color="burgundy" height={20} width={20} />
<ChevronRightIcon
color="baseButtonTextOnFillNormal"
height={20}
width={20}
/>
</Button>
)
}

View File

@@ -3,16 +3,30 @@
display: none;
}
.container form {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.facilities {
font-family: var(--typography-Body-Bold-fontFamily);
margin-bottom: var(--Spacing-x3);
padding-bottom: var(--Spacing-x3);
}
.facilities:first-of-type {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.facilities ul {
margin-top: var(--Spacing-x2);
}
.filter {
display: grid;
grid-template-columns: repeat(2, minmax(min-content, max-content));
gap: var(--Spacing-x-one-and-half);
margin-bottom: var(--Spacing-x-one-and-half);
margin-bottom: var(--Spacing-x1);
align-items: center;
}

View File

@@ -2,26 +2,29 @@
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import { useForm } from "react-hook-form"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./hotelFilter.module.css"
import { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function HotelFilter({ filters }: HotelFiltersProps) {
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
const { watch, handleSubmit, getValues, register } = useForm<
Record<string, boolean | undefined>
>({
const methods = useForm<Record<string, boolean | undefined>>({
defaultValues: searchParams
?.get("filters")
?.split(",")
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
})
const { watch, handleSubmit, getValues, register } = methods
const submitFilter = useCallback(() => {
const newSearchParams = new URLSearchParams(searchParams)
@@ -50,43 +53,42 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
return () => subscription.unsubscribe()
}, [handleSubmit, watch, submitFilter])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
return (
<aside className={styles.container}>
<div className={styles.facilities}>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(submitFilter)}>
{intl.formatMessage({ id: "Hotel facilities" })}
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<input
type="checkbox"
id={`checkbox-${filter.id.toString()}`}
{...register(filter.id.toString())}
/>
<label htmlFor={`checkbox-${filter.id.toString()}`}>
{filter.name}
</label>
</li>
))}
</ul>
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel facilities" })}
</Subtitle>
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
</li>
))}
</ul>
</div>
{intl.formatMessage({ id: "Hotel surroundings" })}
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<input
type="checkbox"
id={`checkbox-${filter.id.toString()}`}
{...register(filter.id.toString())}
/>
<label htmlFor={`checkbox-${filter.id.toString()}`}>
{filter.name}
</label>
</li>
))}
</ul>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel surroundings" })}
</Subtitle>
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
</li>
))}
</ul>
</div>
</form>
</div>
</FormProvider>
</aside>
)
}

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