Merge branch 'master' into feat/sw-929-release-preps
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?" })}
|
||||
|
||||
@@ -1,60 +1,25 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { ChevronRightSmallIcon,HouseIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./breadcrumbs.module.css"
|
||||
import BreadcrumbsComp from "@/components/TempDesignSystem/Breadcrumbs"
|
||||
import { generateBreadcrumbsSchema } from "@/utils/jsonSchemas"
|
||||
|
||||
export default async function Breadcrumbs() {
|
||||
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
|
||||
|
||||
if (!breadcrumbs?.length) {
|
||||
return null
|
||||
}
|
||||
const jsonSchema = generateBreadcrumbsSchema(breadcrumbs)
|
||||
|
||||
const homeBreadcrumb = breadcrumbs.shift()
|
||||
return (
|
||||
<nav className={styles.breadcrumbs}>
|
||||
<ul className={styles.list}>
|
||||
{homeBreadcrumb ? (
|
||||
<li className={styles.listItem}>
|
||||
<Link
|
||||
className={styles.homeLink}
|
||||
color="peach80"
|
||||
href={homeBreadcrumb.href!}
|
||||
variant="breadcrumb"
|
||||
>
|
||||
<HouseIcon width={16} height={16} color="peach80" />
|
||||
</Link>
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{breadcrumbs.map((breadcrumb) => {
|
||||
if (breadcrumb.href) {
|
||||
return (
|
||||
<li key={breadcrumb.uid} className={styles.listItem}>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={breadcrumb.href}
|
||||
variant="breadcrumb"
|
||||
>
|
||||
{breadcrumb.title}
|
||||
</Link>
|
||||
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={breadcrumb.uid} className={styles.listItem}>
|
||||
<Footnote color="burgundy" type="bold">
|
||||
{breadcrumb.title}
|
||||
</Footnote>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<>
|
||||
<script
|
||||
type={jsonSchema.type}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonSchema.jsonLd),
|
||||
}}
|
||||
/>
|
||||
<BreadcrumbsComp breadcrumbs={breadcrumbs} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { about } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import { ChevronRightSmallIcon, TripAdvisorIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -9,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"
|
||||
|
||||
@@ -26,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})`
|
||||
|
||||
@@ -60,13 +60,20 @@ export default function Sidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(poiName: string) {
|
||||
function handleMouseEnter(poiName: string | undefined) {
|
||||
if (!poiName) return
|
||||
|
||||
if (!isClicking) {
|
||||
onActivePoiChange(poiName)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
|
||||
function handlePoiClick(
|
||||
poiName: string | undefined,
|
||||
poiCoordinates: Coordinates
|
||||
) {
|
||||
if (!poiName || !poiCoordinates) return
|
||||
|
||||
setIsClicking(true)
|
||||
toggleFullScreenSidebar()
|
||||
onActivePoiChange(poiName)
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function DynamicMap({
|
||||
activePoi={activePoi}
|
||||
hotelName={hotelName}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={setActivePoi}
|
||||
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
|
||||
coordinates={coordinates}
|
||||
/>
|
||||
<InteractiveMap
|
||||
@@ -121,7 +121,7 @@ export default function DynamicMap({
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.information {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-areas:
|
||||
"address drivingDirections"
|
||||
"contact socials"
|
||||
"email email"
|
||||
"ecoLabel ecoLabel";
|
||||
}
|
||||
|
||||
.address {
|
||||
grid-area: address;
|
||||
}
|
||||
|
||||
.drivingDirections {
|
||||
grid-area: drivingDirections;
|
||||
}
|
||||
|
||||
.contact {
|
||||
grid-area: contact;
|
||||
}
|
||||
|
||||
.socials {
|
||||
grid-area: socials;
|
||||
}
|
||||
|
||||
.socialIcons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.email {
|
||||
grid-area: email;
|
||||
}
|
||||
|
||||
.ecoLabel {
|
||||
grid-area: ecoLabel;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
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 Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./contactInformation.module.css"
|
||||
|
||||
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/contactInformation"
|
||||
|
||||
export default async function ContactInformation({
|
||||
hotelAddress,
|
||||
coordinates,
|
||||
contact,
|
||||
socials,
|
||||
ecoLabels,
|
||||
}: ContactInformationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
|
||||
const { latitude, longitude } = coordinates
|
||||
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Subtitle color="burgundy" asChild>
|
||||
<Title level="h3">
|
||||
{intl.formatMessage({ id: "Practical information" })}
|
||||
</Title>
|
||||
</Subtitle>
|
||||
<div className={styles.information}>
|
||||
<div className={styles.address}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Address" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotelAddress.streetAddress}</Body>
|
||||
<Body color="uiTextHighContrast">{hotelAddress.city}</Body>
|
||||
</div>
|
||||
<div className={styles.drivingDirections}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Driving directions" })}
|
||||
</Body>
|
||||
<Link
|
||||
href={directionsUrl}
|
||||
target="_blank"
|
||||
color="peach80"
|
||||
textDecoration="underline"
|
||||
>
|
||||
Google Maps
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.contact}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Contact us" })}
|
||||
</Body>
|
||||
<Body>
|
||||
<Link
|
||||
href={`tel:+${contact.phoneNumber}`}
|
||||
color="peach80"
|
||||
textDecoration="underline"
|
||||
>
|
||||
{contact.phoneNumber}
|
||||
</Link>
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.socials}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Follow us" })}
|
||||
</Body>
|
||||
<div className={styles.socialIcons}>
|
||||
{socials.instagram && (
|
||||
<Link href={socials.instagram}>
|
||||
<InstagramIcon color="burgundy" />
|
||||
</Link>
|
||||
)}
|
||||
{socials.facebook && (
|
||||
<Link href={socials.facebook}>
|
||||
<FacebookIcon color="burgundy" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.email}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Email" })}
|
||||
</Body>
|
||||
<Link
|
||||
href={`mailto:${contact.email}`}
|
||||
color="peach80"
|
||||
textDecoration="underline"
|
||||
>
|
||||
{contact.email}
|
||||
</Link>
|
||||
</div>
|
||||
{ecoLabels.nordicEcoLabel && (
|
||||
<div className={styles.ecoLabel}>
|
||||
<Image
|
||||
height={38}
|
||||
width={38}
|
||||
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
|
||||
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
|
||||
/>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{ecoLabels.svanenEcoLabelCertificateNumber}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { about } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import ContactInformation from "./ContactInformation"
|
||||
|
||||
import styles from "./aboutTheHotel.module.css"
|
||||
|
||||
import type { AboutTheHotelSidePeekProps } from "@/types/components/hotelPage/sidepeek/aboutTheHotel"
|
||||
|
||||
export default async function AboutTheHotelSidePeek({
|
||||
hotelAddress,
|
||||
coordinates,
|
||||
contact,
|
||||
socials,
|
||||
ecoLabels,
|
||||
descriptions,
|
||||
}: AboutTheHotelSidePeekProps) {
|
||||
const lang = getLang()
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={about[lang]}
|
||||
title={intl.formatMessage({ id: "About the hotel" })}
|
||||
>
|
||||
<section className={styles.wrapper}>
|
||||
<ContactInformation
|
||||
hotelAddress={hotelAddress}
|
||||
coordinates={coordinates}
|
||||
contact={contact}
|
||||
socials={socials}
|
||||
ecoLabels={ecoLabels}
|
||||
/>
|
||||
<Divider color="baseSurfaceSutbleHover" />
|
||||
<Preamble>{descriptions.descriptions.medium}</Preamble>
|
||||
<Body>{descriptions.facilityInformation}</Body>
|
||||
</section>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default async function Facility({ data }: FacilityProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{image.imageSizes.medium && (
|
||||
{image?.imageSizes.medium && (
|
||||
<Image
|
||||
src={image.imageSizes.medium}
|
||||
alt={image.metaData.altText || ""}
|
||||
|
||||
3
components/ContentType/HotelPage/SidePeeks/index.ts
Normal file
3
components/ContentType/HotelPage/SidePeeks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
|
||||
export { default as RoomSidePeek } from "./Room"
|
||||
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"
|
||||
@@ -14,8 +14,8 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
|
||||
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
|
||||
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
|
||||
[FacilityEnum.FreeWiFi]: IconName.Wifi,
|
||||
[FacilityEnum.MeetingRooms]: IconName.People2,
|
||||
[FacilityEnum.MeetingConferenceFacilities]: IconName.People2,
|
||||
[FacilityEnum.MeetingRooms]: IconName.Business,
|
||||
[FacilityEnum.MeetingConferenceFacilities]: IconName.Business,
|
||||
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
|
||||
[FacilityEnum.Sauna]: IconName.Sauna,
|
||||
[FacilityEnum.Restaurant]: IconName.Restaurant,
|
||||
|
||||
@@ -1,77 +1,128 @@
|
||||
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"
|
||||
import MapWithCardWrapper from "./Map/MapWithCard"
|
||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||
import StaticMap from "./Map/StaticMap"
|
||||
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
|
||||
import AmenitiesList from "./AmenitiesList"
|
||||
import Facilities from "./Facilities"
|
||||
import IntroSection from "./IntroSection"
|
||||
import PreviewImages from "./PreviewImages"
|
||||
import { Rooms } from "./Rooms"
|
||||
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,
|
||||
hotelDescription,
|
||||
hotelLocation,
|
||||
hotelAddress,
|
||||
hotelRatings,
|
||||
hotelDetailedFacilities,
|
||||
hotelImages,
|
||||
roomCategories,
|
||||
activitiesCard,
|
||||
name,
|
||||
address,
|
||||
pointsOfInterest,
|
||||
facilities,
|
||||
faq,
|
||||
alerts,
|
||||
gallery,
|
||||
specialAlerts,
|
||||
healthAndWellness,
|
||||
restaurantImages,
|
||||
conferencesAndMeetings,
|
||||
hotelContent,
|
||||
detailedFacilities,
|
||||
healthFacilities,
|
||||
} = 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}
|
||||
/>
|
||||
@@ -79,18 +130,18 @@ export default async function HotelPage() {
|
||||
<div id={HotelHashValues.overview} className={styles.overview}>
|
||||
<div className={styles.introContainer}>
|
||||
<IntroSection
|
||||
hotelName={hotelName}
|
||||
hotelDescription={hotelDescription}
|
||||
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}
|
||||
@@ -101,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} />
|
||||
@@ -111,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}
|
||||
@@ -126,22 +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={address}
|
||||
coordinates={location}
|
||||
contact={contactInformation}
|
||||
socials={socialMedia}
|
||||
ecoLabels={hotelFacts.ecoLabels}
|
||||
descriptions={hotelContent.texts}
|
||||
/>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.about[lang]}
|
||||
title={intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
>
|
||||
Some additional information about the hotel
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.restaurantAndBar[lang]}
|
||||
contentKey={restaurantAndBar[lang]}
|
||||
title={intl.formatMessage({ id: "Restaurant & Bar" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
@@ -152,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()
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import FacebookIcon from "@/components/Icons/Facebook"
|
||||
import InstagramIcon from "@/components/Icons/Instagram"
|
||||
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -76,6 +81,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
subtitle={width}
|
||||
title={roomType.description}
|
||||
value={roomType.value}
|
||||
handleSelectedOnClick={
|
||||
bedType === roomType.value ? completeStep : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -97,6 +105,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
value={pkg.code}
|
||||
handleSelectedOnClick={
|
||||
breakfast === pkg.code ? completeStep : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<RadioCard
|
||||
@@ -113,6 +124,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
})}
|
||||
title={intl.formatMessage({ id: "No breakfast" })}
|
||||
value="false"
|
||||
handleSelectedOnClick={
|
||||
breakfast === "false" ? completeStep : undefined
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -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,9 +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) => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function PaymentCallback({
|
||||
returnUrl,
|
||||
searchObject,
|
||||
}: {
|
||||
returnUrl: string
|
||||
searchObject: URLSearchParams
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||
|
||||
if (bookingData) {
|
||||
const detailsStorage: Record<
|
||||
"state",
|
||||
Pick<DetailsState, "booking">
|
||||
> = JSON.parse(bookingData)
|
||||
const searchParams = createQueryParamsForEnterDetails(
|
||||
detailsStorage.state.booking,
|
||||
searchObject
|
||||
)
|
||||
|
||||
if (searchParams.size > 0) {
|
||||
router.replace(`${returnUrl}?${searchParams.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [returnUrl, router, searchObject])
|
||||
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
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 { useCallback, useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -16,9 +15,10 @@ import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -27,11 +27,13 @@ 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"
|
||||
@@ -39,7 +41,7 @@ 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"
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 4
|
||||
const retryInterval = 2000
|
||||
@@ -51,6 +53,7 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
}
|
||||
|
||||
export default function Payment({
|
||||
user,
|
||||
roomPrice,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
@@ -59,30 +62,28 @@ export default function Payment({
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
||||
const setIsSubmittingDisabled = useDetailsStore(
|
||||
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 {
|
||||
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)
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
|
||||
const availablePaymentOptions =
|
||||
useAvailablePaymentOptions(otherPaymentOptions)
|
||||
const [priceChangeData, setPriceChangeData] = useState<{
|
||||
oldPrice: number
|
||||
newPrice: number
|
||||
} | null>()
|
||||
|
||||
usePaymentFailedToast()
|
||||
|
||||
@@ -103,6 +104,15 @@ export default function Payment({
|
||||
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({
|
||||
@@ -121,25 +131,31 @@ export default function Payment({
|
||||
},
|
||||
})
|
||||
|
||||
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 (window.ApplePaySession) {
|
||||
setAvailablePaymentOptions(otherPaymentOptions)
|
||||
} else {
|
||||
setAvailablePaymentOptions(
|
||||
otherPaymentOptions.filter(
|
||||
(option) => option !== PaymentMethodEnum.applePay
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [otherPaymentOptions, setAvailablePaymentOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
@@ -162,76 +178,102 @@ export default function Payment({
|
||||
setIsSubmittingDisabled,
|
||||
])
|
||||
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
const allQueryParams =
|
||||
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
|
||||
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
|
||||
// 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 savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
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())],
|
||||
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,
|
||||
})),
|
||||
rateCode: 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,
|
||||
payment: {
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
|
||||
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
|
||||
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
error: `${paymentRedirectUrl}/error`,
|
||||
cancel: `${paymentRedirectUrl}/cancel`,
|
||||
},
|
||||
})
|
||||
},
|
||||
[
|
||||
breakfast,
|
||||
bedType,
|
||||
userData,
|
||||
booking,
|
||||
roomPrice,
|
||||
savedCreditCards,
|
||||
lang,
|
||||
user,
|
||||
initiateBooking,
|
||||
]
|
||||
)
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
|
||||
(isPollingForBookingStatus && !bookingStatus.data?.paymentUrl)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -241,79 +283,70 @@ export default function Payment({
|
||||
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{mustBeGuaranteed ? (
|
||||
<>
|
||||
<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}>
|
||||
<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>
|
||||
{savedCreditCards?.length ? (
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
{savedCreditCards?.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={savedCreditCard.id}
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
PAYMENT_METHOD_TITLES[paymentMethod 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" />
|
||||
<section className={styles.section}>
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
@@ -344,19 +377,48 @@ export default function Payment({
|
||||
}
|
||||
)}
|
||||
</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>
|
||||
<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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
26
components/HotelReservation/EnterDetails/StorageCleaner.tsx
Normal file
26
components/HotelReservation/EnterDetails/StorageCleaner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { hotelreservation } from "@/constants/routes/hotelReservation"
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
/**
|
||||
* Cleanup component to make sure no stale data is left
|
||||
* from previous booking when user is not in the booking
|
||||
* flow anymore
|
||||
*/
|
||||
export default function StorageCleaner() {
|
||||
const lang = useLang()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname.startsWith(hotelreservation(lang))) {
|
||||
sessionStorage.removeItem(detailsStorageName)
|
||||
}
|
||||
}, [lang, pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
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,309 +1,57 @@
|
||||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { useEffect, 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,
|
||||
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,
|
||||
} = useDetailsStore(storeSelector)
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
|
||||
if (showMemberPrice) {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
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 = room.localPrice.price + additionalPackageCost.local
|
||||
const roomsPriceEuro = room.euroPrice
|
||||
? room.euroPrice.price + additionalPackageCost.euro
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
|
||||
if (breakfast || breakfast === false) {
|
||||
setChosenBreakfast(breakfast)
|
||||
if (breakfast === false) {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal,
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro:
|
||||
room.euroPrice && roomsPriceEuro
|
||||
? {
|
||||
price: roomsPriceEuro,
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro:
|
||||
room.euroPrice && roomsPriceEuro
|
||||
? {
|
||||
price:
|
||||
roomsPriceEuro +
|
||||
parseInt(breakfast.requestedPrice.totalPrice),
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
bedType,
|
||||
breakfast,
|
||||
roomsPriceLocal,
|
||||
room.localPrice.currency,
|
||||
room.euroPrice,
|
||||
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}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(room.localPrice.price),
|
||||
currency: room.localPrice.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: room.localPrice.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: room.localPrice.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>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.price),
|
||||
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: 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 {
|
||||
max-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,66 +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 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>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.priceCard}>
|
||||
<div className={styles.noRooms}>
|
||||
<div>
|
||||
<ErrorCircleIcon color="red" />
|
||||
</div>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./noPriceAvailable.module.css"
|
||||
|
||||
export default function NoPriceAvailableCard() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.priceCard}>
|
||||
<div className={styles.noRooms}>
|
||||
<div>
|
||||
<ErrorCircleIcon color="red" />
|
||||
</div>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -122,11 +124,6 @@
|
||||
margin-bottom: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.pageListing .prices {
|
||||
align-items: center;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.pageListing .button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -138,4 +135,8 @@
|
||||
.pageListing .address {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.pageListing .prices {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"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"
|
||||
@@ -13,11 +14,13 @@ 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"
|
||||
@@ -25,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",
|
||||
@@ -45,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
|
||||
@@ -105,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>
|
||||
@@ -133,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 {
|
||||
@@ -48,7 +50,7 @@
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
min-width: 201px;
|
||||
min-width: 220px;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
gap: var(--Spacing-x1);
|
||||
display: flex;
|
||||
@@ -67,12 +69,32 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.prices {
|
||||
.priceCard {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
background: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #000000 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #000000 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #000000 75%);
|
||||
background-size: 120px 120px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 60px,
|
||||
60px -60px,
|
||||
-60px 0;
|
||||
}
|
||||
|
||||
.perNight {
|
||||
@@ -88,4 +110,7 @@
|
||||
.memberPrice {
|
||||
display: none;
|
||||
}
|
||||
.dialog {
|
||||
bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import { CloseLargeIcon, TripAdvisorIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
@@ -17,6 +17,8 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import NoPriceAvailableCard from "../HotelCard/NoPriceAvailableCard"
|
||||
|
||||
import styles from "./hotelCardDialog.module.css"
|
||||
|
||||
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
@@ -29,6 +31,7 @@ export default function HotelCardDialog({
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
const intl = useIntl()
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
@@ -57,7 +60,16 @@ export default function HotelCardDialog({
|
||||
height={16}
|
||||
/>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image src={firstImage} alt={altText} fill />
|
||||
{!firstImage || imageError ? (
|
||||
<div className={styles.imagePlaceholder} />
|
||||
) : (
|
||||
<Image
|
||||
src={firstImage}
|
||||
alt={altText}
|
||||
fill
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.tripAdvisor}>
|
||||
<Chip intent="secondary" className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="burgundy" />
|
||||
@@ -85,32 +97,50 @@ export default function HotelCardDialog({
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<Caption type="bold">{intl.formatMessage({ id: "From" })}</Caption>
|
||||
<Subtitle type="two">
|
||||
{publicPrice} {currency}
|
||||
<Body asChild>
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
{memberPrice && (
|
||||
<Subtitle type="two" color="red" className={styles.memberPrice}>
|
||||
{memberPrice} {currency}
|
||||
<Body asChild color="red">
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
{publicPrice || memberPrice ? (
|
||||
<>
|
||||
<div className={styles.priceCard}>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "From" })}
|
||||
</Caption>
|
||||
<Subtitle type="two">
|
||||
{publicPrice} {currency}
|
||||
<Body asChild>
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
{memberPrice && (
|
||||
<Subtitle
|
||||
type="two"
|
||||
color="red"
|
||||
className={styles.memberPrice}
|
||||
>
|
||||
{memberPrice} {currency}
|
||||
<Body asChild color="red">
|
||||
<span>/{intl.formatMessage({ id: "night" })}</span>
|
||||
</Body>
|
||||
</Subtitle>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectRate(lang)}?hotel=${data.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<NoPriceAvailableCard />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button asChild theme="base" size="small" className={styles.button}>
|
||||
<Link
|
||||
href={`${selectRate(lang)}?hotel=${data.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -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[]) => {
|
||||
@@ -60,12 +71,12 @@ export default function HotelCardDialogListing({
|
||||
const elements = document.querySelectorAll("[data-name]")
|
||||
setTimeout(() => {
|
||||
elements.forEach((el) => observerRef.current?.observe(el))
|
||||
}, 500)
|
||||
}, 1000)
|
||||
}
|
||||
}, [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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
hotel.hotelData.hotelContent.images,
|
||||
...(hotel.hotelData.gallery?.heroImages ?? []),
|
||||
],
|
||||
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
|
||||
amenities: hotel.hotelData.detailedFacilities
|
||||
.map((facility) => ({
|
||||
...facility,
|
||||
icon: facility.icon ?? "None",
|
||||
}))
|
||||
.slice(0, 3),
|
||||
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
|
||||
operaId: hotel.hotelData.operaId,
|
||||
}))
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
|
||||
import HotelCard from "../HotelCard"
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type HotelData,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function HotelCardListing({
|
||||
hotelData,
|
||||
@@ -28,6 +31,7 @@ export default function HotelCardListing({
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
const intl = useIntl()
|
||||
|
||||
const sortBy = useMemo(
|
||||
() => searchParams.get("sort") ?? DEFAULT_SORT,
|
||||
@@ -51,7 +55,7 @@ export default function HotelCardListing({
|
||||
return (
|
||||
hotel.price?.member?.localPrice?.pricePerNight ??
|
||||
hotel.price?.public?.localPrice?.pricePerNight ??
|
||||
0
|
||||
Infinity
|
||||
)
|
||||
}
|
||||
return [...hotelData].sort(
|
||||
@@ -69,7 +73,6 @@ export default function HotelCardListing({
|
||||
|
||||
const hotels = useMemo(() => {
|
||||
if (activeFilters.length === 0) {
|
||||
setResultCount(sortedHotels.length)
|
||||
return sortedHotels
|
||||
}
|
||||
|
||||
@@ -81,9 +84,8 @@ export default function HotelCardListing({
|
||||
)
|
||||
)
|
||||
|
||||
setResultCount(filteredHotels.length)
|
||||
return filteredHotels
|
||||
}, [activeFilters, sortedHotels, setResultCount])
|
||||
}, [activeFilters, sortedHotels])
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -95,23 +97,37 @@ export default function HotelCardListing({
|
||||
return () => window.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setResultCount(hotels ? hotels.length : 0)
|
||||
}, [hotels, setResultCount])
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
{hotels?.length
|
||||
? hotels.map((hotel) => (
|
||||
{hotels?.length ? (
|
||||
hotels.map((hotel) => (
|
||||
<div
|
||||
key={hotel.hotelData.operaId}
|
||||
data-active={hotel.hotelData.name === activeCard ? "true" : "false"}
|
||||
>
|
||||
<HotelCard
|
||||
key={hotel.hotelData.operaId}
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
))
|
||||
) : activeFilters ? (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "filters.nohotel.heading" })}
|
||||
text={intl.formatMessage({ id: "filters.nohotel.text" })}
|
||||
/>
|
||||
) : null}
|
||||
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -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: 280px;
|
||||
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,13 +1,13 @@
|
||||
"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"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -15,7 +15,6 @@ import useLang from "@/hooks/useLang"
|
||||
|
||||
import FilterAndSortModal from "../FilterAndSortModal"
|
||||
import HotelListing from "./HotelListing"
|
||||
import { getCentralCoordinates } from "./utils"
|
||||
|
||||
import styles from "./selectHotelMap.module.css"
|
||||
|
||||
@@ -27,6 +26,7 @@ export default function SelectHotelMap({
|
||||
mapId,
|
||||
hotels,
|
||||
filterList,
|
||||
cityCoordinates,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -35,16 +35,25 @@ export default function SelectHotelMap({
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
|
||||
const centralCoordinates = getCentralCoordinates(hotelPins)
|
||||
|
||||
const coordinates = isAboveMobile
|
||||
? centralCoordinates
|
||||
: { ...centralCoordinates, lat: centralCoordinates.lat - 0.006 }
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const selectHotelParams = new URLSearchParams(searchParams.toString())
|
||||
const selectedHotel = selectHotelParams.get("selectedHotel")
|
||||
|
||||
const coordinates = isAboveMobile
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||
|
||||
useEffect(() => {
|
||||
if (listingContainerRef.current) {
|
||||
const activeElement =
|
||||
listingContainerRef.current.querySelector(`[data-active="true"]`)
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
||||
}
|
||||
}
|
||||
}, [activeHotelPin])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedHotel) {
|
||||
setActiveHotelPin(selectedHotel)
|
||||
@@ -92,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"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export function getCentralCoordinates(hotels: HotelPin[]) {
|
||||
const centralCoordinates = hotels.reduce(
|
||||
(acc, pin) => {
|
||||
acc.lat += pin.coordinates.lat
|
||||
acc.lng += pin.coordinates.lng
|
||||
return acc
|
||||
},
|
||||
{ lat: 0, lng: 0 }
|
||||
)
|
||||
|
||||
centralCoordinates.lat /= hotels.length
|
||||
centralCoordinates.lng /= hotels.length
|
||||
|
||||
return centralCoordinates
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.hotelAlert {
|
||||
max-width: var(--max-width-navigation);
|
||||
margin: 0 auto;
|
||||
padding-top: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getRoomsAvailability } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
|
||||
import { generateChildrenString } from "../RoomSelection/utils"
|
||||
|
||||
import styles from "./NoRoomsAlert.module.css"
|
||||
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
type Props = {
|
||||
hotelId: number
|
||||
lang: Lang
|
||||
adultCount: number
|
||||
childArray?: Child[]
|
||||
fromDate: Date
|
||||
toDate: Date
|
||||
}
|
||||
|
||||
export async function NoRoomsAlert({
|
||||
hotelId,
|
||||
fromDate,
|
||||
toDate,
|
||||
childArray,
|
||||
adultCount,
|
||||
lang,
|
||||
}: Props) {
|
||||
const [availability, availabilityError] = await safeTry(
|
||||
getRoomsAvailability({
|
||||
hotelId: hotelId,
|
||||
roomStayStartDate: dt(fromDate).format("YYYY-MM-DD"),
|
||||
roomStayEndDate: dt(toDate).format("YYYY-MM-DD"),
|
||||
adults: adultCount,
|
||||
children: childArray ? generateChildrenString(childArray) : undefined, // TODO: Handle multiple rooms,
|
||||
})
|
||||
)
|
||||
|
||||
if (!availability || availabilityError) {
|
||||
return null
|
||||
}
|
||||
|
||||
const noRoomsAvailable = availability.roomConfigurations.reduce(
|
||||
(acc, room) => {
|
||||
return acc && room.status === "NotAvailable"
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
if (!noRoomsAvailable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const intl = await getIntl(lang)
|
||||
return (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
text={intl.formatMessage({
|
||||
id: "There are no rooms available that match your request",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import useRoomAvailableStore from "@/stores/roomAvailability"
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
@@ -11,39 +10,43 @@ import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import ReadMore from "../../ReadMore"
|
||||
import TripAdvisorChip from "../../TripAdvisorChip"
|
||||
import { NoRoomsAlert } from "./NoRoomsAlert"
|
||||
|
||||
import styles from "./hotelInfoCard.module.css"
|
||||
|
||||
import type { HotelInfoCardProps } from "@/types/components/hotelReservation/selectRate/hotelInfoCardProps"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
type Props = {
|
||||
hotelId: number
|
||||
lang: Lang
|
||||
fromDate: Date
|
||||
toDate: Date
|
||||
adultCount: number
|
||||
childArray?: Child[]
|
||||
}
|
||||
|
||||
export default async function HotelInfoCard({
|
||||
hotelId,
|
||||
lang,
|
||||
...props
|
||||
}: Props) {
|
||||
const hotelData = await getHotelData({
|
||||
hotelId: hotelId.toString(),
|
||||
language: lang,
|
||||
})
|
||||
|
||||
export default function HotelInfoCard({
|
||||
hotelData,
|
||||
noAvailability = false,
|
||||
}: HotelInfoCardProps) {
|
||||
const hotelAttributes = hotelData?.data.attributes
|
||||
const intl = useIntl()
|
||||
|
||||
const noRoomsAvailable = useRoomAvailableStore(
|
||||
(state) => state.noRoomsAvailable
|
||||
)
|
||||
const setNoRoomsAvailable = useRoomAvailableStore(
|
||||
(state) => state.setNoRoomsAvailable
|
||||
)
|
||||
const intl = await getIntl()
|
||||
|
||||
const sortedFacilities = hotelAttributes?.detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
.slice(0, 5)
|
||||
|
||||
useEffect(() => {
|
||||
if (noAvailability) {
|
||||
setNoRoomsAvailable()
|
||||
}
|
||||
}, [noAvailability, setNoRoomsAvailable])
|
||||
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
{hotelAttributes && (
|
||||
@@ -67,7 +70,7 @@ export default 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}
|
||||
@@ -117,16 +120,10 @@ export default function HotelInfoCard({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{noRoomsAvailable ? (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
text={intl.formatMessage({
|
||||
id: "There are no rooms available that match your request",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Suspense fallback={null} key={hotelId}>
|
||||
<NoRoomsAlert hotelId={hotelId} lang={lang} {...props} />
|
||||
</Suspense>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
@@ -99,11 +102,13 @@ export default function RoomFilter({
|
||||
<form onSubmit={handleSubmit(submitFilter)}>
|
||||
<div className={styles.roomsFilter}>
|
||||
{filterOptions.map((option) => {
|
||||
const { code, description } = option
|
||||
const { code, description, itemCode } = option
|
||||
const isPetRoom = code === RoomPackageCodeEnum.PET_ROOM
|
||||
const isAllergyRoom = code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
const isDisabled =
|
||||
(isAllergyRoom && petFriendly) || (isPetRoom && allergyFriendly)
|
||||
(isAllergyRoom && petFriendly) ||
|
||||
(isPetRoom && allergyFriendly) ||
|
||||
!itemCode
|
||||
|
||||
const checkboxChip = (
|
||||
<CheckboxChip
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function FlexibilityOption({
|
||||
</div>
|
||||
<Label size="regular" className={styles.noPricesLabel}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "No Prices available" })}
|
||||
{intl.formatMessage({ id: "No prices available" })}
|
||||
</Caption>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -46,15 +46,10 @@ export default function FlexibilityOption({
|
||||
const { public: publicPrice, member: memberPrice } = product.productType
|
||||
|
||||
function onChange() {
|
||||
const rate = {
|
||||
roomTypeCode,
|
||||
roomType,
|
||||
priceName: name,
|
||||
public: publicPrice,
|
||||
member: memberPrice,
|
||||
features: petRoomPackage ? features : [],
|
||||
}
|
||||
handleSelectRate(rate)
|
||||
handleSelectRate({
|
||||
publicRateCode: publicPrice.rateCode,
|
||||
roomTypeCode: roomTypeCode,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -94,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}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.card {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.priceVariants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./RoomCardSkeleton.module.css"
|
||||
|
||||
export function RoomCardSkeleton() {
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
{/* image container */}
|
||||
<div className={styles.imageContainer}>
|
||||
<SkeletonShimmer width={"100%"} height="100%" />
|
||||
</div>
|
||||
|
||||
<div className={styles.priceVariants}>
|
||||
{/* price variants */}
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<SkeletonShimmer key={index} height={"100px"} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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,9 +176,9 @@ export default function RoomCard({
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{roomConfiguration.roomType}
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,9 +14,9 @@ export default function RoomSelection({
|
||||
roomsAvailability,
|
||||
roomCategories,
|
||||
user,
|
||||
packages,
|
||||
availablePackages,
|
||||
selectedPackages,
|
||||
setRateSummary,
|
||||
setRateCode,
|
||||
rateSummary,
|
||||
}: RoomSelectionProps) {
|
||||
const router = useRouter()
|
||||
@@ -70,9 +70,9 @@ export default function RoomSelection({
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomCategories={roomCategories}
|
||||
handleSelectRate={setRateSummary}
|
||||
handleSelectRate={setRateCode}
|
||||
selectedPackages={selectedPackages}
|
||||
packages={packages}
|
||||
packages={availablePackages}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ export default function RoomSelection({
|
||||
<RateSummary
|
||||
rateSummary={rateSummary}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
packages={packages}
|
||||
packages={availablePackages}
|
||||
roomsAvailability={roomsAvailability}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,54 @@ export function getQueryParamsForEnterDetails(
|
||||
roomTypeCode: room.roomtype,
|
||||
rateCode: room.ratecode,
|
||||
packages: room.packages?.split(",") as RoomPackageCodeEnum[],
|
||||
counterRateCode: room.counterratecode,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createQueryParamsForEnterDetails(
|
||||
bookingData: BookingData,
|
||||
intitalSearchParams: URLSearchParams
|
||||
) {
|
||||
const { hotel, fromDate, toDate, rooms } = bookingData
|
||||
|
||||
const bookingSearchParams = new URLSearchParams({ hotel, fromDate, toDate })
|
||||
const searchParams = new URLSearchParams([
|
||||
...intitalSearchParams,
|
||||
...bookingSearchParams,
|
||||
])
|
||||
|
||||
rooms.forEach((item, index) => {
|
||||
if (item?.adults) {
|
||||
searchParams.set(`room[${index}].adults`, item.adults.toString())
|
||||
}
|
||||
if (item?.children) {
|
||||
item.children.forEach((child, childIndex) => {
|
||||
searchParams.set(
|
||||
`room[${index}].child[${childIndex}].age`,
|
||||
child.age.toString()
|
||||
)
|
||||
searchParams.set(
|
||||
`room[${index}].child[${childIndex}].bed`,
|
||||
child.bed.toString()
|
||||
)
|
||||
})
|
||||
}
|
||||
if (item?.roomTypeCode) {
|
||||
searchParams.set(`room[${index}].roomtype`, item.roomTypeCode)
|
||||
}
|
||||
if (item?.rateCode) {
|
||||
searchParams.set(`room[${index}].ratecode`, item.rateCode)
|
||||
}
|
||||
|
||||
if (item?.counterRateCode) {
|
||||
searchParams.set(`room[${index}].counterratecode`, item.counterRateCode)
|
||||
}
|
||||
|
||||
if (item.packages && item.packages.length > 0) {
|
||||
searchParams.set(`room[${index}].packages`, item.packages.join(","))
|
||||
}
|
||||
})
|
||||
|
||||
return searchParams
|
||||
}
|
||||
|
||||
101
components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx
Normal file
101
components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getHotelData,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getRoomsAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
|
||||
import { generateChildrenString } from "../RoomSelection/utils"
|
||||
import Rooms from "."
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
export type Props = {
|
||||
hotelId: number
|
||||
fromDate: Date
|
||||
toDate: Date
|
||||
adultCount: number
|
||||
childArray?: Child[]
|
||||
lang: Lang
|
||||
}
|
||||
|
||||
export async function RoomsContainer({
|
||||
hotelId,
|
||||
fromDate,
|
||||
toDate,
|
||||
adultCount,
|
||||
childArray,
|
||||
lang,
|
||||
}: Props) {
|
||||
const user = await getProfileSafely()
|
||||
|
||||
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
||||
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
||||
|
||||
const hotelDataPromise = safeTry(
|
||||
getHotelData({ hotelId: hotelId.toString(), language: lang })
|
||||
)
|
||||
|
||||
const packagesPromise = safeTry(
|
||||
getPackages({
|
||||
hotelId: hotelId.toString(),
|
||||
startDate: fromDateString,
|
||||
endDate: toDateString,
|
||||
adults: adultCount,
|
||||
children: childArray ? childArray.length : undefined,
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const roomsAvailabilityPromise = safeTry(
|
||||
getRoomsAvailability({
|
||||
hotelId: hotelId,
|
||||
roomStayStartDate: fromDateString,
|
||||
roomStayEndDate: toDateString,
|
||||
adults: adultCount,
|
||||
children:
|
||||
childArray && childArray.length > 0
|
||||
? generateChildrenString(childArray)
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
|
||||
const [hotelData, hotelDataError] = await hotelDataPromise
|
||||
const [packages, packagesError] = await packagesPromise
|
||||
const [roomsAvailability, roomsAvailabilityError] =
|
||||
await roomsAvailabilityPromise
|
||||
|
||||
if (packagesError) {
|
||||
// TODO: Log packages error
|
||||
console.error("[RoomsContainer] unable to fetch packages")
|
||||
}
|
||||
|
||||
if (roomsAvailabilityError) {
|
||||
// TODO: show proper error component
|
||||
console.error("[RoomsContainer] unable to fetch room availability")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!roomsAvailability) {
|
||||
// HotelInfoCard has the logic for displaying when there are no rooms available
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Rooms
|
||||
user={user}
|
||||
availablePackages={packages ?? []}
|
||||
roomsAvailability={roomsAvailability}
|
||||
roomCategories={hotelData?.included ?? []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.container {
|
||||
padding: var(--Spacing-x2);
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width);
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* used to hide overflowing rows */
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RoomCardSkeleton } from "../RoomSelection/RoomCard/RoomCardSkeleton"
|
||||
|
||||
import styles from "./RoomsContainerSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export async function RoomsContainerSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}></div>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import useRoomAvailableStore from "@/stores/roomAvailability"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import RoomFilter from "../RoomFilter"
|
||||
import RoomSelection from "../RoomSelection"
|
||||
@@ -11,41 +9,51 @@ import { filterDuplicateRoomTypesByLowestPrice } from "./utils"
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import {
|
||||
DefaultFilterOptions,
|
||||
RoomPackageCodeEnum,
|
||||
type RoomPackageCodes,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type {
|
||||
RoomConfiguration,
|
||||
RoomsAvailability,
|
||||
} from "@/server/routers/hotels/output"
|
||||
import type { RoomConfiguration } from "@/server/routers/hotels/output"
|
||||
|
||||
export default function Rooms({
|
||||
roomsAvailability,
|
||||
roomCategories = [],
|
||||
user,
|
||||
packages,
|
||||
availablePackages,
|
||||
}: SelectRateProps) {
|
||||
const visibleRooms: RoomConfiguration[] =
|
||||
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
|
||||
const [rateSummary, setRateSummary] = useState<Rate | null>(null)
|
||||
const [rooms, setRooms] = useState<RoomsAvailability>({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: visibleRooms,
|
||||
})
|
||||
const [selectedRate, setSelectedRate] = useState<
|
||||
{ publicRateCode: string; roomTypeCode: string } | undefined
|
||||
>(undefined)
|
||||
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
|
||||
[]
|
||||
)
|
||||
const noRoomsAvailable = useRoomAvailableStore(
|
||||
(state) => state.noRoomsAvailable
|
||||
)
|
||||
const setNoRoomsAvailable = useRoomAvailableStore(
|
||||
(state) => state.setNoRoomsAvailable
|
||||
)
|
||||
const setRoomsAvailable = useRoomAvailableStore(
|
||||
(state) => state.setRoomsAvailable
|
||||
)
|
||||
const defaultPackages: DefaultFilterOptions[] = [
|
||||
{
|
||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
description: "Accessible Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
description: "Allergy Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.PET_ROOM,
|
||||
description: "Pet Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
]
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(filter: Record<RoomPackageCodeEnum, boolean | undefined>) => {
|
||||
@@ -54,97 +62,92 @@ export default function Rooms({
|
||||
) as RoomPackageCodeEnum[]
|
||||
|
||||
setSelectedPackages(filteredPackages)
|
||||
|
||||
if (filteredPackages.length === 0) {
|
||||
setRooms({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: visibleRooms,
|
||||
})
|
||||
|
||||
if (!!rateSummary) {
|
||||
setRateSummary({
|
||||
...rateSummary,
|
||||
features: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (noRoomsAvailable) {
|
||||
setRoomsAvailable()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const filteredRooms = visibleRooms.filter((room) =>
|
||||
filteredPackages.every((filteredPackage) =>
|
||||
room.features.some((feature) => feature.code === filteredPackage)
|
||||
)
|
||||
)
|
||||
let notAvailableRooms = visibleRooms.filter((room) =>
|
||||
filteredPackages.every(
|
||||
(filteredPackage) =>
|
||||
!room.features.some((feature) => feature.code === filteredPackage)
|
||||
)
|
||||
)
|
||||
// Clone nested object to keep original object intact and not messup the room data
|
||||
notAvailableRooms = JSON.parse(JSON.stringify(notAvailableRooms))
|
||||
notAvailableRooms.forEach((room) => {
|
||||
room.status = "NotAvailable"
|
||||
})
|
||||
setRooms({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: [...filteredRooms, ...notAvailableRooms],
|
||||
})
|
||||
|
||||
if (filteredRooms.length == 0) {
|
||||
setNoRoomsAvailable()
|
||||
} else if (noRoomsAvailable) {
|
||||
setRoomsAvailable()
|
||||
}
|
||||
|
||||
const petRoomPackage =
|
||||
(filteredPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
undefined
|
||||
|
||||
const features = filteredRooms.find((room) =>
|
||||
room.features.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
)?.features
|
||||
|
||||
if (!!rateSummary) {
|
||||
setRateSummary({
|
||||
...rateSummary,
|
||||
features: petRoomPackage && features ? features : [],
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
roomsAvailability,
|
||||
visibleRooms,
|
||||
rateSummary,
|
||||
packages,
|
||||
noRoomsAvailable,
|
||||
setNoRoomsAvailable,
|
||||
setRoomsAvailable,
|
||||
]
|
||||
[]
|
||||
)
|
||||
|
||||
const filteredRooms = useMemo(() => {
|
||||
return visibleRooms.filter((room) =>
|
||||
selectedPackages.every((filteredPackage) =>
|
||||
room.features.some((feature) => feature.code === filteredPackage)
|
||||
)
|
||||
)
|
||||
}, [visibleRooms, selectedPackages])
|
||||
|
||||
const rooms = useMemo(() => {
|
||||
if (selectedPackages.length === 0) {
|
||||
return {
|
||||
...roomsAvailability,
|
||||
roomConfigurations: visibleRooms,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...roomsAvailability,
|
||||
roomConfigurations: [...filteredRooms],
|
||||
}
|
||||
}, [roomsAvailability, visibleRooms, selectedPackages, filteredRooms])
|
||||
|
||||
const rateSummary: Rate | null = useMemo(() => {
|
||||
const room = filteredRooms.find(
|
||||
(room) => room.roomTypeCode === selectedRate?.roomTypeCode
|
||||
)
|
||||
|
||||
if (!room) return null
|
||||
|
||||
const product = room.products.find(
|
||||
(product) =>
|
||||
product.productType.public.rateCode === selectedRate?.publicRateCode
|
||||
)
|
||||
|
||||
if (!product) return null
|
||||
|
||||
const petRoomPackage =
|
||||
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)) ||
|
||||
undefined
|
||||
|
||||
const features = filteredRooms.find((room) =>
|
||||
room.features.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
)?.features
|
||||
|
||||
const rateSummary: Rate = {
|
||||
features: petRoomPackage && features ? features : [],
|
||||
priceName: room.roomType,
|
||||
public: product.productType.public,
|
||||
member: product.productType.member,
|
||||
roomType: room.roomType,
|
||||
roomTypeCode: room.roomTypeCode,
|
||||
}
|
||||
|
||||
return rateSummary
|
||||
}, [filteredRooms, availablePackages, selectedPackages, selectedRate])
|
||||
|
||||
useEffect(() => {
|
||||
if (rateSummary) return
|
||||
if (!selectedRate) return
|
||||
|
||||
setSelectedRate(undefined)
|
||||
}, [rateSummary, selectedRate])
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<RoomFilter
|
||||
numberOfRooms={rooms.roomConfigurations.length}
|
||||
onFilter={handleFilter}
|
||||
filterOptions={packages}
|
||||
filterOptions={defaultPackages}
|
||||
/>
|
||||
<RoomSelection
|
||||
roomsAvailability={rooms}
|
||||
roomCategories={roomCategories}
|
||||
user={user}
|
||||
packages={packages}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
setRateSummary={setRateSummary}
|
||||
setRateCode={setSelectedRate}
|
||||
rateSummary={rateSummary}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user