Merge branch 'develop'
This commit is contained in:
@@ -2,9 +2,10 @@ import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import ContentPage from "@/components/ContentType/ContentPage"
|
||||
import HotelPage from "@/components/ContentType/HotelPage"
|
||||
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
|
||||
import CollectionPage from "@/components/ContentType/StaticPages/CollectionPage"
|
||||
import ContentPage from "@/components/ContentType/StaticPages/ContentPage"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,11 @@ export default function ContentTypePage({
|
||||
setLang(params.lang)
|
||||
|
||||
switch (params.contentType) {
|
||||
case "collection-page":
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
}
|
||||
return <CollectionPage />
|
||||
case "content-page":
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
|
||||
@@ -45,8 +45,8 @@ export default async function BookingConfirmationPage({
|
||||
}
|
||||
)
|
||||
|
||||
const fromDate = dt(booking.temp.fromDate).locale(params.lang)
|
||||
const toDate = dt(booking.temp.toDate).locale(params.lang)
|
||||
const fromDate = dt(booking.checkInDate).locale(params.lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(params.lang)
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{
|
||||
@@ -77,7 +77,7 @@ export default async function BookingConfirmationPage({
|
||||
textTransform="regular"
|
||||
type="h1"
|
||||
>
|
||||
{booking.hotel.name}
|
||||
{booking.hotel?.data.attributes.name}
|
||||
</Title>
|
||||
</hgroup>
|
||||
<Body className={styles.body} textAlign="center">
|
||||
@@ -91,7 +91,7 @@ export default async function BookingConfirmationPage({
|
||||
<Subtitle color="burgundy" type="two">
|
||||
{intl.formatMessage(
|
||||
{ id: "Reference #{bookingNr}" },
|
||||
{ bookingNr: "A92320VV" }
|
||||
{ bookingNr: booking.confirmationNumber }
|
||||
)}
|
||||
</Subtitle>
|
||||
</header>
|
||||
@@ -183,11 +183,13 @@ export default async function BookingConfirmationPage({
|
||||
</Caption>
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{booking.hotel.name}
|
||||
{booking.hotel?.data.attributes.name}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.hotel.email}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.hotel.phoneNumber}
|
||||
{booking.hotel?.data.attributes.contactInformation.email}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.hotel?.data.attributes.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +221,16 @@ export default async function BookingConfirmationPage({
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Total cost" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.temp.total}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{`${intl.formatMessage({ id: "Approx." })} ${booking.temp.totalInEuro}`}
|
||||
</Caption>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function HotelSidePeek({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { hotel: string }>) {
|
||||
const search = new URLSearchParams(searchParams)
|
||||
const { hotel: hotelId } = getQueryParamsForEnterDetails(search)
|
||||
|
||||
if (!hotelId) {
|
||||
return <SidePeek hotel={null} />
|
||||
}
|
||||
|
||||
const hotel = await getHotelData({
|
||||
hotelId: hotelId,
|
||||
language: params.lang,
|
||||
})
|
||||
|
||||
return <SidePeek hotel={hotel} />
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export default async function HotelHeader({
|
||||
if (!searchParams.hotel) {
|
||||
redirect(home)
|
||||
}
|
||||
const hotel = await getHotelData(searchParams.hotel, params.lang)
|
||||
const hotel = await getHotelData({
|
||||
hotelId: searchParams.hotel,
|
||||
language: params.lang,
|
||||
})
|
||||
if (!hotel?.data) {
|
||||
redirect(home)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import SidePeek from "@/components/HotelReservation/EnterDetails/SidePeek"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function HotelSidePeek({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { hotel: string }>) {
|
||||
if (!searchParams.hotel) {
|
||||
redirect(`/${params.lang}`)
|
||||
}
|
||||
const hotel = await getHotelData(searchParams.hotel, params.lang)
|
||||
if (!hotel?.data) {
|
||||
redirect(`/${params.lang}`)
|
||||
}
|
||||
return <SidePeek hotel={hotel.data.attributes} />
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getQueryParamsForEnterDetails,
|
||||
mapChildrenFromString,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { LangParams, PageArgs, SearchParams } from "@/types/params"
|
||||
|
||||
export default async function SummaryPage({
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const { hotel, adults, children, roomTypeCode, rateCode, fromDate, toDate } =
|
||||
getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const availability = await getSelectedRoomAvailability({
|
||||
hotelId: hotel,
|
||||
adults,
|
||||
children: children ? generateChildrenString(children) : undefined,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
|
||||
if (!availability) {
|
||||
console.error("No hotel or availability data", availability)
|
||||
// TODO: handle this case
|
||||
return null
|
||||
}
|
||||
|
||||
const prices = user
|
||||
? {
|
||||
local: {
|
||||
price: availability.memberRate?.localPrice.pricePerStay,
|
||||
currency: availability.memberRate?.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: availability.memberRate?.requestedPrice?.pricePerStay,
|
||||
currency: availability.memberRate?.requestedPrice?.currency,
|
||||
},
|
||||
}
|
||||
: {
|
||||
local: {
|
||||
price: availability.publicRate?.localPrice.pricePerStay,
|
||||
currency: availability.publicRate?.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: availability.publicRate?.requestedPrice?.pricePerStay,
|
||||
currency: availability.publicRate?.requestedPrice?.currency,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Summary
|
||||
isMember={!!user}
|
||||
room={{
|
||||
roomType: availability.selectedRoom.roomType,
|
||||
localPrice: prices.local,
|
||||
euroPrice: prices.euro,
|
||||
adults,
|
||||
children,
|
||||
cancellationText: availability.cancellationText,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
getCreditCardsSafely,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
export function preload() {
|
||||
void getProfileSafely()
|
||||
void getCreditCardsSafely()
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
.layout {
|
||||
min-height: 100dvh;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
}
|
||||
|
||||
@@ -9,7 +8,6 @@
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: auto 1fr;
|
||||
margin: var(--Spacing-x5) auto 0;
|
||||
padding-top: var(--Spacing-x6);
|
||||
/* simulates padding on viewport smaller than --max-width-navigation */
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
@@ -17,8 +15,81 @@
|
||||
);
|
||||
}
|
||||
|
||||
.summary {
|
||||
align-self: flex-start;
|
||||
.summaryContainer {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1/-1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.summaryContainer {
|
||||
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(--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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: block;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.summary {
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
||||
var(--Spacing-x-half)
|
||||
);
|
||||
}
|
||||
|
||||
.hider {
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
import {
|
||||
getCreditCardsSafely,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import { preload } from "./page"
|
||||
import { preload } from "./_preload"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default async function StepLayout({
|
||||
summary,
|
||||
children,
|
||||
hotelHeader,
|
||||
params,
|
||||
sidePeek,
|
||||
}: React.PropsWithChildren<
|
||||
LayoutArgs<LangParams & { step: StepEnum }> & {
|
||||
hotelHeader: React.ReactNode
|
||||
sidePeek: React.ReactNode
|
||||
summary: React.ReactNode
|
||||
}
|
||||
>) {
|
||||
setLang(params.lang)
|
||||
preload()
|
||||
|
||||
const user = await getProfileSafely()
|
||||
|
||||
return (
|
||||
<EnterDetailsProvider step={params.step}>
|
||||
<EnterDetailsProvider step={params.step} isMember={!!user}>
|
||||
<main className={styles.layout}>
|
||||
{hotelHeader}
|
||||
<div className={styles.content}>
|
||||
<SelectedRoom />
|
||||
{children}
|
||||
<aside className={styles.summary}>
|
||||
<Summary />
|
||||
<aside className={styles.summaryContainer}>
|
||||
<div className={styles.hider} />
|
||||
<div className={styles.summary}>{summary}</div>
|
||||
<div className={styles.shadow} />
|
||||
</aside>
|
||||
</div>
|
||||
{sidePeek}
|
||||
</main>
|
||||
</EnterDetailsProvider>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
getCreditCardsSafely,
|
||||
getHotelData,
|
||||
getProfileSafely,
|
||||
getRoomAvailability,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
@@ -14,16 +14,17 @@ import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getQueryParamsForEnterDetails,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export function preload() {
|
||||
void getProfileSafely()
|
||||
void getCreditCardsSafely()
|
||||
}
|
||||
|
||||
function isValidStep(step: string): step is StepEnum {
|
||||
return Object.values(StepEnum).includes(step as StepEnum)
|
||||
}
|
||||
@@ -31,34 +32,53 @@ function isValidStep(step: string): step is StepEnum {
|
||||
export default async function StepPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams & { step: StepEnum }, { hotel: string }>) {
|
||||
if (!searchParams.hotel) {
|
||||
redirect(`/${params.lang}`)
|
||||
}
|
||||
void getBreakfastPackages(searchParams.hotel)
|
||||
void getRoomAvailability({
|
||||
hotelId: searchParams.hotel,
|
||||
adults: Number(searchParams.adults),
|
||||
roomStayStartDate: searchParams.checkIn,
|
||||
roomStayEndDate: searchParams.checkOut,
|
||||
})
|
||||
}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) {
|
||||
const { lang } = params
|
||||
|
||||
const intl = await getIntl()
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const {
|
||||
hotel: hotelId,
|
||||
adults,
|
||||
children,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
fromDate,
|
||||
toDate,
|
||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const hotel = await getHotelData(searchParams.hotel, params.lang)
|
||||
const user = await getProfileSafely()
|
||||
const savedCreditCards = await getCreditCardsSafely()
|
||||
const breakfastPackages = await getBreakfastPackages(searchParams.hotel)
|
||||
const childrenAsString = children && generateChildrenString(children)
|
||||
|
||||
const roomAvailability = await getRoomAvailability({
|
||||
hotelId: searchParams.hotel,
|
||||
adults: Number(searchParams.adults),
|
||||
roomStayStartDate: searchParams.checkIn,
|
||||
roomStayEndDate: searchParams.checkOut,
|
||||
rateCode: searchParams.rateCode,
|
||||
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
||||
void getBreakfastPackages(breakfastInput)
|
||||
void getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
})
|
||||
|
||||
if (!isValidStep(params.step) || !hotel || !roomAvailability) {
|
||||
const hotelData = await getHotelData({
|
||||
hotelId,
|
||||
language: lang,
|
||||
})
|
||||
const roomAvailability = await getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
})
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const user = await getProfileSafely()
|
||||
const savedCreditCards = await getCreditCardsSafely()
|
||||
|
||||
if (!isValidStep(params.step) || !hotelData || !roomAvailability) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
@@ -80,13 +100,20 @@ export default async function StepPage({
|
||||
return (
|
||||
<section>
|
||||
<HistoryStateManager />
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Select bed" })}
|
||||
step={StepEnum.selectBed}
|
||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||
>
|
||||
<BedType />
|
||||
</SectionAccordion>
|
||||
|
||||
<SelectedRoom hotelId={hotelId} room={roomAvailability.selectedRoom} />
|
||||
|
||||
{/* TODO: How to handle no beds found? */}
|
||||
{roomAvailability.bedTypes ? (
|
||||
<SectionAccordion
|
||||
header="Select bed"
|
||||
step={StepEnum.selectBed}
|
||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||
>
|
||||
<BedType bedTypes={roomAvailability.bedTypes} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Food options" })}
|
||||
step={StepEnum.breakfast}
|
||||
@@ -109,7 +136,7 @@ export default async function StepPage({
|
||||
<Payment
|
||||
hotelId={searchParams.hotel}
|
||||
otherPaymentOptions={
|
||||
hotel.data.attributes.merchantInformationData
|
||||
hotelData.data.attributes.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
savedCreditCards={savedCreditCards}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
.layout {
|
||||
min-height: 100dvh;
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,17 @@ import { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default function HotelReservationLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||
sidePeek,
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>> & {
|
||||
sidePeek: React.ReactNode
|
||||
}) {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
}
|
||||
return <div className={styles.layout}>{children}</div>
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
{children}
|
||||
{sidePeek}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
||||
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { MapModal } from "@/components/MapModal"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
generateChildrenString,
|
||||
getCentralCoordinates,
|
||||
getPointOfInterests,
|
||||
} from "../../utils"
|
||||
|
||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectHotelMapPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SelectHotelSearchParams>) {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
setLang(params.lang)
|
||||
const locations = await getLocations()
|
||||
|
||||
if (!locations || "error" in locations) {
|
||||
return null
|
||||
}
|
||||
const city = locations.data.find(
|
||||
(location) =>
|
||||
location.name.toLowerCase() === searchParams.city.toLowerCase()
|
||||
)
|
||||
if (!city) return notFound()
|
||||
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
|
||||
const selectHotelParams = new URLSearchParams(searchParams)
|
||||
const selectHotelParamsObject =
|
||||
getHotelReservationQueryParams(selectHotelParams)
|
||||
const adults = selectHotelParamsObject.room[0].adults // TODO: Handle multiple rooms
|
||||
const children = selectHotelParamsObject.room[0].child
|
||||
? generateChildrenString(selectHotelParamsObject.room[0].child)
|
||||
: undefined // TODO: Handle multiple rooms
|
||||
|
||||
const hotels = await fetchAvailableHotels({
|
||||
cityId: city.id,
|
||||
roomStayStartDate: searchParams.fromDate,
|
||||
roomStayEndDate: searchParams.toDate,
|
||||
adults,
|
||||
children,
|
||||
})
|
||||
|
||||
const pointOfInterests = getPointOfInterests(hotels)
|
||||
|
||||
const centralCoordinates = getCentralCoordinates(pointOfInterests)
|
||||
|
||||
return (
|
||||
<MapModal>
|
||||
<SelectHotelMap
|
||||
apiKey={googleMapsApiKey}
|
||||
coordinates={centralCoordinates}
|
||||
pointsOfInterest={pointOfInterests}
|
||||
mapId={googleMapId}
|
||||
isModal={true}
|
||||
/>
|
||||
</MapModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.layout {
|
||||
min-height: 100dvh;
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
import { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default function HotelReservationLayout({
|
||||
children,
|
||||
modal,
|
||||
}: React.PropsWithChildren<
|
||||
LayoutArgs<LangParams> & { modal: React.ReactNode }
|
||||
>) {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
}
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
{children}
|
||||
{modal}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
display: grid;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: 100dvh;
|
||||
grid-template-columns: 420px 1fr;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getFiltersFromHotels,
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import {
|
||||
PointOfInterest,
|
||||
PointOfInterestCategoryNameEnum,
|
||||
PointOfInterestGroupEnum,
|
||||
} from "@/types/hotel"
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectHotelMapPage({
|
||||
params,
|
||||
}: PageArgs<LangParams, {}>) {
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
setLang(params.lang)
|
||||
|
||||
const hotels = await fetchAvailableHotels({
|
||||
cityId: "8ec4bba3-1c38-4606-82d1-bbe3f6738e54",
|
||||
roomStayStartDate: "2024-11-02",
|
||||
roomStayEndDate: "2024-11-03",
|
||||
adults: 1,
|
||||
})
|
||||
|
||||
const filters = getFiltersFromHotels(hotels)
|
||||
|
||||
// TODO: this is just a quick transformation to get something there. May need rework
|
||||
const pointOfInterests: PointOfInterest[] = hotels.map((hotel) => ({
|
||||
coordinates: {
|
||||
lat: hotel.hotelData.location.latitude,
|
||||
lng: hotel.hotelData.location.longitude,
|
||||
},
|
||||
name: hotel.hotelData.name,
|
||||
distance: hotel.hotelData.location.distanceToCentre,
|
||||
categoryName: PointOfInterestCategoryNameEnum.HOTEL,
|
||||
group: PointOfInterestGroupEnum.LOCATION,
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<SelectHotelMap
|
||||
apiKey={googleMapsApiKey}
|
||||
// TODO: use correct coordinates. The city center?
|
||||
coordinates={{ lat: 59.32, lng: 18.01 }}
|
||||
pointsOfInterest={pointOfInterests}
|
||||
mapId={googleMapId}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
export { default } from "../@modal/(.)map/page"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.main {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x4);
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: 100dvh;
|
||||
@@ -19,8 +19,28 @@
|
||||
padding: var(--Spacing-x2) var(--Spacing-x0);
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mapContainer {
|
||||
display: block;
|
||||
}
|
||||
.main {
|
||||
flex-direction: row;
|
||||
}
|
||||
.buttonContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
generateChildrenString,
|
||||
getFiltersFromHotels,
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
|
||||
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getHotelReservationQueryParams,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import StaticMap from "@/components/Maps/StaticMap"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
@@ -95,25 +98,28 @@ export default async function SelectHotelPage({
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<section className={styles.section}>
|
||||
<Link href={selectHotelMap[params.lang]} keepSearchParams>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
width={340}
|
||||
height={180}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "Show map" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Link>
|
||||
<div className={styles.mapContainer}>
|
||||
<Link href={selectHotelMap[params.lang]} keepSearchParams>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
width={340}
|
||||
height={180}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "Show map" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Link>
|
||||
</div>
|
||||
<MobileMapButtonContainer city={searchParams.city} />
|
||||
<HotelFilter filters={filterList} />
|
||||
</section>
|
||||
<HotelCardListing hotelData={hotels} />
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
|
||||
import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import {
|
||||
type PointOfInterest,
|
||||
PointOfInterestCategoryNameEnum,
|
||||
PointOfInterestGroupEnum,
|
||||
} from "@/types/hotel"
|
||||
|
||||
export async function fetchAvailableHotels(
|
||||
input: AvailabilityInput
|
||||
@@ -17,7 +23,7 @@ export async function fetchAvailableHotels(
|
||||
|
||||
const language = getLang()
|
||||
const hotels = availableHotels.availability.map(async (hotel) => {
|
||||
const hotelData = await serverClient().hotel.hotelData.get({
|
||||
const hotelData = await getHotelData({
|
||||
hotelId: hotel.hotelId.toString(),
|
||||
language,
|
||||
})
|
||||
@@ -59,3 +65,33 @@ export function generateChildrenString(children: Child[]): string {
|
||||
})
|
||||
.join(",")}]`
|
||||
}
|
||||
|
||||
export function getPointOfInterests(hotels: HotelData[]): PointOfInterest[] {
|
||||
// TODO: this is just a quick transformation to get something there. May need rework
|
||||
return hotels.map((hotel) => ({
|
||||
coordinates: {
|
||||
lat: hotel.hotelData.location.latitude,
|
||||
lng: hotel.hotelData.location.longitude,
|
||||
},
|
||||
name: hotel.hotelData.name,
|
||||
distance: hotel.hotelData.location.distanceToCentre,
|
||||
categoryName: PointOfInterestCategoryNameEnum.HOTEL,
|
||||
group: PointOfInterestGroupEnum.LOCATION,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCentralCoordinates(pointOfInterests: PointOfInterest[]) {
|
||||
const centralCoordinates = pointOfInterests.reduce(
|
||||
(acc, poi) => {
|
||||
acc.lat += poi.coordinates.lat
|
||||
acc.lng += poi.coordinates.lng
|
||||
return acc
|
||||
},
|
||||
{ lat: 0, lng: 0 }
|
||||
)
|
||||
|
||||
centralCoordinates.lat /= pointOfInterests.length
|
||||
centralCoordinates.lng /= pointOfInterests.length
|
||||
|
||||
return centralCoordinates
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getHotelData,
|
||||
getLocations,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||
import Rooms from "@/components/HotelReservation/SelectRate/Rooms"
|
||||
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getHotelReservationQueryParams,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import { generateChildrenString } from "../select-hotel/utils"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { LangParams, PageArgs } from "@/types/params"
|
||||
@@ -20,6 +26,17 @@ export default async function SelectRatePage({
|
||||
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
|
||||
setLang(params.lang)
|
||||
|
||||
const locations = await getLocations()
|
||||
if (!locations || "error" in locations) {
|
||||
return null
|
||||
}
|
||||
const hotel = locations.data.find(
|
||||
(location) =>
|
||||
"operaId" in location && location.operaId == searchParams.hotel
|
||||
)
|
||||
if (!hotel) {
|
||||
return notFound()
|
||||
}
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const selectRoomParamsObject =
|
||||
getHotelReservationQueryParams(selectRoomParams)
|
||||
@@ -28,22 +45,27 @@ export default async function SelectRatePage({
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const adults = selectRoomParamsObject.room[0].adults // TODO: Handle multiple rooms
|
||||
const validFromDate =
|
||||
searchParams.fromDate &&
|
||||
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
|
||||
? searchParams.fromDate
|
||||
: dt().utc().format("YYYY-MM-DD")
|
||||
const validToDate =
|
||||
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
|
||||
? searchParams.toDate
|
||||
: dt().utc().add(1, "day").format("YYYY-MM-DD")
|
||||
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
|
||||
const childrenCount = selectRoomParamsObject.room[0].child?.length
|
||||
const children = selectRoomParamsObject.room[0].child
|
||||
? generateChildrenString(selectRoomParamsObject.room[0].child)
|
||||
: undefined // TODO: Handle multiple rooms
|
||||
|
||||
const [hotelData, roomsAvailability, packages, user] = await Promise.all([
|
||||
serverClient().hotel.hotelData.get({
|
||||
hotelId: searchParams.hotel,
|
||||
language: params.lang,
|
||||
include: ["RoomCategories"],
|
||||
}),
|
||||
getHotelData({ hotelId: searchParams.hotel, language: params.lang }),
|
||||
serverClient().hotel.availability.rooms({
|
||||
hotelId: parseInt(searchParams.hotel, 10),
|
||||
roomStayStartDate: searchParams.fromDate,
|
||||
roomStayEndDate: searchParams.toDate,
|
||||
roomStayStartDate: validFromDate,
|
||||
roomStayEndDate: validToDate,
|
||||
adults,
|
||||
children,
|
||||
}),
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import InitLivePreview from "@/components/Current/LivePreview"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import "@/app/globals.css"
|
||||
import "@scandic-hotels/design-system/style.css"
|
||||
|
||||
import type { Metadata } from "next"
|
||||
import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import InitLivePreview from "@/components/LivePreview"
|
||||
import { getIntl } from "@/i18n"
|
||||
import ServerIntlProvider from "@/i18n/Provider"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description: "New web",
|
||||
title: "Scandic Hotels",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||
setLang(params.lang)
|
||||
const { defaultLocale, locale, messages } = await getIntl()
|
||||
|
||||
return (
|
||||
<html lang={params.lang}>
|
||||
<body>
|
||||
<InitLivePreview />
|
||||
{children}
|
||||
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</ServerIntlProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import HotelPage from "@/components/ContentType/HotelPage"
|
||||
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
|
||||
import CollectionPage from "@/components/ContentType/StaticPages/CollectionPage"
|
||||
import ContentPage from "@/components/ContentType/StaticPages/ContentPage"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
import type {
|
||||
ContentTypeParams,
|
||||
LangParams,
|
||||
PageArgs,
|
||||
@@ -13,12 +21,32 @@ export default async function PreviewPage({
|
||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
||||
setLang(params.lang)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
Preview for {params.contentType}:{params.uid} in {params.lang} with
|
||||
params <pre>{JSON.stringify(searchParams, null, 2)}</pre> goes here
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
try {
|
||||
ContentstackLivePreview.setConfigFromParams(searchParams)
|
||||
|
||||
if (!searchParams.live_preview) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
switch (params.contentType) {
|
||||
case "content-page":
|
||||
return <ContentPage />
|
||||
case "loyalty-page":
|
||||
return <LoyaltyPage />
|
||||
case "collection-page":
|
||||
return <CollectionPage />
|
||||
case "hotel-page":
|
||||
return <HotelPage />
|
||||
default:
|
||||
console.log({ PREVIEW: params })
|
||||
const type = params.contentType
|
||||
console.error(`Unsupported content type given: ${type}`)
|
||||
notFound()
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: throw 500
|
||||
console.error("Error in preview page")
|
||||
console.error(error)
|
||||
throw new Error("Something went wrong")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Footer from "@/components/Current/Footer"
|
||||
import LangPopup from "@/components/Current/LangPopup"
|
||||
import InitLivePreview from "@/components/Current/LivePreview"
|
||||
import InitLivePreview from "@/components/LivePreview"
|
||||
import SkipToMainContent from "@/components/SkipToMainContent"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ContentstackLivePreview from "@contentstack/live-preview-utils"
|
||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
||||
|
||||
import { previewRequest } from "@/lib/graphql/previewRequest"
|
||||
import { GetCurrentBlockPage } from "@/lib/graphql/Query/Current/CurrentBlockPage.graphql"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "biro script plus";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "brandon text";
|
||||
font-weight: 700;
|
||||
src:
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "brandon text";
|
||||
font-weight: 900;
|
||||
src:
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira mono";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira mono";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira sans";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
@@ -73,7 +73,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira sans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
@@ -89,7 +89,7 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-display: fallback;
|
||||
font-family: "fira sans";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
|
||||
@@ -42,13 +42,17 @@ export default function CardsGrid({
|
||||
case CardsGridEnum.cards.Card:
|
||||
return (
|
||||
<Card
|
||||
theme={cards_grid.theme ?? "one"}
|
||||
theme={
|
||||
cards_grid.theme ?? (card.backgroundImage ? "image" : "one")
|
||||
}
|
||||
key={card.system.uid}
|
||||
scriptedTopTitle={card.scripted_top_title}
|
||||
heading={card.heading}
|
||||
bodyText={card.body_text}
|
||||
secondaryButton={card.secondaryButton}
|
||||
primaryButton={card.primaryButton}
|
||||
backgroundImage={card.backgroundImage}
|
||||
imageGradient
|
||||
/>
|
||||
)
|
||||
case CardsGridEnum.cards.TeaserCard:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dt } from "@/lib/dt"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
import { getMembership } from "@/utils/user"
|
||||
|
||||
import type { UserProps } from "@/types/components/myPages/user"
|
||||
@@ -16,9 +17,6 @@ export default async function ExpiringPoints({ user }: UserProps) {
|
||||
// TODO: handle this case?
|
||||
return null
|
||||
}
|
||||
|
||||
// sv hardcoded to force space on thousands
|
||||
const formatter = new Intl.NumberFormat(Lang.sv)
|
||||
const d = dt(membership.pointsExpiryDate)
|
||||
|
||||
const dateFormat = getLang() == Lang.fi ? "DD.MM.YYYY" : "YYYY-MM-DD"
|
||||
@@ -29,7 +27,7 @@ export default async function ExpiringPoints({ user }: UserProps) {
|
||||
{intl.formatMessage(
|
||||
{ id: "spendable points expiring by" },
|
||||
{
|
||||
points: formatter.format(membership.pointsToExpire),
|
||||
points: formatNumber(membership.pointsToExpire),
|
||||
date: d.format(dateFormat),
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ function RewardTableHeader({ name, description }: RewardTableHeaderProps) {
|
||||
<details className={styles.details}>
|
||||
<summary className={styles.summary}>
|
||||
<hgroup className={styles.rewardHeader}>
|
||||
<Title as="h5" level="h2" textTransform={"regular"}>
|
||||
<Title as="h4" level="h2" textTransform={"regular"}>
|
||||
{name}
|
||||
</Title>
|
||||
<span className={styles.chevron}>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RewardCard({
|
||||
<details className={styles.details}>
|
||||
<summary className={styles.summary}>
|
||||
<hgroup className={styles.rewardCardHeader}>
|
||||
<Title as="h5" level="h2" textTransform={"regular"}>
|
||||
<Title as="h4" level="h2" textTransform={"regular"}>
|
||||
{title}
|
||||
</Title>
|
||||
<span className={styles.chevron}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
|
||||
import { awardPointsVariants } from "./awardPointsVariants"
|
||||
|
||||
@@ -32,12 +31,10 @@ export default function AwardPoints({
|
||||
variant,
|
||||
})
|
||||
|
||||
// sv hardcoded to force space on thousands
|
||||
const formatter = new Intl.NumberFormat(Lang.sv)
|
||||
return (
|
||||
<Body textTransform="bold" className={classNames}>
|
||||
{isCalculated
|
||||
? formatter.format(awardPoints)
|
||||
? formatNumber(awardPoints)
|
||||
: intl.formatMessage({ id: "Points being calculated" })}
|
||||
</Body>
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function NextLevelRewardsBlock({
|
||||
{ level: nextLevelRewards.level?.name }
|
||||
)}
|
||||
</Body>
|
||||
<Title level="h4" as="h5" color="pale" textAlign="center">
|
||||
<Title level="h4" as="h4" color="pale" textAlign="center">
|
||||
{reward.label}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation"
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import LoginButton from "@/components/Current/Header/LoginButton"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function EmptyPreviousStaysBlock() {
|
||||
const { formatMessage } = await getIntl()
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Title as="h5" level="h3" color="red" textAlign="center">
|
||||
<Title as="h4" level="h3" color="red" textAlign="center">
|
||||
{formatMessage({
|
||||
id: "You have no previous stays.",
|
||||
})}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h5" level="h3" color="red" className={styles.title}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
{formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{formatMessage({ id: "Where should you go next?" })}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function StayCard({ stay }: StayCardProps) {
|
||||
height={240}
|
||||
/>
|
||||
<footer className={styles.footer}>
|
||||
<Title as="h5" className={styles.hotel} level="h3">
|
||||
<Title as="h4" className={styles.hotel} level="h3">
|
||||
{hotelInformation.hotelName}
|
||||
</Title>
|
||||
<div className={styles.date}>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h5" level="h3" color="red" className={styles.title}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
{formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{formatMessage({ id: "Where should you go next?" })}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
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 { 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 getHotelReservationQueryParams from "../HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import MobileToggleButton from "./MobileToggleButton"
|
||||
|
||||
import styles from "./bookingWidget.module.css"
|
||||
@@ -29,6 +30,11 @@ export default function BookingWidgetClient({
|
||||
searchParams,
|
||||
}: BookingWidgetClientProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const bookingWidgetRef = useRef(null)
|
||||
useStickyPosition({
|
||||
ref: bookingWidgetRef,
|
||||
name: StickyElementNameEnum.BOOKING_WIDGET,
|
||||
})
|
||||
|
||||
const sessionStorageSearchData =
|
||||
typeof window !== "undefined"
|
||||
@@ -61,12 +67,14 @@ export default function BookingWidgetClient({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const reqFromDate = bookingWidgetSearchData?.fromDate?.toString()
|
||||
const reqToDate = bookingWidgetSearchData?.toDate?.toString()
|
||||
|
||||
const isDateParamValid =
|
||||
bookingWidgetSearchData?.fromDate &&
|
||||
bookingWidgetSearchData?.toDate &&
|
||||
dt(bookingWidgetSearchData?.toDate.toString()).isAfter(
|
||||
dt(bookingWidgetSearchData?.fromDate.toString())
|
||||
)
|
||||
reqFromDate &&
|
||||
reqToDate &&
|
||||
dt(reqFromDate).isAfter(dt().subtract(1, "day")) &&
|
||||
dt(reqToDate).isAfter(dt(reqFromDate))
|
||||
|
||||
const selectedLocation = bookingWidgetSearchData
|
||||
? getLocationObj(
|
||||
@@ -140,7 +148,10 @@ export default function BookingWidgetClient({
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section className={styles.container} data-open={isOpen}>
|
||||
<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}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import { EditIcon, SearchIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./button.module.css"
|
||||
|
||||
@@ -29,6 +31,12 @@ export default function MobileToggleButton({
|
||||
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,70 +54,82 @@ export default function MobileToggleButton({
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsedLocation && d) {
|
||||
const totalRooms = rooms.length
|
||||
const totalAdults = rooms.reduce((acc, room) => {
|
||||
if (room.adults) {
|
||||
acc = acc + room.adults
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
const totalChildren = rooms.reduce((acc, room) => {
|
||||
if (room.child) {
|
||||
acc = acc + room.child.length
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
return (
|
||||
<div className={styles.complete} onClick={openMobileSearch} role="button">
|
||||
<div>
|
||||
<Caption color="red">{parsedLocation.name}</Caption>
|
||||
<Caption>
|
||||
{`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${
|
||||
totalChildren > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren }
|
||||
) + ", "
|
||||
: ""
|
||||
}${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<EditIcon color="white" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const locationAndDateIsSet = parsedLocation && d
|
||||
|
||||
const totalRooms = rooms.length
|
||||
const totalAdults = rooms.reduce((acc, room) => {
|
||||
if (room.adults) {
|
||||
acc = acc + room.adults
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
const totalChildren = rooms.reduce((acc, room) => {
|
||||
if (room.child) {
|
||||
acc = acc + room.child.length
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className={styles.partial} onClick={openMobileSearch} role="button">
|
||||
<div>
|
||||
<Caption color="red">{intl.formatMessage({ id: "Where to" })}</Caption>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{parsedLocation
|
||||
? parsedLocation.name
|
||||
: intl.formatMessage({ id: "Destination" })}
|
||||
</Body>
|
||||
</div>
|
||||
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
|
||||
<div>
|
||||
<Caption color="red">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<Body>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" />
|
||||
</div>
|
||||
<div
|
||||
className={locationAndDateIsSet ? styles.complete : styles.partial}
|
||||
onClick={openMobileSearch}
|
||||
role="button"
|
||||
ref={bookingWidgetMobileRef}
|
||||
>
|
||||
{!locationAndDateIsSet && (
|
||||
<>
|
||||
<div>
|
||||
<Caption type="bold" color="red">
|
||||
{intl.formatMessage({ id: "Where to" })}
|
||||
</Caption>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{parsedLocation
|
||||
? parsedLocation.name
|
||||
: intl.formatMessage({ id: "Destination" })}
|
||||
</Body>
|
||||
</div>
|
||||
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
|
||||
<div>
|
||||
<Caption type="bold" color="red">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<Body>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{locationAndDateIsSet && (
|
||||
<>
|
||||
<div>
|
||||
<Caption color="red">{parsedLocation?.name}</Caption>
|
||||
<Caption>
|
||||
{`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${
|
||||
totalChildren > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren }
|
||||
) + ", "
|
||||
: ""
|
||||
}${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<EditIcon color="white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
.containerDesktop,
|
||||
.containerMobile,
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.container {
|
||||
.containerMobile {
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
bottom: -100%;
|
||||
display: grid;
|
||||
@@ -14,7 +20,7 @@
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.container[data-open="true"] {
|
||||
.containerMobile[data-open="true"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
@@ -25,7 +31,7 @@
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.container[data-open="true"] + .backdrop {
|
||||
.containerMobile[data-open="true"] + .backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
height: 100%;
|
||||
left: 0;
|
||||
@@ -37,7 +43,7 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
.containerDesktop {
|
||||
display: block;
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
position: sticky;
|
||||
@@ -45,10 +51,6 @@
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
.listItem {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
}
|
||||
|
||||
.homeLink {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { ChevronRightIcon, HouseIcon } from "@/components/Icons"
|
||||
import { ChevronRightSmallIcon, HouseIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
@@ -24,9 +24,9 @@ export default async function Breadcrumbs() {
|
||||
href={homeBreadcrumb.href!}
|
||||
variant="breadcrumb"
|
||||
>
|
||||
<HouseIcon color="peach80" />
|
||||
<HouseIcon width={16} height={16} color="peach80" />
|
||||
</Link>
|
||||
<ChevronRightIcon aria-hidden="true" color="peach80" />
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default async function Breadcrumbs() {
|
||||
>
|
||||
{breadcrumb.title}
|
||||
</Link>
|
||||
<ChevronRightIcon aria-hidden="true" color="peach80" />
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
.amenityItem {
|
||||
display: inline-flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { amenities } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
@@ -29,7 +29,12 @@ export default async function AmenitiesList({
|
||||
return (
|
||||
<div className={styles.amenityItem} key={facility.id}>
|
||||
{IconComponent && (
|
||||
<IconComponent className={styles.icon} color="grey80" />
|
||||
<IconComponent
|
||||
className={styles.icon}
|
||||
color="grey80"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
<Body color="textMediumContrast">{facility.name}</Body>
|
||||
</div>
|
||||
@@ -44,7 +49,7 @@ export default async function AmenitiesList({
|
||||
className={styles.showAllAmenities}
|
||||
>
|
||||
{intl.formatMessage({ id: "Show all amenities" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
<ChevronRightSmallIcon color="burgundy" />
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { about } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import ArrowRight from "@/components/Icons/ArrowRight"
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
@@ -48,7 +47,7 @@ export default async function IntroSection({
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.titleContainer}>
|
||||
<BiroScript tilted="medium" color="red">
|
||||
{intl.formatMessage({ id: "Welcome to" })}:
|
||||
{intl.formatMessage({ id: "Welcome to" })}
|
||||
</BiroScript>
|
||||
<Title level="h2">{hotelName}</Title>
|
||||
</div>
|
||||
@@ -77,7 +76,7 @@ export default async function IntroSection({
|
||||
scroll={false}
|
||||
>
|
||||
{intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
<ChevronRightSmallIcon color="burgundy" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -37,21 +37,26 @@ export default function Sidebar({
|
||||
|
||||
function moveToPoi(poiCoordinates: Coordinates) {
|
||||
if (map) {
|
||||
const hotelLatLng = new google.maps.LatLng(
|
||||
coordinates.lat,
|
||||
coordinates.lng
|
||||
)
|
||||
const poiLatLng = new google.maps.LatLng(
|
||||
poiCoordinates.lat,
|
||||
poiCoordinates.lng
|
||||
)
|
||||
|
||||
const bounds = new google.maps.LatLngBounds()
|
||||
const boundPadding = 0.02
|
||||
bounds.extend(hotelLatLng)
|
||||
bounds.extend(poiLatLng)
|
||||
|
||||
const minLat = Math.min(coordinates.lat, poiCoordinates.lat)
|
||||
const maxLat = Math.max(coordinates.lat, poiCoordinates.lat)
|
||||
const minLng = Math.min(coordinates.lng, poiCoordinates.lng)
|
||||
const maxLng = Math.max(coordinates.lng, poiCoordinates.lng)
|
||||
|
||||
bounds.extend(
|
||||
new google.maps.LatLng(minLat - boundPadding, minLng - boundPadding)
|
||||
)
|
||||
bounds.extend(
|
||||
new google.maps.LatLng(maxLat + boundPadding, maxLng + boundPadding)
|
||||
)
|
||||
map.fitBounds(bounds)
|
||||
|
||||
const currentZoomLevel = map.getZoom()
|
||||
|
||||
if (currentZoomLevel) {
|
||||
map.setZoom(currentZoomLevel - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +66,6 @@ export default function Sidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!isClicking) {
|
||||
onActivePoiChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
|
||||
setIsClicking(true)
|
||||
toggleFullScreenSidebar()
|
||||
@@ -78,66 +77,71 @@ export default function Sidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${styles.sidebar} ${
|
||||
isFullScreenSidebar ? styles.fullscreen : ""
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="text"
|
||||
className={styles.sidebarToggle}
|
||||
onClick={toggleFullScreenSidebar}
|
||||
<>
|
||||
<aside
|
||||
className={`${styles.sidebar} ${
|
||||
isFullScreenSidebar ? styles.fullscreen : ""
|
||||
}`}
|
||||
>
|
||||
<Body textTransform="bold" color="textMediumContrast" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: isFullScreenSidebar ? "View as map" : "View as list",
|
||||
})}
|
||||
</span>
|
||||
</Body>
|
||||
</Button>
|
||||
<div className={styles.sidebarContent}>
|
||||
<Title as="h4" level="h2" textTransform="regular">
|
||||
{intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
</Title>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="text"
|
||||
fullWidth
|
||||
className={styles.sidebarToggle}
|
||||
onClick={toggleFullScreenSidebar}
|
||||
>
|
||||
<Body textTransform="bold" color="textMediumContrast" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: isFullScreenSidebar ? "View as map" : "View as list",
|
||||
})}
|
||||
</span>
|
||||
</Body>
|
||||
</Button>
|
||||
<div className={styles.sidebarContent}>
|
||||
<Title as="h4" level="h2" textTransform="regular">
|
||||
{intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
</Title>
|
||||
|
||||
{poisInGroups.map(({ group, pois }) =>
|
||||
pois.length ? (
|
||||
<div key={group} className={styles.poiGroup}>
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
className={styles.poiHeading}
|
||||
asChild
|
||||
>
|
||||
<h3>
|
||||
<PoiMarker group={group} />
|
||||
{intl.formatMessage({ id: group })}
|
||||
</h3>
|
||||
</Body>
|
||||
<ul className={styles.poiList}>
|
||||
{pois.map((poi) => (
|
||||
<li key={poi.name} className={styles.poiItem}>
|
||||
<button
|
||||
className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
onMouseEnter={() => handleMouseEnter(poi.name)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => handlePoiClick(poi.name, poi.coordinates)}
|
||||
>
|
||||
<span>{poi.name}</span>
|
||||
<span>{poi.distance} km</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
{poisInGroups.map(({ group, pois }) =>
|
||||
pois.length ? (
|
||||
<div key={group} className={styles.poiGroup}>
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
className={styles.poiHeading}
|
||||
asChild
|
||||
>
|
||||
<h3>
|
||||
<PoiMarker group={group} />
|
||||
{intl.formatMessage({ id: group })}
|
||||
</h3>
|
||||
</Body>
|
||||
<ul className={styles.poiList}>
|
||||
{pois.map((poi) => (
|
||||
<li key={poi.name} className={styles.poiItem}>
|
||||
<button
|
||||
className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
onMouseEnter={() => handleMouseEnter(poi.name)}
|
||||
onClick={() =>
|
||||
handlePoiClick(poi.name, poi.coordinates)
|
||||
}
|
||||
>
|
||||
<span>{poi.name}</span>
|
||||
<span>{poi.distance} km</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className={styles.backdrop} onClick={toggleFullScreenSidebar} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +1,12 @@
|
||||
.sidebar {
|
||||
--sidebar-max-width: 26.25rem;
|
||||
--sidebar-mobile-toggle-height: 91px;
|
||||
--sidebar-mobile-fullscreen-height: calc(
|
||||
100vh - var(--main-menu-mobile-height) - var(--sidebar-mobile-toggle-height)
|
||||
);
|
||||
|
||||
position: absolute;
|
||||
top: var(--sidebar-mobile-fullscreen-height);
|
||||
height: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
z-index: 1;
|
||||
transition: top 0.3s;
|
||||
}
|
||||
|
||||
.sidebar:not(.fullscreen) {
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.sidebar.fullscreen {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebarToggle {
|
||||
position: relative;
|
||||
margin: var(--Spacing-x4) 0 var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebarToggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: -0.5rem;
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background-color: var(--UI-Text-High-contrast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebarContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
align-content: start;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
height: var(--sidebar-mobile-fullscreen-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -90,12 +52,65 @@
|
||||
background-color: var(--Base-Surface-Primary-light-Hover);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.sidebar {
|
||||
--sidebar-mobile-toggle-height: 84px;
|
||||
--sidebar-mobile-top-space: 40px;
|
||||
--sidebar-mobile-content-height: calc(
|
||||
var(--hotel-map-height) - var(--sidebar-mobile-toggle-height) -
|
||||
var(--sidebar-mobile-top-space)
|
||||
);
|
||||
|
||||
position: absolute;
|
||||
bottom: calc(-1 * var(--sidebar-mobile-content-height));
|
||||
width: 100%;
|
||||
transition:
|
||||
bottom 0.3s,
|
||||
top 0.3s;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.sidebar.fullscreen + .backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar.fullscreen {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sidebarToggle {
|
||||
position: relative;
|
||||
margin-top: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.sidebarToggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: -0.5rem;
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background-color: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
.sidebarContent {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
height: var(--sidebar-mobile-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
width: 40vw;
|
||||
min-width: 10rem;
|
||||
max-width: var(--sidebar-max-width);
|
||||
max-width: 26.25rem;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
.dynamicMap {
|
||||
position: fixed;
|
||||
top: var(--main-menu-mobile-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
--hotel-map-height: 100dvh;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--dialog-z-index);
|
||||
height: var(--hotel-map-height);
|
||||
width: 100dvw;
|
||||
z-index: var(--hotel-dynamic-map-z-index);
|
||||
display: flex;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.dynamicMap {
|
||||
top: var(--main-menu-desktop-height);
|
||||
}
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Dialog, Modal } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -10,6 +10,7 @@ import CloseLargeIcon from "@/components/Icons/CloseLarge"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import Sidebar from "./Sidebar"
|
||||
|
||||
@@ -25,9 +26,10 @@ export default function DynamicMap({
|
||||
mapId,
|
||||
}: DynamicMapProps) {
|
||||
const intl = useIntl()
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
const [mapHeight, setMapHeight] = useState("0px")
|
||||
const { isDynamicMapOpen, closeDynamicMap } = useHotelPageStore()
|
||||
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
|
||||
const hasMounted = useRef(false)
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
|
||||
useHandleKeyUp((event: KeyboardEvent) => {
|
||||
@@ -36,23 +38,47 @@ export default function DynamicMap({
|
||||
}
|
||||
})
|
||||
|
||||
// Making sure the map is always opened at the top of the page, just below the header.
|
||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
||||
const handleMapHeight = useCallback(() => {
|
||||
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
|
||||
const scrollY = window.scrollY
|
||||
setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`)
|
||||
}, [])
|
||||
|
||||
// Making sure the map is always opened at the top of the page,
|
||||
// just below the header and booking widget as these should stay visible.
|
||||
// When closing, the page should scroll back to the position it was before opening the map.
|
||||
useEffect(() => {
|
||||
// Skip the first render
|
||||
if (!hasMounted.current) {
|
||||
hasMounted.current = true
|
||||
if (!rootDiv.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isDynamicMapOpen && scrollHeightWhenOpened === 0) {
|
||||
setScrollHeightWhenOpened(window.scrollY)
|
||||
const scrollY = window.scrollY
|
||||
setScrollHeightWhenOpened(scrollY)
|
||||
window.scrollTo({ top: 0, behavior: "instant" })
|
||||
} else if (!isDynamicMapOpen && scrollHeightWhenOpened !== 0) {
|
||||
window.scrollTo({ top: scrollHeightWhenOpened, behavior: "instant" })
|
||||
setScrollHeightWhenOpened(0)
|
||||
}
|
||||
}, [isDynamicMapOpen, scrollHeightWhenOpened])
|
||||
}, [isDynamicMapOpen, scrollHeightWhenOpened, rootDiv])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResizeHandler = debounce(function () {
|
||||
handleMapHeight()
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(debouncedResizeHandler)
|
||||
|
||||
observer.observe(document.documentElement)
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.unobserve(document.documentElement)
|
||||
}
|
||||
}
|
||||
}, [rootDiv, isDynamicMapOpen, handleMapHeight])
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
@@ -70,31 +96,37 @@ export default function DynamicMap({
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<Modal isOpen={isDynamicMapOpen}>
|
||||
<Dialog
|
||||
className={styles.dynamicMap}
|
||||
aria-label={intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
<div className={styles.wrapper} ref={rootDiv}>
|
||||
<Modal
|
||||
isOpen={isDynamicMapOpen}
|
||||
UNSTABLE_portalContainer={rootDiv.current || undefined}
|
||||
>
|
||||
<Sidebar
|
||||
activePoi={activePoi}
|
||||
hotelName={hotelName}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={setActivePoi}
|
||||
coordinates={coordinates}
|
||||
/>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
<Dialog
|
||||
className={styles.dynamicMap}
|
||||
style={{ "--hotel-map-height": mapHeight } as React.CSSProperties}
|
||||
aria-label={intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
activePoi={activePoi}
|
||||
hotelName={hotelName}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={setActivePoi}
|
||||
coordinates={coordinates}
|
||||
/>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</div>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
|
||||
24
components/ContentType/HotelPage/Map/MapWithCard/index.tsx
Normal file
24
components/ContentType/HotelPage/Map/MapWithCard/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./mapWithCard.module.css"
|
||||
|
||||
export default function MapWithCard({ children }: PropsWithChildren) {
|
||||
const mapWithCardRef = useRef<HTMLDivElement>(null)
|
||||
useStickyPosition({
|
||||
ref: mapWithCardRef,
|
||||
name: StickyElementNameEnum.HOTEL_STATIC_MAP,
|
||||
group: "hotelPage",
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={mapWithCardRef} className={styles.mapWithCard}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.mapWithCard {
|
||||
position: sticky;
|
||||
top: var(--booking-widget-desktop-height);
|
||||
min-height: 500px; /* Fixed min to not cover the marker with the card */
|
||||
height: calc(
|
||||
100vh - var(--main-menu-desktop-height) -
|
||||
var(--booking-widget-desktop-height)
|
||||
); /* Full height without the header + booking widget */
|
||||
max-height: 935px; /* Fixed max according to figma */
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.mobileToggle {
|
||||
position: sticky;
|
||||
bottom: var(--Spacing-x5);
|
||||
z-index: 1;
|
||||
z-index: var(--hotel-mobile-map-toggle-button-z-index);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.image {
|
||||
|
||||
@@ -2,36 +2,39 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronRightIcon, ImageIcon } from "@/components/Icons"
|
||||
import { GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
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"
|
||||
|
||||
export function RoomCard({
|
||||
badgeTextTransKey,
|
||||
id,
|
||||
images,
|
||||
subtitle,
|
||||
title,
|
||||
}: RoomCardProps) {
|
||||
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
const { images, name, roomSize, occupancy, id } = room
|
||||
const intl = useIntl()
|
||||
const mainImage = images[0]
|
||||
|
||||
const size =
|
||||
roomSize?.min === roomSize?.max
|
||||
? `${roomSize.min} m²`
|
||||
: `${roomSize.min} - ${roomSize.max} m²`
|
||||
|
||||
const personLabel = intl.formatMessage(
|
||||
{ id: "hotelPages.rooms.roomCard.persons" },
|
||||
{ totalOccupancy: occupancy.total }
|
||||
)
|
||||
|
||||
const subtitle = `${size} (${personLabel})`
|
||||
|
||||
function handleImageClick() {
|
||||
// TODO: Implement opening of a model with carousel
|
||||
console.log("Image clicked: ", id)
|
||||
}
|
||||
|
||||
function handleRoomCtaClick() {
|
||||
// TODO: Implement opening side-peek component with room details
|
||||
console.log("Room CTA clicked: ", id)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.roomCard}>
|
||||
<button className={styles.imageWrapper} onClick={handleImageClick}>
|
||||
@@ -42,7 +45,7 @@ export function RoomCard({
|
||||
{/* </span> */}
|
||||
{/* )} */}
|
||||
<span className={styles.imageCount}>
|
||||
<ImageIcon color="white" />
|
||||
<GalleryIcon color="white" />
|
||||
{images.length}
|
||||
</span>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
@@ -64,19 +67,14 @@ export function RoomCard({
|
||||
color="black"
|
||||
className={styles.title}
|
||||
>
|
||||
{title}
|
||||
{name}
|
||||
</Subtitle>
|
||||
<Body color="grey">{subtitle}</Body>
|
||||
</div>
|
||||
<Button
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
onClick={handleRoomCtaClick}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
<RoomDetailsButton
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypes[0].code}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -26,10 +26,11 @@
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
background-color: var(--Main-Grey-90);
|
||||
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-Medium);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -15,34 +15,13 @@ import styles from "./rooms.module.css"
|
||||
import type { RoomsProps } from "@/types/components/hotelPage/room"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export function Rooms({ rooms }: RoomsProps) {
|
||||
export function Rooms({ hotelId, rooms }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const showToggleButton = rooms.length > 3
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const mappedRooms = rooms
|
||||
.map((room) => {
|
||||
const size = `${room.roomSize.min} - ${room.roomSize.max} m²`
|
||||
const personLabel =
|
||||
room.occupancy.total === 1
|
||||
? intl.formatMessage({ id: "hotelPages.rooms.roomCard.person" })
|
||||
: intl.formatMessage({ id: "hotelPages.rooms.roomCard.persons" })
|
||||
|
||||
const subtitle = `${size} (${room.occupancy.total} ${personLabel})`
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
images: room.images,
|
||||
title: room.name,
|
||||
subtitle: subtitle,
|
||||
sortOrder: room.sortOrder,
|
||||
popularChoice: null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
function handleShowMore() {
|
||||
if (scrollRef.current && allRoomsVisible) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
@@ -64,15 +43,9 @@ export function Rooms({ rooms }: RoomsProps) {
|
||||
<Grids.Stackable
|
||||
className={`${styles.grid} ${allRoomsVisible ? styles.allVisible : ""}`}
|
||||
>
|
||||
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
|
||||
<div key={id}>
|
||||
<RoomCard
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
{rooms.map((room) => (
|
||||
<div key={room.id}>
|
||||
<RoomCard hotelId={hotelId} room={room} />
|
||||
</div>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useHash from "@/hooks/useHash"
|
||||
import useScrollSpy from "@/hooks/useScrollSpy"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./tabNavigation.module.css"
|
||||
|
||||
@@ -23,6 +26,12 @@ export default function TabNavigation({
|
||||
const hash = useHash()
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const tabNavigationRef = useRef<HTMLDivElement>(null)
|
||||
useStickyPosition({
|
||||
ref: tabNavigationRef,
|
||||
name: StickyElementNameEnum.HOTEL_TAB_NAVIGATION,
|
||||
group: "hotelPage",
|
||||
})
|
||||
|
||||
const tabLinks: { hash: HotelHashValues; text: string }[] = [
|
||||
{
|
||||
@@ -71,7 +80,7 @@ export default function TabNavigation({
|
||||
}, [activeSectionId, router])
|
||||
|
||||
return (
|
||||
<div className={styles.stickyWrapper}>
|
||||
<div ref={tabNavigationRef} className={styles.stickyWrapper}>
|
||||
<nav className={styles.tabsContainer}>
|
||||
{tabLinks.map((link) => {
|
||||
const isActive =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.stickyWrapper {
|
||||
position: sticky;
|
||||
top: var(--booking-widget-mobile-height);
|
||||
z-index: 2;
|
||||
z-index: var(--hotel-tab-navigation-z-index);
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
.pageContainer {
|
||||
--hotel-tab-navigation-z-index: 2;
|
||||
--hotel-mobile-map-toggle-button-z-index: 1;
|
||||
--hotel-dynamic-map-z-index: 2;
|
||||
|
||||
--hotel-page-navigation-height: 59px;
|
||||
--hotel-page-scroll-margin-top: calc(
|
||||
var(--hotel-page-navigation-height) + var(--Spacing-x2)
|
||||
@@ -69,19 +73,6 @@
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.mapWithCard {
|
||||
position: sticky;
|
||||
top: var(--booking-widget-desktop-height);
|
||||
min-height: 500px; /* Fixed min to not cover the marker with the card */
|
||||
height: calc(
|
||||
100vh - var(--main-menu-desktop-height) -
|
||||
var(--booking-widget-desktop-height)
|
||||
); /* Full height without the header + booking widget */
|
||||
max-height: 935px; /* Fixed max according to figma */
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageContainer > nav {
|
||||
padding-left: var(--Spacing-x5);
|
||||
padding-right: var(--Spacing-x5);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AccordionSection from "@/components/Blocks/Accordion"
|
||||
import SidePeekProvider from "@/components/SidePeekProvider"
|
||||
import HotelReservationSidePeek from "@/components/HotelReservation/SidePeek"
|
||||
import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
@@ -12,6 +13,7 @@ import { getRestaurantHeading } from "@/utils/facilityCards"
|
||||
|
||||
import DynamicMap from "./Map/DynamicMap"
|
||||
import MapCard from "./Map/MapCard"
|
||||
import MapWithCardWrapper from "./Map/MapWithCard"
|
||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||
import StaticMap from "./Map/StaticMap"
|
||||
import AmenitiesList from "./AmenitiesList"
|
||||
@@ -30,14 +32,13 @@ export default async function HotelPage() {
|
||||
const lang = getLang()
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const hotelData = await serverClient().hotel.get({
|
||||
include: ["RoomCategories"],
|
||||
})
|
||||
const hotelData = await serverClient().hotel.get()
|
||||
if (!hotelData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
hotelId,
|
||||
hotelName,
|
||||
hotelDescription,
|
||||
hotelLocation,
|
||||
@@ -98,7 +99,7 @@ export default async function HotelPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Rooms hotelId={hotelId} rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
{faq.accordions.length > 0 && (
|
||||
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
||||
@@ -107,10 +108,10 @@ export default async function HotelPage() {
|
||||
{googleMapsApiKey ? (
|
||||
<>
|
||||
<aside className={styles.mapContainer}>
|
||||
<div className={styles.mapWithCard}>
|
||||
<MapWithCardWrapper>
|
||||
<StaticMap coordinates={coordinates} hotelName={hotelName} />
|
||||
<MapCard hotelName={hotelName} pois={topThreePois} />
|
||||
</div>
|
||||
</MapWithCardWrapper>
|
||||
</aside>
|
||||
<MobileMapToggle />
|
||||
<DynamicMap
|
||||
@@ -167,6 +168,7 @@ export default async function HotelPage() {
|
||||
</SidePeek>
|
||||
{/* eslint-enable import/no-named-as-default-member */}
|
||||
</SidePeekProvider>
|
||||
<HotelReservationSidePeek hotel={null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
22
components/ContentType/StaticPages/CollectionPage/index.tsx
Normal file
22
components/ContentType/StaticPages/CollectionPage/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import StaticPage from ".."
|
||||
|
||||
export default async function CollectionPage() {
|
||||
const collectionPageRes =
|
||||
await serverClient().contentstack.collectionPage.get()
|
||||
|
||||
if (!collectionPageRes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { tracking, collectionPage } = collectionPageRes
|
||||
|
||||
return (
|
||||
<StaticPage
|
||||
content={collectionPage}
|
||||
tracking={tracking}
|
||||
pageType="collection"
|
||||
/>
|
||||
)
|
||||
}
|
||||
17
components/ContentType/StaticPages/ContentPage/index.tsx
Normal file
17
components/ContentType/StaticPages/ContentPage/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import StaticPage from ".."
|
||||
|
||||
export default async function ContentPage() {
|
||||
const contentPageRes = await serverClient().contentstack.contentPage.get()
|
||||
|
||||
if (!contentPageRes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { tracking, contentPage } = contentPageRes
|
||||
|
||||
return (
|
||||
<StaticPage content={contentPage} tracking={tracking} pageType="content" />
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import Hero from "@/components/Hero"
|
||||
import Sidebar from "@/components/Sidebar"
|
||||
@@ -8,21 +6,22 @@ import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import styles from "./contentPage.module.css"
|
||||
import { staticPageVariants } from "./variants"
|
||||
|
||||
export default async function ContentPage() {
|
||||
const contentPageRes = await serverClient().contentstack.contentPage.get()
|
||||
import styles from "./staticPage.module.css"
|
||||
|
||||
if (!contentPageRes) {
|
||||
return null
|
||||
}
|
||||
import type { StaticPageProps } from "./staticPage"
|
||||
|
||||
const { tracking, contentPage } = contentPageRes
|
||||
const { blocks, hero_image, header, sidebar } = contentPage
|
||||
export default function StaticPage({
|
||||
content,
|
||||
tracking,
|
||||
pageType,
|
||||
}: StaticPageProps) {
|
||||
const { blocks, hero_image, header } = content
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.contentPage}>
|
||||
<section className={staticPageVariants({ pageType })}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
{header ? (
|
||||
@@ -54,7 +53,9 @@ export default async function ContentPage() {
|
||||
{blocks ? <Blocks blocks={blocks} /> : null}
|
||||
</main>
|
||||
|
||||
{sidebar?.length ? <Sidebar blocks={sidebar} /> : null}
|
||||
{"sidebar" in content && content.sidebar?.length ? (
|
||||
<Sidebar blocks={content.sidebar} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.contentPage {
|
||||
.page {
|
||||
padding-bottom: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
@@ -32,20 +32,27 @@
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.content .contentContainer {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"main"
|
||||
"sidebar";
|
||||
gap: var(--Spacing-x4);
|
||||
align-items: start;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
grid-area: main;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
width: 100%;
|
||||
gap: var(--Spacing-x6);
|
||||
}
|
||||
|
||||
.content .mainContent {
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -58,12 +65,20 @@
|
||||
.heroContainer {
|
||||
padding: var(--Spacing-x4) 0;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
max-width: var(--max-width-content);
|
||||
padding: var(--Spacing-x4) 0 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content .contentContainer {
|
||||
grid-template-areas: "main sidebar";
|
||||
grid-template-columns: var(--max-width-text-block) 1fr;
|
||||
gap: var(--Spacing-x9);
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x4) 0 0;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
gap: var(--Spacing-x9);
|
||||
}
|
||||
}
|
||||
15
components/ContentType/StaticPages/staticPage.ts
Normal file
15
components/ContentType/StaticPages/staticPage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { staticPageVariants } from "./variants"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
import type { TrackingSDKPageData } from "@/types/components/tracking"
|
||||
import type { CollectionPage } from "@/types/trpc/routers/contentstack/collectionPage"
|
||||
import type { ContentPage } from "@/types/trpc/routers/contentstack/contentPage"
|
||||
|
||||
export interface StaticPageProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "content">,
|
||||
VariantProps<typeof staticPageVariants> {
|
||||
pageType?: "collection" | "content"
|
||||
content: CollectionPage["collection_page"] | ContentPage["content_page"]
|
||||
tracking: TrackingSDKPageData
|
||||
}
|
||||
15
components/ContentType/StaticPages/variants.ts
Normal file
15
components/ContentType/StaticPages/variants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./staticPage.module.css"
|
||||
|
||||
export const staticPageVariants = cva(styles.page, {
|
||||
variants: {
|
||||
pageType: {
|
||||
collection: styles.collection,
|
||||
content: styles.content,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
pageType: "content",
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import { getCurrentFooter } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
@@ -8,9 +8,7 @@ import Navigation from "./Navigation"
|
||||
import styles from "./footer.module.css"
|
||||
|
||||
export default async function Footer() {
|
||||
const footerData = await serverClient().contentstack.base.currentFooter({
|
||||
lang: getLang(),
|
||||
})
|
||||
const footerData = await getCurrentFooter(getLang())
|
||||
if (!footerData) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import { myPages } from "@/constants/routes/myPages"
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import Avatar from "@/components/MyPages/Avatar"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackClick } from "@/utils/tracking"
|
||||
|
||||
import BookingButton from "../BookingButton"
|
||||
import LoginButton from "../LoginButton"
|
||||
|
||||
import styles from "./mainMenu.module.css"
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import { logout } from "@/constants/routes/handleAuth"
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
import { getName } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import LoginButton from "../LoginButton"
|
||||
|
||||
import styles from "./topMenu.module.css"
|
||||
|
||||
import type { TopMenuProps } from "@/types/components/current/header/topMenu"
|
||||
@@ -67,7 +66,7 @@ export default async function TopMenu({
|
||||
) : (
|
||||
<LoginButton
|
||||
position="hamburger menu"
|
||||
trackingId="loginStartTopMeny"
|
||||
trackingId="loginStartTopMenu"
|
||||
className={`${styles.sessionLink} ${styles.loginLink}`}
|
||||
>
|
||||
{formatMessage({ id: "Log in" })}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -32,10 +32,6 @@ export default async function FooterDetails() {
|
||||
<Link href={`/${lang}`}>
|
||||
<Image
|
||||
alt="Scandic Hotels logo"
|
||||
className={styles.logo}
|
||||
data-js="scandiclogoimg"
|
||||
data-nosvgsrc="/_static/img/scandic-logotype.png"
|
||||
itemProp="logo"
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype-white.svg"
|
||||
width={103}
|
||||
@@ -46,7 +42,6 @@ export default async function FooterDetails() {
|
||||
(link) =>
|
||||
link.href && (
|
||||
<a
|
||||
className={styles.socialLink}
|
||||
color="white"
|
||||
href={link.href.href}
|
||||
key={link.href.title}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function FooterSecondaryNav({
|
||||
<div className={styles.secondaryNavigation}>
|
||||
{appDownloads && (
|
||||
<nav className={styles.secondaryNavigationGroup}>
|
||||
<Body color="peach80" textTransform="uppercase">
|
||||
<Body color="baseTextMediumContrast" textTransform="uppercase">
|
||||
{appDownloads.title}
|
||||
</Body>
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
@@ -50,7 +50,7 @@ export default function FooterSecondaryNav({
|
||||
)}
|
||||
{secondaryLinks.map((link) => (
|
||||
<nav className={styles.secondaryNavigationGroup} key={link.title}>
|
||||
<Body color="peach80" textTransform="uppercase">
|
||||
<Body color="baseTextMediumContrast" textTransform="uppercase">
|
||||
{link.title}
|
||||
</Body>
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
|
||||
@@ -29,17 +29,23 @@ export default function SearchList({
|
||||
}: SearchListProps) {
|
||||
const intl = useIntl()
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const [isFormSubmitted, setIsFormSubmitted] = useState(false)
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
formState: { errors, isSubmitted },
|
||||
} = useFormContext()
|
||||
const searchError = errors["search"]
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormSubmitted(isSubmitted)
|
||||
}, [isSubmitted])
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError && searchError.message === "Required") {
|
||||
timeoutID = setTimeout(() => {
|
||||
clearErrors("search")
|
||||
setIsFormSubmitted(false)
|
||||
// magic number originates from animation
|
||||
// 5000ms delay + 120ms exectuion
|
||||
}, 5120)
|
||||
@@ -60,7 +66,7 @@ export default function SearchList({
|
||||
return null
|
||||
}
|
||||
|
||||
if (searchError) {
|
||||
if (searchError && isFormSubmitted) {
|
||||
if (typeof searchError.message === "string") {
|
||||
if (!isOpen) {
|
||||
if (searchError.message === "Required") {
|
||||
|
||||
@@ -48,13 +48,10 @@ export default function Search({ locations }: SearchProps) {
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const value = evt.currentTarget.value
|
||||
if (value) {
|
||||
function dispatchInputValue(inputValue: string) {
|
||||
if (inputValue) {
|
||||
dispatch({
|
||||
payload: { search: value },
|
||||
payload: { search: inputValue },
|
||||
type: ActionType.SEARCH_LOCATIONS,
|
||||
})
|
||||
} else {
|
||||
@@ -62,6 +59,14 @@ export default function Search({ locations }: SearchProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const newValue = evt.currentTarget.value
|
||||
setValue(name, newValue)
|
||||
dispatchInputValue(value)
|
||||
}
|
||||
|
||||
function handleOnFocus(evt: FocusEvent<HTMLInputElement>) {
|
||||
const searchValue = evt.currentTarget.value
|
||||
if (searchValue) {
|
||||
@@ -114,6 +119,7 @@ export default function Search({ locations }: SearchProps) {
|
||||
inputValue={value}
|
||||
itemToString={(value) => (value ? value.name : "")}
|
||||
onSelect={handleOnSelect}
|
||||
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
|
||||
>
|
||||
{({
|
||||
closeMenu,
|
||||
@@ -128,10 +134,16 @@ export default function Search({ locations }: SearchProps) {
|
||||
}) => (
|
||||
<div className={styles.container}>
|
||||
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
|
||||
<Caption color={isOpen ? "uiTextActive" : "red"}>
|
||||
{state.searchData?.type === "hotels"
|
||||
? state.searchData?.relationships?.city?.name
|
||||
: intl.formatMessage({ id: "Where to" })}
|
||||
<Caption
|
||||
type="bold"
|
||||
color={isOpen ? "uiTextActive" : "red"}
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
{state.searchData?.type === "hotels"
|
||||
? state.searchData?.relationships?.city?.name
|
||||
: intl.formatMessage({ id: "Where to" })}
|
||||
</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
|
||||
@@ -34,8 +34,8 @@ export default function Voucher() {
|
||||
>
|
||||
<div className={styles.vouchers}>
|
||||
<label>
|
||||
<Caption color="disabled" type="bold">
|
||||
{vouchers}
|
||||
<Caption color="disabled" type="bold" asChild>
|
||||
<span>{vouchers}</span>
|
||||
</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
@@ -49,21 +49,30 @@ export default function Voucher() {
|
||||
arrow="left"
|
||||
>
|
||||
<div className={styles.options}>
|
||||
<label className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<Checkbox name="useVouchers" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{useVouchers}</Caption>
|
||||
<div className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<Checkbox name="useVouchers" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{useVouchers}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{bonus}</Caption>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{reward}</Caption>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +50,8 @@ export default function FormContent({
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<label>
|
||||
<Caption color="red" type="bold">
|
||||
{rooms}
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{rooms}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<GuestsRoomsProvider selectedGuests={selectedGuests}>
|
||||
|
||||
@@ -65,11 +65,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
id={formId}
|
||||
>
|
||||
<input {...register("location")} type="hidden" />
|
||||
<FormContent
|
||||
locations={locations}
|
||||
formId={formId}
|
||||
formState={formState}
|
||||
/>
|
||||
<FormContent locations={locations} formId={formId} />
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,9 @@ export const signUpSchema = z.object({
|
||||
"Phone is required",
|
||||
"Please enter a valid phone number"
|
||||
),
|
||||
dateOfBirth: z.string().min(1),
|
||||
dateOfBirth: z.string().min(1, {
|
||||
message: "Date of birth is required",
|
||||
}),
|
||||
address: z.object({
|
||||
countryCode: z
|
||||
.string({
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function GuestsRoomsPicker({
|
||||
id: "Disabled booking options header",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Disabled booking options text",
|
||||
id: "Disabled adding room",
|
||||
})
|
||||
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.body {
|
||||
opacity: 0.8;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import { myPages } from "@/constants/routes/myPages"
|
||||
import {
|
||||
getMembershipLevelSafely,
|
||||
getMyPagesNavigation,
|
||||
@@ -7,7 +6,7 @@ import {
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
@@ -50,16 +49,17 @@ export default async function MyPagesMenuWrapper() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={myPages[lang]}
|
||||
<LoginButton
|
||||
className={styles.loginLink}
|
||||
aria-label={intl.formatMessage({ id: "Log in/Join" })}
|
||||
position="top menu"
|
||||
trackingId="loginStartNewTopMenu"
|
||||
>
|
||||
<Avatar />
|
||||
<span className={styles.userName}>
|
||||
{intl.formatMessage({ id: "Log in/Join" })}
|
||||
</span>
|
||||
</Link>
|
||||
</LoginButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -23,9 +23,6 @@ export default async function MainMenu() {
|
||||
<Image
|
||||
alt={intl.formatMessage({ id: "Back to scandichotels.com" })}
|
||||
className={styles.logo}
|
||||
data-js="scandiclogoimg"
|
||||
data-nosvgsrc="/_static/img/scandic-logotype.png"
|
||||
itemProp="logo"
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype.svg"
|
||||
width={103}
|
||||
|
||||
@@ -14,12 +14,14 @@ import { bedTypeSchema } from "./schema"
|
||||
|
||||
import styles from "./bedOptions.module.css"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/enterDetails/bedType"
|
||||
import { BedTypeEnum } from "@/types/enums/bedType"
|
||||
import type {
|
||||
BedTypeProps,
|
||||
BedTypeSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType() {
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const intl = useIntl()
|
||||
const bedType = useEnterDetailsStore((state) => state.data.bedType)
|
||||
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
|
||||
|
||||
const methods = useForm<BedTypeSchema>({
|
||||
defaultValues: bedType
|
||||
@@ -33,10 +35,6 @@ export default function BedType() {
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const text = intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Included</b> (based on availability)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
@@ -58,38 +56,24 @@ export default function BedType() {
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={BedTypeEnum.KING}
|
||||
name="bedType"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
length: "210",
|
||||
width: "180",
|
||||
}
|
||||
)}
|
||||
text={text}
|
||||
title={intl.formatMessage({ id: "King bed" })}
|
||||
value={BedTypeEnum.KING}
|
||||
/>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={BedTypeEnum.QUEEN}
|
||||
name="bedType"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
length: "200",
|
||||
width: "160",
|
||||
}
|
||||
)}
|
||||
text={text}
|
||||
title={intl.formatMessage({ id: "Queen bed" })}
|
||||
value={BedTypeEnum.QUEEN}
|
||||
/>
|
||||
{bedTypes.map((roomType) => {
|
||||
const width =
|
||||
roomType.size.max === roomType.size.min
|
||||
? `${roomType.size.min} cm`
|
||||
: `${roomType.size.min} cm - ${roomType.size.max} cm`
|
||||
return (
|
||||
<RadioCard
|
||||
key={roomType.value}
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={roomType.value}
|
||||
name="bedType"
|
||||
subtitle={width}
|
||||
title={roomType.description}
|
||||
value={roomType.description}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
|
||||
@@ -3,5 +3,5 @@ import { z } from "zod"
|
||||
import { BedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bedType: z.nativeEnum(BedTypeEnum),
|
||||
bedType: z.string(),
|
||||
})
|
||||
|
||||
@@ -17,13 +17,13 @@ import styles from "./breakfast.module.css"
|
||||
import type {
|
||||
BreakfastFormSchema,
|
||||
BreakfastProps,
|
||||
} from "@/types/components/enterDetails/breakfast"
|
||||
} from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Breakfast({ packages }: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfast = useEnterDetailsStore((state) => state.data.breakfast)
|
||||
const breakfast = useEnterDetailsStore((state) => state.userData.breakfast)
|
||||
|
||||
let defaultValues = undefined
|
||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
@@ -78,8 +78,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
? intl.formatMessage<React.ReactNode>(
|
||||
{ id: "breakfast.price.free" },
|
||||
{
|
||||
amount: pkg.originalPrice,
|
||||
currency: pkg.currency,
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
free: (str) => <Highlight>{str}</Highlight>,
|
||||
strikethrough: (str) => <s>{str}</s>,
|
||||
}
|
||||
@@ -87,8 +87,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
: intl.formatMessage(
|
||||
{ id: "breakfast.price" },
|
||||
{
|
||||
amount: pkg.packagePrice,
|
||||
currency: pkg.currency,
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import { detailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
@@ -22,21 +21,21 @@ import styles from "./details.module.css"
|
||||
import type {
|
||||
DetailsProps,
|
||||
DetailsSchema,
|
||||
} from "@/types/components/enterDetails/details"
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const initialData = useEnterDetailsStore((state) => ({
|
||||
countryCode: state.data.countryCode,
|
||||
email: state.data.email,
|
||||
firstName: state.data.firstName,
|
||||
lastName: state.data.lastName,
|
||||
phoneNumber: state.data.phoneNumber,
|
||||
join: state.data.join,
|
||||
dateOfBirth: state.data.dateOfBirth,
|
||||
zipCode: state.data.zipCode,
|
||||
termsAccepted: state.data.termsAccepted,
|
||||
countryCode: state.userData.countryCode,
|
||||
email: state.userData.email,
|
||||
firstName: state.userData.firstName,
|
||||
lastName: state.userData.lastName,
|
||||
phoneNumber: state.userData.phoneNumber,
|
||||
join: state.userData.join,
|
||||
dateOfBirth: state.userData.dateOfBirth,
|
||||
zipCode: state.userData.zipCode,
|
||||
termsAccepted: state.userData.termsAccepted,
|
||||
}))
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
@@ -54,7 +53,7 @@ export default function Details({ user }: DetailsProps) {
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : detailsSchema),
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string(),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
termsAccepted: z.literal(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
@@ -36,15 +36,17 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
})
|
||||
)
|
||||
|
||||
export const detailsSchema = z.discriminatedUnion("join", [
|
||||
export const guestDetailsSchema = z.discriminatedUnion("join", [
|
||||
notJoinDetailsSchema,
|
||||
joinDetailsSchema,
|
||||
])
|
||||
|
||||
// For signed in users we accept partial or invalid data. Users cannot
|
||||
// change their info in this flow, so we don't want to validate it.
|
||||
export const signedInDetailsSchema = z.object({
|
||||
countryCode: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: phoneValidator().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Payment({
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { firstName, lastName, email, phoneNumber, countryCode } =
|
||||
useEnterDetailsStore((state) => state.data)
|
||||
useEnterDetailsStore((state) => state.userData)
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import {
|
||||
@@ -7,15 +8,17 @@ import {
|
||||
initEditDetailsState,
|
||||
} from "@/stores/enter-details"
|
||||
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
import { EnterDetailsProviderProps } from "@/types/components/hotelReservation/enterDetails/store"
|
||||
|
||||
export default function EnterDetailsProvider({
|
||||
step,
|
||||
isMember,
|
||||
children,
|
||||
}: PropsWithChildren<{ step: StepEnum }>) {
|
||||
}: PropsWithChildren<EnterDetailsProviderProps>) {
|
||||
const searchParams = useSearchParams()
|
||||
const initialStore = useRef<EnterDetailsStore>()
|
||||
if (!initialStore.current) {
|
||||
initialStore.current = initEditDetailsState(step)
|
||||
initialStore.current = initEditDetailsState(step, searchParams, isMember)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,12 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import {
|
||||
StepEnum,
|
||||
StepStoreKeys,
|
||||
} from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function SectionAccordion({
|
||||
header,
|
||||
@@ -23,9 +28,27 @@ export default function SectionAccordion({
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.navigate)
|
||||
const stepData = useEnterDetailsStore((state) => state.userData)
|
||||
const stepStoreKey = StepStoreKeys[step]
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed) {
|
||||
const value = stepData.bedType
|
||||
value && setTitle(value)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (step === StepEnum.breakfast && stepData.breakfast) {
|
||||
const value = stepData.breakfast
|
||||
if (value === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||
} else {
|
||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||
}
|
||||
}
|
||||
}, [stepData, stepStoreKey, step, intl])
|
||||
|
||||
useEffect(() => {
|
||||
// We need to set the state on mount because of hydration errors
|
||||
@@ -39,7 +62,6 @@ export default function SectionAccordion({
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
||||
<div className={styles.iconWrapper}>
|
||||
@@ -65,7 +87,7 @@ export default function SectionAccordion({
|
||||
className={styles.selection}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{label}
|
||||
{title}
|
||||
</Subtitle>
|
||||
</div>
|
||||
{isComplete && !isOpen && (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomConfiguration } from "@/server/routers/hotels/output"
|
||||
|
||||
import { EditIcon, ImageIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
export default function SelectedRoom() {
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
room,
|
||||
}: {
|
||||
hotelId: string
|
||||
room: RoomConfiguration
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
@@ -22,42 +32,50 @@ export default function SelectedRoom() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.textContainer}>
|
||||
<Footnote
|
||||
className={styles.label}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Your room" })}
|
||||
</Footnote>
|
||||
<div className={styles.text}>
|
||||
{/**
|
||||
* [TEMP]
|
||||
* No translation on Subtitles as they will be derived
|
||||
* from Room selection.
|
||||
*/}
|
||||
<Subtitle
|
||||
className={styles.room}
|
||||
color="uiTextHighContrast"
|
||||
type="two"
|
||||
<div>
|
||||
<div className={styles.textContainer}>
|
||||
<Footnote
|
||||
className={styles.label}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
Cozy cabin
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Free rebooking
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Pay now
|
||||
</Subtitle>
|
||||
{intl.formatMessage({ id: "Your room" })}
|
||||
</Footnote>
|
||||
<div className={styles.text}>
|
||||
{/**
|
||||
* [TEMP]
|
||||
* No translation on Subtitles as they will be derived
|
||||
* from Room selection.
|
||||
*/}
|
||||
<Subtitle
|
||||
className={styles.room}
|
||||
color="uiTextHighContrast"
|
||||
type="two"
|
||||
>
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Free rebooking
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Pay now
|
||||
</Subtitle>
|
||||
</div>
|
||||
</div>
|
||||
{room?.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Contact from "@/components/HotelReservation/Contact"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./enterDetailsSidePeek.module.css"
|
||||
|
||||
import {
|
||||
SidePeekEnum,
|
||||
SidePeekProps,
|
||||
} from "@/types/components/enterDetails/sidePeek"
|
||||
|
||||
export default function EnterDetailsSidePeek({ hotel }: SidePeekProps) {
|
||||
const activeSidePeek = useEnterDetailsStore((state) => state.activeSidePeek)
|
||||
const close = useEnterDetailsStore((state) => state.closeSidePeek)
|
||||
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={SidePeekEnum.hotelDetails}
|
||||
title={intl.formatMessage({ id: "About the hotel" })}
|
||||
isOpen={activeSidePeek === SidePeekEnum.hotelDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<article className={styles.spacing}>
|
||||
<Contact hotel={hotel} />
|
||||
<Divider />
|
||||
<section className={styles.spacing}>
|
||||
<Body>{hotel.hotelContent.texts.descriptions.medium}</Body>
|
||||
{hotel.hotelContent.texts.facilityInformation
|
||||
.split(/[\n\r]/g)
|
||||
.filter((p) => p)
|
||||
.map((paragraph, idx) => (
|
||||
<Body key={`facilityInfo-${idx}`}>{paragraph}</Body>
|
||||
))}
|
||||
</section>
|
||||
</article>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/enterDetails/sidePeek"
|
||||
|
||||
export default function ToggleSidePeek() {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useEnterDetailsStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
openSidePeek(SidePeekEnum.hotelDetails)
|
||||
}}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
<ChevronRightSmallIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
// TEMP
|
||||
const rooms = [
|
||||
{
|
||||
adults: 1,
|
||||
type: "Cozy cabin",
|
||||
},
|
||||
]
|
||||
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Summary({
|
||||
isMember,
|
||||
room,
|
||||
}: {
|
||||
isMember: boolean
|
||||
room: RoomsData
|
||||
}) {
|
||||
const [chosenBed, setChosenBed] = useState<string>()
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
||||
>()
|
||||
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,
|
||||
})
|
||||
)
|
||||
|
||||
export default async function Summary() {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const fromDate = dt().locale(lang).format("ddd, D MMM")
|
||||
const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM")
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const totalAdults = rooms.reduce((total, room) => total + room.adults, 0)
|
||||
|
||||
const adults = intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: totalAdults }
|
||||
)
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
const addOns = [
|
||||
{
|
||||
price: intl.formatMessage({ id: "Included" }),
|
||||
title: intl.formatMessage({ id: "King bed" }),
|
||||
},
|
||||
{
|
||||
price: intl.formatMessage({ id: "Included" }),
|
||||
title: intl.formatMessage({ id: "Breakfast buffet" }),
|
||||
},
|
||||
]
|
||||
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
|
||||
if (isMember) {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
const mappedRooms = Array.from(
|
||||
rooms
|
||||
.reduce((acc, room) => {
|
||||
const currentRoom = acc.get(room.type)
|
||||
acc.set(room.type, {
|
||||
total: currentRoom ? currentRoom.total + 1 : 1,
|
||||
type: room.type,
|
||||
})
|
||||
return acc
|
||||
}, new Map())
|
||||
.values()
|
||||
)
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
|
||||
if (breakfast) {
|
||||
setChosenBreakfast(breakfast)
|
||||
}
|
||||
}, [bedType, breakfast])
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header>
|
||||
<Body textTransform="bold">
|
||||
{mappedRooms.map(
|
||||
(room, idx) =>
|
||||
`${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}`
|
||||
)}
|
||||
<Subtitle 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>
|
||||
<Body className={styles.date} color="textMediumContrast">
|
||||
{fromDate}
|
||||
<ArrowRightIcon color="uiTextMediumContrast" height={15} width={15} />
|
||||
{toDate}
|
||||
</Body>
|
||||
|
||||
<ToggleSidePeek />
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">{room.roomType}</Body>
|
||||
<Caption color={color}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{`${nights}, ${adults}`}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "4536", currency: "SEK" }
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: room.adults }
|
||||
)}
|
||||
</Caption>
|
||||
{room.children?.length ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: room.children.length }
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Link>
|
||||
</div>
|
||||
{addOns.map((addOn) => (
|
||||
<div className={styles.entry} key={addOn.title}>
|
||||
<Caption color="uiTextMediumContrast">{addOn.title}</Caption>
|
||||
<Caption color="uiTextHighContrast">{addOn.price}</Caption>
|
||||
|
||||
{chosenBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="textHighContrast">{chosenBed}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
{chosenBreakfast ? (
|
||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.price,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.total}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Total incl VAT" })}
|
||||
</Body>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "4686", currency: "SEK" }
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<div>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "455", currency: "EUR" }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="red" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Member price" })}
|
||||
</Body>
|
||||
<Body color="red" textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "4219", currency: "SEK" }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "412", currency: "EUR" }
|
||||
{
|
||||
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.euroPrice.price ?? "0")),
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user