Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-11-25 10:14:12 +01:00
181 changed files with 3840 additions and 1723 deletions
@@ -2,8 +2,7 @@
import { useIntl } from "react-intl"
import FacebookIcon from "@/components/Icons/Facebook"
import InstagramIcon from "@/components/Icons/Instagram"
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
@@ -76,6 +76,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
subtitle={width}
title={roomType.description}
value={roomType.value}
handleSelectedOnClick={
bedType === roomType.value ? completeStep : undefined
}
/>
)
})}
@@ -97,6 +97,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
handleSelectedOnClick={
breakfast === pkg.code ? completeStep : undefined
}
/>
))}
<RadioCard
@@ -113,6 +116,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
handleSelectedOnClick={
breakfast === "false" ? completeStep : undefined
}
/>
</form>
</FormProvider>
@@ -55,4 +55,8 @@ export const signedInDetailsSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
phoneNumber: z.string().optional(),
join: z
.boolean()
.optional()
.transform((_) => false),
})
@@ -0,0 +1,42 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { detailsStorageName } from "@/stores/details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsState } from "@/types/stores/details"
export default function PaymentCallback({
returnUrl,
searchObject,
}: {
returnUrl: string
searchObject: URLSearchParams
}) {
const router = useRouter()
useEffect(() => {
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
> = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.data.booking,
searchObject
)
if (searchParams.size > 0) {
router.replace(`${returnUrl}?${searchParams.toString()}`)
}
}
}, [returnUrl, router, searchObject])
return <LoadingSpinner />
}
@@ -51,6 +51,7 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
}
export default function Payment({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
@@ -59,7 +60,6 @@ export default function Payment({
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const queryParams = useSearchParams()
const { booking, ...userData } = useDetailsStore((state) => state.data)
const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
@@ -163,9 +163,6 @@ export default function Payment({
])
function handleSubmit(data: PaymentFormData) {
const allQueryParams =
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
@@ -175,6 +172,8 @@ export default function Payment({
(card) => card.id === data.paymentMethod
)
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
@@ -185,7 +184,8 @@ export default function Payment({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode: room.rateCode,
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
title: "",
@@ -222,9 +222,9 @@ export default function Payment({
}
: undefined,
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
}
@@ -0,0 +1,26 @@
"use client"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { detailsStorageName } from "@/stores/details"
import useLang from "@/hooks/useLang"
/**
* Cleanup component to make sure no stale data is left
* from previous booking when user is not in the booking
* flow anymore
*/
export default function StorageCleaner() {
const lang = useLang()
const pathname = usePathname()
useEffect(() => {
if (!pathname.startsWith(hotelreservation(lang))) {
sessionStorage.removeItem(detailsStorageName)
}
}, [lang, pathname])
return null
}
@@ -38,7 +38,7 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price),
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
@@ -33,6 +33,8 @@ function storeSelector(state: DetailsState) {
toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice,
join: state.data.join,
membershipNo: state.data.membershipNo,
}
}
@@ -51,6 +53,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
toDate,
toggleSummaryOpen,
totalPrice,
join,
membershipNo,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
@@ -60,10 +64,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
{ totalNights: diff }
)
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
if (showMemberPrice) {
color = "red"
}
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
const [price, setPrice] = useState(room.prices.public)
const additionalPackageCost = room.packages?.reduce(
(acc, curr) => {
@@ -74,11 +76,23 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
{ local: 0, euro: 0 }
) || { local: 0, euro: 0 }
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
const roomsPriceEuro = room.euroPrice
? room.euroPrice.price + additionalPackageCost.euro
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
const roomsPriceEuro = price.euro
? price.euro.amount + additionalPackageCost.euro
: undefined
useEffect(() => {
if (showMemberPrice || join || membershipNo) {
color.current = "red"
if (room.prices.member) {
setPrice(room.prices.member)
}
} else {
color.current = "uiTextHighContrast"
setPrice(room.prices.public)
}
}, [showMemberPrice, join, membershipNo, room.prices])
useEffect(() => {
setChosenBed(bedType)
@@ -87,30 +101,30 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
if (breakfast === false) {
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
amount: roomsPriceLocal,
currency: price.local.currency,
},
euro:
room.euroPrice && roomsPriceEuro
price.euro && roomsPriceEuro
? {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
amount: roomsPriceEuro,
currency: price.euro.currency,
}
: undefined,
})
} else {
setTotalPrice({
local: {
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: room.localPrice.currency,
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: price.local.currency,
},
euro:
room.euroPrice && roomsPriceEuro
price.euro && roomsPriceEuro
? {
price:
amount:
roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency,
currency: price.euro.currency,
}
: undefined,
})
@@ -120,8 +134,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
bedType,
breakfast,
roomsPriceLocal,
room.localPrice.currency,
room.euroPrice,
price.local.currency,
price.euro,
roomsPriceEuro,
setTotalPrice,
])
@@ -151,12 +165,12 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Caption color={color}>
<Caption color={color.current}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(room.localPrice.price),
currency: room.localPrice.currency,
amount: intl.formatNumber(price.local.amount),
currency: price.local.currency,
}
)}
</Caption>
@@ -229,7 +243,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
@@ -243,7 +257,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
@@ -279,22 +293,24 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
</Link>
</div>
<div>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price),
currency: totalPrice.local.currency,
}
)}
</Body>
{totalPrice.euro && (
{totalPrice.local.amount > 0 && (
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
</Body>
)}
{totalPrice.euro && totalPrice.euro.amount > 0 && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.price),
amount: intl.formatNumber(totalPrice.euro.amount),
currency: totalPrice.euro.currency,
}
)}
@@ -0,0 +1,24 @@
import { useIntl } from "react-intl"
import { ErrorCircleIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "../hotelPriceList.module.css"
export default function NoPriceAvailableCard() {
const intl = useIntl()
return (
<div className={styles.priceCard}>
<div className={styles.noRooms}>
<div>
<ErrorCircleIcon color="red" />
</div>
<Body>
{intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
</Body>
</div>
</div>
)
}
@@ -40,6 +40,6 @@
@media screen and (min-width: 1367px) {
.prices {
max-width: 260px;
width: 260px;
}
}
@@ -10,6 +10,7 @@ import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import styles from "./hotelPriceList.module.css"
@@ -48,18 +49,7 @@ export default function HotelPriceList({
</Button>
</>
) : (
<div className={styles.priceCard}>
<div className={styles.noRooms}>
<div>
<ErrorCircleIcon color="red" />
</div>
<Body>
{intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
</Body>
</div>
</div>
<NoPriceAvailableCard />
)}
</div>
)
@@ -122,11 +122,6 @@
margin-bottom: var(--Spacing-x-one-and-half);
}
.pageListing .prices {
align-items: center;
width: 260px;
}
.pageListing .button {
width: 100%;
}
@@ -3,11 +3,10 @@ import { useParams } from "next/dist/client/components/navigation"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
@@ -48,7 +48,7 @@
.content {
width: 100%;
min-width: 201px;
min-width: 220px;
padding: var(--Spacing-x-one-and-half);
gap: var(--Spacing-x1);
display: flex;
@@ -67,12 +67,32 @@
gap: var(--Spacing-x-half);
}
.prices {
.priceCard {
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x-half) var(--Spacing-x1);
background: var(--Base-Surface-Secondary-light-Normal);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.imagePlaceholder {
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 120px 120px;
background-position:
0 0,
0 60px,
60px -60px,
-60px 0;
}
.perNight {
@@ -1,14 +1,14 @@
"use client"
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"
import { CloseLargeIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { CloseLargeIcon, TripAdvisorIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
@@ -17,6 +17,8 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import NoPriceAvailableCard from "../HotelCard/HotelPriceList/NoPriceAvailableCard"
import styles from "./hotelCardDialog.module.css"
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
@@ -29,6 +31,7 @@ export default function HotelCardDialog({
const params = useParams()
const lang = params.lang as Lang
const intl = useIntl()
const [imageError, setImageError] = useState(false)
if (!data) {
return null
@@ -57,7 +60,16 @@ export default function HotelCardDialog({
height={16}
/>
<div className={styles.imageContainer}>
<Image src={firstImage} alt={altText} fill />
{!firstImage || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={firstImage}
alt={altText}
fill
onError={() => setImageError(true)}
/>
)}
<div className={styles.tripAdvisor}>
<Chip intent="secondary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
@@ -85,32 +97,50 @@ export default function HotelCardDialog({
})}
</div>
<div className={styles.prices}>
<Caption type="bold">{intl.formatMessage({ id: "From" })}</Caption>
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{memberPrice && (
<Subtitle type="two" color="red" className={styles.memberPrice}>
{memberPrice} {currency}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{publicPrice || memberPrice ? (
<>
<div className={styles.priceCard}>
<Caption type="bold">
{intl.formatMessage({ id: "From" })}
</Caption>
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{memberPrice && (
<Subtitle
type="two"
color="red"
className={styles.memberPrice}
>
{memberPrice} {currency}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
)}
</div>
<Button
asChild
theme="base"
size="small"
className={styles.button}
>
<Link
href={`${selectRate(lang)}?hotel=${data.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</>
) : (
<NoPriceAvailableCard />
)}
</div>
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate(lang)}?hotel=${data.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</div>
</div>
</dialog>
@@ -60,7 +60,7 @@ export default function HotelCardDialogListing({
const elements = document.querySelectorAll("[data-name]")
setTimeout(() => {
elements.forEach((el) => observerRef.current?.observe(el))
}, 500)
}, 1000)
}
}, [activeCard])
@@ -15,7 +15,12 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
amenities: hotel.hotelData.detailedFacilities
.map((facility) => ({
...facility,
icon: facility.icon ?? "None",
}))
.slice(0, 3),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
operaId: hotel.hotelData.operaId,
}))
@@ -1,9 +1,11 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import HotelCard from "../HotelCard"
@@ -17,6 +19,7 @@ import {
type HotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelCardListing({
hotelData,
@@ -28,6 +31,7 @@ export default function HotelCardListing({
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const intl = useIntl()
const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -69,7 +73,6 @@ export default function HotelCardListing({
const hotels = useMemo(() => {
if (activeFilters.length === 0) {
setResultCount(sortedHotels.length)
return sortedHotels
}
@@ -81,9 +84,8 @@ export default function HotelCardListing({
)
)
setResultCount(filteredHotels.length)
return filteredHotels
}, [activeFilters, sortedHotels, setResultCount])
}, [activeFilters, sortedHotels])
useEffect(() => {
const handleScroll = () => {
@@ -95,23 +97,33 @@ export default function HotelCardListing({
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
setResultCount(hotels ? hotels.length : 0)
}, [hotels, setResultCount])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return (
<section className={styles.hotelCards}>
{hotels?.length
? hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
: null}
{hotels?.length ? (
hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
) : activeFilters ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "filters.nohotel.heading" })}
text={intl.formatMessage({ id: "filters.nohotel.text" })}
/>
) : null}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
</section>
)
@@ -11,7 +11,7 @@
left: 0;
right: 0;
z-index: 10;
height: 280px;
height: 100%;
gap: var(--Spacing-x1);
}
@@ -7,7 +7,7 @@ import { useMediaQuery } from "usehooks-ts"
import { selectHotel } from "@/constants/routes/hotelReservation"
import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button"
@@ -15,7 +15,6 @@ import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import HotelListing from "./HotelListing"
import { getCentralCoordinates } from "./utils"
import styles from "./selectHotelMap.module.css"
@@ -27,6 +26,7 @@ export default function SelectHotelMap({
mapId,
hotels,
filterList,
cityCoordinates,
}: SelectHotelMapProps) {
const searchParams = useSearchParams()
const router = useRouter()
@@ -36,15 +36,13 @@ export default function SelectHotelMap({
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const centralCoordinates = getCentralCoordinates(hotelPins)
const coordinates = isAboveMobile
? centralCoordinates
: { ...centralCoordinates, lat: centralCoordinates.lat - 0.006 }
const selectHotelParams = new URLSearchParams(searchParams.toString())
const selectedHotel = selectHotelParams.get("selectedHotel")
const coordinates = isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
useEffect(() => {
if (selectedHotel) {
setActiveHotelPin(selectedHotel)
@@ -1,17 +0,0 @@
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export function getCentralCoordinates(hotels: HotelPin[]) {
const centralCoordinates = hotels.reduce(
(acc, pin) => {
acc.lat += pin.coordinates.lat
acc.lng += pin.coordinates.lng
return acc
},
{ lat: 0, lng: 0 }
)
centralCoordinates.lat /= hotels.length
centralCoordinates.lng /= hotels.length
return centralCoordinates
}
@@ -0,0 +1,5 @@
.hotelAlert {
max-width: var(--max-width-navigation);
margin: 0 auto;
padding-top: var(--Spacing-x-one-and-half);
}
@@ -0,0 +1,69 @@
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { getRoomAvailability } from "@/lib/trpc/memoizedRequests"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { generateChildrenString } from "../RoomSelection/utils"
import styles from "./NoRoomsAlert.module.css"
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import { AlertTypeEnum } from "@/types/enums/alert"
type Props = {
hotelId: number
lang: Lang
adultCount: number
childArray: Child[]
fromDate: Date
toDate: Date
}
export async function NoRoomsAlert({
hotelId,
fromDate,
toDate,
childArray,
adultCount,
lang,
}: Props) {
const [availability, availabilityError] = await safeTry(
getRoomAvailability({
hotelId: hotelId,
roomStayStartDate: dt(fromDate).format("YYYY-MM-DD"),
roomStayEndDate: dt(toDate).format("YYYY-MM-DD"),
adults: adultCount,
children: generateChildrenString(childArray), // TODO: Handle multiple rooms,
})
)
if (!availability || availabilityError) {
return null
}
const noRoomsAvailable = availability.roomConfigurations.reduce(
(acc, room) => {
return acc && room.status === "NotAvailable"
},
true
)
if (!noRoomsAvailable) {
return null
}
const intl = await getIntl(lang)
return (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "There are no rooms available that match your request",
})}
/>
</div>
)
}
@@ -1,8 +1,7 @@
"use client"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { Suspense } from "react"
import useRoomAvailableStore from "@/stores/roomAvailability"
import { Lang } from "@/constants/languages"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
@@ -11,39 +10,42 @@ import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import ReadMore from "../../ReadMore"
import TripAdvisorChip from "../../TripAdvisorChip"
import { NoRoomsAlert } from "./NoRoomsAlert"
import styles from "./hotelInfoCard.module.css"
import type { HotelInfoCardProps } from "@/types/components/hotelReservation/selectRate/hotelInfoCardProps"
import { AlertTypeEnum } from "@/types/enums/alert"
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
type Props = {
hotelId: number
lang: Lang
fromDate: Date
toDate: Date
adultCount: number
childArray: Child[]
}
export default async function HotelInfoCard({
hotelId,
lang,
...props
}: Props) {
const hotelData = await getHotelData({
hotelId: hotelId.toString(),
language: lang,
})
export default function HotelInfoCard({
hotelData,
noAvailability = false,
}: HotelInfoCardProps) {
const hotelAttributes = hotelData?.data.attributes
const intl = useIntl()
const noRoomsAvailable = useRoomAvailableStore(
(state) => state.noRoomsAvailable
)
const setNoRoomsAvailable = useRoomAvailableStore(
(state) => state.setNoRoomsAvailable
)
const intl = await getIntl()
const sortedFacilities = hotelAttributes?.detailedFacilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.slice(0, 5)
useEffect(() => {
if (noAvailability) {
setNoRoomsAvailable()
}
}, [noAvailability, setNoRoomsAvailable])
return (
<article className={styles.container}>
{hotelAttributes && (
@@ -117,16 +119,10 @@ export default function HotelInfoCard({
</div>
)
})}
{noRoomsAvailable ? (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "There are no rooms available that match your request",
})}
/>
</div>
) : null}
<Suspense fallback={null} key={hotelId}>
<NoRoomsAlert hotelId={hotelId} lang={lang} {...props} />
</Suspense>
</article>
)
}
@@ -46,15 +46,10 @@ export default function FlexibilityOption({
const { public: publicPrice, member: memberPrice } = product.productType
function onChange() {
const rate = {
roomTypeCode,
roomType,
priceName: name,
public: publicPrice,
member: memberPrice,
features: petRoomPackage ? features : [],
}
handleSelectRate(rate)
handleSelectRate({
publicRateCode: publicPrice.rateCode,
roomTypeCode: roomTypeCode,
})
}
return (
@@ -0,0 +1,26 @@
.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%;
}
.priceVariants {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
}
@@ -0,0 +1,21 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./RoomCardSkeleton.module.css"
export function RoomCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 3 }).map((_, index) => (
<SkeletonShimmer key={index} height={"100px"} />
))}
</div>
</article>
)
}
@@ -176,7 +176,7 @@ export default function RoomCard({
<Subtitle className={styles.name} type="two">
{roomConfiguration.roomType}
</Subtitle>
{/* Out of scope for now
{/* Out of scope for now
<Body>{descriptions?.short}</Body>
*/}
</div>
@@ -16,7 +16,7 @@ export default function RoomSelection({
user,
availablePackages,
selectedPackages,
setRateSummary,
setRateCode,
rateSummary,
}: RoomSelectionProps) {
const router = useRouter()
@@ -70,7 +70,7 @@ export default function RoomSelection({
rateDefinitions={rateDefinitions}
roomConfiguration={roomConfiguration}
roomCategories={roomCategories}
handleSelectRate={setRateSummary}
handleSelectRate={setRateCode}
selectedPackages={selectedPackages}
packages={availablePackages}
/>
@@ -50,6 +50,54 @@ export function getQueryParamsForEnterDetails(
roomTypeCode: room.roomtype,
rateCode: room.ratecode,
packages: room.packages?.split(",") as RoomPackageCodeEnum[],
counterRateCode: room.counterratecode,
})),
}
}
export function createQueryParamsForEnterDetails(
bookingData: BookingData,
intitalSearchParams: URLSearchParams
) {
const { hotel, fromDate, toDate, rooms } = bookingData
const bookingSearchParams = new URLSearchParams({ hotel, fromDate, toDate })
const searchParams = new URLSearchParams([
...intitalSearchParams,
...bookingSearchParams,
])
rooms.forEach((item, index) => {
if (item?.adults) {
searchParams.set(`room[${index}].adults`, item.adults.toString())
}
if (item?.children) {
item.children.forEach((child, childIndex) => {
searchParams.set(
`room[${index}].child[${childIndex}].age`,
child.age.toString()
)
searchParams.set(
`room[${index}].child[${childIndex}].bed`,
child.bed.toString()
)
})
}
if (item?.roomTypeCode) {
searchParams.set(`room[${index}].roomtype`, item.roomTypeCode)
}
if (item?.rateCode) {
searchParams.set(`room[${index}].ratecode`, item.rateCode)
}
if (item?.counterRateCode) {
searchParams.set(`room[${index}].counterratecode`, item.counterRateCode)
}
if (item.packages && item.packages.length > 0) {
searchParams.set(`room[${index}].packages`, item.packages.join(","))
}
})
return searchParams
}
@@ -0,0 +1,99 @@
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import {
getHotelData,
getPackages,
getProfileSafely,
getRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import { safeTry } from "@/utils/safeTry"
import { generateChildrenString } from "../RoomSelection/utils"
import Rooms from "."
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
export type Props = {
hotelId: number
fromDate: Date
toDate: Date
adultCount: number
childArray: Child[]
lang: Lang
}
export async function RoomsContainer({
hotelId,
fromDate,
toDate,
adultCount,
childArray,
lang,
}: Props) {
const user = await getProfileSafely()
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).format("YYYY-MM-DD")
const hotelDataPromise = safeTry(
getHotelData({ hotelId: hotelId.toString(), language: lang })
)
const packagesPromise = safeTry(
getPackages({
hotelId: hotelId.toString(),
startDate: fromDateString,
endDate: toDateString,
adults: adultCount,
children: childArray.length > 0 ? childArray.length : undefined,
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
],
})
)
const roomsAvailabilityPromise = safeTry(
getRoomAvailability({
hotelId: hotelId,
roomStayStartDate: fromDateString,
roomStayEndDate: toDateString,
adults: adultCount,
children:
childArray.length > 0 ? generateChildrenString(childArray) : undefined,
})
)
const [hotelData, hotelDataError] = await hotelDataPromise
const [packages, packagesError] = await packagesPromise
const [roomsAvailability, roomsAvailabilityError] =
await roomsAvailabilityPromise
if (packagesError) {
// TODO: Log packages error
console.error("[RoomsContainer] unable to fetch packages")
}
if (roomsAvailabilityError) {
// TODO: show proper error component
console.error("[RoomsContainer] unable to fetch room availability")
return null
}
if (!roomsAvailability) {
// HotelInfoCard has the logic for displaying when there are no rooms available
return null
}
return (
<Rooms
user={user}
availablePackages={packages ?? []}
roomsAvailability={roomsAvailability}
roomCategories={hotelData?.included ?? []}
/>
)
}
@@ -0,0 +1,24 @@
.container {
padding: var(--Spacing-x2);
margin: 0 auto;
max-width: var(--max-width);
}
.filterContainer {
height: 38px;
}
.skeletonContainer {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
/* used to hide overflowing rows */
grid-template-rows: auto;
grid-auto-rows: 0;
overflow: hidden;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 20px;
gap: var(--Spacing-x2);
}
@@ -0,0 +1,20 @@
import { RoomCardSkeleton } from "../RoomSelection/RoomCard/RoomCardSkeleton"
import styles from "./RoomsContainerSkeleton.module.css"
type Props = {
count?: number
}
export async function RoomsContainerSkeleton({ count = 4 }: Props) {
return (
<div className={styles.container}>
<div className={styles.filterContainer}></div>
<div className={styles.skeletonContainer}>
{Array.from({ length: count }).map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
</div>
)
}
@@ -1,8 +1,6 @@
"use client"
import { useCallback, useState } from "react"
import useRoomAvailableStore from "@/stores/roomAvailability"
import { useCallback, useEffect, useMemo, useState } from "react"
import RoomFilter from "../RoomFilter"
import RoomSelection from "../RoomSelection"
@@ -17,10 +15,7 @@ import {
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import type {
RoomConfiguration,
RoomsAvailability,
} from "@/server/routers/hotels/output"
import type { RoomConfiguration } from "@/server/routers/hotels/output"
export default function Rooms({
roomsAvailability,
@@ -30,24 +25,12 @@ export default function Rooms({
}: SelectRateProps) {
const visibleRooms: RoomConfiguration[] =
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
const [rateSummary, setRateSummary] = useState<Rate | null>(null)
const [rooms, setRooms] = useState<RoomsAvailability>({
...roomsAvailability,
roomConfigurations: visibleRooms,
})
const [selectedRate, setSelectedRate] = useState<
{ publicRateCode: string; roomTypeCode: string } | undefined
>(undefined)
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
[]
)
const noRoomsAvailable = useRoomAvailableStore(
(state) => state.noRoomsAvailable
)
const setNoRoomsAvailable = useRoomAvailableStore(
(state) => state.setNoRoomsAvailable
)
const setRoomsAvailable = useRoomAvailableStore(
(state) => state.setRoomsAvailable
)
const defaultPackages: DefaultFilterOptions[] = [
{
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
@@ -79,75 +62,78 @@ export default function Rooms({
) as RoomPackageCodeEnum[]
setSelectedPackages(filteredPackages)
if (filteredPackages.length === 0) {
setRooms({
...roomsAvailability,
roomConfigurations: visibleRooms,
})
if (!!rateSummary) {
setRateSummary({
...rateSummary,
features: [],
})
}
if (noRoomsAvailable) {
setRoomsAvailable()
}
return
}
const filteredRooms = visibleRooms.filter((room) =>
filteredPackages.every((filteredPackage) =>
room.features.some((feature) => feature.code === filteredPackage)
)
)
setRooms({
...roomsAvailability,
roomConfigurations: [...filteredRooms],
})
if (filteredRooms.length == 0) {
setNoRoomsAvailable()
} else if (noRoomsAvailable) {
setRoomsAvailable()
}
const petRoomPackage =
(filteredPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)) ||
undefined
const features = filteredRooms.find((room) =>
room.features.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
)
)?.features
if (!!rateSummary) {
setRateSummary({
...rateSummary,
features: petRoomPackage && features ? features : [],
})
}
},
[
roomsAvailability,
visibleRooms,
rateSummary,
availablePackages,
noRoomsAvailable,
setNoRoomsAvailable,
setRoomsAvailable,
]
[]
)
const filteredRooms = useMemo(() => {
return visibleRooms.filter((room) =>
selectedPackages.every((filteredPackage) =>
room.features.some((feature) => feature.code === filteredPackage)
)
)
}, [visibleRooms, selectedPackages])
const rooms = useMemo(() => {
if (selectedPackages.length === 0) {
return {
...roomsAvailability,
roomConfigurations: visibleRooms,
}
}
return {
...roomsAvailability,
roomConfigurations: [...filteredRooms],
}
}, [roomsAvailability, visibleRooms, selectedPackages, filteredRooms])
const rateSummary: Rate | null = useMemo(() => {
const room = filteredRooms.find(
(room) => room.roomTypeCode === selectedRate?.roomTypeCode
)
if (!room) return null
const product = room.products.find(
(product) =>
product.productType.public.rateCode === selectedRate?.publicRateCode
)
if (!product) return null
const petRoomPackage =
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)) ||
undefined
const features = filteredRooms.find((room) =>
room.features.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
)
)?.features
const rateSummary: Rate = {
features: petRoomPackage && features ? features : [],
priceName: room.roomType,
public: product.productType.public,
member: product.productType.member,
roomType: room.roomType,
roomTypeCode: room.roomTypeCode,
}
return rateSummary
}, [filteredRooms, availablePackages, selectedPackages, selectedRate])
useEffect(() => {
if (rateSummary) return
if (!selectedRate) return
setSelectedRate(undefined)
}, [rateSummary, selectedRate])
return (
<div className={styles.content}>
<RoomFilter
@@ -161,7 +147,7 @@ export default function Rooms({
user={user}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
setRateSummary={setRateSummary}
setRateCode={setSelectedRate}
rateSummary={rateSummary}
/>
</div>
@@ -1,4 +1,4 @@
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { TripAdvisorIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./tripAdvisorChip.module.css"