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