Merge branch 'master' into feat/sw-929-release-preps

This commit is contained in:
Linus Flood
2024-11-27 19:13:21 +01:00
310 changed files with 7543 additions and 4417 deletions

View File

@@ -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?" })}

View File

@@ -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?" })}

View File

@@ -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} />
</>
)
}

View File

@@ -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>

View File

@@ -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})`

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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;
}

View File

@@ -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 || ""}

View File

@@ -0,0 +1,3 @@
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
export { default as RoomSidePeek } from "./Room"
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -0,0 +1,3 @@
export function getRoomNameAsParam(roomName: string) {
return roomName.replace(/[()]/g, "").replaceAll(" ", "-").toLowerCase()
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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()}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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" })}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
import styles from "./room.module.css"
export default function Room() {
return <article className={styles.room}></article>
}

View File

@@ -0,0 +1,5 @@
import styles from "./rooms.module.css"
export default function Rooms() {
return <section className={styles.rooms}></section>
}

View File

@@ -0,0 +1,6 @@
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
grid-area: booking;
}

View File

@@ -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>
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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%));
}
}

View File

@@ -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>
</>
)
}

View File

@@ -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"

View File

@@ -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
}
/>
)
})}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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(""),
})

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View 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>
)
}

View File

@@ -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) => {

View File

@@ -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 />
}

View File

@@ -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}
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -48,8 +48,6 @@
}
.selection {
font-weight: 450;
font-size: var(--typography-Title-4-fontSize);
grid-area: selection;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View 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
}

View 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>
</>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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,
}))

View File

@@ -3,4 +3,6 @@
flex-direction: column;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x2);
max-height: 100vh;
overflow-y: auto;
}

View File

@@ -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>
)

View File

@@ -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;

View File

@@ -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"

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
.hotelAlert {
max-width: var(--max-width-navigation);
margin: 0 auto;
padding-top: var(--Spacing-x-one-and-half);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}
/>
)}

View File

@@ -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
}

View 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 ?? []}
/>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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,

View 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>
)
}

View 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