Merge branch 'develop' into feat/performance-improvements
This commit is contained in:
@@ -37,21 +37,26 @@ export default function Sidebar({
|
||||
|
||||
function moveToPoi(poiCoordinates: Coordinates) {
|
||||
if (map) {
|
||||
const hotelLatLng = new google.maps.LatLng(
|
||||
coordinates.lat,
|
||||
coordinates.lng
|
||||
)
|
||||
const poiLatLng = new google.maps.LatLng(
|
||||
poiCoordinates.lat,
|
||||
poiCoordinates.lng
|
||||
)
|
||||
|
||||
const bounds = new google.maps.LatLngBounds()
|
||||
const boundPadding = 0.02
|
||||
bounds.extend(hotelLatLng)
|
||||
bounds.extend(poiLatLng)
|
||||
|
||||
const minLat = Math.min(coordinates.lat, poiCoordinates.lat)
|
||||
const maxLat = Math.max(coordinates.lat, poiCoordinates.lat)
|
||||
const minLng = Math.min(coordinates.lng, poiCoordinates.lng)
|
||||
const maxLng = Math.max(coordinates.lng, poiCoordinates.lng)
|
||||
|
||||
bounds.extend(
|
||||
new google.maps.LatLng(minLat - boundPadding, minLng - boundPadding)
|
||||
)
|
||||
bounds.extend(
|
||||
new google.maps.LatLng(maxLat + boundPadding, maxLng + boundPadding)
|
||||
)
|
||||
map.fitBounds(bounds)
|
||||
|
||||
const currentZoomLevel = map.getZoom()
|
||||
|
||||
if (currentZoomLevel) {
|
||||
map.setZoom(currentZoomLevel - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +66,6 @@ export default function Sidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!isClicking) {
|
||||
onActivePoiChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
|
||||
setIsClicking(true)
|
||||
toggleFullScreenSidebar()
|
||||
@@ -127,7 +126,6 @@ export default function Sidebar({
|
||||
<button
|
||||
className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
onMouseEnter={() => handleMouseEnter(poi.name)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() =>
|
||||
handlePoiClick(poi.name, poi.coordinates)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,16 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import RoomDetailsButton from "../RoomDetailsButton"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import type { RoomCardProps } from "@/types/components/hotelPage/room"
|
||||
|
||||
export function RoomCard({ room }: RoomCardProps) {
|
||||
export function RoomCard({ hotelId, room }: RoomCardProps) {
|
||||
const { images, name, roomSize, occupancy, id } = room
|
||||
const intl = useIntl()
|
||||
const mainImage = images[0]
|
||||
@@ -70,7 +71,10 @@ export function RoomCard({ room }: RoomCardProps) {
|
||||
</Subtitle>
|
||||
<Body color="grey">{subtitle}</Body>
|
||||
</div>
|
||||
<RoomSidePeek room={room} buttonSize="medium" />
|
||||
<RoomDetailsButton
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypes[0].code}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function RoomDetailsButton({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -15,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({ rooms }: RoomsProps) {
|
||||
export function Rooms({ hotelId, rooms }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const showToggleButton = rooms.length > 3
|
||||
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
|
||||
@@ -45,7 +45,7 @@ export function Rooms({ rooms }: RoomsProps) {
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<div key={room.id}>
|
||||
<RoomCard room={room} />
|
||||
<RoomCard hotelId={hotelId} room={room} />
|
||||
</div>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
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"
|
||||
@@ -37,6 +38,7 @@ export default async function HotelPage() {
|
||||
}
|
||||
|
||||
const {
|
||||
hotelId,
|
||||
hotelName,
|
||||
hotelDescription,
|
||||
hotelLocation,
|
||||
@@ -97,7 +99,7 @@ export default async function HotelPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Rooms hotelId={hotelId} rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
{faq.accordions.length > 0 && (
|
||||
<AccordionSection accordion={faq.accordions} title={faq.title} />
|
||||
@@ -166,6 +168,7 @@ export default async function HotelPage() {
|
||||
</SidePeek>
|
||||
{/* eslint-enable import/no-named-as-default-member */}
|
||||
</SidePeekProvider>
|
||||
<HotelReservationSidePeek hotel={null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export const signUpSchema = z.object({
|
||||
"Phone is required",
|
||||
"Please enter a valid phone number"
|
||||
),
|
||||
dateOfBirth: z.string().min(1),
|
||||
dateOfBirth: z.string().min(1, {
|
||||
message: "Date of birth is required",
|
||||
}),
|
||||
address: z.object({
|
||||
countryCode: z
|
||||
.string({
|
||||
|
||||
@@ -23,7 +23,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string(),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
termsAccepted: z.literal(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomConfiguration } from "@/server/routers/hotels/output"
|
||||
|
||||
import { EditIcon, ImageIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
export default function SelectedRoom() {
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
room,
|
||||
}: {
|
||||
hotelId: string
|
||||
room: RoomConfiguration
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
@@ -22,42 +32,50 @@ export default function SelectedRoom() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.textContainer}>
|
||||
<Footnote
|
||||
className={styles.label}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Your room" })}
|
||||
</Footnote>
|
||||
<div className={styles.text}>
|
||||
{/**
|
||||
* [TEMP]
|
||||
* No translation on Subtitles as they will be derived
|
||||
* from Room selection.
|
||||
*/}
|
||||
<Subtitle
|
||||
className={styles.room}
|
||||
color="uiTextHighContrast"
|
||||
type="two"
|
||||
<div>
|
||||
<div className={styles.textContainer}>
|
||||
<Footnote
|
||||
className={styles.label}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
Cozy cabin
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Free rebooking
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Pay now
|
||||
</Subtitle>
|
||||
{intl.formatMessage({ id: "Your room" })}
|
||||
</Footnote>
|
||||
<div className={styles.text}>
|
||||
{/**
|
||||
* [TEMP]
|
||||
* No translation on Subtitles as they will be derived
|
||||
* from Room selection.
|
||||
*/}
|
||||
<Subtitle
|
||||
className={styles.room}
|
||||
color="uiTextHighContrast"
|
||||
type="two"
|
||||
>
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Free rebooking
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
className={styles.invertFontWeight}
|
||||
color="uiTextMediumContrast"
|
||||
type="two"
|
||||
>
|
||||
Pay now
|
||||
</Subtitle>
|
||||
</div>
|
||||
</div>
|
||||
{room?.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Contact from "@/components/HotelReservation/Contact"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./enterDetailsSidePeek.module.css"
|
||||
|
||||
import {
|
||||
SidePeekEnum,
|
||||
SidePeekProps,
|
||||
} from "@/types/components/hotelReservation/enterDetails/sidePeek"
|
||||
|
||||
export default function EnterDetailsSidePeek({ hotel }: SidePeekProps) {
|
||||
const activeSidePeek = useEnterDetailsStore((state) => state.activeSidePeek)
|
||||
const close = useEnterDetailsStore((state) => state.closeSidePeek)
|
||||
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={SidePeekEnum.hotelDetails}
|
||||
title={intl.formatMessage({ id: "About the hotel" })}
|
||||
isOpen={activeSidePeek === SidePeekEnum.hotelDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<article className={styles.spacing}>
|
||||
<Contact hotel={hotel} />
|
||||
<Divider />
|
||||
<section className={styles.spacing}>
|
||||
<Body>{hotel.hotelContent.texts.descriptions.medium}</Body>
|
||||
{hotel.hotelContent.texts.facilityInformation
|
||||
.split(/[\n\r]/g)
|
||||
.filter((p) => p)
|
||||
.map((paragraph, idx) => (
|
||||
<Body key={`facilityInfo-${idx}`}>{paragraph}</Body>
|
||||
))}
|
||||
</section>
|
||||
</article>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/enterDetails/sidePeek"
|
||||
|
||||
export default function ToggleSidePeek() {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useEnterDetailsStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
openSidePeek(SidePeekEnum.hotelDetails)
|
||||
}}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}{" "}
|
||||
<ChevronRightSmallIcon
|
||||
color="baseButtonTextOnFillNormal"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import Contact from "../Contact"
|
||||
|
||||
import styles from "./readMore.module.css"
|
||||
|
||||
import {
|
||||
ParkingProps,
|
||||
ReadMoreProps,
|
||||
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { Amenities, Hotel } from "@/types/hotel"
|
||||
import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
|
||||
function getAmenitiesList(hotel: Hotel) {
|
||||
const detailedAmenities: Amenities = hotel.detailedFacilities.filter(
|
||||
// Remove Parking facilities since parking accordion is based on hotel.parking
|
||||
(facility) => !facility.name.startsWith("Parking") && facility.public
|
||||
)
|
||||
return detailedAmenities
|
||||
}
|
||||
|
||||
export default function ReadMore({ label, hotel, hotelId }: ReadMoreProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const [sidePeekOpen, setSidePeekOpen] = useState(false)
|
||||
|
||||
const amenitiesList = getAmenitiesList(hotel)
|
||||
export default function ReadMore({ label, hotelId }: ReadMoreProps) {
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setSidePeekOpen(true)
|
||||
}}
|
||||
intent="text"
|
||||
theme="base"
|
||||
wrapping
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Button>
|
||||
<SidePeek
|
||||
title={hotel.name}
|
||||
isOpen={sidePeekOpen}
|
||||
contentKey={`${hotelId}`}
|
||||
handleClose={() => {
|
||||
setSidePeekOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Practical information" })}
|
||||
</Subtitle>
|
||||
<Contact hotel={hotel} />
|
||||
<Accordion>
|
||||
{/* parking */}
|
||||
{hotel.parking.length ? (
|
||||
<AccordionItem title={intl.formatMessage({ id: "Parking" })}>
|
||||
{hotel.parking.map((p) => (
|
||||
<Parking key={p.name} parking={p} />
|
||||
))}
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
<AccordionItem title={intl.formatMessage({ id: "Accessibility" })}>
|
||||
TODO: What content should be in the accessibility section?
|
||||
</AccordionItem>
|
||||
{amenitiesList.map((amenity) => {
|
||||
return (
|
||||
<div key={amenity.id} className={styles.amenity}>
|
||||
{amenity.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
{/* TODO: handle linking to Hotel Page */}
|
||||
<Button theme={"base"}>To the hotel</Button>
|
||||
</div>
|
||||
</SidePeek>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Parking({ parking }: ParkingProps) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div>
|
||||
<Body>{`${intl.formatMessage({ id: parking.type })} (${parking.name})`}</Body>
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
{`${intl.formatMessage({
|
||||
id: "Number of charging points for electric cars",
|
||||
})}: ${parking.numberOfChargingSpaces}`}
|
||||
</li>
|
||||
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel} m`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => {
|
||||
openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })
|
||||
}}
|
||||
intent="text"
|
||||
theme="base"
|
||||
wrapping
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.container {
|
||||
min-width: 272px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.facilities {
|
||||
@@ -24,3 +25,9 @@
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { FilterIcon, MapIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./mobileMapButtonContainer.module.css"
|
||||
|
||||
export default function MobileMapButtonContainer({ city }: { city: string }) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
asChild
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectHotelMap[lang]}`}
|
||||
keepSearchParams
|
||||
color="burgundy"
|
||||
>
|
||||
<MapIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "See on map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
{/* TODO: Add filter toggle */}
|
||||
<Button
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<FilterIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.buttonContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.hotelListing {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hotelListing {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
// TODO: This component is copied from
|
||||
@@ -7,5 +9,5 @@ import { HotelListingProps } from "@/types/components/hotelReservation/selectHot
|
||||
// Look at that for inspiration on how to do the interaction with the map.
|
||||
|
||||
export default function HotelListing({}: HotelListingProps) {
|
||||
return <section>Hotel listing TBI</section>
|
||||
return <section className={styles.hotelListing}>Hotel listing TBI</section>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { CloseIcon } from "@/components/Icons"
|
||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import HotelListing from "./HotelListing"
|
||||
@@ -22,36 +22,60 @@ export default function SelectHotelMap({
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
mapId,
|
||||
isModal,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
|
||||
function handleModalDismiss() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function handlePageRedirect() {
|
||||
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
|
||||
}
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
asChild
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.closeButton}
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
>
|
||||
<Link href={selectHotel[lang]} keepSearchParams color="burgundy">
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Link>
|
||||
<CloseIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Close the map" })}
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
</div>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</div>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,4 +2,39 @@
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.filterContainer .closeButton {
|
||||
color: var(--UI-Text-High-Contrast);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.closeButton {
|
||||
display: flex !important;
|
||||
}
|
||||
.filterContainer {
|
||||
display: none;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { RateDefinition } from "@/server/routers/hotels/output"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import RoomSidePeek from "../../../../SidePeeks/RoomSidePeek"
|
||||
import ImageGallery from "../../ImageGallery"
|
||||
import { getIconForFeatureCode } from "../../utils"
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RoomCardProps } from "@/types/components/hotelReservation/selectRa
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export default function RoomCard({
|
||||
hotelId,
|
||||
rateDefinitions,
|
||||
roomConfiguration,
|
||||
roomCategories,
|
||||
@@ -57,9 +58,10 @@ export default function RoomCard({
|
||||
?.generalTerms
|
||||
}
|
||||
|
||||
const petRoomPackage = packages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPackage =
|
||||
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find(
|
||||
(room) => room.name === roomConfiguration.roomType
|
||||
@@ -86,8 +88,11 @@ export default function RoomCard({
|
||||
: `${roomSize?.min}-${roomSize?.max}`}
|
||||
m²
|
||||
</Caption>
|
||||
{selectedRoom && (
|
||||
<RoomSidePeek room={selectedRoom} buttonSize="small" />
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function RoomSelection({
|
||||
{roomConfigurations.map((roomConfiguration) => (
|
||||
<li key={roomConfiguration.roomTypeCode}>
|
||||
<RoomCard
|
||||
hotelId={roomsAvailability.hotelId.toString()}
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomCategories={roomCategories}
|
||||
|
||||
60
components/HotelReservation/SidePeek/index.tsx
Normal file
60
components/HotelReservation/SidePeek/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import HotelSidePeek from "@/components/SidePeeks/HotelSidePeek"
|
||||
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { HotelData } from "@/types/hotel"
|
||||
|
||||
export default function HotelReservationSidePeek({
|
||||
hotel,
|
||||
}: {
|
||||
hotel: HotelData | null
|
||||
}) {
|
||||
const activeSidePeek = useSidePeekStore((state) => state.activeSidePeek)
|
||||
const hotelId = useSidePeekStore((state) => state.hotelId)
|
||||
const roomTypeCode = useSidePeekStore((state) => state.roomTypeCode)
|
||||
const close = useSidePeekStore((state) => state.closeSidePeek)
|
||||
const lang = useLang()
|
||||
|
||||
const { data: hotelData } = trpc.hotel.hotelData.get.useQuery(
|
||||
{
|
||||
hotelId: hotelId ?? "",
|
||||
language: lang,
|
||||
},
|
||||
{
|
||||
enabled: !!hotelId,
|
||||
initialData: hotel ?? undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const selectedRoom = hotelData?.included?.find((room) =>
|
||||
room.roomTypes.some((type) => type.code === roomTypeCode)
|
||||
)
|
||||
|
||||
if (activeSidePeek) {
|
||||
return (
|
||||
<>
|
||||
{hotelData && (
|
||||
<HotelSidePeek
|
||||
hotel={hotelData.data?.attributes}
|
||||
activeSidePeek={activeSidePeek}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
{selectedRoom && (
|
||||
<RoomSidePeek
|
||||
room={selectedRoom}
|
||||
activeSidePeek={activeSidePeek}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
23
components/Icons/Filter.tsx
Normal file
23
components/Icons/Filter.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function FilterIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.58789 15.8125C9.46046 15.8125 9.34813 15.7681 9.25091 15.6792C9.15368 15.5903 9.10507 15.4819 9.10507 15.3542V10.7292L4.33424 4.9375C4.21618 4.78472 4.19886 4.62153 4.28226 4.44792C4.36568 4.27431 4.508 4.1875 4.70924 4.1875H15.2926C15.4938 4.1875 15.6361 4.27431 15.7196 4.44792C15.803 4.62153 15.7856 4.78472 15.6676 4.9375L10.8967 10.7292V15.3542C10.8967 15.4819 10.8489 15.5903 10.7533 15.6792C10.6577 15.7681 10.5462 15.8125 10.4188 15.8125H9.58789ZM10.0009 9.625L13.3134 5.58333H6.66757L10.0009 9.625Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export { default as ErrorCircleIcon } from "./ErrorCircle"
|
||||
export { default as EyeHideIcon } from "./EyeHide"
|
||||
export { default as EyeShowIcon } from "./EyeShow"
|
||||
export { default as FanIcon } from "./Fan"
|
||||
export { default as FilterIcon } from "./Filter"
|
||||
export { default as FitnessIcon } from "./Fitness"
|
||||
export { default as FootstoolIcon } from "./Footstool"
|
||||
export { default as GalleryIcon } from "./Gallery"
|
||||
|
||||
85
components/MapModal/index.tsx
Normal file
85
components/MapModal/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Dialog, Modal } from "react-aria-components"
|
||||
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
import styles from "./mapModal.module.css"
|
||||
|
||||
export function MapModal({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const [mapHeight, setMapHeight] = useState("0px")
|
||||
const [mapTop, setMapTop] = useState("0px")
|
||||
const [isOpen, setOpen] = useState(true)
|
||||
|
||||
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
|
||||
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleOnOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
if (!open) {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
||||
const handleMapHeight = useCallback(() => {
|
||||
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
|
||||
const scrollY = window.scrollY
|
||||
setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`)
|
||||
setMapTop(`${topPosition + scrollY}px`)
|
||||
}, [])
|
||||
|
||||
// Making sure the map is always opened at the top of the page,
|
||||
// just below the header and booking widget as these should stay visible.
|
||||
// When closing, the page should scroll back to the position it was before opening the map.
|
||||
useEffect(() => {
|
||||
// Skip the first render
|
||||
if (!rootDiv.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollHeightWhenOpened === 0) {
|
||||
const scrollY = window.scrollY
|
||||
setScrollHeightWhenOpened(scrollY)
|
||||
window.scrollTo({ top: 0, behavior: "instant" })
|
||||
}
|
||||
}, [scrollHeightWhenOpened, rootDiv])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResizeHandler = debounce(function () {
|
||||
handleMapHeight()
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(debouncedResizeHandler)
|
||||
|
||||
observer.observe(document.documentElement)
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.unobserve(document.documentElement)
|
||||
}
|
||||
}
|
||||
}, [rootDiv, handleMapHeight])
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={rootDiv}>
|
||||
<Modal isDismissable isOpen={isOpen} onOpenChange={handleOnOpenChange}>
|
||||
<Dialog
|
||||
style={
|
||||
{
|
||||
"--hotel-map-height": mapHeight,
|
||||
"--hotel-map-top": mapTop,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={styles.dynamicMap}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
components/MapModal/mapModal.module.css
Normal file
18
components/MapModal/mapModal.module.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.dynamicMap {
|
||||
--hotel-map-height: 100dvh;
|
||||
--hotel-map-top: 0px;
|
||||
position: absolute;
|
||||
top: var(--hotel-map-top);
|
||||
left: 0;
|
||||
height: var(--hotel-map-height);
|
||||
width: 100dvw;
|
||||
z-index: var(--hotel-dynamic-map-z-index);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
@@ -70,7 +70,6 @@ export default function InteractiveMap({
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={activePoi === poi.name ? 2 : 0}
|
||||
onMouseEnter={() => onActivePoiChange(poi.name)}
|
||||
onMouseLeave={() => onActivePoiChange(null)}
|
||||
onClick={() => toggleActivePoi(poi.name)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.mapContainer {
|
||||
--button-box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
23
components/SidePeeks/HotelSidePeek/hotelSidePeek.module.css
Normal file
23
components/SidePeeks/HotelSidePeek/hotelSidePeek.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.spacing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.amenity {
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
/* padding set to align with AccordionItem which has a different composition */
|
||||
padding: var(--Spacing-x2)
|
||||
calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half));
|
||||
}
|
||||
|
||||
.list {
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
list-style: inside;
|
||||
}
|
||||
90
components/SidePeeks/HotelSidePeek/index.tsx
Normal file
90
components/SidePeeks/HotelSidePeek/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Contact from "@/components/HotelReservation/Contact"
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./hotelSidePeek.module.css"
|
||||
|
||||
import { HotelSidePeekProps } from "@/types/components/hotelReservation/hotelSidePeek"
|
||||
import { ParkingProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { Amenities, Hotel } from "@/types/hotel"
|
||||
|
||||
function getAmenitiesList(hotel: Hotel) {
|
||||
const detailedAmenities: Amenities = hotel.detailedFacilities.filter(
|
||||
// Remove Parking facilities since parking accordion is based on hotel.parking
|
||||
(facility) => !facility.name.startsWith("Parking") && facility.public
|
||||
)
|
||||
return detailedAmenities
|
||||
}
|
||||
|
||||
export default function HotelSidePeek({
|
||||
hotel,
|
||||
activeSidePeek,
|
||||
close,
|
||||
}: HotelSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const amenitiesList = getAmenitiesList(hotel)
|
||||
|
||||
return (
|
||||
<SidePeek
|
||||
title={hotel.name}
|
||||
isOpen={activeSidePeek === SidePeekEnum.hotelDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Practical information" })}
|
||||
</Subtitle>
|
||||
<Contact hotel={hotel} />
|
||||
<Accordion>
|
||||
{/* parking */}
|
||||
{hotel.parking.length ? (
|
||||
<AccordionItem title={intl.formatMessage({ id: "Parking" })}>
|
||||
{hotel.parking.map((p) => (
|
||||
<Parking key={p.name} parking={p} />
|
||||
))}
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
<AccordionItem title={intl.formatMessage({ id: "Accessibility" })}>
|
||||
TODO: What content should be in the accessibility section?
|
||||
</AccordionItem>
|
||||
{amenitiesList.map((amenity) => {
|
||||
return (
|
||||
<div key={amenity.id} className={styles.amenity}>
|
||||
{amenity.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
{/* TODO: handle linking to Hotel Page */}
|
||||
<Button theme={"base"}>To the hotel</Button>
|
||||
</div>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
|
||||
function Parking({ parking }: ParkingProps) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div>
|
||||
<Body>{`${intl.formatMessage({ id: parking.type })} (${parking.name})`}</Body>
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
{`${intl.formatMessage({
|
||||
id: "Number of charging points for electric cars",
|
||||
})}: ${parking.numberOfChargingSpaces}`}
|
||||
</li>
|
||||
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel} m`}</li>
|
||||
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -12,10 +10,14 @@ import { getFacilityIcon } from "./facilityIcon"
|
||||
|
||||
import styles from "./roomSidePeek.module.css"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { RoomSidePeekProps } from "@/types/components/sidePeeks/roomSidePeek"
|
||||
|
||||
export default function RoomSidePeek({ room, buttonSize }: RoomSidePeekProps) {
|
||||
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false)
|
||||
export default function RoomSidePeek({
|
||||
room,
|
||||
activeSidePeek,
|
||||
close,
|
||||
}: RoomSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const roomSize = room.roomSize
|
||||
@@ -24,84 +26,70 @@ export default function RoomSidePeek({ room, buttonSize }: RoomSidePeekProps) {
|
||||
const images = room.images
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
intent="text"
|
||||
type="button"
|
||||
size={buttonSize}
|
||||
theme="base"
|
||||
className={styles.button}
|
||||
onClick={() => setIsSidePeekOpen(true)}
|
||||
>
|
||||
{intl.formatMessage({ id: "See room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
|
||||
</Button>
|
||||
|
||||
<SidePeek
|
||||
title={room.name}
|
||||
isOpen={isSidePeekOpen}
|
||||
handleClose={() => setIsSidePeekOpen(false)}
|
||||
>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.mainContent}>
|
||||
<Body color="baseTextMediumContrast">
|
||||
{roomSize.min === roomSize.max
|
||||
? roomSize.min
|
||||
: `${roomSize.min} - ${roomSize.max}`}
|
||||
m².{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.accommodatesUpTo" },
|
||||
{ nrOfGuests: occupancy }
|
||||
)}
|
||||
</Body>
|
||||
{images && (
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery images={images} title={room.name} />
|
||||
</div>
|
||||
<SidePeek
|
||||
title={room.name}
|
||||
isOpen={activeSidePeek === SidePeekEnum.roomDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.mainContent}>
|
||||
<Body color="baseTextMediumContrast">
|
||||
{roomSize.min === roomSize.max
|
||||
? roomSize.min
|
||||
: `${roomSize.min} - ${roomSize.max}`}
|
||||
m².{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.accommodatesUpTo" },
|
||||
{ nrOfGuests: occupancy }
|
||||
)}
|
||||
<Body color="uiTextHighContrast">{roomDescription}</Body>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })}
|
||||
</Subtitle>
|
||||
<ul className={styles.facilityList}>
|
||||
{room.roomFacilities
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
const Icon = getFacilityIcon(facility.name)
|
||||
return (
|
||||
<li key={facility.name}>
|
||||
{Icon && <Icon color="uiTextMediumContrast" />}
|
||||
<Body
|
||||
asChild
|
||||
className={!Icon ? styles.noIcon : undefined}
|
||||
color="uiTextMediumContrast"
|
||||
>
|
||||
<span>{facility.name}</span>
|
||||
</Body>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "booking.bedOptions" })}
|
||||
</Subtitle>
|
||||
<Body color="grey">
|
||||
{intl.formatMessage({ id: "booking.basedOnAvailability" })}
|
||||
</Body>
|
||||
{/* TODO: Get data for bed options */}
|
||||
</div>
|
||||
</Body>
|
||||
{images && (
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery images={images} title={room.name} />
|
||||
</div>
|
||||
)}
|
||||
<Body color="uiTextHighContrast">{roomDescription}</Body>
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button fullWidth theme="base" intent="primary">
|
||||
{intl.formatMessage({ id: "booking.selectRoom" })}
|
||||
{/* TODO: Implement logic for select room */}
|
||||
</Button>
|
||||
<div className={styles.listContainer}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })}
|
||||
</Subtitle>
|
||||
<ul className={styles.facilityList}>
|
||||
{room.roomFacilities
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
const Icon = getFacilityIcon(facility.name)
|
||||
return (
|
||||
<li key={facility.name}>
|
||||
{Icon && <Icon color="uiTextMediumContrast" />}
|
||||
<Body
|
||||
asChild
|
||||
className={!Icon ? styles.noIcon : undefined}
|
||||
color="uiTextMediumContrast"
|
||||
>
|
||||
<span>{facility.name}</span>
|
||||
</Body>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</SidePeek>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "booking.bedOptions" })}
|
||||
</Subtitle>
|
||||
<Body color="grey">
|
||||
{intl.formatMessage({ id: "booking.basedOnAvailability" })}
|
||||
</Body>
|
||||
{/* TODO: Get data for bed options */}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button fullWidth theme="base" intent="primary">
|
||||
{intl.formatMessage({ id: "booking.selectRoom" })}
|
||||
{/* TODO: Implement logic for select room */}
|
||||
</Button>
|
||||
</div>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
.imageWrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageWrapper::after {
|
||||
|
||||
@@ -18,3 +18,12 @@
|
||||
.year {
|
||||
grid-area: year;
|
||||
}
|
||||
|
||||
/* TODO: Handle this in Select component.
|
||||
- out of scope for now.
|
||||
*/
|
||||
.day.invalid > div > div,
|
||||
.month.invalid > div > div,
|
||||
.year.invalid > div > div {
|
||||
border-color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export const enum DateName {
|
||||
date = "date",
|
||||
day = "day",
|
||||
month = "month",
|
||||
year = "year",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { parseDate } from "@internationalized/date"
|
||||
import { useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { DateInput, DatePicker, Group } from "react-aria-components"
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
@@ -8,8 +8,11 @@ import { useIntl } from "react-intl"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { getLocalizedMonthName } from "@/utils/dateFormatting"
|
||||
import { rangeArray } from "@/utils/rangeArray"
|
||||
|
||||
import ErrorMessage from "../ErrorMessage"
|
||||
import { DateName } from "./date"
|
||||
|
||||
import styles from "./date.module.css"
|
||||
@@ -20,51 +23,75 @@ import type { DateProps } from "./date"
|
||||
|
||||
export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
const intl = useIntl()
|
||||
const currentValue = useWatch({ name })
|
||||
const { control, setValue, trigger } = useFormContext()
|
||||
const { field } = useController({
|
||||
const { control, setValue, formState, watch } = useFormContext()
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name,
|
||||
rules: registerOptions,
|
||||
})
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const currentDateValue = useWatch({ name })
|
||||
const year = watch(DateName.year)
|
||||
const month = watch(DateName.month)
|
||||
const day = watch(DateName.day)
|
||||
|
||||
const lang = useLang()
|
||||
const months = rangeArray(1, 12).map((month) => ({
|
||||
value: month,
|
||||
label: `${month}`,
|
||||
label: getLocalizedMonthName(month, lang),
|
||||
}))
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = rangeArray(1900, currentYear - 18)
|
||||
.reverse()
|
||||
.map((year) => ({ value: year, label: year.toString() }))
|
||||
|
||||
// Ensure the user can't select a date that doesn't exist.
|
||||
const daysInMonth = dt(currentValue).daysInMonth()
|
||||
// Calculate available days based on selected year and month
|
||||
const daysInMonth = getDaysInMonth(
|
||||
year ? Number(year) : null,
|
||||
month ? Number(month) - 1 : null
|
||||
)
|
||||
|
||||
const days = rangeArray(1, daysInMonth).map((day) => ({
|
||||
value: day,
|
||||
label: `${day}`,
|
||||
}))
|
||||
|
||||
function createOnSelect(selector: DateName) {
|
||||
/**
|
||||
* Months are 0 index based and therefore we
|
||||
* must subtract by 1 to get the selected month
|
||||
*/
|
||||
return (select: Key) => {
|
||||
if (selector === DateName.month) {
|
||||
select = Number(select) - 1
|
||||
}
|
||||
const newDate = dt(currentValue).set(selector, Number(select))
|
||||
setValue(name, newDate.format("YYYY-MM-DD"))
|
||||
trigger(name)
|
||||
}
|
||||
}
|
||||
|
||||
const dayLabel = intl.formatMessage({ id: "Day" })
|
||||
const monthLabel = intl.formatMessage({ id: "Month" })
|
||||
const yearLabel = intl.formatMessage({ id: "Year" })
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.isSubmitting) return
|
||||
|
||||
if (month && day) {
|
||||
const maxDays = getDaysInMonth(
|
||||
year ? Number(year) : null,
|
||||
Number(month) - 1
|
||||
)
|
||||
const adjustedDay = Number(day) > maxDays ? maxDays : Number(day)
|
||||
|
||||
if (adjustedDay !== Number(day)) {
|
||||
setValue(DateName.day, adjustedDay)
|
||||
}
|
||||
}
|
||||
|
||||
if (year && month && day) {
|
||||
const newDate = dt()
|
||||
.year(Number(year))
|
||||
.month(Number(month) - 1)
|
||||
.date(Number(day))
|
||||
|
||||
if (newDate.isValid()) {
|
||||
setValue(name, newDate.format("YYYY-MM-DD"), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [year, month, day, setValue, name, formState.isSubmitting])
|
||||
|
||||
let dateValue = null
|
||||
try {
|
||||
/**
|
||||
@@ -72,7 +99,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
* date, but we can't check isNan since
|
||||
* we recieve the date as "1999-01-01"
|
||||
*/
|
||||
dateValue = dt(currentValue).isValid() ? parseDate(currentValue) : null
|
||||
dateValue = dt(currentDateValue).isValid()
|
||||
? parseDate(currentDateValue)
|
||||
: null
|
||||
} catch (error) {
|
||||
console.warn("Known error for parse date in DateSelect: ", error)
|
||||
}
|
||||
@@ -81,6 +110,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
<DatePicker
|
||||
aria-label={intl.formatMessage({ id: "Select date of birth" })}
|
||||
isRequired={!!registerOptions.required}
|
||||
isInvalid={!formState.isValid}
|
||||
name={name}
|
||||
ref={field.ref}
|
||||
value={dateValue}
|
||||
@@ -92,57 +122,60 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
switch (segment.type) {
|
||||
case "day":
|
||||
return (
|
||||
<div className={styles.day}>
|
||||
<div
|
||||
className={`${styles.day} ${fieldState.invalid ? styles.invalid : ""}`}
|
||||
>
|
||||
<Select
|
||||
aria-label={dayLabel}
|
||||
items={days}
|
||||
label={dayLabel}
|
||||
name={DateName.date}
|
||||
onSelect={createOnSelect(DateName.date)}
|
||||
placeholder="DD"
|
||||
name={DateName.day}
|
||||
onSelect={(key: Key) =>
|
||||
setValue(DateName.day, Number(key))
|
||||
}
|
||||
placeholder={dayLabel}
|
||||
required
|
||||
tabIndex={3}
|
||||
defaultSelectedKey={
|
||||
segment.isPlaceholder ? undefined : segment.value
|
||||
}
|
||||
value={segment.isPlaceholder ? undefined : segment.value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "month":
|
||||
return (
|
||||
<div className={styles.month}>
|
||||
<div
|
||||
className={`${styles.month} ${fieldState.invalid ? styles.invalid : ""}`}
|
||||
>
|
||||
<Select
|
||||
aria-label={monthLabel}
|
||||
items={months}
|
||||
label={monthLabel}
|
||||
name={DateName.month}
|
||||
onSelect={createOnSelect(DateName.month)}
|
||||
placeholder="MM"
|
||||
onSelect={(key: Key) =>
|
||||
setValue(DateName.month, Number(key))
|
||||
}
|
||||
placeholder={monthLabel}
|
||||
required
|
||||
tabIndex={2}
|
||||
defaultSelectedKey={
|
||||
segment.isPlaceholder ? undefined : segment.value
|
||||
}
|
||||
value={segment.isPlaceholder ? undefined : segment.value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "year":
|
||||
return (
|
||||
<div className={styles.year}>
|
||||
<div
|
||||
className={`${styles.year} ${fieldState.invalid ? styles.invalid : ""}`}
|
||||
>
|
||||
<Select
|
||||
aria-label={yearLabel}
|
||||
items={years}
|
||||
label={yearLabel}
|
||||
name={DateName.year}
|
||||
onSelect={createOnSelect(DateName.year)}
|
||||
placeholder="YYYY"
|
||||
onSelect={(key: Key) =>
|
||||
setValue(DateName.year, Number(key))
|
||||
}
|
||||
placeholder={yearLabel}
|
||||
required
|
||||
tabIndex={1}
|
||||
defaultSelectedKey={
|
||||
segment.isPlaceholder ? undefined : segment.value
|
||||
}
|
||||
value={segment.isPlaceholder ? undefined : segment.value}
|
||||
/>
|
||||
</div>
|
||||
@@ -154,6 +187,21 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
}}
|
||||
</DateInput>
|
||||
</Group>
|
||||
<ErrorMessage errors={formState.errors} name={field.name} />
|
||||
</DatePicker>
|
||||
)
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number | null, month: number | null): number {
|
||||
if (month === null) {
|
||||
return 31
|
||||
}
|
||||
|
||||
// If month is February and no year selected, return minimum.
|
||||
if (month === 1 && !year) {
|
||||
return 28
|
||||
}
|
||||
|
||||
const yearToUse = year ?? new Date().getFullYear()
|
||||
return dt(`${yearToUse}-${month + 1}-01`).daysInMonth()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user