Merge branch 'develop' into feat/performance-improvements

This commit is contained in:
Linus Flood
2024-11-06 13:48:26 +01:00
70 changed files with 1149 additions and 519 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
.hotelListing {
display: none;
}
@media (min-width: 768px) {
.hotelListing {
display: block;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

@@ -15,6 +15,7 @@
.imageWrapper {
display: flex;
width: 100%;
}
.imageWrapper::after {

View File

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

View File

@@ -2,6 +2,7 @@ import type { RegisterOptions } from "react-hook-form"
export const enum DateName {
date = "date",
day = "day",
month = "month",
year = "year",
}

View File

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