Merge remote-tracking branch 'origin' into feature/tracking
This commit is contained in:
@@ -12,7 +12,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./receipt.module.css"
|
||||
|
||||
import { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
|
||||
import type { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Receipt({
|
||||
|
||||
@@ -10,7 +10,7 @@ import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./contact.module.css"
|
||||
|
||||
import { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
|
||||
export default function Contact({ hotel }: ContactProps) {
|
||||
const lang = useLang()
|
||||
|
||||
@@ -22,10 +22,7 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const initialBedType = useEnterDetailsStore(
|
||||
(state) => state.formValues?.bedType?.roomTypeCode
|
||||
)
|
||||
const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode)
|
||||
const completeStep = useEnterDetailsStore(
|
||||
(state) => state.actions.completeStep
|
||||
)
|
||||
|
||||
const updateBedType = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBedType
|
||||
)
|
||||
@@ -81,9 +78,6 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
subtitle={width}
|
||||
title={roomType.description}
|
||||
value={roomType.value}
|
||||
handleSelectedOnClick={
|
||||
bedType === roomType.value ? completeStep : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { breakfastFormSchema } from "./schema"
|
||||
|
||||
@@ -30,19 +31,15 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
? "false"
|
||||
: undefined
|
||||
)
|
||||
const breakfast = useEnterDetailsStore((state) =>
|
||||
state.breakfast
|
||||
? state.breakfast.code
|
||||
: state.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
)
|
||||
const completeStep = useEnterDetailsStore(
|
||||
(state) => state.actions.completeStep
|
||||
)
|
||||
|
||||
const updateBreakfast = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
)
|
||||
|
||||
const children = useEnterDetailsStore(
|
||||
(state) => state.booking.rooms[0].children
|
||||
)
|
||||
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
defaultValues: formValuesBreakfast
|
||||
? { breakfast: formValuesBreakfast }
|
||||
@@ -75,60 +72,63 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{packages.map((pkg) => (
|
||||
<RadioCard
|
||||
key={pkg.code}
|
||||
id={pkg.code}
|
||||
name="breakfast"
|
||||
subtitle={
|
||||
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
|
||||
? intl.formatMessage<React.ReactNode>(
|
||||
{ id: "breakfast.price.free" },
|
||||
{
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
free: (str) => <Highlight>{str}</Highlight>,
|
||||
strikethrough: (str) => <s>{str}</s>,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "breakfast.price" },
|
||||
{
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
}
|
||||
)
|
||||
}
|
||||
text={intl.formatMessage({
|
||||
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
|
||||
<div className={styles.container}>
|
||||
{children?.length ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Children's breakfast is always free as part of the adult's breakfast.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
value={pkg.code}
|
||||
handleSelectedOnClick={
|
||||
breakfast === pkg.code ? completeStep : undefined
|
||||
}
|
||||
</Body>
|
||||
) : null}
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{packages.map((pkg) => (
|
||||
<RadioCard
|
||||
key={pkg.code}
|
||||
id={pkg.code}
|
||||
name="breakfast"
|
||||
subtitle={
|
||||
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
|
||||
? intl.formatMessage<React.ReactNode>(
|
||||
{ id: "breakfast.price.free" },
|
||||
{
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
free: (str) => <Highlight>{str}</Highlight>,
|
||||
strikethrough: (str) => <s>{str}</s>,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "breakfast.price" },
|
||||
{
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
}
|
||||
)
|
||||
}
|
||||
text={intl.formatMessage({
|
||||
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
value={pkg.code}
|
||||
/>
|
||||
))}
|
||||
<RadioCard
|
||||
name="breakfast"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: "0",
|
||||
currency: packages[0].localPrice.currency,
|
||||
}
|
||||
)}
|
||||
text={intl.formatMessage({
|
||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "No breakfast" })}
|
||||
value="false"
|
||||
/>
|
||||
))}
|
||||
<RadioCard
|
||||
name="breakfast"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: "0",
|
||||
currency: "SEK",
|
||||
}
|
||||
)}
|
||||
text={intl.formatMessage({
|
||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "No breakfast" })}
|
||||
value="false"
|
||||
handleSelectedOnClick={
|
||||
breakfast === "false" ? completeStep : undefined
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ import type {
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
|
||||
const join = useEnterDetailsStore((state) => state.guest.join)
|
||||
const initialData = useEnterDetailsStore((state) => state.guest)
|
||||
const updateDetails = useEnterDetailsStore(
|
||||
(state) => state.actions.updateDetails
|
||||
)
|
||||
@@ -42,7 +41,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
email: user?.email ?? initialData.email,
|
||||
firstName: user?.firstName ?? initialData.firstName,
|
||||
join,
|
||||
join: initialData.join,
|
||||
lastName: user?.lastName ?? initialData.lastName,
|
||||
membershipNo: initialData.membershipNo,
|
||||
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
|
||||
@@ -78,12 +77,14 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
</Footnote>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Last name" })}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true }}
|
||||
|
||||
@@ -2,11 +2,27 @@ import { z } from "zod"
|
||||
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
// stringMatcher regex is copied from current web as specified by requirements.
|
||||
const stringMatcher =
|
||||
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
|
||||
|
||||
const isValidString = (key: string) => stringMatcher.test(key)
|
||||
|
||||
export const baseDetailsSchema = z.object({
|
||||
countryCode: z.string(),
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
countryCode: z.string().min(1, { message: "Country is required" }),
|
||||
email: z.string().email({ message: "Email address is required" }),
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, { message: "First name is required" })
|
||||
.refine(isValidString, {
|
||||
message: "First name can't contain any special characters",
|
||||
}),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(1, { message: "Last name is required" })
|
||||
.refine(isValidString, {
|
||||
message: "Last name can't contain any special characters",
|
||||
}),
|
||||
phoneNumber: phoneValidator(),
|
||||
})
|
||||
|
||||
@@ -26,10 +42,10 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
}, "Only digits are allowed")
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.length === 14
|
||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
||||
}
|
||||
return true
|
||||
}, "Membership number needs to be 14 digits"),
|
||||
}, "Invalid membership number format"),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({ hotelId }: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { detailsStorageName } from "@/stores/enter-details"
|
||||
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
import type { PersistedState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function PaymentCallback({
|
||||
returnUrl,
|
||||
@@ -23,12 +23,9 @@ export default function PaymentCallback({
|
||||
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||
|
||||
if (bookingData) {
|
||||
const detailsStorage: Record<
|
||||
"state",
|
||||
Pick<DetailsState, "booking">
|
||||
> = JSON.parse(bookingData)
|
||||
const detailsStorage: PersistedState = JSON.parse(bookingData)
|
||||
const searchParams = createQueryParamsForEnterDetails(
|
||||
detailsStorage.state.booking,
|
||||
detailsStorage.booking,
|
||||
searchObject
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||
import PriceChangeDialog from "../PriceChangeDialog"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
import { type PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
@@ -403,6 +403,9 @@ export default function PaymentClient({
|
||||
</section>
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import Image from "next/image"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { PAYMENT_METHOD_ICONS, PaymentMethodEnum } from "@/constants/booking"
|
||||
import {
|
||||
PAYMENT_METHOD_ICONS,
|
||||
type PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { trackUpdatePaymentMethod } from "@/utils/tracking"
|
||||
|
||||
import { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
import styles from "./paymentOption.module.css"
|
||||
|
||||
import type { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
export default function PaymentOption({
|
||||
name,
|
||||
value,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface PaymentOptionProps {
|
||||
name: string
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import PaymentClient from "./PaymentClient"
|
||||
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default async function Payment({
|
||||
user,
|
||||
|
||||
@@ -8,7 +8,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./priceChangeDialog.module.css"
|
||||
|
||||
import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
|
||||
import type { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
|
||||
|
||||
export default function PriceChangeDialog({
|
||||
isOpen,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function SectionAccordion({
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const steps = useEnterDetailsStore((state) => state.steps)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
@@ -33,6 +35,9 @@ export default function SectionAccordion({
|
||||
|
||||
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||
|
||||
useScrollToActiveSection(step, steps, currentStep === step)
|
||||
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed && bedType) {
|
||||
setTitle(bedType.description)
|
||||
|
||||
+13
-1
@@ -5,7 +5,7 @@
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
padding-top: var(--Spacing-x3);
|
||||
transition: 0.4s ease-out;
|
||||
transition: 0.3s ease-out;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas: "circle header" "content content";
|
||||
@@ -13,6 +13,7 @@
|
||||
grid-template-rows: var(--header-height) 0fr;
|
||||
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.accordion:last-child {
|
||||
@@ -90,7 +91,18 @@
|
||||
.content {
|
||||
overflow: hidden;
|
||||
grid-area: content;
|
||||
opacity: 0;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
transform-origin: top;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
.accordion[data-open="true"] .content {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.content:has([data-open="true"]) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { PropsWithChildren } from "react"
|
||||
import { type PropsWithChildren, useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
@@ -14,6 +14,7 @@ import styles from "./bottomSheet.module.css"
|
||||
|
||||
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
useEnterDetailsStore((state) => ({
|
||||
@@ -23,6 +24,27 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
isSubmittingDisabled: state.isSubmittingDisabled,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
scrollY.current = window.scrollY
|
||||
document.body.style.position = "fixed"
|
||||
document.body.style.top = `-${scrollY.current}px`
|
||||
} else {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
window.scrollTo({
|
||||
top: scrollY.current,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
}
|
||||
}, [isSummaryOpen])
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
@@ -48,6 +70,7 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
</button>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isSubmittingDisabled}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
|
||||
import SummaryUI from "../UI"
|
||||
import SummaryBottomSheet from "./BottomSheet"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
join: state.guest.join,
|
||||
membershipNo: state.guest.membershipNo,
|
||||
}
|
||||
}
|
||||
|
||||
export default function MobileSummary(props: SummaryProps) {
|
||||
const { join, membershipNo } = useEnterDetailsStore(storeSelector)
|
||||
const showPromo = !props.isMember && !join && !membershipNo
|
||||
return (
|
||||
<div className={styles.mobileSummary}>
|
||||
{showPromo ? <SignupPromoMobile /> : null}
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.wrapper}>
|
||||
<SummaryUI {...props} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
@@ -17,7 +18,6 @@ import useLang from "@/hooks/useLang"
|
||||
import styles from "./ui.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
export function storeSelector(state: DetailsState) {
|
||||
@@ -60,10 +60,14 @@ export default function SummaryUI({
|
||||
const adults = booking.rooms[0].adults
|
||||
const children = booking.rooms[0].children
|
||||
|
||||
const showMemberPrice = !!(
|
||||
(isMember || join || membershipNo) &&
|
||||
roomRate.memberRate
|
||||
)
|
||||
const memberPrice = roomRate.memberRate
|
||||
? {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
amount: roomRate.memberRate.localPrice.pricePerStay,
|
||||
}
|
||||
: null
|
||||
|
||||
const showMemberPrice = !!(isMember || join || membershipNo)
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
@@ -103,27 +107,26 @@ export default function SummaryUI({
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{roomType}</Body>
|
||||
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{intl.formatNumber(roomPrice.local.price, {
|
||||
currency: roomPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</Body>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{`${intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: adults }
|
||||
)}
|
||||
)}${
|
||||
children?.length
|
||||
? `, ${intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: children.length }
|
||||
)}`
|
||||
: ""
|
||||
}`}
|
||||
</Caption>
|
||||
{children?.length ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: children.length }
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
@@ -152,12 +155,12 @@ export default function SummaryUI({
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(parseInt(roomPackage.localPrice.price), {
|
||||
currency: roomPackage.localPrice.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</Body>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
@@ -170,12 +173,12 @@ export default function SummaryUI({
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(0, {
|
||||
currency: roomPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -184,25 +187,49 @@ export default function SummaryUI({
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(0, {
|
||||
currency: roomPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{breakfast ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
|
||||
currency: breakfast.localPrice.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: adults }
|
||||
)}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
|
||||
currency: breakfast.localPrice.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
{children?.length ? (
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: children.length }
|
||||
)}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(0, {
|
||||
currency: breakfast.localPrice.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -240,6 +267,9 @@ export default function SummaryUI({
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
{!showMemberPrice && memberPrice ? (
|
||||
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.card {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.priceVariants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.content {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x2) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.text {
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex-direction: row;
|
||||
}
|
||||
.imageContainer {
|
||||
width: 315px;
|
||||
height: 100%;
|
||||
}
|
||||
.priceVariants {
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./HotelCardSkeleton.module.css"
|
||||
|
||||
export function HotelCardSkeleton() {
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
{/* image container */}
|
||||
<div className={styles.imageContainer}>
|
||||
<SkeletonShimmer width={"100%"} height="100%" />
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<SkeletonShimmer height={"65px"} />
|
||||
<div className={styles.text}>
|
||||
<SkeletonShimmer height={"20px"} />
|
||||
<SkeletonShimmer height={"20px"} />
|
||||
<SkeletonShimmer height={"20px"} />
|
||||
<SkeletonShimmer height={"20px"} />
|
||||
</div>
|
||||
<SkeletonShimmer height={"56px"} />
|
||||
<SkeletonShimmer height={"52px"} width={"150px"} />
|
||||
</div>
|
||||
|
||||
<div className={styles.priceVariants}>
|
||||
{/* price variants */}
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<SkeletonShimmer key={index} height={"100px"} />
|
||||
))}
|
||||
<SkeletonShimmer height={"40px"} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useParams } from "next/dist/client/components/navigation"
|
||||
import { memo, useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
@@ -27,31 +27,33 @@ import styles from "./hotelCard.module.css"
|
||||
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
function HotelCard({
|
||||
hotel,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
state = "default",
|
||||
onHotelCardHover,
|
||||
}: HotelCardProps) {
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
const intl = useIntl()
|
||||
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
|
||||
|
||||
const { hotelData } = hotel
|
||||
const { price } = hotel
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (onHotelCardHover && hotelData) {
|
||||
onHotelCardHover(hotelData.name)
|
||||
if (hotelData) {
|
||||
setActiveHotelPin(hotelData.name)
|
||||
}
|
||||
}, [onHotelCardHover, hotelData])
|
||||
}, [setActiveHotelPin, hotelData])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(null)
|
||||
if (hotelData) {
|
||||
setActiveHotelPin(null)
|
||||
setActiveHotelCard(null)
|
||||
}
|
||||
}, [onHotelCardHover])
|
||||
}, [setActiveHotelPin, hotelData, setActiveHotelCard])
|
||||
|
||||
if (!hotel || !hotelData) return null
|
||||
|
||||
@@ -96,15 +98,20 @@ function HotelCard({
|
||||
{hotelData.address.streetAddress}, {hotelData.address.city}
|
||||
</Caption>
|
||||
</address>
|
||||
<Link
|
||||
className={styles.addressMobile}
|
||||
href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
|
||||
keepSearchParams
|
||||
>
|
||||
<Caption color="baseTextMediumContrast" type="underline">
|
||||
<Caption color="baseTextMediumContrast" type="underline" asChild>
|
||||
<Link
|
||||
href={`https://www.google.com/maps/dir/?api=1&destination=${hotelData.location.latitude},${hotelData.location.longitude}`}
|
||||
target="_blank"
|
||||
aria-label={intl.formatMessage({
|
||||
id: "Driving directions",
|
||||
})}
|
||||
title={intl.formatMessage({
|
||||
id: "Driving directions",
|
||||
})}
|
||||
>
|
||||
{hotelData.address.streetAddress}, {hotelData.address.city}
|
||||
</Caption>
|
||||
</Link>
|
||||
</Link>
|
||||
</Caption>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
|
||||
@@ -106,8 +106,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.facilities,
|
||||
.memberPrice {
|
||||
.facilities {
|
||||
display: none;
|
||||
}
|
||||
.dialog {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
@@ -22,6 +21,7 @@ import NoPriceAvailableCard from "../HotelCard/NoPriceAvailableCard"
|
||||
import styles from "./hotelCardDialog.module.css"
|
||||
|
||||
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export default function HotelCardDialog({
|
||||
data,
|
||||
@@ -103,12 +103,14 @@ export default function HotelCardDialog({
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "From" })}
|
||||
</Caption>
|
||||
<Subtitle type="two">
|
||||
{publicPrice} {currency}
|
||||
<Body asChild>
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
{publicPrice && (
|
||||
<Subtitle type="two">
|
||||
{publicPrice} {currency}
|
||||
<Body asChild>
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
)}
|
||||
{memberPrice && (
|
||||
<Subtitle
|
||||
type="two"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
|
||||
import HotelCardDialog from "../HotelCardDialog"
|
||||
@@ -14,18 +16,21 @@ import type { HotelCardDialogListingProps } from "@/types/components/hotelReserv
|
||||
|
||||
export default function HotelCardDialogListing({
|
||||
hotels,
|
||||
activeCard,
|
||||
onActiveCardChange,
|
||||
}: HotelCardDialogListingProps) {
|
||||
const hotelsPinData = hotels ? getHotelPins(hotels) : []
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
const { activeHotelCard, setActiveHotelCard, setActiveHotelPin } =
|
||||
useHotelsMapStore()
|
||||
|
||||
useClickOutside(dialogRef, !!activeCard && isMobile, () => {
|
||||
onActiveCardChange(null)
|
||||
})
|
||||
function handleClose() {
|
||||
setActiveHotelCard(null)
|
||||
setActiveHotelPin(null)
|
||||
}
|
||||
|
||||
useClickOutside(dialogRef, !!activeHotelCard && isMobile, handleClose)
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
@@ -33,12 +38,12 @@ export default function HotelCardDialogListing({
|
||||
if (entry.isIntersecting) {
|
||||
const cardName = entry.target.getAttribute("data-name")
|
||||
if (cardName) {
|
||||
onActiveCardChange(cardName)
|
||||
setActiveHotelCard(cardName)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[onActiveCardChange]
|
||||
[setActiveHotelCard]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,13 +78,13 @@ export default function HotelCardDialogListing({
|
||||
elements.forEach((el) => observerRef.current?.observe(el))
|
||||
}, 1000)
|
||||
}
|
||||
}, [activeCard])
|
||||
}, [activeHotelCard])
|
||||
|
||||
return (
|
||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||
{!!hotelsPinData?.length &&
|
||||
hotelsPinData.map((data) => {
|
||||
const isActive = data.name === activeCard
|
||||
const isActive = data.name === activeHotelCard
|
||||
return (
|
||||
<div
|
||||
key={data.name}
|
||||
@@ -88,8 +93,8 @@ export default function HotelCardDialogListing({
|
||||
>
|
||||
<HotelCardDialog
|
||||
data={data}
|
||||
isOpen={!!activeCard}
|
||||
handleClose={() => onActiveCardChange(null)}
|
||||
isOpen={!!activeHotelCard}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,10 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
name: hotel.hotelData.name,
|
||||
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
|
||||
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
|
||||
currency: hotel.price?.public?.localPrice.currency || null,
|
||||
currency:
|
||||
hotel.price?.public?.localPrice.currency ||
|
||||
hotel.price?.member?.localPrice.currency ||
|
||||
null,
|
||||
images: [
|
||||
hotel.hotelData.hotelContent.images,
|
||||
...(hotel.hotelData.gallery?.heroImages ?? []),
|
||||
@@ -25,5 +28,8 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
.slice(0, 3),
|
||||
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
|
||||
operaId: hotel.hotelData.operaId,
|
||||
facilityIds: hotel.hotelData.detailedFacilities.map(
|
||||
(facility) => facility.id
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
@@ -24,14 +25,13 @@ import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
export default function HotelCardListing({
|
||||
hotelData,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
activeCard,
|
||||
onHotelCardHover,
|
||||
}: HotelCardListingProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
const intl = useIntl()
|
||||
const { activeHotelCard } = useHotelsMapStore()
|
||||
|
||||
const sortBy = useMemo(
|
||||
() => searchParams.get("sort") ?? DEFAULT_SORT,
|
||||
@@ -111,13 +111,16 @@ export default function HotelCardListing({
|
||||
hotels.map((hotel) => (
|
||||
<div
|
||||
key={hotel.hotelData.operaId}
|
||||
data-active={hotel.hotelData.name === activeCard ? "true" : "false"}
|
||||
data-active={
|
||||
hotel.hotelData.name === activeHotelCard ? "true" : "false"
|
||||
}
|
||||
>
|
||||
<HotelCard
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
state={
|
||||
hotel.hotelData.name === activeHotelCard ? "active" : "default"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -128,7 +131,9 @@ export default function HotelCardListing({
|
||||
text={intl.formatMessage({ id: "filters.nohotel.text" })}
|
||||
/>
|
||||
) : null}
|
||||
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton position="right" onClick={scrollToTop} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./readMore.module.css"
|
||||
|
||||
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
|
||||
export default function ReadMore({ label, hotelId, showCTA }: ReadMoreProps) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
usePathname,
|
||||
useSearchParams,
|
||||
} from "next/dist/client/components/navigation"
|
||||
import { useCallback, useState } from "react"
|
||||
import {
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
@@ -13,25 +18,69 @@ import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
|
||||
|
||||
import HotelFilter from "../HotelFilter"
|
||||
import HotelSorter from "../HotelSorter"
|
||||
import { DEFAULT_SORT } from "../HotelSorter"
|
||||
|
||||
import styles from "./filterAndSortModal.module.css"
|
||||
|
||||
import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal"
|
||||
import {
|
||||
type SortItem,
|
||||
SortOrder,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
export default function FilterAndSortModal({
|
||||
filters,
|
||||
}: FilterAndSortModalProps) {
|
||||
const intl = useIntl()
|
||||
useInitializeFiltersFromUrl()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT)
|
||||
|
||||
const sortItems: SortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({ id: "Distance to city centre" }),
|
||||
value: SortOrder.Distance,
|
||||
},
|
||||
{ label: intl.formatMessage({ id: "Name" }), value: SortOrder.Name },
|
||||
{ label: intl.formatMessage({ id: "Price" }), value: SortOrder.Price },
|
||||
{
|
||||
label: intl.formatMessage({ id: "TripAdvisor rating" }),
|
||||
value: SortOrder.TripAdvisorRating,
|
||||
},
|
||||
]
|
||||
|
||||
const handleSortSelect = useCallback((value: string | number) => {
|
||||
setSort(value.toString())
|
||||
}, [])
|
||||
|
||||
const handleApplyFiltersAndSorting = useCallback(
|
||||
(close: () => void) => {
|
||||
if (sort === searchParams.get("sort")) {
|
||||
close()
|
||||
}
|
||||
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
newSearchParams.set("sort", sort)
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
close()
|
||||
},
|
||||
[pathname, searchParams, sort]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -65,7 +114,16 @@ export default function FilterAndSortModal({
|
||||
</Subtitle>
|
||||
</header>
|
||||
<div className={styles.sorter}>
|
||||
<HotelSorter />
|
||||
<Select
|
||||
items={sortItems}
|
||||
defaultSelectedKey={
|
||||
searchParams.get("sort") ?? DEFAULT_SORT
|
||||
}
|
||||
label={intl.formatMessage({ id: "Sort by" })}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
onSelect={handleSortSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.divider}>
|
||||
<Divider color="subtle" />
|
||||
@@ -78,7 +136,7 @@ export default function FilterAndSortModal({
|
||||
intent="primary"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={close}
|
||||
onClick={() => handleApplyFiltersAndSorting(close)}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{ id: "See results" },
|
||||
|
||||
@@ -13,7 +13,7 @@ import FilterAndSortModal from "../FilterAndSortModal"
|
||||
|
||||
import styles from "./mobileMapButtonContainer.module.css"
|
||||
|
||||
import { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
import type { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function MobileMapButtonContainer({
|
||||
filters,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import HotelCardDialogListing from "@/components/HotelReservation/HotelCardDialogListing"
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
|
||||
@@ -8,27 +10,18 @@ import styles from "./hotelListing.module.css"
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelListing({
|
||||
hotels,
|
||||
activeHotelPin,
|
||||
setActiveHotelPin,
|
||||
}: HotelListingProps) {
|
||||
export default function HotelListing({ hotels }: HotelListingProps) {
|
||||
const { activeHotelPin } = useHotelsMapStore()
|
||||
return (
|
||||
<>
|
||||
<div className={styles.hotelListing}>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
type={HotelCardListingTypeEnum.MapListing}
|
||||
activeCard={activeHotelPin}
|
||||
onHotelCardHover={setActiveHotelPin}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hotelListingMobile} data-open={!!activeHotelPin}>
|
||||
<HotelCardDialogListing
|
||||
hotels={hotels}
|
||||
activeCard={activeHotelPin}
|
||||
onActiveCardChange={setActiveHotelPin}
|
||||
/>
|
||||
<HotelCardDialogListing hotels={hotels} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { getCityCoordinates } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getFiltersFromHotels,
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
|
||||
import { getHotelPins } from "../../HotelCardDialogListing/utils"
|
||||
import SelectHotelMap from "."
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type {
|
||||
HotelData,
|
||||
NullableHotelData,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import type {
|
||||
TrackingChannelEnum,
|
||||
TrackingSDKHotelInfo,
|
||||
TrackingSDKPageData} from "@/types/components/tracking";
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
function isValidHotelData(hotel: NullableHotelData): hotel is HotelData {
|
||||
return hotel !== null && hotel !== undefined
|
||||
}
|
||||
|
||||
export async function SelectHotelMapContainer({
|
||||
city,
|
||||
searchParams,
|
||||
adultsInRoom,
|
||||
childrenInRoom,
|
||||
child,
|
||||
}: SelectHotelMapContainerProps) {
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
|
||||
const fetchAvailableHotelsPromise = safeTry(
|
||||
fetchAvailableHotels({
|
||||
cityId: city.id,
|
||||
roomStayStartDate: searchParams.fromDate,
|
||||
roomStayEndDate: searchParams.toDate,
|
||||
adults: adultsInRoom,
|
||||
children: childrenInRoom,
|
||||
})
|
||||
)
|
||||
|
||||
const [hotels] = await fetchAvailableHotelsPromise
|
||||
|
||||
const validHotels = hotels?.filter(isValidHotelData) || []
|
||||
|
||||
const hotelPins = getHotelPins(validHotels)
|
||||
const filterList = getFiltersFromHotels(validHotels)
|
||||
const cityCoordinates = await getCityCoordinates({
|
||||
city: city.name,
|
||||
hotel: { address: hotels?.[0]?.hotelData?.address.streetAddress },
|
||||
})
|
||||
|
||||
const arrivalDate = new Date(searchParams.fromDate)
|
||||
const departureDate = new Date(searchParams.toDate)
|
||||
|
||||
const pageTrackingData: TrackingSDKPageData = {
|
||||
pageId: "select-hotel",
|
||||
domainLanguage: searchParams.lang as Lang,
|
||||
channel: TrackingChannelEnum["hotelreservation"],
|
||||
pageName: "hotelreservation|select-hotel|mapview",
|
||||
siteSections: "hotelreservation|select-hotel|mapview",
|
||||
pageType: "bookinghotelsmapviewpage",
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
const hotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
availableResults: validHotels.length,
|
||||
searchTerm: searchParams.city,
|
||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
noOfAdults: adultsInRoom,
|
||||
noOfChildren: child?.length,
|
||||
ageOfChildren: child?.map((c) => c.age).join(","),
|
||||
childBedPreference: child?.map((c) => ChildBedMapEnum[c.bed]).join("|"),
|
||||
noOfRooms: 1, // // TODO: Handle multiple rooms
|
||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
searchType: "destination",
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
country: validHotels?.[0].hotelData.address.country,
|
||||
region: validHotels?.[0].hotelData.address.city,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectHotelMap
|
||||
apiKey={googleMapsApiKey}
|
||||
hotelPins={hotelPins}
|
||||
mapId={googleMapId}
|
||||
hotels={validHotels}
|
||||
filterList={filterList}
|
||||
cityCoordinates={cityCoordinates}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
hotelInfo={hotelsTrackingData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.listingContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-top: var(--Spacing-x6);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeletonItem {
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
max-width: 505px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.skeletonContainer {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import { RoomCardSkeleton } from "../../SelectRate/RoomSelection/RoomCard/RoomCardSkeleton"
|
||||
|
||||
import styles from "./SelectHotelMapContainerSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SelectHotelMapContainerSkeleton({ count = 2 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer}>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className={styles.skeletonItem}>
|
||||
<RoomCardSkeleton />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mapContainer}>
|
||||
<SkeletonShimmer width={"100%"} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
import { useMap } from "@vis.gl/react-google-maps"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/SelectRate/RoomSelection/RoomCard/RoomCardSkeleton"
|
||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import FilterAndSortModal from "../../FilterAndSortModal"
|
||||
import HotelListing from "../HotelListing"
|
||||
import { getVisibleHotels } from "./utils"
|
||||
|
||||
import styles from "./selectHotelMapContent.module.css"
|
||||
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
const SKELETON_LOAD_DELAY = 750
|
||||
|
||||
export default function SelectHotelContent({
|
||||
hotelPins,
|
||||
cityCoordinates,
|
||||
mapId,
|
||||
hotels,
|
||||
filterList,
|
||||
}: Omit<SelectHotelMapProps, "apiKey">) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
const [visibleHotels, setVisibleHotels] = useState<HotelData[]>([])
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
const [showSkeleton, setShowSkeleton] = useState<boolean>(false)
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const { activeHotelCard, activeHotelPin } = useHotelsMapStore()
|
||||
|
||||
const coordinates = useMemo(
|
||||
() =>
|
||||
isAboveMobile
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 },
|
||||
[isAboveMobile, cityCoordinates]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (listingContainerRef.current) {
|
||||
const activeElement =
|
||||
listingContainerRef.current.querySelector(`[data-active="true"]`)
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
||||
}
|
||||
}
|
||||
}, [activeHotelCard, activeHotelPin])
|
||||
|
||||
useEffect(() => {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
if (!hotelListingElement) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const hasScrolledPast = hotelListingElement.scrollTop > 490
|
||||
setShowBackToTop(hasScrolledPast)
|
||||
}
|
||||
|
||||
hotelListingElement.addEventListener("scroll", handleScroll)
|
||||
return () => hotelListingElement.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
function scrollToTop() {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
hotelListingElement?.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
const filteredHotelPins = useMemo(
|
||||
() =>
|
||||
hotelPins.filter((hotel) =>
|
||||
activeFilters.every((filterId) =>
|
||||
hotel.facilityIds.includes(Number(filterId))
|
||||
)
|
||||
),
|
||||
[activeFilters, hotelPins]
|
||||
)
|
||||
|
||||
const getHotelCards = useCallback(() => {
|
||||
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
|
||||
setVisibleHotels(visibleHotels)
|
||||
setTimeout(() => {
|
||||
setShowSkeleton(true)
|
||||
}, SKELETON_LOAD_DELAY)
|
||||
}, [hotels, filteredHotelPins, map])
|
||||
|
||||
/**
|
||||
* Updates visible hotels when map viewport changes (zoom/pan)
|
||||
* - Debounces updates to prevent excessive re-renders during map interaction
|
||||
* - Shows loading skeleton while map tiles load
|
||||
* - Triggers on: initial load, zoom, pan, and tile loading completion
|
||||
*/
|
||||
const debouncedUpdateHotelCards = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!map) return
|
||||
setShowSkeleton(false)
|
||||
getHotelCards()
|
||||
}, 100),
|
||||
[map, getHotelCards]
|
||||
)
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.closeButton}
|
||||
asChild
|
||||
>
|
||||
<Link href={selectHotel(lang)} keepSearchParams>
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer} ref={listingContainerRef}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
className={styles.filterContainerCloseButton}
|
||||
asChild
|
||||
>
|
||||
<Link href={selectHotel(lang)} keepSearchParams>
|
||||
<CloseLargeIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<FilterAndSortModal filters={filterList} />
|
||||
</div>
|
||||
{showSkeleton ? (
|
||||
<>
|
||||
<RoomCardSkeleton />
|
||||
<RoomCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<HotelListing hotels={visibleHotels} />
|
||||
)}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton position="left" onClick={scrollToTop} />
|
||||
)}
|
||||
</div>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
hotelPins={filteredHotelPins}
|
||||
mapId={mapId}
|
||||
onTilesLoaded={debouncedUpdateHotelCards}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-4
@@ -14,10 +14,6 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
height: 44px;
|
||||
@@ -40,6 +36,7 @@
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
min-width: 420px;
|
||||
width: 420px;
|
||||
position: relative;
|
||||
}
|
||||
.container {
|
||||
@@ -49,5 +46,6 @@
|
||||
.filterContainer {
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 var(--Spacing-x1);
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export function getVisibleHotelPins(
|
||||
map: google.maps.Map | null,
|
||||
filteredHotelPins: HotelPin[]
|
||||
) {
|
||||
if (!map || !filteredHotelPins) return []
|
||||
|
||||
const bounds = map.getBounds()
|
||||
if (!bounds) return []
|
||||
|
||||
return filteredHotelPins.filter((pin) => {
|
||||
const { lat, lng } = pin.coordinates
|
||||
return bounds.contains({ lat, lng })
|
||||
})
|
||||
}
|
||||
|
||||
export function getVisibleHotels(
|
||||
hotels: HotelData[],
|
||||
filteredHotelPins: HotelPin[],
|
||||
map: google.maps.Map | null
|
||||
) {
|
||||
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
|
||||
const visibleHotels = hotels.filter((hotel) =>
|
||||
visibleHotelPins.some((pin) => pin.operaId === hotel.hotelData.operaId)
|
||||
)
|
||||
return visibleHotels
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
import SelectHotelMapContent from "./SelectHotelMapContent"
|
||||
|
||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import FilterAndSortModal from "../FilterAndSortModal"
|
||||
import HotelListing from "./HotelListing"
|
||||
|
||||
import styles from "./selectHotelMap.module.css"
|
||||
|
||||
import { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function SelectHotelMap({
|
||||
apiKey,
|
||||
@@ -28,109 +14,15 @@ export default function SelectHotelMap({
|
||||
filterList,
|
||||
cityCoordinates,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const selectHotelParams = new URLSearchParams(searchParams.toString())
|
||||
const selectedHotel = selectHotelParams.get("selectedHotel")
|
||||
|
||||
const coordinates = isAboveMobile
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||
|
||||
useEffect(() => {
|
||||
if (listingContainerRef.current) {
|
||||
const activeElement =
|
||||
listingContainerRef.current.querySelector(`[data-active="true"]`)
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
||||
}
|
||||
}
|
||||
}, [activeHotelPin])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedHotel) {
|
||||
setActiveHotelPin(selectedHotel)
|
||||
}
|
||||
}, [selectedHotel])
|
||||
|
||||
useEffect(() => {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
if (!hotelListingElement) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const hasScrolledPast = hotelListingElement.scrollTop > 490
|
||||
setShowBackToTop(hasScrolledPast)
|
||||
}
|
||||
|
||||
hotelListingElement.addEventListener("scroll", handleScroll)
|
||||
return () => hotelListingElement.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
function scrollToTop() {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
hotelListingElement?.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
function handlePageRedirect() {
|
||||
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
|
||||
}
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.closeButton}
|
||||
onClick={handlePageRedirect}
|
||||
>
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer} ref={listingContainerRef}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={handlePageRedirect}
|
||||
className={styles.filterContainerCloseButton}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<FilterAndSortModal filters={filterList} />
|
||||
</div>
|
||||
<HotelListing
|
||||
hotels={hotels}
|
||||
activeHotelPin={activeHotelPin}
|
||||
setActiveHotelPin={setActiveHotelPin}
|
||||
/>
|
||||
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||
</div>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
hotelPins={hotelPins}
|
||||
activeHotelPin={activeHotelPin}
|
||||
onActiveHotelPinChange={setActiveHotelPin}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</div>
|
||||
<SelectHotelMapContent
|
||||
hotelPins={hotelPins}
|
||||
cityCoordinates={cityCoordinates}
|
||||
mapId={mapId}
|
||||
hotels={hotels}
|
||||
filterList={filterList}
|
||||
/>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import { HotelCardSkeleton } from "../HotelCard/HotelCardSkeleton"
|
||||
|
||||
import styles from "./selectHotel.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SelectHotelSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.skeletonContainer}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.breadcrumbs}>
|
||||
<SkeletonShimmer height={"25px"} width={"400px"} />
|
||||
</div>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.cityInformation}>
|
||||
<SkeletonShimmer height={"25px"} width={"200px"} />
|
||||
</div>
|
||||
<div className={styles.sorter}>
|
||||
<SkeletonShimmer height={"60px"} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.sideBar}>
|
||||
<div className={styles.sideBarItem}>
|
||||
<SkeletonShimmer height={"280px"} width={"340px"} />
|
||||
</div>
|
||||
<div className={styles.sideBarItem}>
|
||||
<SkeletonShimmer height={"400px"} width={"340px"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hotelList}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<HotelCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import {
|
||||
selectHotel,
|
||||
selectHotelMap,
|
||||
} from "@/constants/routes/hotelReservation"
|
||||
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getFiltersFromHotels,
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import StaticMap from "@/components/Maps/StaticMap"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
|
||||
import HotelCardListing from "../HotelCardListing"
|
||||
import HotelCount from "./HotelCount"
|
||||
import HotelFilter from "./HotelFilter"
|
||||
import HotelSorter from "./HotelSorter"
|
||||
import MobileMapButtonContainer from "./MobileMapButtonContainer"
|
||||
|
||||
import styles from "./selectHotel.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type {
|
||||
TrackingChannelEnum,
|
||||
TrackingSDKHotelInfo,
|
||||
TrackingSDKPageData} from "@/types/components/tracking";
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export default async function SelectHotel({
|
||||
city,
|
||||
params,
|
||||
reservationParams,
|
||||
}: SelectHotelProps) {
|
||||
const {
|
||||
selectHotelParams,
|
||||
searchParams,
|
||||
adultsParams,
|
||||
childrenParams,
|
||||
child,
|
||||
} = reservationParams
|
||||
|
||||
const intl = await getIntl()
|
||||
|
||||
const hotelsPromise = safeTry(
|
||||
fetchAvailableHotels({
|
||||
cityId: city.id,
|
||||
roomStayStartDate: searchParams.fromDate,
|
||||
roomStayEndDate: searchParams.toDate,
|
||||
adults: adultsParams,
|
||||
children: childrenParams?.toString(),
|
||||
})
|
||||
)
|
||||
|
||||
const [hotels] = await hotelsPromise
|
||||
|
||||
const arrivalDate = new Date(searchParams.fromDate)
|
||||
const departureDate = new Date(searchParams.toDate)
|
||||
|
||||
const isCityWithCountry = (city: any): city is { country: string } =>
|
||||
"country" in city
|
||||
|
||||
const validHotels =
|
||||
hotels?.filter((hotel): hotel is HotelData => hotel !== null) || []
|
||||
|
||||
const filterList = getFiltersFromHotels(validHotels)
|
||||
const breadcrumbs = [
|
||||
{
|
||||
title: intl.formatMessage({ id: "Home" }),
|
||||
href: `/${params.lang}`,
|
||||
uid: "home-page",
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: "Hotel reservation" }),
|
||||
href: `/${params.lang}/hotelreservation`,
|
||||
uid: "hotel-reservation",
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: "Select hotel" }),
|
||||
href: `${selectHotel(params.lang)}/?${selectHotelParams}`,
|
||||
uid: "select-hotel",
|
||||
},
|
||||
{
|
||||
title: city.name,
|
||||
uid: city.id,
|
||||
},
|
||||
]
|
||||
|
||||
const isAllUnavailable = hotels?.every((hotel) => hotel.price === undefined)
|
||||
|
||||
const pageTrackingData: TrackingSDKPageData = {
|
||||
pageId: "select-hotel",
|
||||
domainLanguage: params.lang as Lang,
|
||||
channel: TrackingChannelEnum["hotelreservation"],
|
||||
pageName: "hotelreservation|select-hotel",
|
||||
siteSections: "hotelreservation|select-hotel",
|
||||
pageType: "bookinghotelspage",
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
const hotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
availableResults: validHotels.length,
|
||||
searchTerm: searchParams.city,
|
||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
noOfAdults: adultsParams,
|
||||
noOfChildren: child?.length,
|
||||
ageOfChildren: child?.map((c) => c.age).join(","),
|
||||
childBedPreference: child?.map((c) => ChildBedMapEnum[c.bed]).join("|"),
|
||||
noOfRooms: 1, // // TODO: Handle multiple rooms
|
||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
searchType: "destination",
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
country: validHotels?.[0].hotelData.address.country,
|
||||
region: validHotels?.[0].hotelData.address.city,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
<div className={styles.title}>
|
||||
<div className={styles.cityInformation}>
|
||||
<Subtitle>{city.name}</Subtitle>
|
||||
<HotelCount />
|
||||
</div>
|
||||
<div className={styles.sorter}>
|
||||
<HotelSorter discreet />
|
||||
</div>
|
||||
</div>
|
||||
<MobileMapButtonContainer filters={filterList} />
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.sideBar}>
|
||||
{hotels && hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap(params.lang)}
|
||||
keepSearchParams
|
||||
>
|
||||
<div className={styles.mapContainer}>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
country={isCityWithCountry(city) ? city.country : undefined}
|
||||
width={340}
|
||||
height={180}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
<Button wrapping size="medium" intent="text" theme="base">
|
||||
{intl.formatMessage({ id: "See map" })}
|
||||
<ChevronRightIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.mapContainer}>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
width={340}
|
||||
height={180}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<HotelFilter filters={filterList} className={styles.filter} />
|
||||
</div>
|
||||
<div className={styles.hotelList}>
|
||||
{isAllUnavailable && (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<HotelCardListing hotelData={validHotels} />
|
||||
</div>
|
||||
</main>
|
||||
<Suspense fallback={null}>
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
hotelInfo={hotelsTrackingData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.main {
|
||||
display: flex;
|
||||
padding: 0 var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2);
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: 100dvh;
|
||||
flex-direction: column;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cityInformation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sideBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sideBarItem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hotelList {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeletonContainer .title {
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main {
|
||||
padding: var(--Spacing-x5);
|
||||
}
|
||||
.header {
|
||||
display: block;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
|
||||
var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: block;
|
||||
max-width: var(--max-width-navigation);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
display: block;
|
||||
width: 339px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: var(--Spacing-x3) auto 0;
|
||||
display: flex;
|
||||
max-width: var(--max-width-navigation);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sideBar {
|
||||
max-width: 340px;
|
||||
}
|
||||
.sideBarItem {
|
||||
display: block;
|
||||
}
|
||||
.filter {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
padding-bottom: var(--Spacing-x6);
|
||||
}
|
||||
.mapContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
.main {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
.buttonContainer {
|
||||
display: none;
|
||||
}
|
||||
.skeletonContainer .title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.skeletonContainer .sideBar {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
.skeletonContainer .breadcrumbs {
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-navigation);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import SelectionCard from "../SelectionCard"
|
||||
|
||||
import styles from "./bedSelection.module.css"
|
||||
|
||||
import { BedSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import type { BedSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default function BedSelection({
|
||||
alternatives,
|
||||
|
||||
@@ -5,7 +5,7 @@ import SelectionCard from "../SelectionCard"
|
||||
|
||||
import styles from "./breakfastSelection.module.css"
|
||||
|
||||
import { BreakfastSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import type { BreakfastSelectionProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default function BreakfastSelection({
|
||||
alternatives,
|
||||
|
||||
@@ -5,7 +5,7 @@ import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import { DetailsProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import type { DetailsProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
export default function Details({ nextPath }: DetailsProps) {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getRoomsAvailability } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
@@ -10,8 +9,9 @@ import { generateChildrenString } from "../RoomSelection/utils"
|
||||
|
||||
import styles from "./NoRoomsAlert.module.css"
|
||||
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
type Props = {
|
||||
hotelId: number
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
@@ -19,7 +18,8 @@ import { NoRoomsAlert } from "./NoRoomsAlert"
|
||||
|
||||
import styles from "./hotelInfoCard.module.css"
|
||||
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
type Props = {
|
||||
hotelId: number
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
@@ -30,17 +31,22 @@ export default function RoomFilter({
|
||||
const isTabletAndUp = useMediaQuery("(min-width: 768px)")
|
||||
const [isAboveMobile, setIsAboveMobile] = useState(false)
|
||||
|
||||
const initialFilterValues = useMemo(
|
||||
() =>
|
||||
filterOptions.reduce(
|
||||
(acc, option) => {
|
||||
acc[option.code] = false
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, boolean | undefined>
|
||||
),
|
||||
[filterOptions]
|
||||
)
|
||||
const searchParams = useSearchParams()
|
||||
const initialFilterValues = useMemo(() => {
|
||||
const packagesFromSearchParams =
|
||||
searchParams.get("room[0].packages")?.split(",") ?? []
|
||||
|
||||
const values = filterOptions.reduce(
|
||||
(acc, option) => {
|
||||
acc[option.code] = packagesFromSearchParams.includes(option.code)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, boolean | undefined>
|
||||
)
|
||||
|
||||
onFilter(values)
|
||||
return values
|
||||
}, [filterOptions, onFilter, searchParams])
|
||||
|
||||
const intl = useIntl()
|
||||
const methods = useForm<Record<string, boolean | undefined>>({
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { calculatePricesPerNight } from "./utils"
|
||||
|
||||
import styles from "./priceList.module.css"
|
||||
|
||||
import { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
import type { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function PriceList({
|
||||
publicPrice = {},
|
||||
|
||||
@@ -11,7 +11,7 @@ import PriceTable from "./PriceList"
|
||||
|
||||
import styles from "./flexibilityOption.module.css"
|
||||
|
||||
import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOption({
|
||||
product,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -72,15 +74,7 @@ export default function RateSummary({
|
||||
|
||||
return (
|
||||
<div className={styles.summary} data-visible={isVisible}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.memberDiscountBannerMobile}>
|
||||
<Footnote color="burgundy">
|
||||
{intl.formatMessage({
|
||||
id: "Join or log in while booking for member pricing.",
|
||||
})}
|
||||
</Footnote>
|
||||
</div>
|
||||
)}
|
||||
{showMemberDiscountBanner && <SignupPromoMobile />}
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryText}>
|
||||
<Subtitle color="uiTextHighContrast">{roomType}</Subtitle>
|
||||
@@ -88,23 +82,13 @@ export default function RateSummary({
|
||||
</div>
|
||||
<div className={styles.summaryPriceContainer}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.memberDiscountBannerDesktop}>
|
||||
<Footnote color="burgundy">
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.",
|
||||
},
|
||||
{
|
||||
span: (str) => (
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{str}</span>
|
||||
</Caption>
|
||||
),
|
||||
amount: member.localPrice.pricePerStay,
|
||||
currency: member.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: member.localPrice.pricePerStay,
|
||||
currency: member.localPrice.currency,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
|
||||
+8
-26
@@ -33,7 +33,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.promoContainer {
|
||||
display: none;
|
||||
max-width: 264px;
|
||||
}
|
||||
.summaryPrice {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: var(--Spacing-x4);
|
||||
@@ -50,6 +55,7 @@
|
||||
}
|
||||
|
||||
.summaryPriceTextDesktop {
|
||||
align-self: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -64,27 +70,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memberDiscountBannerDesktop {
|
||||
display: none;
|
||||
background: var(--Primary-Light-Surface-Normal);
|
||||
border-radius: var(--Corner-radius-xLarge) var(--Corner-radius-xLarge) 0px
|
||||
var(--Corner-radius-xLarge);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
gap: var(--Spacing-x2);
|
||||
max-width: 264px;
|
||||
}
|
||||
|
||||
.memberDiscountBannerMobile {
|
||||
width: 100%;
|
||||
background: var(--Primary-Light-Surface-Normal);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.summary {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5);
|
||||
@@ -93,15 +78,12 @@
|
||||
flex-direction: row;
|
||||
}
|
||||
.petInfo,
|
||||
.promoContainer,
|
||||
.summaryText,
|
||||
.summaryPriceTextDesktop {
|
||||
display: block;
|
||||
}
|
||||
.memberDiscountBannerDesktop {
|
||||
display: flex;
|
||||
}
|
||||
.summaryPriceTextMobile,
|
||||
.memberDiscountBannerMobile {
|
||||
.summaryPriceTextMobile {
|
||||
display: none;
|
||||
}
|
||||
.summaryPrice,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { createElement, useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RateDefinition } from "@/server/routers/hotels/output"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
@@ -20,9 +18,12 @@ import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||
import type { RateDefinition } from "@/server/routers/hotels/output"
|
||||
|
||||
export default function RoomCard({
|
||||
hotelId,
|
||||
hotelType,
|
||||
rateDefinitions,
|
||||
roomConfiguration,
|
||||
roomCategories,
|
||||
@@ -59,17 +60,14 @@ export default function RoomCard({
|
||||
}
|
||||
|
||||
const getBreakfastMessage = (rate: RateDefinition | undefined) => {
|
||||
const breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded
|
||||
switch (breakfastIncluded) {
|
||||
case true:
|
||||
return intl.formatMessage({ id: "Breakfast is included." })
|
||||
case false:
|
||||
return intl.formatMessage({ id: "Breakfast selection in next step." })
|
||||
default:
|
||||
return intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
})
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
})
|
||||
}
|
||||
return getRateDefinitionForRate(rate)?.breakfastIncluded
|
||||
? intl.formatMessage({ id: "Breakfast is included." })
|
||||
: intl.formatMessage({ id: "Breakfast selection in next step." })
|
||||
}
|
||||
|
||||
const petRoomPackage =
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function RoomSelection({
|
||||
selectedPackages,
|
||||
setRateCode,
|
||||
rateSummary,
|
||||
hotelType,
|
||||
}: RoomSelectionProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -67,6 +68,7 @@ export default function RoomSelection({
|
||||
<li key={roomConfiguration.roomTypeCode}>
|
||||
<RoomCard
|
||||
hotelId={roomsAvailability.hotelId.toString()}
|
||||
hotelType={hotelType}
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomCategories={roomCategories}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ChildBedTypeEnum } from "@/constants/booking"
|
||||
import { getFormattedUrlQueryParams } from "@/utils/url"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type {
|
||||
Child,
|
||||
SelectRateSearchParams,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getHotelData,
|
||||
@@ -13,7 +12,8 @@ import { generateChildrenString } from "../RoomSelection/utils"
|
||||
import Rooms from "."
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export type Props = {
|
||||
hotelId: number
|
||||
@@ -96,6 +96,7 @@ export async function RoomsContainer({
|
||||
availablePackages={packages ?? []}
|
||||
roomsAvailability={roomsAvailability}
|
||||
roomCategories={hotelData?.included ?? []}
|
||||
hotelType={hotelData?.data.attributes?.hotelType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { filterDuplicateRoomTypesByLowestPrice } from "./utils"
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import {
|
||||
DefaultFilterOptions,
|
||||
type DefaultFilterOptions,
|
||||
RoomPackageCodeEnum,
|
||||
type RoomPackageCodes,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
@@ -25,6 +25,7 @@ export default function Rooms({
|
||||
roomCategories = [],
|
||||
user,
|
||||
availablePackages,
|
||||
hotelType,
|
||||
}: SelectRateProps) {
|
||||
const visibleRooms: RoomConfiguration[] =
|
||||
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
|
||||
@@ -34,29 +35,33 @@ export default function Rooms({
|
||||
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
|
||||
[]
|
||||
)
|
||||
const defaultPackages: DefaultFilterOptions[] = [
|
||||
{
|
||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
description: "Accessible Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
description: "Allergy Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.PET_ROOM,
|
||||
description: "Pet Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
]
|
||||
|
||||
const defaultPackages: DefaultFilterOptions[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
description: "Accessible Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
description: "Allergy Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.PET_ROOM,
|
||||
description: "Pet Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
],
|
||||
[availablePackages]
|
||||
)
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(filter: Record<RoomPackageCodeEnum, boolean | undefined>) => {
|
||||
@@ -165,6 +170,7 @@ export default function Rooms({
|
||||
selectedPackages={selectedPackages}
|
||||
setRateCode={setSelectedRate}
|
||||
rateSummary={rateSummary}
|
||||
hotelType={hotelType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoomConfiguration } from "@/server/routers/hotels/output"
|
||||
import type { RoomConfiguration } from "@/server/routers/hotels/output"
|
||||
|
||||
/**
|
||||
* Get the lowest priced room for each room type that appears more than once.
|
||||
|
||||
@@ -6,7 +6,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./selectionCard.module.css"
|
||||
|
||||
import { SelectionCardProps } from "@/types/components/hotelReservation/selectRate/selectionCard"
|
||||
import type { SelectionCardProps } from "@/types/components/hotelReservation/selectRate/selectionCard"
|
||||
|
||||
export default function SelectionCard({
|
||||
price,
|
||||
|
||||
@@ -7,7 +7,7 @@ import HotelSidePeek from "@/components/SidePeeks/HotelSidePeek"
|
||||
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { HotelData } from "@/types/hotel"
|
||||
import type { HotelData } from "@/types/hotel"
|
||||
|
||||
export default function HotelReservationSidePeek({
|
||||
hotel,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./signupPromo.module.css"
|
||||
|
||||
import type { SignupPromoProps } from "@/types/components/hotelReservation/signupPromo"
|
||||
|
||||
export default function SignupPromoDesktop({
|
||||
memberPrice,
|
||||
badgeContent,
|
||||
}: SignupPromoProps) {
|
||||
const intl = useIntl()
|
||||
if (!memberPrice) {
|
||||
return null
|
||||
}
|
||||
const { amount, currency } = memberPrice
|
||||
const price = intl.formatNumber(amount, { currency, style: "currency" })
|
||||
|
||||
return memberPrice ? (
|
||||
<div className={styles.memberDiscountBannerDesktop}>
|
||||
{badgeContent && <span className={styles.badge}>{badgeContent}</span>}
|
||||
<Footnote color="burgundy">
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "To get the member price <span>{price}</span>, log in or join when completing the booking.",
|
||||
},
|
||||
{
|
||||
span: (str) => (
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{str}</span>
|
||||
</Caption>
|
||||
),
|
||||
price,
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./signupPromo.module.css"
|
||||
|
||||
export default function SignupPromoMobile() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.memberDiscountBannerMobile}>
|
||||
<Footnote color="burgundy">
|
||||
{intl.formatMessage({
|
||||
id: "Join or log in while booking for member pricing.",
|
||||
})}
|
||||
</Footnote>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.memberDiscountBannerMobile {
|
||||
width: 100%;
|
||||
background: var(--Primary-Light-Surface-Normal);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.memberDiscountBannerDesktop {
|
||||
display: none;
|
||||
background: var(--Primary-Light-Surface-Normal);
|
||||
border-radius: var(--Corner-radius-xLarge) var(--Corner-radius-xLarge) 0px
|
||||
var(--Corner-radius-xLarge);
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: -12px;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: 50%;
|
||||
font-size: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.memberDiscountBannerMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.memberDiscountBannerDesktop {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user