Merge branch 'develop'
This commit is contained in:
@@ -42,13 +42,17 @@ export default function CardsGrid({
|
||||
case CardsGridEnum.cards.Card:
|
||||
return (
|
||||
<Card
|
||||
theme={cards_grid.theme ?? "one"}
|
||||
theme={
|
||||
cards_grid.theme ?? (card.backgroundImage ? "image" : "one")
|
||||
}
|
||||
key={card.system.uid}
|
||||
scriptedTopTitle={card.scripted_top_title}
|
||||
heading={card.heading}
|
||||
bodyText={card.body_text}
|
||||
secondaryButton={card.secondaryButton}
|
||||
primaryButton={card.primaryButton}
|
||||
backgroundImage={card.backgroundImage}
|
||||
imageGradient
|
||||
/>
|
||||
)
|
||||
case CardsGridEnum.cards.TeaserCard:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dt } from "@/lib/dt"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
import { getMembership } from "@/utils/user"
|
||||
|
||||
import type { UserProps } from "@/types/components/myPages/user"
|
||||
@@ -16,9 +17,6 @@ export default async function ExpiringPoints({ user }: UserProps) {
|
||||
// TODO: handle this case?
|
||||
return null
|
||||
}
|
||||
|
||||
// sv hardcoded to force space on thousands
|
||||
const formatter = new Intl.NumberFormat(Lang.sv)
|
||||
const d = dt(membership.pointsExpiryDate)
|
||||
|
||||
const dateFormat = getLang() == Lang.fi ? "DD.MM.YYYY" : "YYYY-MM-DD"
|
||||
@@ -29,7 +27,7 @@ export default async function ExpiringPoints({ user }: UserProps) {
|
||||
{intl.formatMessage(
|
||||
{ id: "spendable points expiring by" },
|
||||
{
|
||||
points: formatter.format(membership.pointsToExpire),
|
||||
points: formatNumber(membership.pointsToExpire),
|
||||
date: d.format(dateFormat),
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ function RewardTableHeader({ name, description }: RewardTableHeaderProps) {
|
||||
<details className={styles.details}>
|
||||
<summary className={styles.summary}>
|
||||
<hgroup className={styles.rewardHeader}>
|
||||
<Title as="h5" level="h2" textTransform={"regular"}>
|
||||
<Title as="h4" level="h2" textTransform={"regular"}>
|
||||
{name}
|
||||
</Title>
|
||||
<span className={styles.chevron}>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RewardCard({
|
||||
<details className={styles.details}>
|
||||
<summary className={styles.summary}>
|
||||
<hgroup className={styles.rewardCardHeader}>
|
||||
<Title as="h5" level="h2" textTransform={"regular"}>
|
||||
<Title as="h4" level="h2" textTransform={"regular"}>
|
||||
{title}
|
||||
</Title>
|
||||
<span className={styles.chevron}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
|
||||
import { awardPointsVariants } from "./awardPointsVariants"
|
||||
|
||||
@@ -32,12 +31,10 @@ export default function AwardPoints({
|
||||
variant,
|
||||
})
|
||||
|
||||
// sv hardcoded to force space on thousands
|
||||
const formatter = new Intl.NumberFormat(Lang.sv)
|
||||
return (
|
||||
<Body textTransform="bold" className={classNames}>
|
||||
{isCalculated
|
||||
? formatter.format(awardPoints)
|
||||
? formatNumber(awardPoints)
|
||||
: intl.formatMessage({ id: "Points being calculated" })}
|
||||
</Body>
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function NextLevelRewardsBlock({
|
||||
{ level: nextLevelRewards.level?.name }
|
||||
)}
|
||||
</Body>
|
||||
<Title level="h4" as="h5" color="pale" textAlign="center">
|
||||
<Title level="h4" as="h4" color="pale" textAlign="center">
|
||||
{reward.label}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation"
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import LoginButton from "@/components/Current/Header/LoginButton"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function EmptyPreviousStaysBlock() {
|
||||
const { formatMessage } = await getIntl()
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Title as="h5" level="h3" color="red" textAlign="center">
|
||||
<Title as="h4" level="h3" color="red" textAlign="center">
|
||||
{formatMessage({
|
||||
id: "You have no previous stays.",
|
||||
})}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h5" level="h3" color="red" className={styles.title}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
{formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{formatMessage({ id: "Where should you go next?" })}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function StayCard({ stay }: StayCardProps) {
|
||||
height={240}
|
||||
/>
|
||||
<footer className={styles.footer}>
|
||||
<Title as="h5" className={styles.hotel} level="h3">
|
||||
<Title as="h4" className={styles.hotel} level="h3">
|
||||
{hotelInformation.hotelName}
|
||||
</Title>
|
||||
<div className={styles.date}>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h5" level="h3" color="red" className={styles.title}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
{formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{formatMessage({ id: "Where should you go next?" })}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import Form from "@/components/Forms/BookingWidget"
|
||||
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
import { getFormattedUrlQueryParams } from "@/utils/url"
|
||||
|
||||
import getHotelReservationQueryParams from "../HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import MobileToggleButton from "./MobileToggleButton"
|
||||
|
||||
import styles from "./bookingWidget.module.css"
|
||||
@@ -29,6 +30,11 @@ export default function BookingWidgetClient({
|
||||
searchParams,
|
||||
}: BookingWidgetClientProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const bookingWidgetRef = useRef(null)
|
||||
useStickyPosition({
|
||||
ref: bookingWidgetRef,
|
||||
name: StickyElementNameEnum.BOOKING_WIDGET,
|
||||
})
|
||||
|
||||
const sessionStorageSearchData =
|
||||
typeof window !== "undefined"
|
||||
@@ -61,12 +67,14 @@ export default function BookingWidgetClient({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const reqFromDate = bookingWidgetSearchData?.fromDate?.toString()
|
||||
const reqToDate = bookingWidgetSearchData?.toDate?.toString()
|
||||
|
||||
const isDateParamValid =
|
||||
bookingWidgetSearchData?.fromDate &&
|
||||
bookingWidgetSearchData?.toDate &&
|
||||
dt(bookingWidgetSearchData?.toDate.toString()).isAfter(
|
||||
dt(bookingWidgetSearchData?.fromDate.toString())
|
||||
)
|
||||
reqFromDate &&
|
||||
reqToDate &&
|
||||
dt(reqFromDate).isAfter(dt().subtract(1, "day")) &&
|
||||
dt(reqToDate).isAfter(dt(reqFromDate))
|
||||
|
||||
const selectedLocation = bookingWidgetSearchData
|
||||
? getLocationObj(
|
||||
@@ -140,7 +148,10 @@ export default function BookingWidgetClient({
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section className={styles.container} data-open={isOpen}>
|
||||
<section ref={bookingWidgetRef} className={styles.containerDesktop}>
|
||||
<Form locations={locations} type={type} />
|
||||
</section>
|
||||
<section className={styles.containerMobile} data-open={isOpen}>
|
||||
<button
|
||||
className={styles.close}
|
||||
onClick={closeMobileSearch}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import { EditIcon, SearchIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./button.module.css"
|
||||
|
||||
@@ -29,6 +31,12 @@ export default function MobileToggleButton({
|
||||
const location = useWatch({ name: "location" })
|
||||
const rooms: BookingWidgetSchema["rooms"] = useWatch({ name: "rooms" })
|
||||
|
||||
const bookingWidgetMobileRef = useRef(null)
|
||||
useStickyPosition({
|
||||
ref: bookingWidgetMobileRef,
|
||||
name: StickyElementNameEnum.BOOKING_WIDGET_MOBILE,
|
||||
})
|
||||
|
||||
const parsedLocation: Location | null = location
|
||||
? JSON.parse(decodeURIComponent(location))
|
||||
: null
|
||||
@@ -46,70 +54,82 @@ export default function MobileToggleButton({
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsedLocation && d) {
|
||||
const totalRooms = rooms.length
|
||||
const totalAdults = rooms.reduce((acc, room) => {
|
||||
if (room.adults) {
|
||||
acc = acc + room.adults
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
const totalChildren = rooms.reduce((acc, room) => {
|
||||
if (room.child) {
|
||||
acc = acc + room.child.length
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
return (
|
||||
<div className={styles.complete} onClick={openMobileSearch} role="button">
|
||||
<div>
|
||||
<Caption color="red">{parsedLocation.name}</Caption>
|
||||
<Caption>
|
||||
{`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${
|
||||
totalChildren > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren }
|
||||
) + ", "
|
||||
: ""
|
||||
}${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<EditIcon color="white" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const locationAndDateIsSet = parsedLocation && d
|
||||
|
||||
const totalRooms = rooms.length
|
||||
const totalAdults = rooms.reduce((acc, room) => {
|
||||
if (room.adults) {
|
||||
acc = acc + room.adults
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
const totalChildren = rooms.reduce((acc, room) => {
|
||||
if (room.child) {
|
||||
acc = acc + room.child.length
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className={styles.partial} onClick={openMobileSearch} role="button">
|
||||
<div>
|
||||
<Caption color="red">{intl.formatMessage({ id: "Where to" })}</Caption>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{parsedLocation
|
||||
? parsedLocation.name
|
||||
: intl.formatMessage({ id: "Destination" })}
|
||||
</Body>
|
||||
</div>
|
||||
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
|
||||
<div>
|
||||
<Caption color="red">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<Body>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" />
|
||||
</div>
|
||||
<div
|
||||
className={locationAndDateIsSet ? styles.complete : styles.partial}
|
||||
onClick={openMobileSearch}
|
||||
role="button"
|
||||
ref={bookingWidgetMobileRef}
|
||||
>
|
||||
{!locationAndDateIsSet && (
|
||||
<>
|
||||
<div>
|
||||
<Caption type="bold" color="red">
|
||||
{intl.formatMessage({ id: "Where to" })}
|
||||
</Caption>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{parsedLocation
|
||||
? parsedLocation.name
|
||||
: intl.formatMessage({ id: "Destination" })}
|
||||
</Body>
|
||||
</div>
|
||||
<Divider color="baseSurfaceSubtleNormal" variant="vertical" />
|
||||
<div>
|
||||
<Caption type="bold" color="red">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
</Caption>
|
||||
<Body>
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<SearchIcon color="white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{locationAndDateIsSet && (
|
||||
<>
|
||||
<div>
|
||||
<Caption color="red">{parsedLocation?.name}</Caption>
|
||||
<Caption>
|
||||
{`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${
|
||||
totalChildren > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren }
|
||||
) + ", "
|
||||
: ""
|
||||
}${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
<EditIcon color="white" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
.containerDesktop,
|
||||
.containerMobile,
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.container {
|
||||
.containerMobile {
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
bottom: -100%;
|
||||
display: grid;
|
||||
@@ -14,7 +20,7 @@
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.container[data-open="true"] {
|
||||
.containerMobile[data-open="true"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
@@ -25,7 +31,7 @@
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.container[data-open="true"] + .backdrop {
|
||||
.containerMobile[data-open="true"] + .backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
height: 100%;
|
||||
left: 0;
|
||||
@@ -37,7 +43,7 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
.containerDesktop {
|
||||
display: block;
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
position: sticky;
|
||||
@@ -45,10 +51,6 @@
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
.listItem {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
}
|
||||
|
||||
.homeLink {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { ChevronRightIcon, HouseIcon } from "@/components/Icons"
|
||||
import { ChevronRightSmallIcon, HouseIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
@@ -24,9 +24,9 @@ export default async function Breadcrumbs() {
|
||||
href={homeBreadcrumb.href!}
|
||||
variant="breadcrumb"
|
||||
>
|
||||
<HouseIcon color="peach80" />
|
||||
<HouseIcon width={16} height={16} color="peach80" />
|
||||
</Link>
|
||||
<ChevronRightIcon aria-hidden="true" color="peach80" />
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default async function Breadcrumbs() {
|
||||
>
|
||||
{breadcrumb.title}
|
||||
</Link>
|
||||
<ChevronRightIcon aria-hidden="true" color="peach80" />
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
.amenityItem {
|
||||
display: inline-flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { amenities } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
@@ -29,7 +29,12 @@ export default async function AmenitiesList({
|
||||
return (
|
||||
<div className={styles.amenityItem} key={facility.id}>
|
||||
{IconComponent && (
|
||||
<IconComponent className={styles.icon} color="grey80" />
|
||||
<IconComponent
|
||||
className={styles.icon}
|
||||
color="grey80"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
<Body color="textMediumContrast">{facility.name}</Body>
|
||||
</div>
|
||||
@@ -44,7 +49,7 @@ export default async function AmenitiesList({
|
||||
className={styles.showAllAmenities}
|
||||
>
|
||||
{intl.formatMessage({ id: "Show all amenities" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
<ChevronRightSmallIcon color="burgundy" />
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { about } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import ArrowRight from "@/components/Icons/ArrowRight"
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
@@ -48,7 +47,7 @@ export default async function IntroSection({
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.titleContainer}>
|
||||
<BiroScript tilted="medium" color="red">
|
||||
{intl.formatMessage({ id: "Welcome to" })}:
|
||||
{intl.formatMessage({ id: "Welcome to" })}
|
||||
</BiroScript>
|
||||
<Title level="h2">{hotelName}</Title>
|
||||
</div>
|
||||
@@ -77,7 +76,7 @@ export default async function IntroSection({
|
||||
scroll={false}
|
||||
>
|
||||
{intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
<ChevronRightSmallIcon color="burgundy" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -37,21 +37,26 @@ export default function Sidebar({
|
||||
|
||||
function moveToPoi(poiCoordinates: Coordinates) {
|
||||
if (map) {
|
||||
const hotelLatLng = new google.maps.LatLng(
|
||||
coordinates.lat,
|
||||
coordinates.lng
|
||||
)
|
||||
const poiLatLng = new google.maps.LatLng(
|
||||
poiCoordinates.lat,
|
||||
poiCoordinates.lng
|
||||
)
|
||||
|
||||
const bounds = new google.maps.LatLngBounds()
|
||||
const boundPadding = 0.02
|
||||
bounds.extend(hotelLatLng)
|
||||
bounds.extend(poiLatLng)
|
||||
|
||||
const minLat = Math.min(coordinates.lat, poiCoordinates.lat)
|
||||
const maxLat = Math.max(coordinates.lat, poiCoordinates.lat)
|
||||
const minLng = Math.min(coordinates.lng, poiCoordinates.lng)
|
||||
const maxLng = Math.max(coordinates.lng, poiCoordinates.lng)
|
||||
|
||||
bounds.extend(
|
||||
new google.maps.LatLng(minLat - boundPadding, minLng - boundPadding)
|
||||
)
|
||||
bounds.extend(
|
||||
new google.maps.LatLng(maxLat + boundPadding, maxLng + boundPadding)
|
||||
)
|
||||
map.fitBounds(bounds)
|
||||
|
||||
const currentZoomLevel = map.getZoom()
|
||||
|
||||
if (currentZoomLevel) {
|
||||
map.setZoom(currentZoomLevel - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +66,6 @@ export default function Sidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!isClicking) {
|
||||
onActivePoiChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
|
||||
setIsClicking(true)
|
||||
toggleFullScreenSidebar()
|
||||
@@ -78,66 +77,71 @@ export default function Sidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${styles.sidebar} ${
|
||||
isFullScreenSidebar ? styles.fullscreen : ""
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="text"
|
||||
className={styles.sidebarToggle}
|
||||
onClick={toggleFullScreenSidebar}
|
||||
<>
|
||||
<aside
|
||||
className={`${styles.sidebar} ${
|
||||
isFullScreenSidebar ? styles.fullscreen : ""
|
||||
}`}
|
||||
>
|
||||
<Body textTransform="bold" color="textMediumContrast" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: isFullScreenSidebar ? "View as map" : "View as list",
|
||||
})}
|
||||
</span>
|
||||
</Body>
|
||||
</Button>
|
||||
<div className={styles.sidebarContent}>
|
||||
<Title as="h4" level="h2" textTransform="regular">
|
||||
{intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
</Title>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="text"
|
||||
fullWidth
|
||||
className={styles.sidebarToggle}
|
||||
onClick={toggleFullScreenSidebar}
|
||||
>
|
||||
<Body textTransform="bold" color="textMediumContrast" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: isFullScreenSidebar ? "View as map" : "View as list",
|
||||
})}
|
||||
</span>
|
||||
</Body>
|
||||
</Button>
|
||||
<div className={styles.sidebarContent}>
|
||||
<Title as="h4" level="h2" textTransform="regular">
|
||||
{intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
</Title>
|
||||
|
||||
{poisInGroups.map(({ group, pois }) =>
|
||||
pois.length ? (
|
||||
<div key={group} className={styles.poiGroup}>
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
className={styles.poiHeading}
|
||||
asChild
|
||||
>
|
||||
<h3>
|
||||
<PoiMarker group={group} />
|
||||
{intl.formatMessage({ id: group })}
|
||||
</h3>
|
||||
</Body>
|
||||
<ul className={styles.poiList}>
|
||||
{pois.map((poi) => (
|
||||
<li key={poi.name} className={styles.poiItem}>
|
||||
<button
|
||||
className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
onMouseEnter={() => handleMouseEnter(poi.name)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => handlePoiClick(poi.name, poi.coordinates)}
|
||||
>
|
||||
<span>{poi.name}</span>
|
||||
<span>{poi.distance} km</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
{poisInGroups.map(({ group, pois }) =>
|
||||
pois.length ? (
|
||||
<div key={group} className={styles.poiGroup}>
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
className={styles.poiHeading}
|
||||
asChild
|
||||
>
|
||||
<h3>
|
||||
<PoiMarker group={group} />
|
||||
{intl.formatMessage({ id: group })}
|
||||
</h3>
|
||||
</Body>
|
||||
<ul className={styles.poiList}>
|
||||
{pois.map((poi) => (
|
||||
<li key={poi.name} className={styles.poiItem}>
|
||||
<button
|
||||
className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
onMouseEnter={() => handleMouseEnter(poi.name)}
|
||||
onClick={() =>
|
||||
handlePoiClick(poi.name, poi.coordinates)
|
||||
}
|
||||
>
|
||||
<span>{poi.name}</span>
|
||||
<span>{poi.distance} km</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className={styles.backdrop} onClick={toggleFullScreenSidebar} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +1,12 @@
|
||||
.sidebar {
|
||||
--sidebar-max-width: 26.25rem;
|
||||
--sidebar-mobile-toggle-height: 91px;
|
||||
--sidebar-mobile-fullscreen-height: calc(
|
||||
100vh - var(--main-menu-mobile-height) - var(--sidebar-mobile-toggle-height)
|
||||
);
|
||||
|
||||
position: absolute;
|
||||
top: var(--sidebar-mobile-fullscreen-height);
|
||||
height: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
z-index: 1;
|
||||
transition: top 0.3s;
|
||||
}
|
||||
|
||||
.sidebar:not(.fullscreen) {
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.sidebar.fullscreen {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebarToggle {
|
||||
position: relative;
|
||||
margin: var(--Spacing-x4) 0 var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebarToggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: -0.5rem;
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background-color: var(--UI-Text-High-contrast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebarContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
align-content: start;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
height: var(--sidebar-mobile-fullscreen-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -90,12 +52,65 @@
|
||||
background-color: var(--Base-Surface-Primary-light-Hover);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.sidebar {
|
||||
--sidebar-mobile-toggle-height: 84px;
|
||||
--sidebar-mobile-top-space: 40px;
|
||||
--sidebar-mobile-content-height: calc(
|
||||
var(--hotel-map-height) - var(--sidebar-mobile-toggle-height) -
|
||||
var(--sidebar-mobile-top-space)
|
||||
);
|
||||
|
||||
position: absolute;
|
||||
bottom: calc(-1 * var(--sidebar-mobile-content-height));
|
||||
width: 100%;
|
||||
transition:
|
||||
bottom 0.3s,
|
||||
top 0.3s;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
}
|
||||
|
||||
.sidebar.fullscreen + .backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar.fullscreen {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sidebarToggle {
|
||||
position: relative;
|
||||
margin-top: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.sidebarToggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: -0.5rem;
|
||||
width: 100px;
|
||||
height: 3px;
|
||||
background-color: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
.sidebarContent {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
height: var(--sidebar-mobile-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
width: 40vw;
|
||||
min-width: 10rem;
|
||||
max-width: var(--sidebar-max-width);
|
||||
max-width: 26.25rem;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
.dynamicMap {
|
||||
position: fixed;
|
||||
top: var(--main-menu-mobile-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
--hotel-map-height: 100dvh;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--dialog-z-index);
|
||||
height: var(--hotel-map-height);
|
||||
width: 100dvw;
|
||||
z-index: var(--hotel-dynamic-map-z-index);
|
||||
display: flex;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.dynamicMap {
|
||||
top: var(--main-menu-desktop-height);
|
||||
}
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Dialog, Modal } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -10,6 +10,7 @@ import CloseLargeIcon from "@/components/Icons/CloseLarge"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import Sidebar from "./Sidebar"
|
||||
|
||||
@@ -25,9 +26,10 @@ export default function DynamicMap({
|
||||
mapId,
|
||||
}: DynamicMapProps) {
|
||||
const intl = useIntl()
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
const [mapHeight, setMapHeight] = useState("0px")
|
||||
const { isDynamicMapOpen, closeDynamicMap } = useHotelPageStore()
|
||||
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
|
||||
const hasMounted = useRef(false)
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
|
||||
useHandleKeyUp((event: KeyboardEvent) => {
|
||||
@@ -36,23 +38,47 @@ export default function DynamicMap({
|
||||
}
|
||||
})
|
||||
|
||||
// Making sure the map is always opened at the top of the page, just below the header.
|
||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
||||
const handleMapHeight = useCallback(() => {
|
||||
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
|
||||
const scrollY = window.scrollY
|
||||
setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`)
|
||||
}, [])
|
||||
|
||||
// Making sure the map is always opened at the top of the page,
|
||||
// just below the header and booking widget as these should stay visible.
|
||||
// When closing, the page should scroll back to the position it was before opening the map.
|
||||
useEffect(() => {
|
||||
// Skip the first render
|
||||
if (!hasMounted.current) {
|
||||
hasMounted.current = true
|
||||
if (!rootDiv.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isDynamicMapOpen && scrollHeightWhenOpened === 0) {
|
||||
setScrollHeightWhenOpened(window.scrollY)
|
||||
const scrollY = window.scrollY
|
||||
setScrollHeightWhenOpened(scrollY)
|
||||
window.scrollTo({ top: 0, behavior: "instant" })
|
||||
} else if (!isDynamicMapOpen && scrollHeightWhenOpened !== 0) {
|
||||
window.scrollTo({ top: scrollHeightWhenOpened, behavior: "instant" })
|
||||
setScrollHeightWhenOpened(0)
|
||||
}
|
||||
}, [isDynamicMapOpen, scrollHeightWhenOpened])
|
||||
}, [isDynamicMapOpen, scrollHeightWhenOpened, rootDiv])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResizeHandler = debounce(function () {
|
||||
handleMapHeight()
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(debouncedResizeHandler)
|
||||
|
||||
observer.observe(document.documentElement)
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.unobserve(document.documentElement)
|
||||
}
|
||||
}
|
||||
}, [rootDiv, isDynamicMapOpen, handleMapHeight])
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
@@ -70,31 +96,37 @@ export default function DynamicMap({
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<Modal isOpen={isDynamicMapOpen}>
|
||||
<Dialog
|
||||
className={styles.dynamicMap}
|
||||
aria-label={intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
<div className={styles.wrapper} ref={rootDiv}>
|
||||
<Modal
|
||||
isOpen={isDynamicMapOpen}
|
||||
UNSTABLE_portalContainer={rootDiv.current || undefined}
|
||||
>
|
||||
<Sidebar
|
||||
activePoi={activePoi}
|
||||
hotelName={hotelName}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={setActivePoi}
|
||||
coordinates={coordinates}
|
||||
/>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
<Dialog
|
||||
className={styles.dynamicMap}
|
||||
style={{ "--hotel-map-height": mapHeight } as React.CSSProperties}
|
||||
aria-label={intl.formatMessage(
|
||||
{ id: "Things nearby HOTEL_NAME" },
|
||||
{ hotelName }
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
activePoi={activePoi}
|
||||
hotelName={hotelName}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={setActivePoi}
|
||||
coordinates={coordinates}
|
||||
/>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</div>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./mapWithCard.module.css"
|
||||
|
||||
export default function MapWithCard({ children }: PropsWithChildren) {
|
||||
const mapWithCardRef = useRef<HTMLDivElement>(null)
|
||||
useStickyPosition({
|
||||
ref: mapWithCardRef,
|
||||
name: StickyElementNameEnum.HOTEL_STATIC_MAP,
|
||||
group: "hotelPage",
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={mapWithCardRef} className={styles.mapWithCard}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.mapWithCard {
|
||||
position: sticky;
|
||||
top: var(--booking-widget-desktop-height);
|
||||
min-height: 500px; /* Fixed min to not cover the marker with the card */
|
||||
height: calc(
|
||||
100vh - var(--main-menu-desktop-height) -
|
||||
var(--booking-widget-desktop-height)
|
||||
); /* Full height without the header + booking widget */
|
||||
max-height: 935px; /* Fixed max according to figma */
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.mobileToggle {
|
||||
position: sticky;
|
||||
bottom: var(--Spacing-x5);
|
||||
z-index: 1;
|
||||
z-index: var(--hotel-mobile-map-toggle-button-z-index);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.image {
|
||||
|
||||
@@ -2,36 +2,39 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronRightIcon, ImageIcon } from "@/components/Icons"
|
||||
import { GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import RoomDetailsButton from "../RoomDetailsButton"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
||||
|
||||
export function RoomCard({
|
||||
badgeTextTransKey,
|
||||
id,
|
||||
images,
|
||||
subtitle,
|
||||
title,
|
||||
}: RoomCardProps) {
|
||||
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
const { images, name, roomSize, occupancy, id } = room
|
||||
const intl = useIntl()
|
||||
const mainImage = images[0]
|
||||
|
||||
const size =
|
||||
roomSize?.min === roomSize?.max
|
||||
? `${roomSize.min} m²`
|
||||
: `${roomSize.min} - ${roomSize.max} m²`
|
||||
|
||||
const personLabel = intl.formatMessage(
|
||||
{ id: "hotelPages.rooms.roomCard.persons" },
|
||||
{ totalOccupancy: occupancy.total }
|
||||
)
|
||||
|
||||
const subtitle = `${size} (${personLabel})`
|
||||
|
||||
function handleImageClick() {
|
||||
// TODO: Implement opening of a model with carousel
|
||||
console.log("Image clicked: ", id)
|
||||
}
|
||||
|
||||
function handleRoomCtaClick() {
|
||||
// TODO: Implement opening side-peek component with room details
|
||||
console.log("Room CTA clicked: ", id)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.roomCard}>
|
||||
<button className={styles.imageWrapper} onClick={handleImageClick}>
|
||||
@@ -42,7 +45,7 @@ export function RoomCard({
|
||||
{/* </span> */}
|
||||
{/* )} */}
|
||||
<span className={styles.imageCount}>
|
||||
<ImageIcon color="white" />
|
||||
<GalleryIcon color="white" />
|
||||
{images.length}
|
||||
</span>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
@@ -64,19 +67,14 @@ export function RoomCard({
|
||||
color="black"
|
||||
className={styles.title}
|
||||
>
|
||||
{title}
|
||||
{name}
|
||||
</Subtitle>
|
||||
<Body color="grey">{subtitle}</Body>
|
||||
</div>
|
||||
<Button
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
onClick={handleRoomCtaClick}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
<RoomDetailsButton
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypes[0].code}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -26,10 +26,11 @@
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
background-color: var(--Main-Grey-90);
|
||||
background-color: var(--UI-Grey-90);
|
||||
opacity: 90%;
|
||||
color: var(--UI-Input-Controls-Fill-Normal);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function RoomDetailsButton({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -15,34 +15,13 @@ import styles from "./rooms.module.css"
|
||||
import type { RoomsProps } from "@/types/components/hotelPage/room"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export function Rooms({ rooms }: RoomsProps) {
|
||||
export function Rooms({ hotelId, rooms }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const showToggleButton = rooms.length > 3
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const mappedRooms = rooms
|
||||
.map((room) => {
|
||||
const size = `${room.roomSize.min} - ${room.roomSize.max} m²`
|
||||
const personLabel =
|
||||
room.occupancy.total === 1
|
||||
? intl.formatMessage({ id: "hotelPages.rooms.roomCard.person" })
|
||||
: intl.formatMessage({ id: "hotelPages.rooms.roomCard.persons" })
|
||||
|
||||
const subtitle = `${size} (${room.occupancy.total} ${personLabel})`
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
images: room.images,
|
||||
title: room.name,
|
||||
subtitle: subtitle,
|
||||
sortOrder: room.sortOrder,
|
||||
popularChoice: null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
function handleShowMore() {
|
||||
if (scrollRef.current && allRoomsVisible) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
@@ -64,15 +43,9 @@ export function Rooms({ rooms }: RoomsProps) {
|
||||
<Grids.Stackable
|
||||
className={`${styles.grid} ${allRoomsVisible ? styles.allVisible : ""}`}
|
||||
>
|
||||
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
|
||||
<div key={id}>
|
||||
<RoomCard
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
{rooms.map((room) => (
|
||||
<div key={room.id}>
|
||||
<RoomCard hotelId={hotelId} room={room} />
|
||||
</div>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useHash from "@/hooks/useHash"
|
||||
import useScrollSpy from "@/hooks/useScrollSpy"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./tabNavigation.module.css"
|
||||
|
||||
@@ -23,6 +26,12 @@ export default function TabNavigation({
|
||||
const hash = useHash()
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const tabNavigationRef = useRef<HTMLDivElement>(null)
|
||||
useStickyPosition({
|
||||
ref: tabNavigationRef,
|
||||
name: StickyElementNameEnum.HOTEL_TAB_NAVIGATION,
|
||||
group: "hotelPage",
|
||||
})
|
||||
|
||||
const tabLinks: { hash: HotelHashValues; text: string }[] = [
|
||||
{
|
||||
@@ -71,7 +80,7 @@ export default function TabNavigation({
|
||||
}, [activeSectionId, router])
|
||||
|
||||
return (
|
||||
<div className={styles.stickyWrapper}>
|
||||
<div ref={tabNavigationRef} className={styles.stickyWrapper}>
|
||||
<nav className={styles.tabsContainer}>
|
||||
{tabLinks.map((link) => {
|
||||
const isActive =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.stickyWrapper {
|
||||
position: sticky;
|
||||
top: var(--booking-widget-mobile-height);
|
||||
z-index: 2;
|
||||
z-index: var(--hotel-tab-navigation-z-index);
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
.pageContainer {
|
||||
--hotel-tab-navigation-z-index: 2;
|
||||
--hotel-mobile-map-toggle-button-z-index: 1;
|
||||
--hotel-dynamic-map-z-index: 2;
|
||||
|
||||
--hotel-page-navigation-height: 59px;
|
||||
--hotel-page-scroll-margin-top: calc(
|
||||
var(--hotel-page-navigation-height) + var(--Spacing-x2)
|
||||
@@ -69,19 +73,6 @@
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.mapWithCard {
|
||||
position: sticky;
|
||||
top: var(--booking-widget-desktop-height);
|
||||
min-height: 500px; /* Fixed min to not cover the marker with the card */
|
||||
height: calc(
|
||||
100vh - var(--main-menu-desktop-height) -
|
||||
var(--booking-widget-desktop-height)
|
||||
); /* Full height without the header + booking widget */
|
||||
max-height: 935px; /* Fixed max according to figma */
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageContainer > nav {
|
||||
padding-left: var(--Spacing-x5);
|
||||
padding-right: var(--Spacing-x5);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AccordionSection from "@/components/Blocks/Accordion"
|
||||
import SidePeekProvider from "@/components/SidePeekProvider"
|
||||
import HotelReservationSidePeek from "@/components/HotelReservation/SidePeek"
|
||||
import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
@@ -12,6 +13,7 @@ import { getRestaurantHeading } from "@/utils/facilityCards"
|
||||
|
||||
import DynamicMap from "./Map/DynamicMap"
|
||||
import MapCard from "./Map/MapCard"
|
||||
import MapWithCardWrapper from "./Map/MapWithCard"
|
||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||
import StaticMap from "./Map/StaticMap"
|
||||
import AmenitiesList from "./AmenitiesList"
|
||||
@@ -30,14 +32,13 @@ export default async function HotelPage() {
|
||||
const lang = getLang()
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const hotelData = await serverClient().hotel.get({
|
||||
include: ["RoomCategories"],
|
||||
})
|
||||
const hotelData = await serverClient().hotel.get()
|
||||
if (!hotelData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
hotelId,
|
||||
hotelName,
|
||||
hotelDescription,
|
||||
hotelLocation,
|
||||
@@ -98,7 +99,7 @@ export default async function HotelPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Rooms hotelId={hotelId} rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
{faq.accordions.length > 0 && (
|
||||
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
||||
@@ -107,10 +108,10 @@ export default async function HotelPage() {
|
||||
{googleMapsApiKey ? (
|
||||
<>
|
||||
<aside className={styles.mapContainer}>
|
||||
<div className={styles.mapWithCard}>
|
||||
<MapWithCardWrapper>
|
||||
<StaticMap coordinates={coordinates} hotelName={hotelName} />
|
||||
<MapCard hotelName={hotelName} pois={topThreePois} />
|
||||
</div>
|
||||
</MapWithCardWrapper>
|
||||
</aside>
|
||||
<MobileMapToggle />
|
||||
<DynamicMap
|
||||
@@ -167,6 +168,7 @@ export default async function HotelPage() {
|
||||
</SidePeek>
|
||||
{/* eslint-enable import/no-named-as-default-member */}
|
||||
</SidePeekProvider>
|
||||
<HotelReservationSidePeek hotel={null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import StaticPage from ".."
|
||||
|
||||
export default async function CollectionPage() {
|
||||
const collectionPageRes =
|
||||
await serverClient().contentstack.collectionPage.get()
|
||||
|
||||
if (!collectionPageRes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { tracking, collectionPage } = collectionPageRes
|
||||
|
||||
return (
|
||||
<StaticPage
|
||||
content={collectionPage}
|
||||
tracking={tracking}
|
||||
pageType="collection"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import StaticPage from ".."
|
||||
|
||||
export default async function ContentPage() {
|
||||
const contentPageRes = await serverClient().contentstack.contentPage.get()
|
||||
|
||||
if (!contentPageRes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { tracking, contentPage } = contentPageRes
|
||||
|
||||
return (
|
||||
<StaticPage content={contentPage} tracking={tracking} pageType="content" />
|
||||
)
|
||||
}
|
||||
+13
-12
@@ -1,5 +1,3 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import Hero from "@/components/Hero"
|
||||
import Sidebar from "@/components/Sidebar"
|
||||
@@ -8,21 +6,22 @@ import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import styles from "./contentPage.module.css"
|
||||
import { staticPageVariants } from "./variants"
|
||||
|
||||
export default async function ContentPage() {
|
||||
const contentPageRes = await serverClient().contentstack.contentPage.get()
|
||||
import styles from "./staticPage.module.css"
|
||||
|
||||
if (!contentPageRes) {
|
||||
return null
|
||||
}
|
||||
import type { StaticPageProps } from "./staticPage"
|
||||
|
||||
const { tracking, contentPage } = contentPageRes
|
||||
const { blocks, hero_image, header, sidebar } = contentPage
|
||||
export default function StaticPage({
|
||||
content,
|
||||
tracking,
|
||||
pageType,
|
||||
}: StaticPageProps) {
|
||||
const { blocks, hero_image, header } = content
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.contentPage}>
|
||||
<section className={staticPageVariants({ pageType })}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
{header ? (
|
||||
@@ -54,7 +53,9 @@ export default async function ContentPage() {
|
||||
{blocks ? <Blocks blocks={blocks} /> : null}
|
||||
</main>
|
||||
|
||||
{sidebar?.length ? <Sidebar blocks={sidebar} /> : null}
|
||||
{"sidebar" in content && content.sidebar?.length ? (
|
||||
<Sidebar blocks={content.sidebar} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+21
-6
@@ -1,4 +1,4 @@
|
||||
.contentPage {
|
||||
.page {
|
||||
padding-bottom: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
@@ -32,20 +32,27 @@
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.content .contentContainer {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"main"
|
||||
"sidebar";
|
||||
gap: var(--Spacing-x4);
|
||||
align-items: start;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
grid-area: main;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
width: 100%;
|
||||
gap: var(--Spacing-x6);
|
||||
}
|
||||
|
||||
.content .mainContent {
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -58,12 +65,20 @@
|
||||
.heroContainer {
|
||||
padding: var(--Spacing-x4) 0;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
max-width: var(--max-width-content);
|
||||
padding: var(--Spacing-x4) 0 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content .contentContainer {
|
||||
grid-template-areas: "main sidebar";
|
||||
grid-template-columns: var(--max-width-text-block) 1fr;
|
||||
gap: var(--Spacing-x9);
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x4) 0 0;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
gap: var(--Spacing-x9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { staticPageVariants } from "./variants"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
import type { TrackingSDKPageData } from "@/types/components/tracking"
|
||||
import type { CollectionPage } from "@/types/trpc/routers/contentstack/collectionPage"
|
||||
import type { ContentPage } from "@/types/trpc/routers/contentstack/contentPage"
|
||||
|
||||
export interface StaticPageProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "content">,
|
||||
VariantProps<typeof staticPageVariants> {
|
||||
pageType?: "collection" | "content"
|
||||
content: CollectionPage["collection_page"] | ContentPage["content_page"]
|
||||
tracking: TrackingSDKPageData
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./staticPage.module.css"
|
||||
|
||||
export const staticPageVariants = cva(styles.page, {
|
||||
variants: {
|
||||
pageType: {
|
||||
collection: styles.collection,
|
||||
content: styles.content,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
pageType: "content",
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import { getCurrentFooter } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
@@ -8,9 +8,7 @@ import Navigation from "./Navigation"
|
||||
import styles from "./footer.module.css"
|
||||
|
||||
export default async function Footer() {
|
||||
const footerData = await serverClient().contentstack.base.currentFooter({
|
||||
lang: getLang(),
|
||||
})
|
||||
const footerData = await getCurrentFooter(getLang())
|
||||
if (!footerData) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import { myPages } from "@/constants/routes/myPages"
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import Avatar from "@/components/MyPages/Avatar"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackClick } from "@/utils/tracking"
|
||||
|
||||
import BookingButton from "../BookingButton"
|
||||
import LoginButton from "../LoginButton"
|
||||
|
||||
import styles from "./mainMenu.module.css"
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import { logout } from "@/constants/routes/handleAuth"
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
import { getName } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import LoginButton from "../LoginButton"
|
||||
|
||||
import styles from "./topMenu.module.css"
|
||||
|
||||
import type { TopMenuProps } from "@/types/components/current/header/topMenu"
|
||||
@@ -67,7 +66,7 @@ export default async function TopMenu({
|
||||
) : (
|
||||
<LoginButton
|
||||
position="hamburger menu"
|
||||
trackingId="loginStartTopMeny"
|
||||
trackingId="loginStartTopMenu"
|
||||
className={`${styles.sessionLink} ${styles.loginLink}`}
|
||||
>
|
||||
{formatMessage({ id: "Log in" })}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -32,10 +32,6 @@ export default async function FooterDetails() {
|
||||
<Link href={`/${lang}`}>
|
||||
<Image
|
||||
alt="Scandic Hotels logo"
|
||||
className={styles.logo}
|
||||
data-js="scandiclogoimg"
|
||||
data-nosvgsrc="/_static/img/scandic-logotype.png"
|
||||
itemProp="logo"
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype-white.svg"
|
||||
width={103}
|
||||
@@ -46,7 +42,6 @@ export default async function FooterDetails() {
|
||||
(link) =>
|
||||
link.href && (
|
||||
<a
|
||||
className={styles.socialLink}
|
||||
color="white"
|
||||
href={link.href.href}
|
||||
key={link.href.title}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function FooterSecondaryNav({
|
||||
<div className={styles.secondaryNavigation}>
|
||||
{appDownloads && (
|
||||
<nav className={styles.secondaryNavigationGroup}>
|
||||
<Body color="peach80" textTransform="uppercase">
|
||||
<Body color="baseTextMediumContrast" textTransform="uppercase">
|
||||
{appDownloads.title}
|
||||
</Body>
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
@@ -50,7 +50,7 @@ export default function FooterSecondaryNav({
|
||||
)}
|
||||
{secondaryLinks.map((link) => (
|
||||
<nav className={styles.secondaryNavigationGroup} key={link.title}>
|
||||
<Body color="peach80" textTransform="uppercase">
|
||||
<Body color="baseTextMediumContrast" textTransform="uppercase">
|
||||
{link.title}
|
||||
</Body>
|
||||
<ul className={styles.secondaryNavigationList}>
|
||||
|
||||
@@ -29,17 +29,23 @@ export default function SearchList({
|
||||
}: SearchListProps) {
|
||||
const intl = useIntl()
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const [isFormSubmitted, setIsFormSubmitted] = useState(false)
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
formState: { errors, isSubmitted },
|
||||
} = useFormContext()
|
||||
const searchError = errors["search"]
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormSubmitted(isSubmitted)
|
||||
}, [isSubmitted])
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError && searchError.message === "Required") {
|
||||
timeoutID = setTimeout(() => {
|
||||
clearErrors("search")
|
||||
setIsFormSubmitted(false)
|
||||
// magic number originates from animation
|
||||
// 5000ms delay + 120ms exectuion
|
||||
}, 5120)
|
||||
@@ -60,7 +66,7 @@ export default function SearchList({
|
||||
return null
|
||||
}
|
||||
|
||||
if (searchError) {
|
||||
if (searchError && isFormSubmitted) {
|
||||
if (typeof searchError.message === "string") {
|
||||
if (!isOpen) {
|
||||
if (searchError.message === "Required") {
|
||||
|
||||
@@ -48,13 +48,10 @@ export default function Search({ locations }: SearchProps) {
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const value = evt.currentTarget.value
|
||||
if (value) {
|
||||
function dispatchInputValue(inputValue: string) {
|
||||
if (inputValue) {
|
||||
dispatch({
|
||||
payload: { search: value },
|
||||
payload: { search: inputValue },
|
||||
type: ActionType.SEARCH_LOCATIONS,
|
||||
})
|
||||
} else {
|
||||
@@ -62,6 +59,14 @@ export default function Search({ locations }: SearchProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const newValue = evt.currentTarget.value
|
||||
setValue(name, newValue)
|
||||
dispatchInputValue(value)
|
||||
}
|
||||
|
||||
function handleOnFocus(evt: FocusEvent<HTMLInputElement>) {
|
||||
const searchValue = evt.currentTarget.value
|
||||
if (searchValue) {
|
||||
@@ -114,6 +119,7 @@ export default function Search({ locations }: SearchProps) {
|
||||
inputValue={value}
|
||||
itemToString={(value) => (value ? value.name : "")}
|
||||
onSelect={handleOnSelect}
|
||||
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
|
||||
>
|
||||
{({
|
||||
closeMenu,
|
||||
@@ -128,10 +134,16 @@ export default function Search({ locations }: SearchProps) {
|
||||
}) => (
|
||||
<div className={styles.container}>
|
||||
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
|
||||
<Caption color={isOpen ? "uiTextActive" : "red"}>
|
||||
{state.searchData?.type === "hotels"
|
||||
? state.searchData?.relationships?.city?.name
|
||||
: intl.formatMessage({ id: "Where to" })}
|
||||
<Caption
|
||||
type="bold"
|
||||
color={isOpen ? "uiTextActive" : "red"}
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
{state.searchData?.type === "hotels"
|
||||
? state.searchData?.relationships?.city?.name
|
||||
: intl.formatMessage({ id: "Where to" })}
|
||||
</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
|
||||
@@ -34,8 +34,8 @@ export default function Voucher() {
|
||||
>
|
||||
<div className={styles.vouchers}>
|
||||
<label>
|
||||
<Caption color="disabled" type="bold">
|
||||
{vouchers}
|
||||
<Caption color="disabled" type="bold" asChild>
|
||||
<span>{vouchers}</span>
|
||||
</Caption>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
@@ -49,21 +49,30 @@ export default function Voucher() {
|
||||
arrow="left"
|
||||
>
|
||||
<div className={styles.options}>
|
||||
<label className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<Checkbox name="useVouchers" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{useVouchers}</Caption>
|
||||
<div className={`${styles.option} ${styles.checkboxVoucher}`}>
|
||||
<Checkbox name="useVouchers" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{useVouchers}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{bonus}</Caption>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
<label className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }} />
|
||||
<Caption color="disabled">{reward}</Caption>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +50,8 @@ export default function FormContent({
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<label>
|
||||
<Caption color="red" type="bold">
|
||||
{rooms}
|
||||
<Caption color="red" type="bold" asChild>
|
||||
<span>{rooms}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<GuestsRoomsProvider selectedGuests={selectedGuests}>
|
||||
|
||||
@@ -65,11 +65,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
|
||||
id={formId}
|
||||
>
|
||||
<input {...register("location")} type="hidden" />
|
||||
<FormContent
|
||||
locations={locations}
|
||||
formId={formId}
|
||||
formState={formState}
|
||||
/>
|
||||
<FormContent locations={locations} formId={formId} />
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,9 @@ export const signUpSchema = z.object({
|
||||
"Phone is required",
|
||||
"Please enter a valid phone number"
|
||||
),
|
||||
dateOfBirth: z.string().min(1),
|
||||
dateOfBirth: z.string().min(1, {
|
||||
message: "Date of birth is required",
|
||||
}),
|
||||
address: z.object({
|
||||
countryCode: z
|
||||
.string({
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function GuestsRoomsPicker({
|
||||
id: "Disabled booking options header",
|
||||
})
|
||||
const disabledBookingOptionsText = intl.formatMessage({
|
||||
id: "Disabled booking options text",
|
||||
id: "Disabled adding room",
|
||||
})
|
||||
const addRoomLabel = intl.formatMessage({ id: "Add Room" })
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.body {
|
||||
opacity: 0.8;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import { myPages } from "@/constants/routes/myPages"
|
||||
import {
|
||||
getMembershipLevelSafely,
|
||||
getMyPagesNavigation,
|
||||
@@ -7,7 +6,7 @@ import {
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
@@ -50,16 +49,17 @@ export default async function MyPagesMenuWrapper() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={myPages[lang]}
|
||||
<LoginButton
|
||||
className={styles.loginLink}
|
||||
aria-label={intl.formatMessage({ id: "Log in/Join" })}
|
||||
position="top menu"
|
||||
trackingId="loginStartNewTopMenu"
|
||||
>
|
||||
<Avatar />
|
||||
<span className={styles.userName}>
|
||||
{intl.formatMessage({ id: "Log in/Join" })}
|
||||
</span>
|
||||
</Link>
|
||||
</LoginButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -23,9 +23,6 @@ export default async function MainMenu() {
|
||||
<Image
|
||||
alt={intl.formatMessage({ id: "Back to scandichotels.com" })}
|
||||
className={styles.logo}
|
||||
data-js="scandiclogoimg"
|
||||
data-nosvgsrc="/_static/img/scandic-logotype.png"
|
||||
itemProp="logo"
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype.svg"
|
||||
width={103}
|
||||
|
||||
@@ -14,12 +14,14 @@ import { bedTypeSchema } from "./schema"
|
||||
|
||||
import styles from "./bedOptions.module.css"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/enterDetails/bedType"
|
||||
import { BedTypeEnum } from "@/types/enums/bedType"
|
||||
import type {
|
||||
BedTypeProps,
|
||||
BedTypeSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType() {
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const intl = useIntl()
|
||||
const bedType = useEnterDetailsStore((state) => state.data.bedType)
|
||||
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
|
||||
|
||||
const methods = useForm<BedTypeSchema>({
|
||||
defaultValues: bedType
|
||||
@@ -33,10 +35,6 @@ export default function BedType() {
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const text = intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Included</b> (based on availability)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
@@ -58,38 +56,24 @@ export default function BedType() {
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={BedTypeEnum.KING}
|
||||
name="bedType"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
length: "210",
|
||||
width: "180",
|
||||
}
|
||||
)}
|
||||
text={text}
|
||||
title={intl.formatMessage({ id: "King bed" })}
|
||||
value={BedTypeEnum.KING}
|
||||
/>
|
||||
<RadioCard
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={BedTypeEnum.QUEEN}
|
||||
name="bedType"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{width} cm × {length} cm" },
|
||||
{
|
||||
length: "200",
|
||||
width: "160",
|
||||
}
|
||||
)}
|
||||
text={text}
|
||||
title={intl.formatMessage({ id: "Queen bed" })}
|
||||
value={BedTypeEnum.QUEEN}
|
||||
/>
|
||||
{bedTypes.map((roomType) => {
|
||||
const width =
|
||||
roomType.size.max === roomType.size.min
|
||||
? `${roomType.size.min} cm`
|
||||
: `${roomType.size.min} cm - ${roomType.size.max} cm`
|
||||
return (
|
||||
<RadioCard
|
||||
key={roomType.value}
|
||||
Icon={KingBedIcon}
|
||||
iconWidth={46}
|
||||
id={roomType.value}
|
||||
name="bedType"
|
||||
subtitle={width}
|
||||
title={roomType.description}
|
||||
value={roomType.description}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
|
||||
@@ -3,5 +3,5 @@ import { z } from "zod"
|
||||
import { BedTypeEnum } from "@/types/enums/bedType"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bedType: z.nativeEnum(BedTypeEnum),
|
||||
bedType: z.string(),
|
||||
})
|
||||
|
||||
@@ -17,13 +17,13 @@ import styles from "./breakfast.module.css"
|
||||
import type {
|
||||
BreakfastFormSchema,
|
||||
BreakfastProps,
|
||||
} from "@/types/components/enterDetails/breakfast"
|
||||
} from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Breakfast({ packages }: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfast = useEnterDetailsStore((state) => state.data.breakfast)
|
||||
const breakfast = useEnterDetailsStore((state) => state.userData.breakfast)
|
||||
|
||||
let defaultValues = undefined
|
||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
@@ -78,8 +78,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
? intl.formatMessage<React.ReactNode>(
|
||||
{ id: "breakfast.price.free" },
|
||||
{
|
||||
amount: pkg.originalPrice,
|
||||
currency: pkg.currency,
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
free: (str) => <Highlight>{str}</Highlight>,
|
||||
strikethrough: (str) => <s>{str}</s>,
|
||||
}
|
||||
@@ -87,8 +87,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
: intl.formatMessage(
|
||||
{ id: "breakfast.price" },
|
||||
{
|
||||
amount: pkg.packagePrice,
|
||||
currency: pkg.currency,
|
||||
amount: pkg.localPrice.price,
|
||||
currency: pkg.localPrice.currency,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import { detailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
@@ -22,21 +21,21 @@ import styles from "./details.module.css"
|
||||
import type {
|
||||
DetailsProps,
|
||||
DetailsSchema,
|
||||
} from "@/types/components/enterDetails/details"
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const initialData = useEnterDetailsStore((state) => ({
|
||||
countryCode: state.data.countryCode,
|
||||
email: state.data.email,
|
||||
firstName: state.data.firstName,
|
||||
lastName: state.data.lastName,
|
||||
phoneNumber: state.data.phoneNumber,
|
||||
join: state.data.join,
|
||||
dateOfBirth: state.data.dateOfBirth,
|
||||
zipCode: state.data.zipCode,
|
||||
termsAccepted: state.data.termsAccepted,
|
||||
countryCode: state.userData.countryCode,
|
||||
email: state.userData.email,
|
||||
firstName: state.userData.firstName,
|
||||
lastName: state.userData.lastName,
|
||||
phoneNumber: state.userData.phoneNumber,
|
||||
join: state.userData.join,
|
||||
dateOfBirth: state.userData.dateOfBirth,
|
||||
zipCode: state.userData.zipCode,
|
||||
termsAccepted: state.userData.termsAccepted,
|
||||
}))
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
@@ -54,7 +53,7 @@ export default function Details({ user }: DetailsProps) {
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : detailsSchema),
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string(),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
termsAccepted: z.literal(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
@@ -36,15 +36,17 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
})
|
||||
)
|
||||
|
||||
export const detailsSchema = z.discriminatedUnion("join", [
|
||||
export const guestDetailsSchema = z.discriminatedUnion("join", [
|
||||
notJoinDetailsSchema,
|
||||
joinDetailsSchema,
|
||||
])
|
||||
|
||||
// For signed in users we accept partial or invalid data. Users cannot
|
||||
// change their info in this flow, so we don't want to validate it.
|
||||
export const signedInDetailsSchema = z.object({
|
||||
countryCode: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: phoneValidator().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Payment({
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { firstName, lastName, email, phoneNumber, countryCode } =
|
||||
useEnterDetailsStore((state) => state.data)
|
||||
useEnterDetailsStore((state) => state.userData)
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import {
|
||||
@@ -7,15 +8,17 @@ import {
|
||||
initEditDetailsState,
|
||||
} from "@/stores/enter-details"
|
||||
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
import { EnterDetailsProviderProps } from "@/types/components/hotelReservation/enterDetails/store"
|
||||
|
||||
export default function EnterDetailsProvider({
|
||||
step,
|
||||
isMember,
|
||||
children,
|
||||
}: PropsWithChildren<{ step: StepEnum }>) {
|
||||
}: PropsWithChildren<EnterDetailsProviderProps>) {
|
||||
const searchParams = useSearchParams()
|
||||
const initialStore = useRef<EnterDetailsStore>()
|
||||
if (!initialStore.current) {
|
||||
initialStore.current = initEditDetailsState(step)
|
||||
initialStore.current = initEditDetailsState(step, searchParams, isMember)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,12 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import {
|
||||
StepEnum,
|
||||
StepStoreKeys,
|
||||
} from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function SectionAccordion({
|
||||
header,
|
||||
@@ -23,9 +28,27 @@ export default function SectionAccordion({
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.navigate)
|
||||
const stepData = useEnterDetailsStore((state) => state.userData)
|
||||
const stepStoreKey = StepStoreKeys[step]
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed) {
|
||||
const value = stepData.bedType
|
||||
value && setTitle(value)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (step === StepEnum.breakfast && stepData.breakfast) {
|
||||
const value = stepData.breakfast
|
||||
if (value === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||
} else {
|
||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||
}
|
||||
}
|
||||
}, [stepData, stepStoreKey, step, intl])
|
||||
|
||||
useEffect(() => {
|
||||
// We need to set the state on mount because of hydration errors
|
||||
@@ -39,7 +62,6 @@ export default function SectionAccordion({
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
||||
<div className={styles.iconWrapper}>
|
||||
@@ -65,7 +87,7 @@ export default function SectionAccordion({
|
||||
className={styles.selection}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{label}
|
||||
{title}
|
||||
</Subtitle>
|
||||
</div>
|
||||
{isComplete && !isOpen && (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomConfiguration } from "@/server/routers/hotels/output"
|
||||
|
||||
import { EditIcon, ImageIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
export default function SelectedRoom() {
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
room,
|
||||
}: {
|
||||
hotelId: string
|
||||
room: RoomConfiguration
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
@@ -22,42 +32,50 @@ export default function SelectedRoom() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.textContainer}>
|
||||
<Footnote
|
||||
className={styles.label}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Your room" })}
|
||||
</Footnote>
|
||||
<div className={styles.text}>
|
||||
{/**
|
||||
* [TEMP]
|
||||
* No translation on Subtitles as they will be derived
|
||||
* from Room selection.
|
||||
*/}
|
||||
<Subtitle
|
||||
className={styles.room}
|
||||
color="uiTextHighContrast"
|
||||
type="two"
|
||||
<div>
|
||||
<div className={styles.textContainer}>
|
||||
<Footnote
|
||||
className={styles.label}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
Cozy cabin
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Free rebooking
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Pay now
|
||||
</Subtitle>
|
||||
{intl.formatMessage({ id: "Your room" })}
|
||||
</Footnote>
|
||||
<div className={styles.text}>
|
||||
{/**
|
||||
* [TEMP]
|
||||
* No translation on Subtitles as they will be derived
|
||||
* from Room selection.
|
||||
*/}
|
||||
<Subtitle
|
||||
className={styles.room}
|
||||
color="uiTextHighContrast"
|
||||
type="two"
|
||||
>
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Free rebooking
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Pay now
|
||||
</Subtitle>
|
||||
</div>
|
||||
</div>
|
||||
{room?.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Contact from "@/components/HotelReservation/Contact"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./enterDetailsSidePeek.module.css"
|
||||
|
||||
import {
|
||||
SidePeekEnum,
|
||||
SidePeekProps,
|
||||
} from "@/types/components/enterDetails/sidePeek"
|
||||
|
||||
export default function EnterDetailsSidePeek({ hotel }: SidePeekProps) {
|
||||
const activeSidePeek = useEnterDetailsStore((state) => state.activeSidePeek)
|
||||
const close = useEnterDetailsStore((state) => state.closeSidePeek)
|
||||
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={SidePeekEnum.hotelDetails}
|
||||
title={intl.formatMessage({ id: "About the hotel" })}
|
||||
isOpen={activeSidePeek === SidePeekEnum.hotelDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<article className={styles.spacing}>
|
||||
<Contact hotel={hotel} />
|
||||
<Divider />
|
||||
<section className={styles.spacing}>
|
||||
<Body>{hotel.hotelContent.texts.descriptions.medium}</Body>
|
||||
{hotel.hotelContent.texts.facilityInformation
|
||||
.split(/[\n\r]/g)
|
||||
.filter((p) => p)
|
||||
.map((paragraph, idx) => (
|
||||
<Body key={`facilityInfo-${idx}`}>{paragraph}</Body>
|
||||
))}
|
||||
</section>
|
||||
</article>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/enterDetails/sidePeek"
|
||||
|
||||
export default function ToggleSidePeek() {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useEnterDetailsStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
openSidePeek(SidePeekEnum.hotelDetails)
|
||||
}}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
<ChevronRightSmallIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatNumber } from "@/utils/format"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
// TEMP
|
||||
const rooms = [
|
||||
{
|
||||
adults: 1,
|
||||
type: "Cozy cabin",
|
||||
},
|
||||
]
|
||||
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Summary({
|
||||
isMember,
|
||||
room,
|
||||
}: {
|
||||
isMember: boolean
|
||||
room: RoomsData
|
||||
}) {
|
||||
const [chosenBed, setChosenBed] = useState<string>()
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
||||
>()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
fromDate: state.roomData.fromDate,
|
||||
toDate: state.roomData.toDate,
|
||||
bedType: state.userData.bedType,
|
||||
breakfast: state.userData.breakfast,
|
||||
})
|
||||
)
|
||||
|
||||
export default async function Summary() {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const fromDate = dt().locale(lang).format("ddd, D MMM")
|
||||
const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM")
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const totalAdults = rooms.reduce((total, room) => total + room.adults, 0)
|
||||
|
||||
const adults = intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: totalAdults }
|
||||
)
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
const addOns = [
|
||||
{
|
||||
price: intl.formatMessage({ id: "Included" }),
|
||||
title: intl.formatMessage({ id: "King bed" }),
|
||||
},
|
||||
{
|
||||
price: intl.formatMessage({ id: "Included" }),
|
||||
title: intl.formatMessage({ id: "Breakfast buffet" }),
|
||||
},
|
||||
]
|
||||
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
|
||||
if (isMember) {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
const mappedRooms = Array.from(
|
||||
rooms
|
||||
.reduce((acc, room) => {
|
||||
const currentRoom = acc.get(room.type)
|
||||
acc.set(room.type, {
|
||||
total: currentRoom ? currentRoom.total + 1 : 1,
|
||||
type: room.type,
|
||||
})
|
||||
return acc
|
||||
}, new Map())
|
||||
.values()
|
||||
)
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
|
||||
if (breakfast) {
|
||||
setChosenBreakfast(breakfast)
|
||||
}
|
||||
}, [bedType, breakfast])
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header>
|
||||
<Body textTransform="bold">
|
||||
{mappedRooms.map(
|
||||
(room, idx) =>
|
||||
`${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}`
|
||||
)}
|
||||
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Body className={styles.date} color="textMediumContrast">
|
||||
{fromDate}
|
||||
<ArrowRightIcon color="uiTextMediumContrast" height={15} width={15} />
|
||||
{toDate}
|
||||
</Body>
|
||||
|
||||
<ToggleSidePeek />
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">{room.roomType}</Body>
|
||||
<Caption color={color}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{`${nights}, ${adults}`}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "4536", currency: "SEK" }
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: room.adults }
|
||||
)}
|
||||
</Caption>
|
||||
{room.children?.length ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: room.children.length }
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Link>
|
||||
</div>
|
||||
{addOns.map((addOn) => (
|
||||
<div className={styles.entry} key={addOn.title}>
|
||||
<Caption color="uiTextMediumContrast">{addOn.title}</Caption>
|
||||
<Caption color="uiTextHighContrast">{addOn.price}</Caption>
|
||||
|
||||
{chosenBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="textHighContrast">{chosenBed}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
{chosenBreakfast ? (
|
||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.entry}>
|
||||
<Body color="textHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.price,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.total}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Total incl VAT" })}
|
||||
</Body>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "4686", currency: "SEK" }
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<div>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "455", currency: "EUR" }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="red" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Member price" })}
|
||||
</Body>
|
||||
<Body color="red" textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "4219", currency: "SEK" }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.entry}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "412", currency: "EUR" }
|
||||
{
|
||||
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
|
||||
currency: room.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: formatNumber(parseInt(room.euroPrice.price ?? "0")),
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
.summary {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.date {
|
||||
@@ -22,7 +21,7 @@
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.entry {
|
||||
@@ -31,6 +30,9 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,120 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import Contact from "../Contact"
|
||||
|
||||
import styles from "./readMore.module.css"
|
||||
|
||||
import {
|
||||
DetailedAmenity,
|
||||
ParkingProps,
|
||||
ReadMoreProps,
|
||||
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { Hotel } from "@/types/hotel"
|
||||
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
|
||||
function getAmenitiesList(hotel: Hotel) {
|
||||
const detailedAmenities: DetailedAmenity[] = Object.entries(
|
||||
hotel.hotelFacts.hotelFacilityDetail
|
||||
).map(([key, value]) => ({ name: key, ...value }))
|
||||
|
||||
// Remove Parking facilities since parking accordion is based on hotel.parking
|
||||
const simpleAmenities = hotel.detailedFacilities.filter(
|
||||
(facility) => !facility.name.startsWith("Parking")
|
||||
)
|
||||
return [...detailedAmenities, ...simpleAmenities]
|
||||
}
|
||||
|
||||
export default function ReadMore({ label, hotel, hotelId }: ReadMoreProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const [sidePeekOpen, setSidePeekOpen] = useState(false)
|
||||
|
||||
const amenitiesList = getAmenitiesList(hotel)
|
||||
export default function ReadMore({ label, hotelId }: ReadMoreProps) {
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setSidePeekOpen(true)
|
||||
}}
|
||||
intent="text"
|
||||
theme="base"
|
||||
wrapping
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Button>
|
||||
<SidePeek
|
||||
title={hotel.name}
|
||||
isOpen={sidePeekOpen}
|
||||
contentKey={`${hotelId}`}
|
||||
handleClose={() => {
|
||||
setSidePeekOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Practical information" })}
|
||||
</Subtitle>
|
||||
<Contact hotel={hotel} />
|
||||
<Accordion>
|
||||
{/* parking */}
|
||||
{hotel.parking.length ? (
|
||||
<AccordionItem title={intl.formatMessage({ id: "Parking" })}>
|
||||
{hotel.parking.map((p) => (
|
||||
<Parking key={p.name} parking={p} />
|
||||
))}
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
<AccordionItem title={intl.formatMessage({ id: "Accessibility" })}>
|
||||
TODO: What content should be in the accessibility section?
|
||||
</AccordionItem>
|
||||
{amenitiesList.map((amenity) => {
|
||||
return "description" in amenity ? (
|
||||
<AccordionItem key={amenity.name} title={amenity.heading}>
|
||||
{amenity.description}
|
||||
</AccordionItem>
|
||||
) : (
|
||||
<div key={amenity.id} className={styles.amenity}>
|
||||
{amenity.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
{/* TODO: handle linking to Hotel Page */}
|
||||
<Button theme={"base"}>To the hotel</Button>
|
||||
</div>
|
||||
</SidePeek>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Parking({ parking }: ParkingProps) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div>
|
||||
<Body>{`${intl.formatMessage({ id: parking.type })} (${parking.name})`}</Body>
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
{`${intl.formatMessage({
|
||||
id: "Number of charging points for electric cars",
|
||||
})}: ${parking.numberOfChargingSpaces}`}
|
||||
</li>
|
||||
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => {
|
||||
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })
|
||||
}}
|
||||
intent="text"
|
||||
theme="base"
|
||||
wrapping
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.container {
|
||||
min-width: 272px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.facilities {
|
||||
@@ -24,3 +25,9 @@
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { FilterIcon, MapIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./mobileMapButtonContainer.module.css"
|
||||
|
||||
export default function MobileMapButtonContainer({ city }: { city: string }) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
asChild
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectHotelMap[lang]}`}
|
||||
keepSearchParams
|
||||
color="burgundy"
|
||||
>
|
||||
<MapIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "See on map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
{/* TODO: Add filter toggle */}
|
||||
<Button
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<FilterIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.buttonContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.hotelListing {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hotelListing {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
// TODO: This component is copied from
|
||||
@@ -7,5 +9,5 @@ import { HotelListingProps } from "@/types/components/hotelReservation/selectHot
|
||||
// Look at that for inspiration on how to do the interaction with the map.
|
||||
|
||||
export default function HotelListing({}: HotelListingProps) {
|
||||
return <section>Hotel listing TBI</section>
|
||||
return <section className={styles.hotelListing}>Hotel listing TBI</section>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { CloseIcon } from "@/components/Icons"
|
||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import HotelListing from "./HotelListing"
|
||||
@@ -22,36 +22,60 @@ export default function SelectHotelMap({
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
mapId,
|
||||
isModal,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
|
||||
function handleModalDismiss() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function handlePageRedirect() {
|
||||
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
|
||||
}
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
asChild
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.closeButton}
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
>
|
||||
<Link href={selectHotel[lang]} keepSearchParams color="burgundy">
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Link>
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
</div>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</div>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,4 +2,39 @@
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.filterContainer .closeButton {
|
||||
color: var(--UI-Text-High-Contrast);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.closeButton {
|
||||
display: flex !important;
|
||||
}
|
||||
.filterContainer {
|
||||
display: none;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
+40
-5
@@ -1,9 +1,14 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import { calculatePricesPerNight } from "./utils"
|
||||
|
||||
import styles from "./priceList.module.css"
|
||||
|
||||
import { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
@@ -11,6 +16,7 @@ import { PriceListProps } from "@/types/components/hotelReservation/selectRate/f
|
||||
export default function PriceList({
|
||||
publicPrice = {},
|
||||
memberPrice = {},
|
||||
petRoomPackage,
|
||||
}: PriceListProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
@@ -19,7 +25,34 @@ export default function PriceList({
|
||||
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
|
||||
memberPrice
|
||||
|
||||
const petRoomLocalPrice = petRoomPackage?.localPrice
|
||||
const petRoomRequestedPrice = petRoomPackage?.requestedPrice
|
||||
|
||||
const showRequestedPrice = publicRequestedPrice && memberRequestedPrice
|
||||
const searchParams = useSearchParams()
|
||||
const fromDate = searchParams.get("fromDate")
|
||||
const toDate = searchParams.get("toDate")
|
||||
|
||||
let nights = 1
|
||||
|
||||
if (fromDate && toDate) {
|
||||
nights = dt(toDate).diff(dt(fromDate), "days")
|
||||
}
|
||||
|
||||
const {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
} = calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
})
|
||||
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
@@ -27,7 +60,9 @@ export default function PriceList({
|
||||
<dt>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={publicLocalPrice ? "uiTextHighContrast" : "disabled"}
|
||||
color={
|
||||
totalPublicLocalPricePerNight ? "uiTextHighContrast" : "disabled"
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Standard price" })}
|
||||
</Caption>
|
||||
@@ -36,7 +71,7 @@ export default function PriceList({
|
||||
{publicLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{publicLocalPrice.pricePerNight}
|
||||
{totalPublicLocalPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{publicLocalPrice.currency}
|
||||
@@ -63,7 +98,7 @@ export default function PriceList({
|
||||
{memberLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="red">
|
||||
{memberLocalPrice.pricePerNight}
|
||||
{totalMemberLocalPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="red" textTransform="bold">
|
||||
{memberLocalPrice.currency}
|
||||
@@ -91,8 +126,8 @@ export default function PriceList({
|
||||
<dd>
|
||||
{showRequestedPrice ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{publicRequestedPrice.pricePerNight}/
|
||||
{memberRequestedPrice.pricePerNight}{" "}
|
||||
{totalPublicRequestedPricePerNight}/
|
||||
{totalMemberRequestedPricePerNight}{" "}
|
||||
{publicRequestedPrice.currency}
|
||||
</Caption>
|
||||
) : (
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { CalculatePricesPerNightProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
|
||||
export function calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
}: CalculatePricesPerNightProps) {
|
||||
const totalPublicLocalPricePerNight = publicLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Number(publicLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
: Number(publicLocalPrice.pricePerNight)
|
||||
: undefined
|
||||
|
||||
const totalMemberLocalPricePerNight = memberLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Number(memberLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
: Number(memberLocalPrice.pricePerNight)
|
||||
: undefined
|
||||
|
||||
const totalPublicRequestedPricePerNight = publicRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Number(publicRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
: Number(publicRequestedPrice.pricePerNight)
|
||||
: undefined
|
||||
|
||||
const totalMemberRequestedPricePerNight = memberRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Number(memberRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
: Number(memberRequestedPrice.pricePerNight)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export default function FlexibilityOption({
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
features,
|
||||
petRoomPackage,
|
||||
handleSelectRate,
|
||||
}: FlexibilityOptionProps) {
|
||||
const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined)
|
||||
@@ -113,7 +114,11 @@ export default function FlexibilityOption({
|
||||
<Caption color="uiTextHighContrast">{name}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
<PriceTable publicPrice={publicPrice} memberPrice={memberPrice} />
|
||||
<PriceTable
|
||||
publicPrice={publicPrice}
|
||||
memberPrice={memberPrice}
|
||||
petRoomPackage={petRoomPackage}
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
color="blue"
|
||||
className={styles.checkIcon}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { differenceInCalendarDays } from "date-fns"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -36,12 +37,12 @@ export default function RateSummary({
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
|
||||
const petRoomPrice = petRoomPackage?.calculatedPrice ?? null
|
||||
const petRoomCurrency = petRoomPackage?.currency ?? null
|
||||
const petRoomPrice = petRoomPackage?.localPrice.totalPrice ?? null
|
||||
const petRoomCurrency = petRoomPackage?.localPrice.currency ?? null
|
||||
|
||||
const checkInDate = new Date(roomsAvailability.checkInDate)
|
||||
const checkOutDate = new Date(roomsAvailability.checkOutDate)
|
||||
const nights = differenceInCalendarDays(checkOutDate, checkInDate)
|
||||
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
||||
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -13,26 +14,29 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ImageGallery from "../../ImageGallery"
|
||||
import { getIconForFeatureCode } from "../../utils"
|
||||
import RoomSidePeek from "../RoomSidePeek"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export default function RoomCard({
|
||||
hotelId,
|
||||
rateDefinitions,
|
||||
roomConfiguration,
|
||||
roomCategories,
|
||||
selectedPackages,
|
||||
packages,
|
||||
handleSelectRate,
|
||||
}: RoomCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const rates = {
|
||||
saveRate: rateDefinitions.find(
|
||||
(rate) => rate.cancellationRule === "NonCancellable"
|
||||
(rate) => rate.cancellationRule === "NotCancellable"
|
||||
),
|
||||
changeRate: rateDefinitions.find(
|
||||
(rate) => rate.cancellationRule === "Modifiable"
|
||||
(rate) => rate.cancellationRule === "Changeable"
|
||||
),
|
||||
flexRate: rateDefinitions.find(
|
||||
(rate) => rate.cancellationRule === "CancellableBefore6PM"
|
||||
@@ -54,9 +58,15 @@ export default function RoomCard({
|
||||
?.generalTerms
|
||||
}
|
||||
|
||||
const petRoomPackage =
|
||||
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find(
|
||||
(room) => room.name === roomConfiguration.roomType
|
||||
)
|
||||
|
||||
const { roomSize, occupancy, descriptions, images } = selectedRoom || {}
|
||||
const mainImage = images?.[0]
|
||||
|
||||
@@ -78,10 +88,12 @@ export default function RoomCard({
|
||||
: `${roomSize?.min}-${roomSize?.max}`}
|
||||
m²
|
||||
</Caption>
|
||||
<RoomSidePeek
|
||||
selectedRoom={selectedRoom}
|
||||
roomConfiguration={roomConfiguration}
|
||||
/>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.roomDetails}>
|
||||
@@ -117,6 +129,7 @@ export default function RoomCard({
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
features={roomConfiguration.features}
|
||||
petRoomPackage={petRoomPackage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -133,15 +146,17 @@ export default function RoomCard({
|
||||
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
|
||||
</span>
|
||||
)}
|
||||
{roomConfiguration.features.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackages.includes(feature.code))
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ImageGallery from "../../ImageGallery"
|
||||
import { getFacilityIcon } from "./facilityIcon"
|
||||
|
||||
import styles from "./roomSidePeek.module.css"
|
||||
|
||||
import type { RoomSidePeekProps } from "@/types/components/hotelReservation/selectRate/roomSidePeek"
|
||||
|
||||
export default function RoomSidePeek({
|
||||
selectedRoom,
|
||||
roomConfiguration,
|
||||
}: RoomSidePeekProps) {
|
||||
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false)
|
||||
const intl = useIntl()
|
||||
|
||||
const roomSize = selectedRoom?.roomSize
|
||||
const occupancy = selectedRoom?.occupancy.total
|
||||
const roomDescription = selectedRoom?.descriptions.medium
|
||||
const images = selectedRoom?.images
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.button}
|
||||
onClick={() => setIsSidePeekOpen(true)}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
|
||||
<SidePeek
|
||||
title={roomConfiguration.roomType}
|
||||
isOpen={isSidePeekOpen}
|
||||
handleClose={() => setIsSidePeekOpen(false)}
|
||||
>
|
||||
<Body color="baseTextMediumContrast">
|
||||
{roomSize?.min === roomSize?.max
|
||||
? roomSize?.min
|
||||
: `${roomSize?.min} - ${roomSize?.max}`}
|
||||
m².{" "}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "booking.accommodatesUpTo",
|
||||
},
|
||||
{ nrOfGuests: occupancy }
|
||||
)}
|
||||
</Body>
|
||||
|
||||
{images && (
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery images={images} title={roomConfiguration.roomType} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Body className={styles.description} color="uiTextHighContrast">
|
||||
{roomDescription}
|
||||
</Body>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })}
|
||||
</Subtitle>
|
||||
<ul className={styles.facilityList}>
|
||||
{selectedRoom?.roomFacilities
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
const Icon = getFacilityIcon(facility.name)
|
||||
|
||||
return (
|
||||
<li key={facility.name}>
|
||||
{Icon && <Icon color="uiTextMediumContrast" />}
|
||||
<Body
|
||||
asChild
|
||||
className={!Icon ? styles.noIcon : undefined}
|
||||
color="uiTextMediumContrast"
|
||||
>
|
||||
<span>{facility.name}</span>
|
||||
</Body>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</SidePeek>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
.button {
|
||||
margin-left: auto;
|
||||
padding: 0 0 0 var(--Spacing-x-half);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
min-height: 185px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: var(--Spacing-x-one-and-half);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
.facilityList {
|
||||
margin-top: var(--Spacing-x-one-and-half);
|
||||
column-count: 2;
|
||||
column-gap: var(--Spacing-x2);
|
||||
}
|
||||
.facilityList li {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.noIcon {
|
||||
margin-left: var(--Spacing-x4);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
||||
|
||||
import RateSummary from "./RateSummary"
|
||||
import RoomCard from "./RoomCard"
|
||||
import getHotelReservationQueryParams from "./utils"
|
||||
import { getHotelReservationQueryParams } from "./utils"
|
||||
|
||||
import styles from "./roomSelection.module.css"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function RoomSelection({
|
||||
roomCategories,
|
||||
user,
|
||||
packages,
|
||||
selectedPackages,
|
||||
}: RoomSelectionProps) {
|
||||
const [rateSummary, setRateSummary] = useState<Rate | null>(null)
|
||||
|
||||
@@ -42,10 +43,13 @@ export default function RoomSelection({
|
||||
rateSummary.member.rateCode
|
||||
)
|
||||
}
|
||||
if (selectedPackages.length > 0) {
|
||||
params.set(`room[${index}].packages`, selectedPackages.join(","))
|
||||
}
|
||||
})
|
||||
|
||||
return params
|
||||
}, [searchParams, rateSummary])
|
||||
}, [searchParams, rateSummary, selectedPackages])
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
@@ -63,10 +67,13 @@ export default function RoomSelection({
|
||||
{roomConfigurations.map((roomConfiguration) => (
|
||||
<li key={roomConfiguration.roomTypeCode}>
|
||||
<RoomCard
|
||||
hotelId={roomsAvailability.hotelId.toString()}
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomCategories={roomCategories}
|
||||
handleSelectRate={setRateSummary}
|
||||
selectedPackages={selectedPackages}
|
||||
packages={packages}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
import { getFormattedUrlQueryParams } from "@/utils/url"
|
||||
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type {
|
||||
Child,
|
||||
SelectRateSearchParams,
|
||||
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
function getHotelReservationQueryParams(searchParams: URLSearchParams) {
|
||||
export function getHotelReservationQueryParams(searchParams: URLSearchParams) {
|
||||
return getFormattedUrlQueryParams(searchParams, {
|
||||
adults: "number",
|
||||
age: "number",
|
||||
}) as SelectRateSearchParams
|
||||
}
|
||||
|
||||
export default getHotelReservationQueryParams
|
||||
const bedTypeMap: Record<number, string> = {
|
||||
[BedTypeEnum.IN_ADULTS_BED]: "ParentsBed",
|
||||
[BedTypeEnum.IN_CRIB]: "Crib",
|
||||
[BedTypeEnum.IN_EXTRA_BED]: "ExtraBed",
|
||||
}
|
||||
|
||||
export function generateChildrenString(children: Child[]): string {
|
||||
return `[${children
|
||||
.map((child) => {
|
||||
const age = child.age
|
||||
const bedType = bedTypeMap[parseInt(child.bed.toString())]
|
||||
return `${age}:${bedType}`
|
||||
})
|
||||
.join(",")}]`
|
||||
}
|
||||
|
||||
export function mapChildrenFromString(rawChildrenString: string) {
|
||||
const children = rawChildrenString.split(",")
|
||||
return children.map((child) => {
|
||||
const [age, bed] = child.split(":")
|
||||
return {
|
||||
age: parseInt(age),
|
||||
bed: BedTypeEnum[bed as keyof typeof BedTypeEnum],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getQueryParamsForEnterDetails(searchParams: URLSearchParams) {
|
||||
const selectRoomParamsObject = getHotelReservationQueryParams(searchParams)
|
||||
|
||||
const { room } = selectRoomParamsObject
|
||||
return {
|
||||
...selectRoomParamsObject,
|
||||
adults: room[0].adults, // TODO: Handle multiple rooms
|
||||
children: room[0].child, // TODO: Handle multiple rooms and children
|
||||
roomTypeCode: room[0].roomtype,
|
||||
rateCode: room[0].ratecode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import RoomSelection from "../RoomSelection"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import {
|
||||
RoomPackageCodeEnum,
|
||||
type RoomPackageCodes,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
|
||||
export default function Rooms({
|
||||
@@ -16,20 +20,25 @@ export default function Rooms({
|
||||
roomCategories = [],
|
||||
user,
|
||||
packages,
|
||||
}: RoomSelectionProps) {
|
||||
const defaultRooms = roomsAvailability.roomConfigurations.filter(
|
||||
(room) => room.features.length === 0
|
||||
)
|
||||
}: Omit<RoomSelectionProps, "selectedPackages">) {
|
||||
const defaultRooms = roomsAvailability.roomConfigurations
|
||||
const [rooms, setRooms] = useState<RoomsAvailability>({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: defaultRooms,
|
||||
})
|
||||
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
|
||||
[]
|
||||
)
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(filter: Record<string, boolean | undefined>) => {
|
||||
const selectedCodes = Object.keys(filter).filter((key) => filter[key])
|
||||
(filter: Record<RoomPackageCodeEnum, boolean | undefined>) => {
|
||||
const filteredPackages = Object.keys(filter).filter(
|
||||
(key) => filter[key as RoomPackageCodeEnum]
|
||||
) as RoomPackageCodeEnum[]
|
||||
|
||||
if (selectedCodes.length === 0) {
|
||||
setSelectedPackages(filteredPackages)
|
||||
|
||||
if (filteredPackages.length === 0) {
|
||||
setRooms({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: defaultRooms,
|
||||
@@ -39,8 +48,8 @@ export default function Rooms({
|
||||
|
||||
const filteredRooms = roomsAvailability.roomConfigurations.filter(
|
||||
(room) =>
|
||||
selectedCodes.every((selectedCode) =>
|
||||
room.features.some((feature) => feature.code === selectedCode)
|
||||
filteredPackages.every((filteredPackage) =>
|
||||
room.features.some((feature) => feature.code === filteredPackage)
|
||||
)
|
||||
)
|
||||
setRooms({ ...roomsAvailability, roomConfigurations: filteredRooms })
|
||||
@@ -60,6 +69,7 @@ export default function Rooms({
|
||||
roomCategories={roomCategories}
|
||||
user={user}
|
||||
packages={packages}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function SelectionCard({
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div>
|
||||
<Title className={styles.name} as="h5" level="h3">
|
||||
<Title className={styles.name} as="h4" level="h3">
|
||||
{title}
|
||||
</Title>
|
||||
<div className={styles.nameInfo}>i</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import HotelSidePeek from "@/components/SidePeeks/HotelSidePeek"
|
||||
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { HotelData } from "@/types/hotel"
|
||||
|
||||
export default function HotelReservationSidePeek({
|
||||
hotel,
|
||||
}: {
|
||||
hotel: HotelData | null
|
||||
}) {
|
||||
const activeSidePeek = useSidePeekStore((state) => state.activeSidePeek)
|
||||
const hotelId = useSidePeekStore((state) => state.hotelId)
|
||||
const roomTypeCode = useSidePeekStore((state) => state.roomTypeCode)
|
||||
const close = useSidePeekStore((state) => state.closeSidePeek)
|
||||
const lang = useLang()
|
||||
|
||||
const { data: hotelData } = trpc.hotel.hotelData.get.useQuery(
|
||||
{
|
||||
hotelId: hotelId ?? "",
|
||||
language: lang,
|
||||
},
|
||||
{
|
||||
enabled: !!hotelId,
|
||||
initialData: hotel ?? undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const selectedRoom = hotelData?.included?.find((room) =>
|
||||
room.roomTypes.some((type) => type.code === roomTypeCode)
|
||||
)
|
||||
|
||||
if (activeSidePeek) {
|
||||
return (
|
||||
<>
|
||||
{hotelData && (
|
||||
<HotelSidePeek
|
||||
hotel={hotelData.data?.attributes}
|
||||
activeSidePeek={activeSidePeek}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
{selectedRoom && (
|
||||
<RoomSidePeek
|
||||
room={selectedRoom}
|
||||
activeSidePeek={activeSidePeek}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function FilterIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.58789 15.8125C9.46046 15.8125 9.34813 15.7681 9.25091 15.6792C9.15368 15.5903 9.10507 15.4819 9.10507 15.3542V10.7292L4.33424 4.9375C4.21618 4.78472 4.19886 4.62153 4.28226 4.44792C4.36568 4.27431 4.508 4.1875 4.70924 4.1875H15.2926C15.4938 4.1875 15.6361 4.27431 15.7196 4.44792C15.803 4.62153 15.7856 4.78472 15.6676 4.9375L10.8967 10.7292V15.3542C10.8967 15.4819 10.8489 15.5903 10.7533 15.6792C10.6577 15.7681 10.5462 15.8125 10.4188 15.8125H9.58789ZM10.0009 9.625L13.3134 5.58333H6.66757L10.0009 9.625Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export { default as ErrorCircleIcon } from "./ErrorCircle"
|
||||
export { default as EyeHideIcon } from "./EyeHide"
|
||||
export { default as EyeShowIcon } from "./EyeShow"
|
||||
export { default as FanIcon } from "./Fan"
|
||||
export { default as FilterIcon } from "./Filter"
|
||||
export { default as FitnessIcon } from "./Fitness"
|
||||
export { default as FootstoolIcon } from "./Footstool"
|
||||
export { default as GalleryIcon } from "./Gallery"
|
||||
|
||||
+17
-1
@@ -7,9 +7,25 @@ import type { CSSProperties } from "react"
|
||||
|
||||
import type { ImageProps } from "@/types/components/image"
|
||||
|
||||
function isBlockedByFirewall(src: string): boolean {
|
||||
return (
|
||||
src.includes("test.scandichotels.com") ||
|
||||
src.includes("test2.scandichotels.com") ||
|
||||
src.includes("test3.scandichotels.com") ||
|
||||
src.includes("stage.scandichotels.com") ||
|
||||
src.includes("prod.scandichotels.com")
|
||||
)
|
||||
}
|
||||
|
||||
function imageLoader({ quality, src, width }: ImageLoaderProps) {
|
||||
const isAbsoluteUrl = src.startsWith("https://") || src.startsWith("http://")
|
||||
const hasQS = src.indexOf("?") !== -1
|
||||
return `${src}${hasQS ? "&" : "?"}w=${width}${quality ? "&q=" + quality : ""}`
|
||||
|
||||
if (!isAbsoluteUrl || isBlockedByFirewall(src)) {
|
||||
return `${src}${hasQS ? "&" : "?"}w=${width}${quality ? "&q=" + quality : ""}`
|
||||
}
|
||||
|
||||
return `https://img.scandichotels.com/.netlify/images?url=${src}&w=${width}${quality ? "&q=" + quality : ""}`
|
||||
}
|
||||
|
||||
// Next/Image adds & instead of ? before the params
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import ContentstackLivePreview from "@contentstack/live-preview-utils"
|
||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function InitLivePreview() {
|
||||
@@ -27,7 +27,7 @@ export default function LoginButton({
|
||||
variant?: "default" | "signupVerification"
|
||||
}>) {
|
||||
const lang = useLang()
|
||||
const pathName = useLazyPathname()
|
||||
const pathName = useLazyPathname({ includeSearchParams: true })
|
||||
|
||||
const href = pathName
|
||||
? `${login[lang]}?redirectTo=${encodeURIComponent(pathName)}`
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Dialog, Modal } from "react-aria-components"
|
||||
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import styles from "./mapModal.module.css"
|
||||
|
||||
export function MapModal({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const [mapHeight, setMapHeight] = useState("0px")
|
||||
const [mapTop, setMapTop] = useState("0px")
|
||||
const [isOpen, setOpen] = useState(true)
|
||||
|
||||
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
|
||||
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleOnOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
if (!open) {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
||||
const handleMapHeight = useCallback(() => {
|
||||
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
|
||||
const scrollY = window.scrollY
|
||||
setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`)
|
||||
setMapTop(`${topPosition + scrollY}px`)
|
||||
}, [])
|
||||
|
||||
// Making sure the map is always opened at the top of the page,
|
||||
// just below the header and booking widget as these should stay visible.
|
||||
// When closing, the page should scroll back to the position it was before opening the map.
|
||||
useEffect(() => {
|
||||
// Skip the first render
|
||||
if (!rootDiv.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollHeightWhenOpened === 0) {
|
||||
const scrollY = window.scrollY
|
||||
setScrollHeightWhenOpened(scrollY)
|
||||
window.scrollTo({ top: 0, behavior: "instant" })
|
||||
}
|
||||
}, [scrollHeightWhenOpened, rootDiv])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResizeHandler = debounce(function () {
|
||||
handleMapHeight()
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(debouncedResizeHandler)
|
||||
|
||||
observer.observe(document.documentElement)
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.unobserve(document.documentElement)
|
||||
}
|
||||
}
|
||||
}, [rootDiv, handleMapHeight])
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={rootDiv}>
|
||||
<Modal isDismissable isOpen={isOpen} onOpenChange={handleOnOpenChange}>
|
||||
<Dialog
|
||||
style={
|
||||
{
|
||||
"--hotel-map-height": mapHeight,
|
||||
"--hotel-map-top": mapTop,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={styles.dynamicMap}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.dynamicMap {
|
||||
--hotel-map-height: 100dvh;
|
||||
--hotel-map-top: 0px;
|
||||
position: absolute;
|
||||
top: var(--hotel-map-top);
|
||||
left: 0;
|
||||
height: var(--hotel-map-height);
|
||||
width: 100dvw;
|
||||
z-index: var(--hotel-dynamic-map-z-index);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
@@ -70,7 +70,6 @@ export default function InteractiveMap({
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={activePoi === poi.name ? 2 : 0}
|
||||
onMouseEnter={() => onActivePoiChange(poi.name)}
|
||||
onMouseLeave={() => onActivePoiChange(null)}
|
||||
onClick={() => toggleActivePoi(poi.name)}
|
||||
>
|
||||
<span
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user