Merge master
This commit is contained in:
@@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
<Title
|
||||
as="h4"
|
||||
level="h3"
|
||||
color="red"
|
||||
className={styles.title}
|
||||
textAlign="center"
|
||||
>
|
||||
{intl.formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{intl.formatMessage({ id: "Where should you go next?" })}
|
||||
|
||||
@@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() {
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h4" level="h3" color="red" className={styles.title}>
|
||||
<Title
|
||||
as="h4"
|
||||
level="h3"
|
||||
color="red"
|
||||
className={styles.title}
|
||||
textAlign="center"
|
||||
>
|
||||
{intl.formatMessage({ id: "You have no upcoming stays." })}
|
||||
<span className={styles.burgundyTitle}>
|
||||
{intl.formatMessage({ id: "Where should you go next?" })}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./hotelListingItem.module.css"
|
||||
|
||||
@@ -47,7 +48,7 @@ export default async function HotelListingItem({
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: distanceToCentre }
|
||||
{ number: getSingleDecimal(distanceToCentre / 1000) }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./introSection.module.css"
|
||||
|
||||
@@ -25,7 +26,7 @@ export default async function IntroSection({
|
||||
const { distanceToCentre } = location
|
||||
const formattedDistanceText = intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: distanceToCentre }
|
||||
{ number: getSingleDecimal(distanceToCentre / 1000) }
|
||||
)
|
||||
const lang = getLang()
|
||||
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import { getRoomNameAsParam } from "../../utils"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
|
||||
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
export function RoomCard({ room }: RoomCardProps) {
|
||||
const { images, name, roomSize, occupancy } = room
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
const size =
|
||||
roomSize?.min === roomSize?.max
|
||||
@@ -51,21 +50,11 @@ export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={() =>
|
||||
openSidePeek({
|
||||
key: SidePeekEnum.roomDetails,
|
||||
hotelId,
|
||||
roomTypeCode: room.roomTypes[0].code,
|
||||
})
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
<Button intent="text" type="button" size="medium" theme="base" asChild>
|
||||
<Link scroll={false} href={`?s=${getRoomNameAsParam(name)}`}>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -15,7 +15,7 @@ import styles from "./rooms.module.css"
|
||||
import type { RoomsProps } from "@/types/components/hotelPage/room"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
|
||||
export function Rooms({ hotelId, rooms }: RoomsProps) {
|
||||
export function Rooms({ rooms }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const showToggleButton = rooms.length > 3
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
||||
@@ -45,7 +45,7 @@ export function Rooms({ hotelId, rooms }: RoomsProps) {
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<div key={room.id}>
|
||||
<RoomCard hotelId={hotelId} room={room} />
|
||||
<RoomCard room={room} />
|
||||
</div>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
|
||||
118
components/ContentType/HotelPage/SidePeeks/Room/index.tsx
Normal file
118
components/ContentType/HotelPage/SidePeeks/Room/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import { getBedIcon } from "@/components/SidePeeks/RoomSidePeek/bedIcon"
|
||||
import { getFacilityIcon } from "@/components/SidePeeks/RoomSidePeek/facilityIcon"
|
||||
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 { getIntl } from "@/i18n"
|
||||
|
||||
import { getRoomNameAsParam } from "../../utils"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import type { RoomSidePeekProps } from "@/types/components/hotelPage/sidepeek/room"
|
||||
|
||||
export default async function RoomSidePeek({ room }: RoomSidePeekProps) {
|
||||
const intl = await getIntl()
|
||||
const { roomSize, occupancy, descriptions, images } = room
|
||||
const roomDescription = descriptions.medium
|
||||
const totalOccupancy = occupancy.total
|
||||
// TODO: Not defined where this should lead.
|
||||
const ctaUrl = ""
|
||||
|
||||
return (
|
||||
<SidePeek contentKey={getRoomNameAsParam(room.name)} title={room.name}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.innerContent}>
|
||||
<Body color="baseTextMediumContrast">
|
||||
{roomSize.min === roomSize.max
|
||||
? roomSize.min
|
||||
: `${roomSize.min} - ${roomSize.max}`}
|
||||
m².{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.accommodatesUpTo" },
|
||||
{ nrOfGuests: totalOccupancy }
|
||||
)}
|
||||
</Body>
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery images={images} title={room.name} height={280} />
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">{roomDescription}</Body>
|
||||
</div>
|
||||
|
||||
<div className={styles.innerContent}>
|
||||
<Subtitle type="two" color="uiTextHighContrast" asChild>
|
||||
<h3>
|
||||
{intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })}
|
||||
</h3>
|
||||
</Subtitle>
|
||||
<ul className={styles.facilityList}>
|
||||
{room.roomFacilities
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
const Icon = getFacilityIcon(facility.icon)
|
||||
return (
|
||||
<li className={styles.listItem} key={facility.name}>
|
||||
{Icon && (
|
||||
<Icon
|
||||
width={24}
|
||||
height={24}
|
||||
color="uiTextMediumContrast"
|
||||
/>
|
||||
)}
|
||||
<Body
|
||||
asChild
|
||||
className={!Icon ? styles.noIcon : undefined}
|
||||
color="uiTextMediumContrast"
|
||||
>
|
||||
<span>{facility.name}</span>
|
||||
</Body>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.innerContent}>
|
||||
<Subtitle type="two" color="uiTextHighContrast" asChild>
|
||||
<h3>{intl.formatMessage({ id: "booking.bedOptions" })}</h3>
|
||||
</Subtitle>
|
||||
<Body color="grey">
|
||||
{intl.formatMessage({ id: "booking.basedOnAvailability" })}
|
||||
</Body>
|
||||
<ul className={styles.bedOptions}>
|
||||
{room.roomTypes.map((roomType) => {
|
||||
const BedIcon = getBedIcon(roomType.mainBed.type)
|
||||
return (
|
||||
<li className={styles.listItem} key={roomType.code}>
|
||||
{BedIcon && (
|
||||
<BedIcon
|
||||
color="uiTextMediumContrast"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
)}
|
||||
<Body color="uiTextMediumContrast" asChild>
|
||||
<span>{roomType.mainBed.description}</span>
|
||||
</Body>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{ctaUrl && (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button fullWidth theme="base" intent="primary" asChild>
|
||||
<Link href={ctaUrl}>
|
||||
{intl.formatMessage({ id: "booking.selectRoom" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
margin-bottom: calc(
|
||||
var(--Spacing-x4) * 2 + 80px
|
||||
); /* Creates space between the wrapper and buttonContainer */
|
||||
}
|
||||
.innerContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.facilityList {
|
||||
column-count: 2;
|
||||
column-gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bedOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.noIcon {
|
||||
margin-left: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
|
||||
export { default as RoomSidePeek } from "./Room"
|
||||
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import hotelPageParams from "@/constants/routes/hotelPageParams"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import {
|
||||
activities,
|
||||
amenities,
|
||||
meetingsAndConferences,
|
||||
restaurantAndBar,
|
||||
} from "@/constants/routes/hotelPageParams"
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import { getHotelData, getHotelPage } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import AccordionSection from "@/components/Blocks/Accordion"
|
||||
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"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { getRestaurantHeading } from "@/utils/facilityCards"
|
||||
import { generateHotelSchema } from "@/utils/jsonSchemas"
|
||||
|
||||
import DynamicMap from "./Map/DynamicMap"
|
||||
import MapCard from "./Map/MapCard"
|
||||
@@ -21,60 +28,101 @@ import Facilities from "./Facilities"
|
||||
import IntroSection from "./IntroSection"
|
||||
import PreviewImages from "./PreviewImages"
|
||||
import { Rooms } from "./Rooms"
|
||||
import { AboutTheHotelSidePeek, WellnessAndExerciseSidePeek } from "./SidePeeks"
|
||||
import {
|
||||
AboutTheHotelSidePeek,
|
||||
RoomSidePeek,
|
||||
WellnessAndExerciseSidePeek,
|
||||
} from "./SidePeeks"
|
||||
import TabNavigation from "./TabNavigation"
|
||||
|
||||
import styles from "./hotelPage.module.css"
|
||||
|
||||
import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities"
|
||||
import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage"
|
||||
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
|
||||
import type { Facility } from "@/types/hotel"
|
||||
|
||||
export default async function HotelPage() {
|
||||
const intl = await getIntl()
|
||||
export default async function HotelPage({ hotelId }: HotelPageProps) {
|
||||
const lang = getLang()
|
||||
const [intl, hotelPageData, hotelData] = await Promise.all([
|
||||
getIntl(),
|
||||
getHotelPage(),
|
||||
getHotelData({ hotelId, language: lang }),
|
||||
])
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const hotelData = await serverClient().hotel.get()
|
||||
if (!hotelData) {
|
||||
return null
|
||||
|
||||
if (!hotelData?.data || !hotelPageData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const jsonSchema = generateHotelSchema(hotelData.data.attributes)
|
||||
const { faq, content } = hotelPageData
|
||||
const {
|
||||
hotelId,
|
||||
hotelName,
|
||||
hotelDescriptions,
|
||||
hotelLocation,
|
||||
hotelAddress,
|
||||
hotelRatings,
|
||||
hotelDetailedFacilities,
|
||||
hotelImages,
|
||||
roomCategories,
|
||||
activitiesCard,
|
||||
name,
|
||||
address,
|
||||
pointsOfInterest,
|
||||
facilities,
|
||||
faq,
|
||||
alerts,
|
||||
gallery,
|
||||
specialAlerts,
|
||||
healthAndWellness,
|
||||
restaurantImages,
|
||||
conferencesAndMeetings,
|
||||
hotelContent,
|
||||
detailedFacilities,
|
||||
healthFacilities,
|
||||
contact,
|
||||
socials,
|
||||
ecoLabels,
|
||||
} = hotelData
|
||||
contactInformation,
|
||||
socialMedia,
|
||||
hotelFacts,
|
||||
location,
|
||||
ratings,
|
||||
} = hotelData.data.attributes
|
||||
const roomCategories =
|
||||
hotelData.included?.filter((item) => item.type === "roomcategories") || []
|
||||
const images = gallery?.smallerImages
|
||||
const description = hotelContent.texts.descriptions.short
|
||||
const activitiesCard = content?.[0]?.upcoming_activities_card || null
|
||||
|
||||
const facilities: Facility[] = [
|
||||
{
|
||||
...restaurantImages,
|
||||
id: FacilityCardTypeEnum.restaurant,
|
||||
headingText: restaurantImages?.headingText ?? "",
|
||||
heroImages: restaurantImages?.heroImages ?? [],
|
||||
},
|
||||
{
|
||||
...conferencesAndMeetings,
|
||||
id: FacilityCardTypeEnum.conference,
|
||||
headingText: conferencesAndMeetings?.headingText ?? "",
|
||||
heroImages: conferencesAndMeetings?.heroImages ?? [],
|
||||
},
|
||||
{
|
||||
...healthAndWellness,
|
||||
id: FacilityCardTypeEnum.wellness,
|
||||
headingText: healthAndWellness?.headingText ?? "",
|
||||
heroImages: healthAndWellness?.heroImages ?? [],
|
||||
},
|
||||
]
|
||||
|
||||
const topThreePois = pointsOfInterest.slice(0, 3)
|
||||
|
||||
const coordinates = {
|
||||
lat: hotelLocation.latitude,
|
||||
lng: hotelLocation.longitude,
|
||||
lat: location.latitude,
|
||||
lng: location.longitude,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pageContainer}>
|
||||
<script
|
||||
type={jsonSchema.type}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonSchema.jsonLd),
|
||||
}}
|
||||
/>
|
||||
<div className={styles.hotelImages}>
|
||||
{hotelImages?.length && (
|
||||
<PreviewImages images={hotelImages} hotelName={hotelName} />
|
||||
)}
|
||||
{images?.length && <PreviewImages images={images} hotelName={name} />}
|
||||
</div>
|
||||
<TabNavigation
|
||||
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
|
||||
restaurantTitle={getRestaurantHeading(detailedFacilities)}
|
||||
hasActivities={!!activitiesCard}
|
||||
hasFAQ={!!faq.accordions.length}
|
||||
/>
|
||||
@@ -82,18 +130,18 @@ export default async function HotelPage() {
|
||||
<div id={HotelHashValues.overview} className={styles.overview}>
|
||||
<div className={styles.introContainer}>
|
||||
<IntroSection
|
||||
hotelName={hotelName}
|
||||
hotelDescription={hotelDescriptions.descriptions.short}
|
||||
location={hotelLocation}
|
||||
address={hotelAddress}
|
||||
tripAdvisor={hotelRatings?.tripAdvisor}
|
||||
hotelName={name}
|
||||
hotelDescription={description}
|
||||
location={location}
|
||||
address={address}
|
||||
tripAdvisor={ratings?.tripAdvisor}
|
||||
/>
|
||||
|
||||
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
|
||||
<AmenitiesList detailedFacilities={detailedFacilities} />
|
||||
</div>
|
||||
{alerts.length ? (
|
||||
{specialAlerts.length ? (
|
||||
<div className={styles.alertsContainer}>
|
||||
{alerts.map((alert) => (
|
||||
{specialAlerts.map((alert) => (
|
||||
<Alert
|
||||
key={alert.id}
|
||||
type={alert.type}
|
||||
@@ -104,7 +152,7 @@ export default async function HotelPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Rooms hotelId={hotelId} rooms={roomCategories} />
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
{faq.accordions.length > 0 && (
|
||||
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
||||
@@ -114,14 +162,14 @@ export default async function HotelPage() {
|
||||
<>
|
||||
<aside className={styles.mapContainer}>
|
||||
<MapWithCardWrapper>
|
||||
<StaticMap coordinates={coordinates} hotelName={hotelName} />
|
||||
<MapCard hotelName={hotelName} pois={topThreePois} />
|
||||
<StaticMap coordinates={coordinates} hotelName={name} />
|
||||
<MapCard hotelName={name} pois={topThreePois} />
|
||||
</MapWithCardWrapper>
|
||||
</aside>
|
||||
<MobileMapToggle />
|
||||
<DynamicMap
|
||||
apiKey={googleMapsApiKey}
|
||||
hotelName={hotelName}
|
||||
hotelName={name}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
mapId={googleMapId}
|
||||
@@ -129,24 +177,23 @@ export default async function HotelPage() {
|
||||
</>
|
||||
) : null}
|
||||
<SidePeekProvider>
|
||||
{/* eslint-disable import/no-named-as-default-member */}
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.amenities[lang]}
|
||||
contentKey={amenities[lang]}
|
||||
title={intl.formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
{/* TODO: Render amenities as per the design. */}
|
||||
Read more about the amenities here
|
||||
</SidePeek>
|
||||
<AboutTheHotelSidePeek
|
||||
hotelAddress={hotelAddress}
|
||||
coordinates={hotelLocation}
|
||||
contact={contact}
|
||||
socials={socials}
|
||||
ecoLabels={ecoLabels}
|
||||
descriptions={hotelDescriptions}
|
||||
hotelAddress={address}
|
||||
coordinates={location}
|
||||
contact={contactInformation}
|
||||
socials={socialMedia}
|
||||
ecoLabels={hotelFacts.ecoLabels}
|
||||
descriptions={hotelContent.texts}
|
||||
/>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.restaurantAndBar[lang]}
|
||||
contentKey={restaurantAndBar[lang]}
|
||||
title={intl.formatMessage({ id: "Restaurant & Bar" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
@@ -157,22 +204,23 @@ export default async function HotelPage() {
|
||||
buttonUrl="#"
|
||||
/>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.activities[lang]}
|
||||
contentKey={activities[lang]}
|
||||
title={intl.formatMessage({ id: "Activities" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Activities
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.meetingsAndConferences[lang]}
|
||||
contentKey={meetingsAndConferences[lang]}
|
||||
title={intl.formatMessage({ id: "Meetings & Conferences" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Meetings & Conferences
|
||||
</SidePeek>
|
||||
{/* eslint-enable import/no-named-as-default-member */}
|
||||
{roomCategories.map((room) => (
|
||||
<RoomSidePeek key={room.name} room={room} />
|
||||
))}
|
||||
</SidePeekProvider>
|
||||
<HotelReservationSidePeek hotel={null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
3
components/ContentType/HotelPage/utils.ts
Normal file
3
components/ContentType/HotelPage/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getRoomNameAsParam(roomName: string) {
|
||||
return roomName.replace(/[()]/g, "").replaceAll(" ", "-").toLowerCase()
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 99999;
|
||||
z-index: var(--header-z-index);
|
||||
height: var(--current-mobile-site-header-height);
|
||||
max-width: var(--max-width-navigation);
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -33,9 +33,18 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
|
||||
const [isSelectingFrom, setIsSelectingFrom] = useState(true)
|
||||
|
||||
function close() {
|
||||
const close = useCallback(() => {
|
||||
if (!selectedDate.toDate) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-DD"),
|
||||
})
|
||||
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [name, setValue, selectedDate])
|
||||
|
||||
function showOnFocus() {
|
||||
setIsOpen(true)
|
||||
@@ -72,19 +81,10 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
const closeIfOutside = useCallback(
|
||||
(target: HTMLElement) => {
|
||||
if (ref.current && target && !ref.current.contains(target)) {
|
||||
if (!selectedDate.toDate) {
|
||||
setValue(name, {
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: dt(selectedDate.fromDate)
|
||||
.add(1, "day")
|
||||
.format("YYYY-MM-DD"),
|
||||
})
|
||||
setIsSelectingFrom(true)
|
||||
}
|
||||
setIsOpen(false)
|
||||
close()
|
||||
}
|
||||
},
|
||||
[setIsOpen, setValue, setIsSelectingFrom, selectedDate, name, ref]
|
||||
[close, ref]
|
||||
)
|
||||
|
||||
function closeOnBlur(evt: FocusEvent) {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
content: "·";
|
||||
margin-left: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&::after {
|
||||
content: "";
|
||||
@@ -56,12 +57,14 @@
|
||||
.details {
|
||||
padding: var(--Spacing-x6) var(--Spacing-x5) var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.bottomContainer {
|
||||
border-top: 1px solid var(--Base-Text-Medium-contrast);
|
||||
padding-top: var(--Spacing-x2);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navigationContainer {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
.mainNavigationItem {
|
||||
padding: var(--Spacing-x3) 0;
|
||||
border-bottom: 1px solid var(--Base-Border-Normal);
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -66,11 +66,13 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
zipCode: "",
|
||||
},
|
||||
password: "",
|
||||
termsAccepted: false,
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
resolver: zodResolver(signUpSchema),
|
||||
reValidateMode: "onChange",
|
||||
shouldFocusError: true,
|
||||
})
|
||||
|
||||
async function onSubmit(data: SignUpSchema) {
|
||||
@@ -145,7 +147,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
</header>
|
||||
<NewPassword
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
label={intl.formatMessage({ id: "Password" })}
|
||||
/>
|
||||
</section>
|
||||
@@ -157,17 +158,21 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
</header>
|
||||
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
|
||||
})}{" "}
|
||||
<Link
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
|
||||
</Link>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "signupPage.terms" },
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</Checkbox>
|
||||
</section>
|
||||
@@ -181,7 +186,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
{!methods.formState.isValid ? (
|
||||
<Button
|
||||
className={styles.signUpButton}
|
||||
type="button"
|
||||
type="submit"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
onClick={() => methods.trigger()}
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function ChildInfoSelector({
|
||||
{...register(ageFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
isNestedInModal={true}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -114,6 +115,7 @@ export default function ChildInfoSelector({
|
||||
{...register(bedFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
isNestedInModal={true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,11 @@ import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
export default function GuestsRoomsPickerDialog({
|
||||
rooms,
|
||||
onClose,
|
||||
isOverflowed = false,
|
||||
}: {
|
||||
rooms: GuestsRoom[]
|
||||
onClose: () => void
|
||||
isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const doneLabel = intl.formatMessage({ id: "Done" })
|
||||
@@ -124,7 +126,7 @@ export default function GuestsRoomsPickerDialog({
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
position={isOverflowed ? "top" : "bottom"}
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -21,16 +21,19 @@ import styles from "./guests-rooms-picker.module.css"
|
||||
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
|
||||
|
||||
export default function GuestsRoomsPickerForm() {
|
||||
const { watch } = useFormContext()
|
||||
const { watch, trigger } = useFormContext()
|
||||
const rooms = watch("rooms") as GuestsRoom[]
|
||||
|
||||
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
|
||||
const [isDesktop, setIsDesktop] = useState(true)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
const childCount = rooms[0] ? rooms[0].child.length : 0 // ToDo Update for multiroom later
|
||||
|
||||
const htmlElement =
|
||||
typeof window !== "undefined" ? document.querySelector("body") : null
|
||||
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
|
||||
function setOverflowClip(isOpen: boolean) {
|
||||
async function setOverflowClip(isOpen: boolean) {
|
||||
if (htmlElement) {
|
||||
if (isOpen) {
|
||||
htmlElement.style.overflow = "visible"
|
||||
@@ -40,24 +43,86 @@ export default function GuestsRoomsPickerForm() {
|
||||
htmlElement.style.overflow = "clip !important"
|
||||
}
|
||||
}
|
||||
if (!isOpen) {
|
||||
const state = await trigger("rooms")
|
||||
if (state) {
|
||||
setIsOpen(isOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(checkIsDesktop)
|
||||
}, [checkIsDesktop])
|
||||
|
||||
const updateHeight = useCallback(() => {
|
||||
if (typeof window !== undefined) {
|
||||
// Get available space for picker to show without going beyond screen
|
||||
let maxHeight =
|
||||
window.innerHeight -
|
||||
(document.querySelector("#booking-widget")?.getBoundingClientRect()
|
||||
.bottom ?? 0) -
|
||||
50
|
||||
const innerContainerHeight = document
|
||||
.querySelector(".guests_picker_popover")
|
||||
?.getBoundingClientRect().height
|
||||
if (
|
||||
maxHeight != containerHeight &&
|
||||
innerContainerHeight &&
|
||||
maxHeight <= innerContainerHeight
|
||||
) {
|
||||
setContainerHeight(maxHeight)
|
||||
} else if (
|
||||
containerHeight &&
|
||||
innerContainerHeight &&
|
||||
maxHeight > innerContainerHeight
|
||||
) {
|
||||
setContainerHeight(0)
|
||||
}
|
||||
}
|
||||
}, [containerHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== undefined && isDesktop) {
|
||||
updateHeight()
|
||||
}
|
||||
}, [childCount, isDesktop, updateHeight])
|
||||
|
||||
return isDesktop ? (
|
||||
<DialogTrigger onOpenChange={setOverflowClip}>
|
||||
<Trigger rooms={rooms} className={styles.triggerDesktop} />
|
||||
<Popover placement="bottom start" offset={36}>
|
||||
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
|
||||
<Trigger
|
||||
rooms={rooms}
|
||||
className={styles.triggerDesktop}
|
||||
triggerFn={() => {
|
||||
setIsOpen(true)
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
className="guests_picker_popover"
|
||||
placement="bottom start"
|
||||
offset={36}
|
||||
style={containerHeight ? { overflow: "auto" } : {}}
|
||||
>
|
||||
<Dialog className={styles.pickerContainerDesktop}>
|
||||
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
|
||||
{({ close }) => (
|
||||
<PickerForm
|
||||
rooms={rooms}
|
||||
onClose={close}
|
||||
isOverflowed={!!containerHeight}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger>
|
||||
<Trigger rooms={rooms} className={styles.triggerMobile} />
|
||||
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
|
||||
<Trigger
|
||||
rooms={rooms}
|
||||
className={styles.triggerMobile}
|
||||
triggerFn={() => {
|
||||
setIsOpen(true)
|
||||
}}
|
||||
/>
|
||||
<Modal>
|
||||
<Dialog className={styles.pickerContainerMobile}>
|
||||
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
|
||||
@@ -70,14 +135,20 @@ export default function GuestsRoomsPickerForm() {
|
||||
function Trigger({
|
||||
rooms,
|
||||
className,
|
||||
triggerFn,
|
||||
}: {
|
||||
rooms: GuestsRoom[]
|
||||
className: string
|
||||
triggerFn?: () => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<Button className={`${className} ${styles.btn}`} type="button">
|
||||
<Button
|
||||
className={`${className} ${styles.btn}`}
|
||||
type="button"
|
||||
onPress={triggerFn}
|
||||
>
|
||||
<Body>
|
||||
{rooms.map((room, i) => (
|
||||
<span key={i}>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.actions {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.actions {
|
||||
& > button[class*="btn"][class*="icon"][class*="small"] {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: 0;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.actions {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--Spacing-x3);
|
||||
grid-auto-columns: auto;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
CalendarIcon,
|
||||
ContractIcon,
|
||||
DownloadIcon,
|
||||
PrinterIcon,
|
||||
} from "@/components/Icons"
|
||||
import { CalendarAddIcon, DownloadIcon, EditIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./actions.module.css"
|
||||
@@ -15,20 +9,13 @@ export default async function Actions() {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<CalendarIcon />
|
||||
<CalendarAddIcon />
|
||||
{intl.formatMessage({ id: "Add to calendar" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<ContractIcon />
|
||||
{intl.formatMessage({ id: "View terms" })}
|
||||
<EditIcon />
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<PrinterIcon />
|
||||
{intl.formatMessage({ id: "Print confirmation" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<DownloadIcon />
|
||||
{intl.formatMessage({ id: "Download invoice" })}
|
||||
@@ -1,12 +1,12 @@
|
||||
.header,
|
||||
.hgroup {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: var(--Spacing-x3);
|
||||
gap: var(--Spacing-x2);
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.hgroup {
|
||||
@@ -14,5 +14,5 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 560px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import Actions from "./Actions"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
@@ -30,31 +31,15 @@ export default async function Header({
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<hgroup className={styles.hgroup}>
|
||||
<BiroScript color="red" tilted="small" type="two">
|
||||
{intl.formatMessage({ id: "See you soon!" })}
|
||||
</BiroScript>
|
||||
<Title
|
||||
as="h4"
|
||||
color="red"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h2"
|
||||
>
|
||||
<Title as="h2" color="red" textTransform="uppercase" type="h2">
|
||||
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
||||
</Title>
|
||||
<Title
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h1"
|
||||
>
|
||||
<Title as="h2" color="burgundy" textTransform="uppercase" type="h1">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
</hgroup>
|
||||
<Body className={styles.body} textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
<Body className={styles.body}>{text}</Body>
|
||||
<Actions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./room.module.css"
|
||||
|
||||
export default function Room() {
|
||||
return <article className={styles.room}></article>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function Rooms() {
|
||||
return <section className={styles.rooms}></section>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.rooms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
grid-area: booking;
|
||||
}
|
||||
@@ -1,154 +1,5 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } 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 styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export default function Summary() {
|
||||
return <aside className={styles.summary}>SUMMARY</aside>
|
||||
}
|
||||
|
||||
@@ -1,31 +1,4 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
background-color: hotpink;
|
||||
grid-area: summary;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } 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 styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-template-areas:
|
||||
"image"
|
||||
"details"
|
||||
"actions";
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.booking {
|
||||
grid-template-areas:
|
||||
"details image"
|
||||
"actions actions";
|
||||
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Actions from "./Actions"
|
||||
import Details from "./Details"
|
||||
import Header from "./Header"
|
||||
import HotelImage from "./HotelImage"
|
||||
import Summary from "./Summary"
|
||||
import TotalPrice from "./TotalPrice"
|
||||
|
||||
import styles from "./bookingConfirmation.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default function BookingConfirmation({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
return (
|
||||
<>
|
||||
<Header confirmationNumber={confirmationNumber} />
|
||||
<section className={styles.section}>
|
||||
<div className={styles.booking}>
|
||||
<Details confirmationNumber={confirmationNumber} />
|
||||
<HotelImage confirmationNumber={confirmationNumber} />
|
||||
<Actions />
|
||||
</div>
|
||||
{/* Supposed Ancillaries */}
|
||||
<Summary confirmationNumber={confirmationNumber} />
|
||||
<TotalPrice confirmationNumber={confirmationNumber} />
|
||||
{/* Supposed Info Card - Where should it come from?? */}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { KingBedIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -20,12 +19,19 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
||||
const initialBedType = useEnterDetailsStore(
|
||||
(state) => state.formValues?.bedType?.roomTypeCode
|
||||
)
|
||||
const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode)
|
||||
const completeStep = useEnterDetailsStore(
|
||||
(state) => state.actions.completeStep
|
||||
)
|
||||
const updateBedType = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBedType
|
||||
)
|
||||
|
||||
const methods = useForm<BedTypeFormSchema>({
|
||||
defaultValues: bedType ? { bedType } : undefined,
|
||||
defaultValues: initialBedType ? { bedType: initialBedType } : undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeFormSchema),
|
||||
@@ -43,10 +49,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
roomTypeCode: matchingRoom.value,
|
||||
}
|
||||
updateBedType(bedType)
|
||||
completeStep()
|
||||
}
|
||||
},
|
||||
[bedTypes, completeStep, updateBedType]
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -24,20 +23,30 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
export default function Breakfast({ packages }: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfast = useDetailsStore(({ data }) =>
|
||||
data.breakfast
|
||||
? data.breakfast.code
|
||||
: data.breakfast === false
|
||||
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
|
||||
formValues?.breakfast
|
||||
? formValues.breakfast.code
|
||||
: formValues?.breakfast === false
|
||||
? "false"
|
||||
: data.breakfast
|
||||
: undefined
|
||||
)
|
||||
const updateBreakfast = useDetailsStore(
|
||||
const breakfast = useEnterDetailsStore((state) =>
|
||||
state.breakfast
|
||||
? state.breakfast.code
|
||||
: state.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
)
|
||||
const completeStep = useEnterDetailsStore(
|
||||
(state) => state.actions.completeStep
|
||||
)
|
||||
const updateBreakfast = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
defaultValues: breakfast ? { breakfast } : undefined,
|
||||
defaultValues: formValuesBreakfast
|
||||
? { breakfast: formValuesBreakfast }
|
||||
: undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastFormSchema),
|
||||
@@ -52,9 +61,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
} else {
|
||||
updateBreakfast(false)
|
||||
}
|
||||
completeStep()
|
||||
},
|
||||
[completeStep, packages, updateBreakfast]
|
||||
[packages, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./joinScandicFriendsCard.module.css"
|
||||
|
||||
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export default function JoinScandicFriendsCard({
|
||||
name,
|
||||
@@ -65,7 +65,6 @@ export default function JoinScandicFriendsCard({
|
||||
position="enter details"
|
||||
trackingId="join-scandic-friends-enter-details"
|
||||
variant="breadcrumb"
|
||||
target="_blank"
|
||||
>
|
||||
{intl.formatMessage({ id: "Log in" })}
|
||||
</LoginButton>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useCallback } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
@@ -27,45 +26,35 @@ import type {
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const initialData = useDetailsStore((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,
|
||||
membershipNo: state.data.membershipNo,
|
||||
}))
|
||||
|
||||
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
|
||||
const join = useEnterDetailsStore((state) => state.guest.join)
|
||||
const updateDetails = useEnterDetailsStore(
|
||||
(state) => state.actions.updateDetails
|
||||
)
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
defaultValues: {
|
||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||
email: user?.email ?? initialData.email,
|
||||
firstName: user?.firstName ?? initialData.firstName,
|
||||
lastName: user?.lastName ?? initialData.lastName,
|
||||
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
|
||||
join: initialData.join,
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
zipCode: initialData.zipCode,
|
||||
membershipNo: initialData.membershipNo,
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: {
|
||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
email: user?.email ?? initialData.email,
|
||||
firstName: user?.firstName ?? initialData.firstName,
|
||||
join,
|
||||
lastName: user?.lastName ?? initialData.lastName,
|
||||
membershipNo: initialData.membershipNo,
|
||||
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
|
||||
zipCode: initialData.zipCode,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: DetailsSchema) => {
|
||||
updateDetails(values)
|
||||
completeStep()
|
||||
},
|
||||
[completeStep, updateDetails]
|
||||
[updateDetails]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,7 +38,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
join: z.literal<boolean>(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
membershipNo: z.string().optional(),
|
||||
membershipNo: z.string().default(""),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -50,13 +50,16 @@ export const guestDetailsSchema = z.discriminatedUnion("join", [
|
||||
// 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().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
countryCode: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
firstName: z.string().default(""),
|
||||
lastName: z.string().default(""),
|
||||
membershipNo: z.string().default(""),
|
||||
phoneNumber: z.string().default(""),
|
||||
join: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.transform((_) => false),
|
||||
dateOfBirth: z.string().default(""),
|
||||
zipCode: z.string().default(""),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import ChevronRight from "@/components/Icons/ChevronRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({ hotelId }: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="textInverted"
|
||||
wrapping
|
||||
className={styles.toggle}
|
||||
>
|
||||
{intl.formatMessage({ id: "See hotel details" })}
|
||||
<ChevronRight height="14" color="white" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
background-color: rgba(57, 57, 57, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.address {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.wrapper {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.wrapper {
|
||||
padding: var(--Spacing-x6) var(--Spacing-x5);
|
||||
}
|
||||
}
|
||||
53
components/HotelReservation/EnterDetails/Header/index.tsx
Normal file
53
components/HotelReservation/EnterDetails/Header/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Image from "@/components/Image"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
|
||||
|
||||
export default async function HotelHeader({ hotelData }: HotelHeaderProps) {
|
||||
const intl = await getIntl()
|
||||
const hotel = hotelData.data.attributes
|
||||
|
||||
const image = hotel.hotelContent?.images
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Image
|
||||
className={styles.hero}
|
||||
alt={image.metaData.altText || image.metaData.altText_En || ""}
|
||||
src={image.imageSizes.large}
|
||||
height={200}
|
||||
width={1196}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h1" level="h1" color="white">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<address className={styles.address}>
|
||||
<Caption color="white">
|
||||
{hotel.address.streetAddress}, {hotel.address.city}
|
||||
</Caption>
|
||||
<Caption color="white">∙</Caption>
|
||||
<Caption color="white">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</address>
|
||||
</div>
|
||||
<ToggleSidePeek hotelId={hotel.operaId} />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
export default function HistoryStateManager() {
|
||||
const setCurrentStep = useStepsStore((state) => state.setStep)
|
||||
const currentStep = useStepsStore((state) => state.currentStep)
|
||||
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
|
||||
const handleBackButton = useCallback(
|
||||
(event: PopStateEvent) => {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { detailsStorageName } from "@/stores/details"
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import { DetailsState } from "@/types/stores/details"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function PaymentCallback({
|
||||
returnUrl,
|
||||
@@ -25,10 +25,10 @@ export default function PaymentCallback({
|
||||
if (bookingData) {
|
||||
const detailsStorage: Record<
|
||||
"state",
|
||||
Pick<DetailsState, "data">
|
||||
Pick<DetailsState, "booking">
|
||||
> = JSON.parse(bookingData)
|
||||
const searchParams = createQueryParamsForEnterDetails(
|
||||
detailsStorage.state.data.booking,
|
||||
detailsStorage.state.booking,
|
||||
searchObject
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||
import PriceChangeDialog from "../PriceChangeDialog"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 4
|
||||
const retryInterval = 2000
|
||||
|
||||
export const formId = "submit-booking"
|
||||
|
||||
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
||||
}
|
||||
|
||||
export default function PaymentClient({
|
||||
user,
|
||||
roomPrice,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
mustBeGuaranteed,
|
||||
}: PaymentClientProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
||||
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
}))
|
||||
const userData = useEnterDetailsStore((state) => state.guest)
|
||||
const setIsSubmittingDisabled = useEnterDetailsStore(
|
||||
(state) => state.actions.setIsSubmittingDisabled
|
||||
)
|
||||
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
|
||||
const availablePaymentOptions =
|
||||
useAvailablePaymentOptions(otherPaymentOptions)
|
||||
const [priceChangeData, setPriceChangeData] = useState<{
|
||||
oldPrice: number
|
||||
newPrice: number
|
||||
} | null>()
|
||||
|
||||
usePaymentFailedToast()
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.confirmationNumber) {
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
|
||||
if (result.metadata?.priceChangedMetadata) {
|
||||
setPriceChangeData({
|
||||
oldPrice: roomPrice.publicPrice,
|
||||
newPrice: result.metadata.priceChangedMetadata.totalPrice,
|
||||
})
|
||||
} else {
|
||||
setIsPollingForBookingStatus(true)
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const priceChange = trpc.booking.priceChange.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.confirmationNumber) {
|
||||
setIsPollingForBookingStatus(true)
|
||||
} else {
|
||||
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
|
||||
}
|
||||
|
||||
setPriceChangeData(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
setPriceChangeData(null)
|
||||
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
confirmationNumber,
|
||||
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
enabled: isPollingForBookingStatus,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [bookingStatus, router, intl])
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmittingDisabled(
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
)
|
||||
}, [
|
||||
methods.formState.isValid,
|
||||
methods.formState.isSubmitting,
|
||||
setIsSubmittingDisabled,
|
||||
])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: PaymentFormData) => {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNo,
|
||||
join,
|
||||
dateOfBirth,
|
||||
zipCode,
|
||||
} = userData
|
||||
const { toDate, fromDate, rooms, hotel } = booking
|
||||
|
||||
// set payment method to card if saved card is submitted
|
||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||
? data.paymentMethod
|
||||
: PaymentMethodEnum.card
|
||||
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
|
||||
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel,
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
rooms: rooms.map((room) => ({
|
||||
adults: room.adults,
|
||||
childrenAges: room.children?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
rateCode:
|
||||
user || join || membershipNo ? room.counterRateCode : room.rateCode,
|
||||
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
guest: {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNumber: membershipNo,
|
||||
becomeMember: join,
|
||||
dateOfBirth,
|
||||
postalCode: zipCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: !!(breakfast && breakfast.code),
|
||||
allergyFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
|
||||
false,
|
||||
petFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
||||
accessibility:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
|
||||
false,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
roomPrice,
|
||||
})),
|
||||
payment: {
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
error: `${paymentRedirectUrl}/error`,
|
||||
cancel: `${paymentRedirectUrl}/cancel`,
|
||||
},
|
||||
})
|
||||
},
|
||||
[
|
||||
breakfast,
|
||||
bedType,
|
||||
userData,
|
||||
booking,
|
||||
roomPrice,
|
||||
savedCreditCards,
|
||||
lang,
|
||||
user,
|
||||
initiateBooking,
|
||||
]
|
||||
)
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(isPollingForBookingStatus && !bookingStatus.data?.paymentUrl)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
const guaranteeing = intl.formatMessage({ id: "guaranteeing" })
|
||||
const paying = intl.formatMessage({ id: "paying" })
|
||||
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{mustBeGuaranteed ? (
|
||||
<section className={styles.section}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
|
||||
})}
|
||||
</Body>
|
||||
<GuaranteeDetails />
|
||||
</section>
|
||||
) : null}
|
||||
{savedCreditCards?.length ? (
|
||||
<section className={styles.section}>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "MY SAVED CARDS" })}
|
||||
</Body>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
{savedCreditCards?.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
name="paymentMethod"
|
||||
value={savedCreditCard.id}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className={styles.section}>
|
||||
{savedCreditCards?.length ? (
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "booking.terms",
|
||||
},
|
||||
{
|
||||
paymentVerb,
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Checkbox name="termsAndConditions">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I accept the terms and conditions",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</section>
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{priceChangeData ? (
|
||||
<PriceChangeDialog
|
||||
isOpen={!!priceChangeData}
|
||||
oldPrice={priceChangeData.oldPrice}
|
||||
newPrice={priceChangeData.newPrice}
|
||||
currency={totalPrice.local.currency}
|
||||
onCancel={() => {
|
||||
const allSearchParams = searchParams.size
|
||||
? `?${searchParams.toString()}`
|
||||
: ""
|
||||
router.push(`${selectRate(lang)}${allSearchParams}`)
|
||||
}}
|
||||
onAccept={() => priceChange.mutate({ confirmationNumber })}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,362 +1,27 @@
|
||||
"use client"
|
||||
import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Label as AriaLabel } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import PaymentClient from "./PaymentClient"
|
||||
|
||||
import {
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 4
|
||||
const retryInterval = 2000
|
||||
|
||||
export const formId = "submit-booking"
|
||||
|
||||
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
||||
}
|
||||
|
||||
export default function Payment({
|
||||
export default async function Payment({
|
||||
user,
|
||||
roomPrice,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
mustBeGuaranteed,
|
||||
supportedCards,
|
||||
}: PaymentProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
||||
const setIsSubmittingDisabled = useDetailsStore(
|
||||
(state) => state.actions.setIsSubmittingDisabled
|
||||
)
|
||||
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
breakfast,
|
||||
bedType,
|
||||
membershipNo,
|
||||
join,
|
||||
dateOfBirth,
|
||||
zipCode,
|
||||
} = userData
|
||||
const { toDate, fromDate, rooms, hotel } = booking
|
||||
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
||||
useState(otherPaymentOptions)
|
||||
|
||||
usePaymentFailedToast()
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
const savedCreditCards = await getSavedPaymentCardsSafely({
|
||||
supportedCards,
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.confirmationNumber) {
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
confirmationNumber,
|
||||
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (window.ApplePaySession) {
|
||||
setAvailablePaymentOptions(otherPaymentOptions)
|
||||
} else {
|
||||
setAvailablePaymentOptions(
|
||||
otherPaymentOptions.filter(
|
||||
(option) => option !== PaymentMethodEnum.applePay
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [otherPaymentOptions, setAvailablePaymentOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [bookingStatus, router, intl])
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmittingDisabled(
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
)
|
||||
}, [
|
||||
methods.formState.isValid,
|
||||
methods.formState.isSubmitting,
|
||||
setIsSubmittingDisabled,
|
||||
])
|
||||
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
// set payment method to card if saved card is submitted
|
||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||
? data.paymentMethod
|
||||
: PaymentMethodEnum.card
|
||||
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
|
||||
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel,
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
rooms: rooms.map((room) => ({
|
||||
adults: room.adults,
|
||||
childrenAges: room.children?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
rateCode:
|
||||
user || join || membershipNo ? room.counterRateCode : room.rateCode,
|
||||
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
guest: {
|
||||
title: "",
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNumber: membershipNo,
|
||||
becomeMember: join,
|
||||
dateOfBirth,
|
||||
postalCode: zipCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: !!(breakfast && breakfast.code),
|
||||
allergyFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||
petFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
||||
accessibility:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
|
||||
false,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
roomPrice,
|
||||
})),
|
||||
payment: {
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
error: `${paymentRedirectUrl}/error`,
|
||||
cancel: `${paymentRedirectUrl}/cancel`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
const guaranteeing = intl.formatMessage({ id: "guaranteeing" })
|
||||
const paying = intl.formatMessage({ id: "paying" })
|
||||
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{mustBeGuaranteed ? (
|
||||
<section className={styles.section}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
|
||||
})}
|
||||
</Body>
|
||||
<GuaranteeDetails />
|
||||
</section>
|
||||
) : null}
|
||||
{savedCreditCards?.length ? (
|
||||
<section className={styles.section}>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "MY SAVED CARDS" })}
|
||||
</Body>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
{savedCreditCards?.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
name="paymentMethod"
|
||||
value={savedCreditCard.id}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className={styles.section}>
|
||||
{savedCreditCards?.length ? (
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<AriaLabel className={styles.terms}>
|
||||
<Checkbox name="termsAndConditions" />
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "booking.terms",
|
||||
},
|
||||
{
|
||||
paymentVerb,
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</AriaLabel>
|
||||
</section>
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
<PaymentClient
|
||||
user={user}
|
||||
roomPrice={roomPrice}
|
||||
otherPaymentOptions={otherPaymentOptions}
|
||||
savedCreditCards={savedCreditCards}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { InfoCircleIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./priceChangeDialog.module.css"
|
||||
|
||||
import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
|
||||
|
||||
export default function PriceChangeDialog({
|
||||
isOpen,
|
||||
oldPrice,
|
||||
newPrice,
|
||||
currency,
|
||||
onCancel,
|
||||
onAccept,
|
||||
}: PriceChangeDialogProps) {
|
||||
const intl = useIntl()
|
||||
const title = intl.formatMessage({ id: "The price has increased" })
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={styles.overlay}
|
||||
isOpen={isOpen}
|
||||
isKeyboardDismissDisabled
|
||||
>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog aria-label={title} className={styles.dialog}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleContainer}>
|
||||
<InfoCircleIcon height={48} width={48} color="burgundy" />
|
||||
<Title
|
||||
level="h1"
|
||||
as="h3"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
</div>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "The price has increased since you selected your room.",
|
||||
})}
|
||||
<br />
|
||||
{intl.formatMessage({
|
||||
id: "You can still book the room but you need to confirm that you accept the new price",
|
||||
})}
|
||||
<br />
|
||||
<span className={styles.oldPrice}>
|
||||
{intl.formatNumber(oldPrice, { style: "currency", currency })}
|
||||
</span>{" "}
|
||||
<strong className={styles.newPrice}>
|
||||
{intl.formatNumber(newPrice, { style: "currency", currency })}
|
||||
</strong>
|
||||
</Body>
|
||||
</header>
|
||||
<footer className={styles.footer}>
|
||||
<Button intent="secondary" onClick={onCancel}>
|
||||
{intl.formatMessage({ id: "Cancel" })}
|
||||
</Button>
|
||||
<Button onClick={onAccept}>
|
||||
{intl.formatMessage({ id: "Accept new price" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@keyframes modal-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
height: var(--visual-viewport-height);
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
z-index: 100;
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
&[data-entering] {
|
||||
animation: slide-up 200ms;
|
||||
}
|
||||
&[data-exiting] {
|
||||
animation: slide-up 200ms reverse ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: var(--Scandic-Brand-Pale-Peach);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x5) var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.oldPrice {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.newPrice {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
@@ -11,53 +10,50 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export default function SectionAccordion({
|
||||
children,
|
||||
header,
|
||||
label,
|
||||
step,
|
||||
children,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const currentStep = useStepsStore((state) => state.currentStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const isValid = useDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useStepsStore((state) => state.navigate)
|
||||
const stepData = useDetailsStore((state) => state.data)
|
||||
const stepStoreKey = StepStoreKeys[step]
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
|
||||
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
|
||||
bedType: state.bedType,
|
||||
breakfast: state.breakfast,
|
||||
}))
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed) {
|
||||
const value = stepData.bedType
|
||||
value && setTitle(value.description)
|
||||
if (step === StepEnum.selectBed && bedType) {
|
||||
setTitle(bedType.description)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (
|
||||
step === StepEnum.breakfast &&
|
||||
(stepData.breakfast || stepData.breakfast === false)
|
||||
) {
|
||||
const value = stepData.breakfast
|
||||
if (value === false) {
|
||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||
if (step === StepEnum.breakfast && breakfast !== undefined) {
|
||||
if (breakfast === false) {
|
||||
setTitle(noBreakfastTitle)
|
||||
} else {
|
||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||
setTitle(breakfastTitle)
|
||||
}
|
||||
}
|
||||
}, [stepData, stepStoreKey, step, intl])
|
||||
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
|
||||
|
||||
useEffect(() => {
|
||||
// We need to set the state on mount because of hydration errors
|
||||
setIsComplete(isValid)
|
||||
}, [isValid])
|
||||
}, [isValid, setIsComplete])
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(currentStep === step)
|
||||
}, [currentStep, step])
|
||||
}, [currentStep, setIsOpen, step])
|
||||
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
|
||||
@@ -48,8 +48,6 @@
|
||||
}
|
||||
|
||||
.selection {
|
||||
font-weight: 450;
|
||||
font-size: var(--typography-Title-4-fontSize);
|
||||
grid-area: selection;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import ChevronRight from "@/components/Icons/ChevronRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
|
||||
@@ -14,7 +14,7 @@ import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
import { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
|
||||
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
|
||||
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
grid-template-areas:
|
||||
"title button"
|
||||
"title title"
|
||||
"description button";
|
||||
}
|
||||
|
||||
@@ -25,14 +25,13 @@
|
||||
}
|
||||
|
||||
.description {
|
||||
font-weight: 450;
|
||||
font-size: var(--typography-Title-4-fontSize);
|
||||
grid-area: description;
|
||||
}
|
||||
|
||||
.button {
|
||||
grid-area: button;
|
||||
justify-self: flex-end;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { hotelreservation } from "@/constants/routes/hotelReservation"
|
||||
import { detailsStorageName } from "@/stores/details"
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
|
||||
97
components/HotelReservation/EnterDetails/Summary/Client.tsx
Normal file
97
components/HotelReservation/EnterDetails/Summary/Client.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Summary from "@/components/HotelReservation/Summary"
|
||||
import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
breakfast: state.breakfast,
|
||||
fromDate: state.booking.fromDate,
|
||||
join: state.guest.join,
|
||||
membershipNo: state.guest.membershipNo,
|
||||
packages: state.packages,
|
||||
roomRate: state.roomRate,
|
||||
roomPrice: state.roomPrice,
|
||||
toDate: state.booking.toDate,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ClientSummary({
|
||||
adults,
|
||||
cancellationText,
|
||||
isMember,
|
||||
kids,
|
||||
memberRate,
|
||||
rateDetails,
|
||||
roomType,
|
||||
}: ClientSummaryProps) {
|
||||
const {
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
join,
|
||||
membershipNo,
|
||||
packages,
|
||||
roomPrice,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
|
||||
const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo
|
||||
const room = {
|
||||
adults,
|
||||
cancellationText,
|
||||
children: kids,
|
||||
packages,
|
||||
rateDetails,
|
||||
roomPrice,
|
||||
roomType,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.mobileSummary}>
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
bedType={bedType}
|
||||
breakfast={breakfast}
|
||||
fromDate={fromDate}
|
||||
showMemberPrice={showMemberPrice}
|
||||
room={room}
|
||||
toDate={toDate}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
totalPrice={totalPrice}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
</div>
|
||||
<div className={styles.desktopSummary}>
|
||||
<div className={styles.hider} />
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
bedType={bedType}
|
||||
breakfast={breakfast}
|
||||
fromDate={fromDate}
|
||||
showMemberPrice={showMemberPrice}
|
||||
room={room}
|
||||
toDate={toDate}
|
||||
totalPrice={totalPrice}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +1,57 @@
|
||||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ChevronDown } from "react-feather"
|
||||
import { useIntl } from "react-intl"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import {
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Popover from "@/components/TempDesignSystem/Popover"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import ClientSummary from "./Client"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||
import type { DetailsState } from "@/types/stores/details"
|
||||
export default async function Summary({
|
||||
adults,
|
||||
fromDate,
|
||||
hotelId,
|
||||
kids,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
toDate,
|
||||
}: SummaryPageProps) {
|
||||
const lang = getLang()
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
fromDate: state.data.booking.fromDate,
|
||||
toDate: state.data.booking.toDate,
|
||||
bedType: state.data.bedType,
|
||||
breakfast: state.data.breakfast,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
setTotalPrice: state.actions.setTotalPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
join: state.data.join,
|
||||
membershipNo: state.data.membershipNo,
|
||||
const availability = await getSelectedRoomAvailability({
|
||||
adults,
|
||||
children: kids ? generateChildrenString(kids) : undefined,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
roomTypeCode,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
|
||||
if (!availability || !availability.selectedRoom) {
|
||||
console.error("No hotel or availability data", availability)
|
||||
// TODO: handle this case
|
||||
redirect(selectRate(lang))
|
||||
}
|
||||
}
|
||||
|
||||
export default function Summary({ showMemberPrice, room }: SummaryProps) {
|
||||
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | false
|
||||
>()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
setTotalPrice,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
join,
|
||||
membershipNo,
|
||||
} = useDetailsStore(storeSelector)
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
|
||||
const [price, setPrice] = useState(room.prices.public)
|
||||
|
||||
const additionalPackageCost = room.packages?.reduce(
|
||||
(acc, curr) => {
|
||||
acc.local = acc.local + parseInt(curr.localPrice.totalPrice)
|
||||
acc.euro = acc.euro + parseInt(curr.requestedPrice.totalPrice)
|
||||
return acc
|
||||
},
|
||||
{ local: 0, euro: 0 }
|
||||
) || { local: 0, euro: 0 }
|
||||
|
||||
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
|
||||
const roomsPriceEuro = price.euro
|
||||
? price.euro.amount + additionalPackageCost.euro
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (showMemberPrice || join || membershipNo) {
|
||||
color.current = "red"
|
||||
if (room.prices.member) {
|
||||
setPrice(room.prices.member)
|
||||
}
|
||||
} else {
|
||||
color.current = "uiTextHighContrast"
|
||||
setPrice(room.prices.public)
|
||||
}
|
||||
}, [showMemberPrice, join, membershipNo, room.prices])
|
||||
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
|
||||
if (breakfast || breakfast === false) {
|
||||
setChosenBreakfast(breakfast)
|
||||
if (breakfast === false) {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
amount: roomsPriceLocal,
|
||||
currency: price.local.currency,
|
||||
},
|
||||
euro:
|
||||
price.euro && roomsPriceEuro
|
||||
? {
|
||||
amount: roomsPriceEuro,
|
||||
currency: price.euro.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||
currency: price.local.currency,
|
||||
},
|
||||
euro:
|
||||
price.euro && roomsPriceEuro
|
||||
? {
|
||||
amount:
|
||||
roomsPriceEuro +
|
||||
parseInt(breakfast.requestedPrice.totalPrice),
|
||||
currency: price.euro.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
bedType,
|
||||
breakfast,
|
||||
roomsPriceLocal,
|
||||
price.local.currency,
|
||||
price.euro,
|
||||
roomsPriceEuro,
|
||||
setTotalPrice,
|
||||
])
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} 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>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={toggleSummaryOpen}
|
||||
>
|
||||
<ChevronDown height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Caption color={color.current}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(price.local.amount),
|
||||
currency: price.local.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ 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>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
}
|
||||
>
|
||||
<aside className={styles.rateDetailsPopover}>
|
||||
<header>
|
||||
<Caption type="bold">{room.cancellationText}</Caption>
|
||||
</header>
|
||||
{room.rateDetails?.map((detail, idx) => (
|
||||
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||
))}
|
||||
</aside>
|
||||
</Popover>
|
||||
</div>
|
||||
{room.packages
|
||||
? room.packages.map((roomPackage) => (
|
||||
<div className={styles.entry} key={roomPackage.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomPackage.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: roomPackage.localPrice.price,
|
||||
currency: roomPackage.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{chosenBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: price.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chosenBreakfast === false ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: price.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : chosenBreakfast?.code ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.totalPrice,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.total}>
|
||||
<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>
|
||||
{totalPrice.local.amount > 0 && (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.amount),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
)}
|
||||
{totalPrice.euro && totalPrice.euro.amount > 0 && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.amount),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
<ClientSummary
|
||||
adults={adults}
|
||||
cancellationText={availability.cancellationText}
|
||||
isMember={!!user}
|
||||
kids={kids}
|
||||
memberRate={availability.memberRate}
|
||||
rateDetails={availability.rateDetails}
|
||||
roomType={availability.selectedRoom.roomType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +1,68 @@
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
background-color: var(--Main-Grey-White);
|
||||
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
.hider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
.shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
||||
var(--Spacing-x-half)
|
||||
);
|
||||
z-index: 9;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: block;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: block;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
position: sticky;
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
margin-top: var(--Spacing-x4);
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,6 @@
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.noRooms {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--Spacing-x-half) 0;
|
||||
}
|
||||
@@ -37,9 +25,3 @@
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.prices {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "../hotelPriceList.module.css"
|
||||
import styles from "./hotelPriceCard.module.css"
|
||||
|
||||
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useParams } from "next/dist/client/components/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import HotelPriceCard from "./HotelPriceCard"
|
||||
import NoPriceAvailableCard from "./NoPriceAvailableCard"
|
||||
|
||||
import styles from "./hotelPriceList.module.css"
|
||||
|
||||
import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps"
|
||||
|
||||
export default function HotelPriceList({
|
||||
price,
|
||||
hotelId,
|
||||
}: HotelPriceListProps) {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
|
||||
return (
|
||||
<div className={styles.prices}>
|
||||
{price ? (
|
||||
<>
|
||||
{price.public && <HotelPriceCard productTypePrices={price.public} />}
|
||||
{price.member && (
|
||||
<HotelPriceCard productTypePrices={price.member} isMemberPrice />
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
intent="primary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectRate(lang)}?hotel=${hotelId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<NoPriceAvailableCard />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "../hotelPriceList.module.css"
|
||||
import styles from "./noPriceAvailable.module.css"
|
||||
|
||||
export default function NoPriceAvailableCard() {
|
||||
const intl = useIntl()
|
||||
@@ -0,0 +1,12 @@
|
||||
.priceCard {
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.noRooms {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -70,10 +70,6 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.detailsButton {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-width: 160px;
|
||||
}
|
||||
@@ -84,6 +80,12 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.card.pageListing {
|
||||
flex-direction: row;
|
||||
@@ -133,4 +135,8 @@
|
||||
.pageListing .address {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.pageListing .prices {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
"use client"
|
||||
import { useParams } from "next/dist/client/components/navigation"
|
||||
import { memo, useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
import TripAdvisorChip from "../TripAdvisorChip"
|
||||
import HotelLogo from "./HotelLogo"
|
||||
import HotelPriceList from "./HotelPriceList"
|
||||
import HotelPriceCard from "./HotelPriceCard"
|
||||
import NoPriceAvailableCard from "./NoPriceAvailableCard"
|
||||
import { hotelCardVariants } from "./variants"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
@@ -24,7 +28,7 @@ import styles from "./hotelCard.module.css"
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
|
||||
export default function HotelCard({
|
||||
function HotelCard({
|
||||
hotel,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
state = "default",
|
||||
@@ -44,16 +48,17 @@ export default function HotelCard({
|
||||
state,
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(hotelData.name)
|
||||
}
|
||||
}
|
||||
const handleMouseLeave = () => {
|
||||
}, [onHotelCardHover, hotelData.name])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(null)
|
||||
}
|
||||
}
|
||||
}, [onHotelCardHover])
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -104,7 +109,11 @@ export default function HotelCard({
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: hotelData.location.distanceToCentre }
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotelData.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
@@ -132,8 +141,41 @@ export default function HotelCard({
|
||||
showCTA={true}
|
||||
/>
|
||||
</section>
|
||||
<HotelPriceList price={price} hotelId={hotel.hotelData.operaId} />
|
||||
<div className={styles.prices}>
|
||||
{!price ? (
|
||||
<NoPriceAvailableCard />
|
||||
) : (
|
||||
<>
|
||||
{price.public && (
|
||||
<HotelPriceCard productTypePrices={price.public} />
|
||||
)}
|
||||
{price.member && (
|
||||
<HotelPriceCard
|
||||
productTypePrices={price.member}
|
||||
isMemberPrice
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
intent="primary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectRate(lang)}?hotel=${hotel.hotelData.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HotelCard)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.dialog {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
bottom: 32px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: none;
|
||||
@@ -33,6 +33,8 @@
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
min-width: 177px;
|
||||
border-radius: var(--Corner-radius-Medium) 0 0 var(--Corner-radius-Medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imageContainer img {
|
||||
@@ -108,4 +110,7 @@
|
||||
.memberPrice {
|
||||
display: none;
|
||||
}
|
||||
.dialog {
|
||||
bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import NoPriceAvailableCard from "../HotelCard/HotelPriceList/NoPriceAvailableCard"
|
||||
import NoPriceAvailableCard from "../HotelCard/NoPriceAvailableCard"
|
||||
|
||||
import styles from "./hotelCardDialog.module.css"
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.hotelCardDialogListing {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.hotelCardDialogListing dialog {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hotelCardDialogListing > div:first-child {
|
||||
margin-left: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotelCardDialogListing > div:last-child {
|
||||
margin-right: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
|
||||
import HotelCardDialog from "../HotelCardDialog"
|
||||
import { getHotelPins } from "./utils"
|
||||
|
||||
import styles from "./hotelCardDialogListing.module.css"
|
||||
|
||||
import type { HotelCardDialogListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelCardDialogListing({
|
||||
@@ -15,6 +20,12 @@ export default function HotelCardDialogListing({
|
||||
const hotelsPinData = getHotelPins(hotels)
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
|
||||
useClickOutside(dialogRef, !!activeCard && isMobile, () => {
|
||||
onActiveCardChange(null)
|
||||
})
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
@@ -65,7 +76,7 @@ export default function HotelCardDialogListing({
|
||||
}, [activeCard])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||
{hotelsPinData?.length &&
|
||||
hotelsPinData.map((data) => {
|
||||
const isActive = data.name === activeCard
|
||||
@@ -83,6 +94,6 @@ export default function HotelCardDialogListing({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,13 +109,17 @@ export default function HotelCardListing({
|
||||
<section className={styles.hotelCards}>
|
||||
{hotels?.length ? (
|
||||
hotels.map((hotel) => (
|
||||
<HotelCard
|
||||
<div
|
||||
key={hotel.hotelData.operaId}
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
data-active={hotel.hotelData.name === activeCard ? "true" : "false"}
|
||||
>
|
||||
<HotelCard
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : activeFilters ? (
|
||||
<Alert
|
||||
|
||||
@@ -59,22 +59,42 @@
|
||||
|
||||
.content {
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
padding: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x-half)
|
||||
var(--Spacing-x2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--Base-Text-Accent);
|
||||
border-radius: var(--Corner-radius-xLarge);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: var(--Spacing-x2);
|
||||
padding-top: calc(var(--Spacing-x3) + var(--Spacing-x-half));
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
margin-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.filters ul li {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: right;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
@@ -93,11 +113,15 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
@@ -112,6 +136,11 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
padding: 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
@@ -139,6 +168,10 @@
|
||||
overflow-y: unset;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.sorter,
|
||||
.filters,
|
||||
.footer,
|
||||
@@ -158,19 +191,27 @@
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.filters aside h1 {
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
.filters aside > form {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.filters aside > div:last-child {
|
||||
margin-top: var(--Spacing-x4);
|
||||
padding-bottom: 0;
|
||||
.filters aside form > div:last-child {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.filters aside ul {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: var(--Spacing-x3);
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.filters ul li:hover {
|
||||
background: var(--UI-Input-Controls-Surface-Hover);
|
||||
border-radius: var(--Corner-radius-Medium,);
|
||||
outline: none;
|
||||
}
|
||||
.filters ul li {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1024) {
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
|
||||
|
||||
import HotelFilter from "../HotelFilter"
|
||||
import HotelSorter from "../HotelSorter"
|
||||
@@ -26,15 +28,20 @@ export default function FilterAndSortModal({
|
||||
filters,
|
||||
}: FilterAndSortModalProps) {
|
||||
const intl = useIntl()
|
||||
useInitializeFiltersFromUrl()
|
||||
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTrigger>
|
||||
<Button intent="secondary" size="small" theme="base">
|
||||
<FilterIcon color="burgundy" />
|
||||
<Button intent="secondary" size="small" theme="base" variant="icon">
|
||||
<FilterIcon color="baseTextHighcontrast" />
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
{activeFilters.length > 0 && (
|
||||
<Footnote className={styles.badge}>{activeFilters.length}</Footnote>
|
||||
)}
|
||||
</Button>
|
||||
<ModalOverlay className={styles.overlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
@@ -60,7 +67,9 @@ export default function FilterAndSortModal({
|
||||
<div className={styles.sorter}>
|
||||
<HotelSorter />
|
||||
</div>
|
||||
<Divider color="subtle" className="divider" />
|
||||
<div className={styles.divider}>
|
||||
<Divider color="subtle" />
|
||||
</div>
|
||||
<div className={styles.filters}>
|
||||
<HotelFilter filters={filters} />
|
||||
</div>
|
||||
@@ -76,7 +85,6 @@ export default function FilterAndSortModal({
|
||||
{ count: resultCount }
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setFilters([])}
|
||||
intent="text"
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
forced-color-adjust: none;
|
||||
background: var(--UI-Input-Controls-Surface-Normal);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
.facilities ul {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
.facilities:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: grid;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
|
||||
|
||||
import FilterCheckbox from "./FilterCheckbox"
|
||||
|
||||
@@ -20,19 +21,9 @@ export default function HotelFilter({ className, filters }: HotelFiltersProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const toggleFilter = useHotelFilterStore((state) => state.toggleFilter)
|
||||
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||
useInitializeFiltersFromUrl()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
|
||||
// Initialize the filters from the URL
|
||||
useEffect(() => {
|
||||
const filtersFromUrl = searchParams.get("filters")
|
||||
if (filtersFromUrl) {
|
||||
setFilters(filtersFromUrl.split(","))
|
||||
} else {
|
||||
setFilters([])
|
||||
}
|
||||
}, [searchParams, setFilters])
|
||||
|
||||
// Update the URL when the filters changes
|
||||
useEffect(() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
@@ -60,42 +51,46 @@ export default function HotelFilter({ className, filters }: HotelFiltersProps) {
|
||||
|
||||
return (
|
||||
<aside className={`${styles.container} ${className}`}>
|
||||
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
|
||||
<ul>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={() => toggleFilter(filter.id.toString())}
|
||||
isSelected={
|
||||
!!activeFilters.find((f) => f === filter.id.toString())
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<form>
|
||||
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
|
||||
<ul>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={() => toggleFilter(filter.id.toString())}
|
||||
isSelected={
|
||||
!!activeFilters.find((f) => f === filter.id.toString())
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>{intl.formatMessage({ id: "Hotel surroundings" })}</Subtitle>
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={() => toggleFilter(filter.id.toString())}
|
||||
isSelected={
|
||||
!!activeFilters.find((f) => f === filter.id.toString())
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
||||
</Subtitle>
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={() => toggleFilter(filter.id.toString())}
|
||||
isSelected={
|
||||
!!activeFilters.find((f) => f === filter.id.toString())
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,9 +25,20 @@ export default function MobileMapButtonContainer({
|
||||
|
||||
return (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button asChild variant="icon" intent="secondary" size="small">
|
||||
<Link href={selectHotelMap(lang)} keepSearchParams color="burgundy">
|
||||
<MapIcon color="burgundy" />
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
>
|
||||
<Link
|
||||
href={selectHotelMap(lang)}
|
||||
color="baseButtonTextOnFillNormal"
|
||||
keepSearchParams
|
||||
weight="bold"
|
||||
>
|
||||
<MapIcon color="baseTextHighcontrast" />
|
||||
{intl.formatMessage({ id: "See on map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -4,35 +4,18 @@
|
||||
|
||||
.hotelListingMobile {
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
overflow-x: auto;
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
bottom: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelListingMobile[data-open="true"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hotelListingMobile dialog {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hotelListingMobile > div:first-child {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.hotelListingMobile > div:last-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hotelListing {
|
||||
display: block;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function SelectHotelMap({
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const selectHotelParams = new URLSearchParams(searchParams.toString())
|
||||
const selectedHotel = selectHotelParams.get("selectedHotel")
|
||||
@@ -43,6 +44,16 @@ export default function SelectHotelMap({
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||
|
||||
useEffect(() => {
|
||||
if (listingContainerRef.current) {
|
||||
const activeElement =
|
||||
listingContainerRef.current.querySelector(`[data-active="true"]`)
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
||||
}
|
||||
}
|
||||
}, [activeHotelPin])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedHotel) {
|
||||
setActiveHotelPin(selectedHotel)
|
||||
@@ -90,7 +101,7 @@ export default function SelectHotelMap({
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer}>
|
||||
<div className={styles.listingContainer} ref={listingContainerRef}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.container .listingContainer .filterContainer > button {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getRoomAvailability } from "@/lib/trpc/memoizedRequests"
|
||||
import { getRoomsAvailability } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { getIntl } from "@/i18n"
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
hotelId: number
|
||||
lang: Lang
|
||||
adultCount: number
|
||||
childArray: Child[]
|
||||
childArray?: Child[]
|
||||
fromDate: Date
|
||||
toDate: Date
|
||||
}
|
||||
@@ -31,12 +31,12 @@ export async function NoRoomsAlert({
|
||||
lang,
|
||||
}: Props) {
|
||||
const [availability, availabilityError] = await safeTry(
|
||||
getRoomAvailability({
|
||||
getRoomsAvailability({
|
||||
hotelId: hotelId,
|
||||
roomStayStartDate: dt(fromDate).format("YYYY-MM-DD"),
|
||||
roomStayEndDate: dt(toDate).format("YYYY-MM-DD"),
|
||||
adults: adultCount,
|
||||
children: generateChildrenString(childArray), // TODO: Handle multiple rooms,
|
||||
children: childArray ? generateChildrenString(childArray) : undefined, // TODO: Handle multiple rooms,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import ReadMore from "../../ReadMore"
|
||||
import TripAdvisorChip from "../../TripAdvisorChip"
|
||||
@@ -26,7 +27,7 @@ type Props = {
|
||||
fromDate: Date
|
||||
toDate: Date
|
||||
adultCount: number
|
||||
childArray: Child[]
|
||||
childArray?: Child[]
|
||||
}
|
||||
|
||||
export default async function HotelInfoCard({
|
||||
@@ -69,7 +70,7 @@ export default async function HotelInfoCard({
|
||||
</Title>
|
||||
<div className={styles.hotelAddressDescription}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{`${hotelAttributes.address.streetAddress}, ${hotelAttributes.address.city} ∙ ${hotelAttributes.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
{`${hotelAttributes.address.streetAddress}, ${hotelAttributes.address.city} ∙ ${getSingleDecimal(hotelAttributes.location.distanceToCentre / 1000)} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotelAttributes.hotelContent.texts.descriptions.medium}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { z } from "zod"
|
||||
|
||||
import { InfoCircleIcon } from "@/components/Icons"
|
||||
import CheckboxChip from "@/components/TempDesignSystem/Form/FilterChip/Checkbox"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -78,10 +77,14 @@ export default function RoomFilter({
|
||||
</div>
|
||||
<div className={styles.infoMobile}>
|
||||
<div className={styles.filterInfo}>
|
||||
<Caption type="label" color="burgundy" textTransform="uppercase">
|
||||
<Caption
|
||||
type="label"
|
||||
color="baseTextMediumContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Filter" })}
|
||||
</Caption>
|
||||
<Caption type="label" color="burgundy">
|
||||
<Caption type="label" color="baseTextMediumContrast">
|
||||
{Object.entries(selectedFilters)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => intl.formatMessage({ id: key }))
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.infoDesktop {
|
||||
|
||||
@@ -46,6 +46,13 @@ input[type="radio"]:checked + .card .checkIcon {
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.priceType {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
|
||||
@@ -89,8 +89,10 @@ export default function FlexibilityOption({
|
||||
</Caption>
|
||||
))}
|
||||
</Popover>
|
||||
<Caption color="uiTextHighContrast">{name}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
<div className={styles.priceType}>
|
||||
<Caption color="uiTextHighContrast">{name}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<PriceTable
|
||||
publicPrice={publicPrice}
|
||||
|
||||
@@ -77,11 +77,13 @@ export default function RoomCard({
|
||||
packages?.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find(
|
||||
(room) => room.name === roomConfiguration.roomType
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.some(
|
||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||
)
|
||||
)
|
||||
|
||||
const { roomSize, occupancy, images } = selectedRoom || {}
|
||||
const { name, roomSize, occupancy, images } = selectedRoom || {}
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
@@ -174,7 +176,7 @@ export default function RoomCard({
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{roomConfiguration.roomType}
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: 0 var(--Spacing-x1) 0 var(--Spacing-x-one-and-half);
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.specification .guests {
|
||||
@@ -34,6 +34,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toggleSidePeek button {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x2);
|
||||
display: flex;
|
||||
|
||||
@@ -44,7 +44,7 @@ export function getQueryParamsForEnterDetails(
|
||||
fromDate: selectRoomParamsObject.fromDate,
|
||||
toDate: selectRoomParamsObject.toDate,
|
||||
hotel: selectRoomParamsObject.hotel,
|
||||
rooms: room.map((room) => ({
|
||||
rooms: room?.map((room) => ({
|
||||
adults: room.adults, // TODO: Handle multiple rooms
|
||||
children: room.child, // TODO: Handle multiple rooms and children
|
||||
roomTypeCode: room.roomtype,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getHotelData,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getRoomAvailability,
|
||||
getRoomsAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
@@ -20,7 +20,7 @@ export type Props = {
|
||||
fromDate: Date
|
||||
toDate: Date
|
||||
adultCount: number
|
||||
childArray: Child[]
|
||||
childArray?: Child[]
|
||||
lang: Lang
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function RoomsContainer({
|
||||
startDate: fromDateString,
|
||||
endDate: toDateString,
|
||||
adults: adultCount,
|
||||
children: childArray.length > 0 ? childArray.length : undefined,
|
||||
children: childArray ? childArray.length : undefined,
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
@@ -57,13 +57,15 @@ export async function RoomsContainer({
|
||||
)
|
||||
|
||||
const roomsAvailabilityPromise = safeTry(
|
||||
getRoomAvailability({
|
||||
getRoomsAvailability({
|
||||
hotelId: hotelId,
|
||||
roomStayStartDate: fromDateString,
|
||||
roomStayEndDate: toDateString,
|
||||
adults: adultCount,
|
||||
children:
|
||||
childArray.length > 0 ? generateChildrenString(childArray) : undefined,
|
||||
childArray && childArray.length > 0
|
||||
? generateChildrenString(childArray)
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -3,21 +3,20 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import { formId } from "../../Payment"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
useDetailsStore((state) => ({
|
||||
useEnterDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
@@ -38,7 +37,7 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.amount),
|
||||
amount: intl.formatNumber(totalPrice.local.price),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
234
components/HotelReservation/Summary/index.tsx
Normal file
234
components/HotelReservation/Summary/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Popover from "@/components/TempDesignSystem/Popover"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export default function Summary({
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
showMemberPrice,
|
||||
room,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
}: SummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function handleToggleSummary() {
|
||||
if (toggleSummaryOpen) {
|
||||
toggleSummaryOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} 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>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={handleToggleSummary}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(room.roomPrice.local.price),
|
||||
currency: room.roomPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ 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>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
}
|
||||
>
|
||||
<aside className={styles.rateDetailsPopover}>
|
||||
<header>
|
||||
<Caption type="bold">{room.cancellationText}</Caption>
|
||||
</header>
|
||||
{room.rateDetails?.map((detail, idx) => (
|
||||
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||
))}
|
||||
</aside>
|
||||
</Popover>
|
||||
</div>
|
||||
{room.packages
|
||||
? room.packages.map((roomPackage) => (
|
||||
<div className={styles.entry} key={roomPackage.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomPackage.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: roomPackage.localPrice.price,
|
||||
currency: roomPackage.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{bedType ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">{bedType.description}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.roomPrice.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{breakfast === false ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.roomPrice.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
{breakfast ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: breakfast.localPrice.totalPrice,
|
||||
currency: breakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.total}>
|
||||
<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>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.price, {
|
||||
currency: totalPrice.local.currency,
|
||||
style: "currency",
|
||||
}),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.euro && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.price, {
|
||||
currency: CurrencyEnum.EUR,
|
||||
style: "currency",
|
||||
}),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
83
components/HotelReservation/Summary/summary.module.css
Normal file
83
components/HotelReservation/Summary/summary.module.css
Normal file
@@ -0,0 +1,83 @@
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary .header .chevronButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
31
components/Icons/CalendarAdd.tsx
Normal file
31
components/Icons/CalendarAdd.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CalendarAddIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.25 6.81254H15.75V5.06254H4.25V6.81254ZM4.25 18.125C3.82031 18.125 3.45247 17.972 3.14648 17.6661C2.84049 17.3601 2.6875 16.9922 2.6875 16.5625V5.06254C2.6875 4.63285 2.84049 4.26501 3.14648 3.95902C3.45247 3.65303 3.82031 3.50004 4.25 3.50004H5.1875V2.61462C5.1875 2.39935 5.26389 2.21532 5.41667 2.06254C5.56944 1.90976 5.75347 1.83337 5.96875 1.83337C6.18403 1.83337 6.36806 1.90976 6.52083 2.06254C6.67361 2.21532 6.75 2.39935 6.75 2.61462V3.50004H13.25V2.61462C13.25 2.39935 13.3264 2.21532 13.4792 2.06254C13.6319 1.90976 13.816 1.83337 14.0313 1.83337C14.2465 1.83337 14.4306 1.90976 14.5833 2.06254C14.7361 2.21532 14.8125 2.39935 14.8125 2.61462V3.50004H15.75C16.1797 3.50004 16.5475 3.65303 16.8535 3.95902C17.1595 4.26501 17.3125 4.63285 17.3125 5.06254V11.3917C17.0643 11.2875 16.8095 11.2007 16.5482 11.1313C16.2869 11.0618 16.0208 11.0132 15.75 10.9855V8.37504H4.25V16.5625H11.4896C11.5806 16.849 11.6878 17.1224 11.8113 17.3829C11.9348 17.6433 12.081 17.8907 12.25 18.125H4.25Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M13.3333 16.5813H15.0521V18.3C15.0521 18.5153 15.1285 18.6993 15.2812 18.8521C15.434 19.0049 15.6181 19.0813 15.8333 19.0813C16.0486 19.0813 16.2326 19.0049 16.3854 18.8521C16.5382 18.6993 16.6146 18.5153 16.6146 18.3V16.5813H18.3333C18.5486 16.5813 18.7326 16.5049 18.8854 16.3521C19.0382 16.1993 19.1146 16.0153 19.1146 15.8C19.1146 15.5848 19.0382 15.4007 18.8854 15.248C18.7326 15.0952 18.5486 15.0188 18.3333 15.0188H16.6146V13.3C16.6146 13.0848 16.5382 12.9007 16.3854 12.748C16.2326 12.5952 16.0486 12.5188 15.8333 12.5188C15.6181 12.5188 15.434 12.5952 15.2812 12.748C15.1285 12.9007 15.0521 13.0848 15.0521 13.3V15.0188H13.3333C13.1181 15.0188 12.934 15.0952 12.7812 15.248C12.6285 15.4007 12.5521 15.5848 12.5521 15.8C12.5521 16.0153 12.6285 16.1993 12.7812 16.3521C12.934 16.5049 13.1181 16.5813 13.3333 16.5813Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -82,6 +82,11 @@
|
||||
fill: var(--Base-Button-Text-On-Fill-Normal);
|
||||
}
|
||||
|
||||
.baseTextHighcontrast,
|
||||
.baseTextHighcontrast * {
|
||||
fill: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
.disabled,
|
||||
.disabled * {
|
||||
fill: var(--Base-Text-Disabled);
|
||||
|
||||
@@ -20,6 +20,7 @@ export { default as BreakfastIcon } from "./Breakfast"
|
||||
export { default as BusinessIcon } from "./Business"
|
||||
export { default as CableIcon } from "./Cable"
|
||||
export { default as CalendarIcon } from "./Calendar"
|
||||
export { default as CalendarAddIcon } from "./CalendarAdd"
|
||||
export { default as CameraIcon } from "./Camera"
|
||||
export { default as CellphoneIcon } from "./Cellphone"
|
||||
export { default as ChairIcon } from "./Chair"
|
||||
|
||||
@@ -8,6 +8,7 @@ const config = {
|
||||
baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal,
|
||||
baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal,
|
||||
baseIconLowContrast: styles.baseIconLowContrast,
|
||||
baseTextHighcontrast: styles.baseTextHighcontrast,
|
||||
black: styles.black,
|
||||
blue: styles.blue,
|
||||
burgundy: styles.burgundy,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { memo, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { GalleryIcon } from "@/components/Icons"
|
||||
@@ -12,7 +12,7 @@ import styles from "./imageGallery.module.css"
|
||||
|
||||
import type { ImageGalleryProps } from "@/types/components/imageGallery"
|
||||
|
||||
export default function ImageGallery({
|
||||
function ImageGallery({
|
||||
images,
|
||||
title,
|
||||
fill,
|
||||
@@ -58,3 +58,5 @@ export default function ImageGallery({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ImageGallery)
|
||||
|
||||
@@ -20,6 +20,10 @@ export default function FullView({
|
||||
currentIndex,
|
||||
totalImages,
|
||||
}: FullViewProps) {
|
||||
function handleSwipe(offset: number) {
|
||||
if (offset > 30) onPrev()
|
||||
if (offset < -30) onNext()
|
||||
}
|
||||
return (
|
||||
<div className={styles.fullViewContainer}>
|
||||
<Button
|
||||
@@ -53,6 +57,8 @@ export default function FullView({
|
||||
exit={{ opacity: 0, x: -300 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={styles.fullViewImage}
|
||||
drag="x"
|
||||
onDragEnd={(e, info) => handleSwipe(info.offset.x)}
|
||||
>
|
||||
<Image
|
||||
alt={image.metaData.altText}
|
||||
|
||||
@@ -2,11 +2,10 @@ import {
|
||||
AdvancedMarker,
|
||||
AdvancedMarkerAnchorPoint,
|
||||
} from "@vis.gl/react-google-maps"
|
||||
import { useRef, useState } from "react"
|
||||
import { memo, useCallback, useState } from "react"
|
||||
|
||||
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
|
||||
import HotelMarker from "../../Markers/HotelMarker"
|
||||
|
||||
@@ -14,33 +13,45 @@ import styles from "./hotelListingMapContent.module.css"
|
||||
|
||||
import type { HotelListingMapContentProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelListingMapContent({
|
||||
function HotelListingMapContent({
|
||||
activeHotelPin,
|
||||
hotelPins,
|
||||
onActiveHotelPinChange,
|
||||
}: HotelListingMapContentProps) {
|
||||
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
function toggleActiveHotelPin(pinName: string | null) {
|
||||
if (onActiveHotelPinChange) {
|
||||
onActiveHotelPinChange(activeHotelPin === pinName ? null : pinName)
|
||||
setHoveredHotelPin(null)
|
||||
}
|
||||
}
|
||||
const toggleActiveHotelPin = useCallback(
|
||||
(pinName: string | null) => {
|
||||
if (onActiveHotelPinChange) {
|
||||
const newActivePin = activeHotelPin === pinName ? null : pinName
|
||||
onActiveHotelPinChange(newActivePin)
|
||||
if (newActivePin === null) {
|
||||
setHoveredHotelPin(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeHotelPin, onActiveHotelPinChange]
|
||||
)
|
||||
|
||||
function isPinActiveOrHovered(pinName: string) {
|
||||
return activeHotelPin === pinName || hoveredHotelPin === pinName
|
||||
}
|
||||
|
||||
useClickOutside(dialogRef, isPinActiveOrHovered(activeHotelPin ?? ""), () => {
|
||||
toggleActiveHotelPin(null)
|
||||
})
|
||||
const handleHover = useCallback(
|
||||
(pinName: string | null) => {
|
||||
if (pinName !== null && activeHotelPin !== pinName) {
|
||||
setHoveredHotelPin(pinName)
|
||||
if (activeHotelPin && onActiveHotelPinChange) {
|
||||
onActiveHotelPinChange(null)
|
||||
}
|
||||
} else if (pinName === null) {
|
||||
setHoveredHotelPin(null)
|
||||
}
|
||||
},
|
||||
[activeHotelPin, onActiveHotelPinChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hotelPins.map((pin) => {
|
||||
const isActiveOrHovered = isPinActiveOrHovered(pin.name)
|
||||
const isActiveOrHovered =
|
||||
activeHotelPin === pin.name || hoveredHotelPin === pin.name
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={pin.name}
|
||||
@@ -48,15 +59,11 @@ export default function HotelListingMapContent({
|
||||
position={pin.coordinates}
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={isActiveOrHovered ? 2 : 0}
|
||||
onMouseEnter={() => setHoveredHotelPin(pin.name)}
|
||||
onMouseLeave={() => setHoveredHotelPin(null)}
|
||||
onClick={() => {
|
||||
toggleActiveHotelPin(
|
||||
activeHotelPin === pin.name ? null : pin.name
|
||||
)
|
||||
}}
|
||||
onMouseEnter={() => handleHover(pin.name)}
|
||||
onMouseLeave={() => handleHover(null)}
|
||||
onClick={() => toggleActiveHotelPin(pin.name)}
|
||||
>
|
||||
<div className={styles.dialogContainer} ref={dialogRef}>
|
||||
<div className={styles.dialogContainer}>
|
||||
<HotelCardDialog
|
||||
isOpen={isActiveOrHovered}
|
||||
handleClose={(event: { stopPropagation: () => void }) => {
|
||||
@@ -92,3 +99,5 @@ export default function HotelListingMapContent({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HotelListingMapContent)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StaticMapProps } from "@/types/components/maps/staticMap"
|
||||
|
||||
export default function StaticMap({
|
||||
city,
|
||||
country,
|
||||
coordinates,
|
||||
width,
|
||||
height,
|
||||
@@ -18,7 +19,9 @@ export default function StaticMap({
|
||||
const key = env.GOOGLE_STATIC_MAP_KEY
|
||||
const secret = env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET
|
||||
const baseUrl = "https://maps.googleapis.com/maps/api/staticmap"
|
||||
const center = coordinates ? `${coordinates.lat},${coordinates.lng}` : city
|
||||
const center = coordinates
|
||||
? `${coordinates.lat},${coordinates.lng}`
|
||||
: `${city}, ${country}`
|
||||
|
||||
if (!center) {
|
||||
return null
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user