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

@@ -0,0 +1,25 @@
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import SidePeek from "@/components/HotelReservation/SidePeek"
import type { LangParams, PageArgs } from "@/types/params"
export default async function HotelSidePeek({
params,
searchParams,
}: PageArgs<LangParams, { hotel: string }>) {
const search = new URLSearchParams(searchParams)
const { hotel: hotelId } = getQueryParamsForEnterDetails(search)
if (!hotelId) {
return <SidePeek hotel={null} />
}
const hotel = await getHotelData({
hotelId: hotelId,
language: params.lang,
})
return <SidePeek hotel={hotel} />
}

View File

@@ -1,24 +0,0 @@
import { redirect } from "next/navigation"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import SidePeek from "@/components/HotelReservation/EnterDetails/SidePeek"
import type { LangParams, PageArgs } from "@/types/params"
export default async function HotelSidePeek({
params,
searchParams,
}: PageArgs<LangParams, { hotel: string }>) {
if (!searchParams.hotel) {
redirect(`/${params.lang}`)
}
const hotel = await getHotelData({
hotelId: searchParams.hotel,
language: params.lang,
})
if (!hotel?.data) {
redirect(`/${params.lang}`)
}
return <SidePeek hotel={hotel.data.attributes} />
}

View File

@@ -0,0 +1,9 @@
import {
getCreditCardsSafely,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
export function preload() {
void getProfileSafely()
void getCreditCardsSafely()
}

View File

@@ -1,10 +1,12 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests" import {
getCreditCardsSafely,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider" import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { preload } from "./page" import { preload } from "./_preload"
import styles from "./layout.module.css" import styles from "./layout.module.css"
@@ -16,11 +18,9 @@ export default async function StepLayout({
children, children,
hotelHeader, hotelHeader,
params, params,
sidePeek,
}: React.PropsWithChildren< }: React.PropsWithChildren<
LayoutArgs<LangParams & { step: StepEnum }> & { LayoutArgs<LangParams & { step: StepEnum }> & {
hotelHeader: React.ReactNode hotelHeader: React.ReactNode
sidePeek: React.ReactNode
summary: React.ReactNode summary: React.ReactNode
} }
>) { >) {
@@ -34,7 +34,6 @@ export default async function StepLayout({
<main className={styles.layout}> <main className={styles.layout}>
{hotelHeader} {hotelHeader}
<div className={styles.content}> <div className={styles.content}>
<SelectedRoom />
{children} {children}
<aside className={styles.summaryContainer}> <aside className={styles.summaryContainer}>
<div className={styles.hider} /> <div className={styles.hider} />
@@ -42,7 +41,6 @@ export default async function StepLayout({
<div className={styles.shadow} /> <div className={styles.shadow} />
</aside> </aside>
</div> </div>
{sidePeek}
</main> </main>
</EnterDetailsProvider> </EnterDetailsProvider>
) )

View File

@@ -14,6 +14,7 @@ import Details from "@/components/HotelReservation/EnterDetails/Details"
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager" import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
import Payment from "@/components/HotelReservation/EnterDetails/Payment" import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion" import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import { import {
generateChildrenString, generateChildrenString,
getQueryParamsForEnterDetails, getQueryParamsForEnterDetails,
@@ -24,11 +25,6 @@ import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export function preload() {
void getProfileSafely()
void getCreditCardsSafely()
}
function isValidStep(step: string): step is StepEnum { function isValidStep(step: string): step is StepEnum {
return Object.values(StepEnum).includes(step as StepEnum) return Object.values(StepEnum).includes(step as StepEnum)
} }
@@ -104,6 +100,9 @@ export default async function StepPage({
return ( return (
<section> <section>
<HistoryStateManager /> <HistoryStateManager />
<SelectedRoom hotelId={hotelId} room={roomAvailability.selectedRoom} />
{/* TODO: How to handle no beds found? */} {/* TODO: How to handle no beds found? */}
{roomAvailability.bedTypes ? ( {roomAvailability.bedTypes ? (
<SectionAccordion <SectionAccordion

View File

@@ -8,9 +8,17 @@ import { LangParams, LayoutArgs } from "@/types/params"
export default function HotelReservationLayout({ export default function HotelReservationLayout({
children, children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) { sidePeek,
}: React.PropsWithChildren<LayoutArgs<LangParams>> & {
sidePeek: React.ReactNode
}) {
if (env.HIDE_FOR_NEXT_RELEASE) { if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound() return notFound()
} }
return <div className={styles.layout}>{children}</div> return (
<div className={styles.layout}>
{children}
{sidePeek}
</div>
)
} }

View File

@@ -0,0 +1,75 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { MapModal } from "@/components/MapModal"
import { setLang } from "@/i18n/serverContext"
import {
fetchAvailableHotels,
generateChildrenString,
getCentralCoordinates,
getPointOfInterests,
} from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelMapPage({
params,
searchParams,
}: PageArgs<LangParams, SelectHotelSearchParams>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
setLang(params.lang)
const locations = await getLocations()
if (!locations || "error" in locations) {
return null
}
const city = locations.data.find(
(location) =>
location.name.toLowerCase() === searchParams.city.toLowerCase()
)
if (!city) return notFound()
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const selectHotelParams = new URLSearchParams(searchParams)
const selectHotelParamsObject =
getHotelReservationQueryParams(selectHotelParams)
const adults = selectHotelParamsObject.room[0].adults // TODO: Handle multiple rooms
const children = selectHotelParamsObject.room[0].child
? generateChildrenString(selectHotelParamsObject.room[0].child)
: undefined // TODO: Handle multiple rooms
const hotels = await fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: searchParams.fromDate,
roomStayEndDate: searchParams.toDate,
adults,
children,
})
const pointOfInterests = getPointOfInterests(hotels)
const centralCoordinates = getCentralCoordinates(pointOfInterests)
return (
<MapModal>
<SelectHotelMap
apiKey={googleMapsApiKey}
coordinates={centralCoordinates}
pointsOfInterest={pointOfInterests}
mapId={googleMapId}
isModal={true}
/>
</MapModal>
)
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null
}

View File

@@ -0,0 +1,5 @@
.layout {
min-height: 100dvh;
background-color: var(--Base-Background-Primary-Normal);
position: relative;
}

View File

@@ -0,0 +1,24 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import styles from "./layout.module.css"
import { LangParams, LayoutArgs } from "@/types/params"
export default function HotelReservationLayout({
children,
modal,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & { modal: React.ReactNode }
>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return (
<div className={styles.layout}>
{children}
{modal}
</div>
)
}

View File

@@ -2,5 +2,5 @@
display: grid; display: grid;
background-color: var(--Scandic-Brand-Warm-White); background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh; min-height: 100dvh;
grid-template-columns: 420px 1fr; position: relative;
} }

View File

@@ -1,58 +1 @@
import { env } from "@/env/server" export { default } from "../@modal/(.)map/page"
import {
fetchAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
import {
PointOfInterest,
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
import { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelMapPage({
params,
}: PageArgs<LangParams, {}>) {
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
setLang(params.lang)
const hotels = await fetchAvailableHotels({
cityId: "8ec4bba3-1c38-4606-82d1-bbe3f6738e54",
roomStayStartDate: "2024-11-02",
roomStayEndDate: "2024-11-03",
adults: 1,
})
const filters = getFiltersFromHotels(hotels)
// TODO: this is just a quick transformation to get something there. May need rework
const pointOfInterests: PointOfInterest[] = hotels.map((hotel) => ({
coordinates: {
lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
distance: hotel.hotelData.location.distanceToCentre,
categoryName: PointOfInterestCategoryNameEnum.HOTEL,
group: PointOfInterestGroupEnum.LOCATION,
}))
return (
<main className={styles.main}>
<SelectHotelMap
apiKey={googleMapsApiKey}
// TODO: use correct coordinates. The city center?
coordinates={{ lat: 59.32, lng: 18.01 }}
pointsOfInterest={pointOfInterests}
mapId={googleMapId}
/>
</main>
)
}

View File

@@ -1,6 +1,6 @@
.main { .main {
display: flex; display: flex;
gap: var(--Spacing-x4); gap: var(--Spacing-x3);
padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4); padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4);
background-color: var(--Scandic-Brand-Warm-White); background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh; min-height: 100dvh;
@@ -19,8 +19,28 @@
padding: var(--Spacing-x2) var(--Spacing-x0); padding: var(--Spacing-x2) var(--Spacing-x0);
} }
.mapContainer {
display: none;
}
.buttonContainer {
display: flex;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x3);
}
.button {
flex: 1;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.mapContainer {
display: block;
}
.main { .main {
flex-direction: row; flex-direction: row;
} }
.buttonContainer {
display: none;
}
} }

View File

@@ -9,6 +9,7 @@ import {
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing" import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter" import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
import { import {
generateChildrenString, generateChildrenString,
getHotelReservationQueryParams, getHotelReservationQueryParams,
@@ -62,25 +63,28 @@ export default async function SelectHotelPage({
return ( return (
<main className={styles.main}> <main className={styles.main}>
<section className={styles.section}> <section className={styles.section}>
<Link href={selectHotelMap[params.lang]} keepSearchParams> <div className={styles.mapContainer}>
<StaticMap <Link href={selectHotelMap[params.lang]} keepSearchParams>
city={searchParams.city} <StaticMap
width={340} city={searchParams.city}
height={180} width={340}
zoomLevel={11} height={180}
mapType="roadmap" zoomLevel={11}
altText={`Map of ${searchParams.city} city center`} mapType="roadmap"
/> altText={`Map of ${searchParams.city} city center`}
</Link> />
<Link </Link>
className={styles.link} <Link
color="burgundy" className={styles.link}
href={selectHotelMap[params.lang]} color="burgundy"
keepSearchParams href={selectHotelMap[params.lang]}
> keepSearchParams
{intl.formatMessage({ id: "Show map" })} >
<ChevronRightIcon color="burgundy" /> {intl.formatMessage({ id: "Show map" })}
</Link> <ChevronRightIcon color="burgundy" />
</Link>
</div>
<MobileMapButtonContainer city={searchParams.city} />
<HotelFilter filters={filterList} /> <HotelFilter filters={filterList} />
</section> </section>
<HotelCardListing hotelData={hotels} /> <HotelCardListing hotelData={hotels} />

View File

@@ -3,9 +3,16 @@ import { serverClient } from "@/lib/trpc/server"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFilters" import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
type PointOfInterest,
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
export async function fetchAvailableHotels( export async function fetchAvailableHotels(
input: AvailabilityInput input: AvailabilityInput
@@ -42,3 +49,49 @@ export function getFiltersFromHotels(hotels: HotelData[]) {
return filterList return filterList
} }
const bedTypeMap: Record<number, string> = {
[BedTypeEnum.IN_ADULTS_BED]: "ParentsBed",
[BedTypeEnum.IN_CRIB]: "Crib",
[BedTypeEnum.IN_EXTRA_BED]: "ExtraBed",
}
export function generateChildrenString(children: Child[]): string {
return `[${children
?.map((child) => {
const age = child.age
const bedType = bedTypeMap[+child.bed]
return `${age}:${bedType}`
})
.join(",")}]`
}
export function getPointOfInterests(hotels: HotelData[]): PointOfInterest[] {
// TODO: this is just a quick transformation to get something there. May need rework
return hotels.map((hotel) => ({
coordinates: {
lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
distance: hotel.hotelData.location.distanceToCentre,
categoryName: PointOfInterestCategoryNameEnum.HOTEL,
group: PointOfInterestGroupEnum.LOCATION,
}))
}
export function getCentralCoordinates(pointOfInterests: PointOfInterest[]) {
const centralCoordinates = pointOfInterests.reduce(
(acc, poi) => {
acc.lat += poi.coordinates.lat
acc.lng += poi.coordinates.lng
return acc
},
{ lat: 0, lng: 0 }
)
centralCoordinates.lat /= pointOfInterests.length
centralCoordinates.lng /= pointOfInterests.length
return centralCoordinates
}

View File

@@ -37,21 +37,26 @@ export default function Sidebar({
function moveToPoi(poiCoordinates: Coordinates) { function moveToPoi(poiCoordinates: Coordinates) {
if (map) { 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 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) 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) { function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
setIsClicking(true) setIsClicking(true)
toggleFullScreenSidebar() toggleFullScreenSidebar()
@@ -127,7 +126,6 @@ export default function Sidebar({
<button <button
className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`} className={`${styles.poiButton} ${activePoi === poi.name ? styles.active : ""}`}
onMouseEnter={() => handleMouseEnter(poi.name)} onMouseEnter={() => handleMouseEnter(poi.name)}
onMouseLeave={handleMouseLeave}
onClick={() => onClick={() =>
handlePoiClick(poi.name, poi.coordinates) handlePoiClick(poi.name, poi.coordinates)
} }

View File

@@ -4,15 +4,16 @@ import { useIntl } from "react-intl"
import { GalleryIcon } from "@/components/Icons" import { GalleryIcon } from "@/components/Icons"
import Image from "@/components/Image" import Image from "@/components/Image"
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import RoomDetailsButton from "../RoomDetailsButton"
import styles from "./roomCard.module.css" import styles from "./roomCard.module.css"
import type { RoomCardProps } from "@/types/components/hotelPage/room" 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 { images, name, roomSize, occupancy, id } = room
const intl = useIntl() const intl = useIntl()
const mainImage = images[0] const mainImage = images[0]
@@ -70,7 +71,10 @@ export function RoomCard({ room }: RoomCardProps) {
</Subtitle> </Subtitle>
<Body color="grey">{subtitle}</Body> <Body color="grey">{subtitle}</Body>
</div> </div>
<RoomSidePeek room={room} buttonSize="medium" /> <RoomDetailsButton
hotelId={hotelId}
roomTypeCode={room.roomTypes[0].code}
/>
</div> </div>
</article> </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 type { RoomsProps } from "@/types/components/hotelPage/room"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
export function Rooms({ rooms }: RoomsProps) { export function Rooms({ hotelId, rooms }: RoomsProps) {
const intl = useIntl() const intl = useIntl()
const showToggleButton = rooms.length > 3 const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton) const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
@@ -45,7 +45,7 @@ export function Rooms({ rooms }: RoomsProps) {
> >
{rooms.map((room) => ( {rooms.map((room) => (
<div key={room.id}> <div key={room.id}>
<RoomCard room={room} /> <RoomCard hotelId={hotelId} room={room} />
</div> </div>
))} ))}
</Grids.Stackable> </Grids.Stackable>

View File

@@ -3,6 +3,7 @@ import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import AccordionSection from "@/components/Blocks/Accordion" import AccordionSection from "@/components/Blocks/Accordion"
import HotelReservationSidePeek from "@/components/HotelReservation/SidePeek"
import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider" import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import SidePeek from "@/components/TempDesignSystem/SidePeek" import SidePeek from "@/components/TempDesignSystem/SidePeek"
@@ -37,6 +38,7 @@ export default async function HotelPage() {
} }
const { const {
hotelId,
hotelName, hotelName,
hotelDescription, hotelDescription,
hotelLocation, hotelLocation,
@@ -97,7 +99,7 @@ export default async function HotelPage() {
</div> </div>
) : null} ) : null}
</div> </div>
<Rooms rooms={roomCategories} /> <Rooms hotelId={hotelId} rooms={roomCategories} />
<Facilities facilities={facilities} activitiesCard={activitiesCard} /> <Facilities facilities={facilities} activitiesCard={activitiesCard} />
{faq.accordions.length > 0 && ( {faq.accordions.length > 0 && (
<AccordionSection accordion={faq.accordions} title={faq.title} /> <AccordionSection accordion={faq.accordions} title={faq.title} />
@@ -166,6 +168,7 @@ export default async function HotelPage() {
</SidePeek> </SidePeek>
{/* eslint-enable import/no-named-as-default-member */} {/* eslint-enable import/no-named-as-default-member */}
</SidePeekProvider> </SidePeekProvider>
<HotelReservationSidePeek hotel={null} />
</div> </div>
) )
} }

View File

@@ -16,7 +16,9 @@ export const signUpSchema = z.object({
"Phone is required", "Phone is required",
"Please enter a valid phone number" "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({ address: z.object({
countryCode: z countryCode: z
.string({ .string({

View File

@@ -23,7 +23,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({ z.object({
join: z.literal(true), join: z.literal(true),
zipCode: z.string().min(1, { message: "Zip code is required" }), 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, { termsAccepted: z.literal(true, {
errorMap: (err, ctx) => { errorMap: (err, ctx) => {
switch (err.code) { 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 { useIntl } from "react-intl"
import { RoomConfiguration } from "@/server/routers/hotels/output"
import { EditIcon, ImageIcon } from "@/components/Icons" import { EditIcon, ImageIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./selectedRoom.module.css" import styles from "./selectedRoom.module.css"
export default function SelectedRoom() { export default function SelectedRoom({
hotelId,
room,
}: {
hotelId: string
room: RoomConfiguration
}) {
const intl = useIntl() const intl = useIntl()
return ( return (
<article className={styles.container}> <article className={styles.container}>
@@ -22,42 +32,50 @@ export default function SelectedRoom() {
/> />
</div> </div>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.textContainer}> <div>
<Footnote <div className={styles.textContainer}>
className={styles.label} <Footnote
color="uiTextPlaceholder" className={styles.label}
textTransform="uppercase" 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"
> >
Cozy cabin {intl.formatMessage({ id: "Your room" })}
</Subtitle> </Footnote>
<Subtitle <div className={styles.text}>
className={styles.invertFontWeight} {/**
color="uiTextMediumContrast" * [TEMP]
type="two" * No translation on Subtitles as they will be derived
> * from Room selection.
Free rebooking */}
</Subtitle> <Subtitle
<Subtitle className={styles.room}
className={styles.invertFontWeight} color="uiTextHighContrast"
color="uiTextMediumContrast" type="two"
type="two" >
> {room.roomType}
Pay now </Subtitle>
</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> </div>
{room?.roomTypeCode && (
<ToggleSidePeek
hotelId={hotelId}
roomTypeCode={room.roomTypeCode}
/>
)}
</div> </div>
<Button <Button
asChild 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" "use client"
import { useState } from "react" import useSidePeekStore from "@/stores/sidepeek"
import { useIntl } from "react-intl"
import { ChevronRightIcon } from "@/components/Icons" 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 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 styles from "./readMore.module.css"
import { import { ReadMoreProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
ParkingProps, import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
ReadMoreProps,
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { Amenities, Hotel } from "@/types/hotel"
function getAmenitiesList(hotel: Hotel) { export default function ReadMore({ label, hotelId }: ReadMoreProps) {
const detailedAmenities: Amenities = hotel.detailedFacilities.filter( const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
// 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)
return ( return (
<> <Button
<Button onPress={() => {
onPress={() => { openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })
setSidePeekOpen(true) }}
}} intent="text"
intent="text" theme="base"
theme="base" wrapping
wrapping className={styles.detailsButton}
className={styles.detailsButton} >
> {label}
{label} <ChevronRightIcon color="burgundy" />
<ChevronRightIcon color="burgundy" /> </Button>
</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>
) )
} }

View File

@@ -1,5 +1,6 @@
.container { .container {
min-width: 272px; min-width: 272px;
display: none;
} }
.facilities { .facilities {
@@ -24,3 +25,9 @@
height: 1.25rem; height: 1.25rem;
margin: 0; 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" "use client"
import styles from "./hotelListing.module.css"
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map" import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
// TODO: This component is copied from // 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. // Look at that for inspiration on how to do the interaction with the map.
export default function HotelListing({}: HotelListingProps) { 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" "use client"
import { APIProvider } from "@vis.gl/react-google-maps" import { APIProvider } from "@vis.gl/react-google-maps"
import { useRouter, useSearchParams } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { selectHotel } from "@/constants/routes/hotelReservation" import { selectHotel } from "@/constants/routes/hotelReservation"
import { CloseIcon } from "@/components/Icons" import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap" import InteractiveMap from "@/components/Maps/InteractiveMap"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import HotelListing from "./HotelListing" import HotelListing from "./HotelListing"
@@ -22,36 +22,60 @@ export default function SelectHotelMap({
coordinates, coordinates,
pointsOfInterest, pointsOfInterest,
mapId, mapId,
isModal,
}: SelectHotelMapProps) { }: SelectHotelMapProps) {
const searchParams = useSearchParams()
const router = useRouter()
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
const [activePoi, setActivePoi] = useState<string | null>(null) const [activePoi, setActivePoi] = useState<string | null>(null)
function handleModalDismiss() {
router.back()
}
function handlePageRedirect() {
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
}
const closeButton = ( const closeButton = (
<Button <Button
asChild
intent="inverted" intent="inverted"
size="small" size="small"
theme="base" theme="base"
className={styles.closeButton} className={styles.closeButton}
onClick={isModal ? handleModalDismiss : handlePageRedirect}
> >
<Link href={selectHotel[lang]} keepSearchParams color="burgundy"> <CloseIcon color="burgundy" />
<CloseIcon color="burgundy" /> {intl.formatMessage({ id: "Close the map" })}
{intl.formatMessage({ id: "Close the map" })}
</Link>
</Button> </Button>
) )
return ( return (
<APIProvider apiKey={apiKey}> <APIProvider apiKey={apiKey}>
<HotelListing /> <div className={styles.container}>
<InteractiveMap <div className={styles.filterContainer}>
closeButton={closeButton} <Button
coordinates={coordinates} intent="text"
pointsOfInterest={pointsOfInterest} size="small"
activePoi={activePoi} variant="icon"
onActivePoiChange={setActivePoi} wrapping
mapId={mapId} 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> </APIProvider>
) )
} }

View File

@@ -2,4 +2,39 @@
pointer-events: initial; pointer-events: initial;
box-shadow: var(--button-box-shadow); box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half); 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 { RateDefinition } from "@/server/routers/hotels/output"
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption" import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import RoomSidePeek from "../../../../SidePeeks/RoomSidePeek"
import ImageGallery from "../../ImageGallery" import ImageGallery from "../../ImageGallery"
import { getIconForFeatureCode } from "../../utils" import { getIconForFeatureCode } from "../../utils"
@@ -21,6 +21,7 @@ import type { RoomCardProps } from "@/types/components/hotelReservation/selectRa
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default function RoomCard({ export default function RoomCard({
hotelId,
rateDefinitions, rateDefinitions,
roomConfiguration, roomConfiguration,
roomCategories, roomCategories,
@@ -57,9 +58,10 @@ export default function RoomCard({
?.generalTerms ?.generalTerms
} }
const petRoomPackage = packages.find( const petRoomPackage =
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM (selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
) packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
undefined
const selectedRoom = roomCategories.find( const selectedRoom = roomCategories.find(
(room) => room.name === roomConfiguration.roomType (room) => room.name === roomConfiguration.roomType
@@ -86,8 +88,11 @@ export default function RoomCard({
: `${roomSize?.min}-${roomSize?.max}`} : `${roomSize?.min}-${roomSize?.max}`}
m² m²
</Caption> </Caption>
{selectedRoom && ( {roomConfiguration.roomTypeCode && (
<RoomSidePeek room={selectedRoom} buttonSize="small" /> <ToggleSidePeek
hotelId={hotelId}
roomTypeCode={roomConfiguration.roomTypeCode}
/>
)} )}
</div> </div>
<div className={styles.container}> <div className={styles.container}>

View File

@@ -67,6 +67,7 @@ export default function RoomSelection({
{roomConfigurations.map((roomConfiguration) => ( {roomConfigurations.map((roomConfiguration) => (
<li key={roomConfiguration.roomTypeCode}> <li key={roomConfiguration.roomTypeCode}>
<RoomCard <RoomCard
hotelId={roomsAvailability.hotelId.toString()}
rateDefinitions={rateDefinitions} rateDefinitions={rateDefinitions}
roomConfiguration={roomConfiguration} roomConfiguration={roomConfiguration}
roomCategories={roomCategories} 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 EyeHideIcon } from "./EyeHide"
export { default as EyeShowIcon } from "./EyeShow" export { default as EyeShowIcon } from "./EyeShow"
export { default as FanIcon } from "./Fan" export { default as FanIcon } from "./Fan"
export { default as FilterIcon } from "./Filter"
export { default as FitnessIcon } from "./Fitness" export { default as FitnessIcon } from "./Fitness"
export { default as FootstoolIcon } from "./Footstool" export { default as FootstoolIcon } from "./Footstool"
export { default as GalleryIcon } from "./Gallery" 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} anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activePoi === poi.name ? 2 : 0} zIndex={activePoi === poi.name ? 2 : 0}
onMouseEnter={() => onActivePoiChange(poi.name)} onMouseEnter={() => onActivePoiChange(poi.name)}
onMouseLeave={() => onActivePoiChange(null)}
onClick={() => toggleActivePoi(poi.name)} onClick={() => toggleActivePoi(poi.name)}
> >
<span <span

View File

@@ -1,6 +1,7 @@
.mapContainer { .mapContainer {
--button-box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1); --button-box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1);
width: 100%; width: 100%;
height: 100%;
position: relative; position: relative;
z-index: 0; 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 { useIntl } from "react-intl"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek" import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
@@ -12,10 +10,14 @@ import { getFacilityIcon } from "./facilityIcon"
import styles from "./roomSidePeek.module.css" import styles from "./roomSidePeek.module.css"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import type { RoomSidePeekProps } from "@/types/components/sidePeeks/roomSidePeek" import type { RoomSidePeekProps } from "@/types/components/sidePeeks/roomSidePeek"
export default function RoomSidePeek({ room, buttonSize }: RoomSidePeekProps) { export default function RoomSidePeek({
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false) room,
activeSidePeek,
close,
}: RoomSidePeekProps) {
const intl = useIntl() const intl = useIntl()
const roomSize = room.roomSize const roomSize = room.roomSize
@@ -24,84 +26,70 @@ export default function RoomSidePeek({ room, buttonSize }: RoomSidePeekProps) {
const images = room.images const images = room.images
return ( return (
<div> <SidePeek
<Button title={room.name}
intent="text" isOpen={activeSidePeek === SidePeekEnum.roomDetails}
type="button" handleClose={close}
size={buttonSize} >
theme="base" <div className={styles.wrapper}>
className={styles.button} <div className={styles.mainContent}>
onClick={() => setIsSidePeekOpen(true)} <Body color="baseTextMediumContrast">
> {roomSize.min === roomSize.max
{intl.formatMessage({ id: "See room details" })} ? roomSize.min
<ChevronRightSmallIcon color="burgundy" width={20} height={20} /> : `${roomSize.min} - ${roomSize.max}`}
</Button> m².{" "}
{intl.formatMessage(
<SidePeek { id: "booking.accommodatesUpTo" },
title={room.name} { nrOfGuests: occupancy }
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>
)} )}
<Body color="uiTextHighContrast">{roomDescription}</Body> </Body>
</div> {images && (
<div className={styles.listContainer}> <div className={styles.imageContainer}>
<Subtitle type="two" color="uiTextHighContrast"> <ImageGallery images={images} title={room.name} />
{intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })} </div>
</Subtitle> )}
<ul className={styles.facilityList}> <Body color="uiTextHighContrast">{roomDescription}</Body>
{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>
</div> </div>
<div className={styles.buttonContainer}> <div className={styles.listContainer}>
<Button fullWidth theme="base" intent="primary"> <Subtitle type="two" color="uiTextHighContrast">
{intl.formatMessage({ id: "booking.selectRoom" })} {intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })}
{/* TODO: Implement logic for select room */} </Subtitle>
</Button> <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>
</SidePeek> <div className={styles.listContainer}>
</div> <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 { .imageWrapper {
display: flex; display: flex;
width: 100%;
} }
.imageWrapper::after { .imageWrapper::after {

View File

@@ -18,3 +18,12 @@
.year { .year {
grid-area: 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 { export const enum DateName {
date = "date", date = "date",
day = "day",
month = "month", month = "month",
year = "year", year = "year",
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { parseDate } from "@internationalized/date" import { parseDate } from "@internationalized/date"
import { useState } from "react" import { useEffect } from "react"
import { DateInput, DatePicker, Group } from "react-aria-components" import { DateInput, DatePicker, Group } from "react-aria-components"
import { useController, useFormContext, useWatch } from "react-hook-form" import { useController, useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -8,8 +8,11 @@ import { useIntl } from "react-intl"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import Select from "@/components/TempDesignSystem/Select" import Select from "@/components/TempDesignSystem/Select"
import useLang from "@/hooks/useLang"
import { getLocalizedMonthName } from "@/utils/dateFormatting"
import { rangeArray } from "@/utils/rangeArray" import { rangeArray } from "@/utils/rangeArray"
import ErrorMessage from "../ErrorMessage"
import { DateName } from "./date" import { DateName } from "./date"
import styles from "./date.module.css" import styles from "./date.module.css"
@@ -20,51 +23,75 @@ import type { DateProps } from "./date"
export default function DateSelect({ name, registerOptions = {} }: DateProps) { export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const intl = useIntl() const intl = useIntl()
const currentValue = useWatch({ name }) const { control, setValue, formState, watch } = useFormContext()
const { control, setValue, trigger } = useFormContext() const { field, fieldState } = useController({
const { field } = useController({
control, control,
name, name,
rules: registerOptions, 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) => ({ const months = rangeArray(1, 12).map((month) => ({
value: month, value: month,
label: `${month}`, label: getLocalizedMonthName(month, lang),
})) }))
const currentYear = new Date().getFullYear()
const years = rangeArray(1900, currentYear - 18) const years = rangeArray(1900, currentYear - 18)
.reverse() .reverse()
.map((year) => ({ value: year, label: year.toString() })) .map((year) => ({ value: year, label: year.toString() }))
// Ensure the user can't select a date that doesn't exist. // Calculate available days based on selected year and month
const daysInMonth = dt(currentValue).daysInMonth() const daysInMonth = getDaysInMonth(
year ? Number(year) : null,
month ? Number(month) - 1 : null
)
const days = rangeArray(1, daysInMonth).map((day) => ({ const days = rangeArray(1, daysInMonth).map((day) => ({
value: day, value: day,
label: `${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 dayLabel = intl.formatMessage({ id: "Day" })
const monthLabel = intl.formatMessage({ id: "Month" }) const monthLabel = intl.formatMessage({ id: "Month" })
const yearLabel = intl.formatMessage({ id: "Year" }) 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 let dateValue = null
try { try {
/** /**
@@ -72,7 +99,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
* date, but we can't check isNan since * date, but we can't check isNan since
* we recieve the date as "1999-01-01" * we recieve the date as "1999-01-01"
*/ */
dateValue = dt(currentValue).isValid() ? parseDate(currentValue) : null dateValue = dt(currentDateValue).isValid()
? parseDate(currentDateValue)
: null
} catch (error) { } catch (error) {
console.warn("Known error for parse date in DateSelect: ", error) console.warn("Known error for parse date in DateSelect: ", error)
} }
@@ -81,6 +110,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
<DatePicker <DatePicker
aria-label={intl.formatMessage({ id: "Select date of birth" })} aria-label={intl.formatMessage({ id: "Select date of birth" })}
isRequired={!!registerOptions.required} isRequired={!!registerOptions.required}
isInvalid={!formState.isValid}
name={name} name={name}
ref={field.ref} ref={field.ref}
value={dateValue} value={dateValue}
@@ -92,57 +122,60 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
switch (segment.type) { switch (segment.type) {
case "day": case "day":
return ( return (
<div className={styles.day}> <div
className={`${styles.day} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select <Select
aria-label={dayLabel} aria-label={dayLabel}
items={days} items={days}
label={dayLabel} label={dayLabel}
name={DateName.date} name={DateName.day}
onSelect={createOnSelect(DateName.date)} onSelect={(key: Key) =>
placeholder="DD" setValue(DateName.day, Number(key))
}
placeholder={dayLabel}
required required
tabIndex={3} tabIndex={3}
defaultSelectedKey={
segment.isPlaceholder ? undefined : segment.value
}
value={segment.isPlaceholder ? undefined : segment.value} value={segment.isPlaceholder ? undefined : segment.value}
/> />
</div> </div>
) )
case "month": case "month":
return ( return (
<div className={styles.month}> <div
className={`${styles.month} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select <Select
aria-label={monthLabel} aria-label={monthLabel}
items={months} items={months}
label={monthLabel} label={monthLabel}
name={DateName.month} name={DateName.month}
onSelect={createOnSelect(DateName.month)} onSelect={(key: Key) =>
placeholder="MM" setValue(DateName.month, Number(key))
}
placeholder={monthLabel}
required required
tabIndex={2} tabIndex={2}
defaultSelectedKey={
segment.isPlaceholder ? undefined : segment.value
}
value={segment.isPlaceholder ? undefined : segment.value} value={segment.isPlaceholder ? undefined : segment.value}
/> />
</div> </div>
) )
case "year": case "year":
return ( return (
<div className={styles.year}> <div
className={`${styles.year} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select <Select
aria-label={yearLabel} aria-label={yearLabel}
items={years} items={years}
label={yearLabel} label={yearLabel}
name={DateName.year} name={DateName.year}
onSelect={createOnSelect(DateName.year)} onSelect={(key: Key) =>
placeholder="YYYY" setValue(DateName.year, Number(key))
}
placeholder={yearLabel}
required required
tabIndex={1} tabIndex={1}
defaultSelectedKey={
segment.isPlaceholder ? undefined : segment.value
}
value={segment.isPlaceholder ? undefined : segment.value} value={segment.isPlaceholder ? undefined : segment.value}
/> />
</div> </div>
@@ -154,6 +187,21 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
}} }}
</DateInput> </DateInput>
</Group> </Group>
<ErrorMessage errors={formState.errors} name={field.name} />
</DatePicker> </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()
}

View File

@@ -286,6 +286,7 @@
"See all photos": "Se alle billeder", "See all photos": "Se alle billeder",
"See hotel details": "Se hoteloplysninger", "See hotel details": "Se hoteloplysninger",
"See less FAQ": "Se mindre FAQ", "See less FAQ": "Se mindre FAQ",
"See on map": "Se på kort",
"See room details": "Se værelsesdetaljer", "See room details": "Se værelsesdetaljer",
"See rooms": "Se værelser", "See rooms": "Se værelser",
"Select a country": "Vælg et land", "Select a country": "Vælg et land",

View File

@@ -285,6 +285,7 @@
"See all photos": "Alle Fotos ansehen", "See all photos": "Alle Fotos ansehen",
"See hotel details": "Hotelinformationen ansehen", "See hotel details": "Hotelinformationen ansehen",
"See less FAQ": "Weniger anzeigen FAQ", "See less FAQ": "Weniger anzeigen FAQ",
"See on map": "Karte ansehen",
"See room details": "Zimmerdetails ansehen", "See room details": "Zimmerdetails ansehen",
"See rooms": "Zimmer ansehen", "See rooms": "Zimmer ansehen",
"Select a country": "Wähle ein Land", "Select a country": "Wähle ein Land",

View File

@@ -299,6 +299,7 @@
"See all photos": "See all photos", "See all photos": "See all photos",
"See hotel details": "See hotel details", "See hotel details": "See hotel details",
"See less FAQ": "See less FAQ", "See less FAQ": "See less FAQ",
"See on map": "See on map",
"See room details": "See room details", "See room details": "See room details",
"See rooms": "See rooms", "See rooms": "See rooms",
"Select a country": "Select a country", "Select a country": "Select a country",

View File

@@ -287,6 +287,7 @@
"See all photos": "Katso kaikki kuvat", "See all photos": "Katso kaikki kuvat",
"See hotel details": "Katso hotellin tiedot", "See hotel details": "Katso hotellin tiedot",
"See less FAQ": "Katso vähemmän UKK", "See less FAQ": "Katso vähemmän UKK",
"See on map": "Näytä kartalla",
"See room details": "Katso huoneen tiedot", "See room details": "Katso huoneen tiedot",
"See rooms": "Katso huoneet", "See rooms": "Katso huoneet",
"Select a country": "Valitse maa", "Select a country": "Valitse maa",

View File

@@ -284,6 +284,7 @@
"See all photos": "Se alle bilder", "See all photos": "Se alle bilder",
"See hotel details": "Se hotellinformasjon", "See hotel details": "Se hotellinformasjon",
"See less FAQ": "Se mindre FAQ", "See less FAQ": "Se mindre FAQ",
"See on map": "Se på kart",
"See room details": "Se detaljer om rommet", "See room details": "Se detaljer om rommet",
"See rooms": "Se rom", "See rooms": "Se rom",
"Select a country": "Velg et land", "Select a country": "Velg et land",

View File

@@ -284,6 +284,7 @@
"See all photos": "Se alla foton", "See all photos": "Se alla foton",
"See hotel details": "Se hotellinformation", "See hotel details": "Se hotellinformation",
"See less FAQ": "See färre FAQ", "See less FAQ": "See färre FAQ",
"See on map": "Se på karta",
"See room details": "Se rumsdetaljer", "See room details": "Se rumsdetaljer",
"See rooms": "Se rum", "See rooms": "Se rum",
"Select a country": "Välj ett land", "Select a country": "Välj ett land",

View File

@@ -332,6 +332,7 @@ export const hotelQueryRouter = router({
] ]
return { return {
hotelId,
hotelName: hotelAttributes.name, hotelName: hotelAttributes.name,
hotelDescription: hotelAttributes.hotelContent.texts.descriptions.short, hotelDescription: hotelAttributes.hotelContent.texts.descriptions.short,
hotelLocation: hotelAttributes.location, hotelLocation: hotelAttributes.location,

View File

@@ -11,10 +11,9 @@ import {
} from "@/components/HotelReservation/EnterDetails/Details/schema" } from "@/components/HotelReservation/EnterDetails/Details/schema"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details" import { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details"
import { SidePeekEnum } from "@/types/components/hotelReservation/enterDetails/sidePeek"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
@@ -28,7 +27,6 @@ interface EnterDetailsState {
roomData: BookingData roomData: BookingData
steps: StepEnum[] steps: StepEnum[]
currentStep: StepEnum currentStep: StepEnum
activeSidePeek: SidePeekEnum | null
isValid: Record<StepEnum, boolean> isValid: Record<StepEnum, boolean>
completeStep: (updatedData: Partial<EnterDetailsState["userData"]>) => void completeStep: (updatedData: Partial<EnterDetailsState["userData"]>) => void
navigate: ( navigate: (
@@ -36,8 +34,6 @@ interface EnterDetailsState {
updatedData?: Record<string, string | boolean | BreakfastPackage> updatedData?: Record<string, string | boolean | BreakfastPackage>
) => void ) => void
setCurrentStep: (step: StepEnum) => void setCurrentStep: (step: StepEnum) => void
openSidePeek: (key: SidePeekEnum | null) => void
closeSidePeek: () => void
} }
export function initEditDetailsState( export function initEditDetailsState(
@@ -139,10 +135,7 @@ export function initEditDetailsState(
window.history.pushState({ step }, "", step + window.location.search) window.history.pushState({ step }, "", step + window.location.search)
}) })
), ),
openSidePeek: (key) => set({ activeSidePeek: key }),
closeSidePeek: () => set({ activeSidePeek: null }),
currentStep, currentStep,
activeSidePeek: null,
isValid, isValid,
completeStep: (updatedData) => completeStep: (updatedData) =>
set( set(

31
stores/sidepeek.ts Normal file
View File

@@ -0,0 +1,31 @@
import { create } from "zustand"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
interface SidePeekState {
activeSidePeek: SidePeekEnum | null
hotelId: string | null
roomTypeCode: string | null
openSidePeek: ({
key,
hotelId,
roomTypeCode,
}: {
key: SidePeekEnum | null
hotelId: string
roomTypeCode?: string
}) => void
closeSidePeek: () => void
}
const useSidePeekStore = create<SidePeekState>((set) => ({
activeSidePeek: null,
hotelId: null,
roomTypeCode: null,
openSidePeek: ({ key, hotelId, roomTypeCode }) =>
set({ activeSidePeek: key, hotelId, roomTypeCode }),
closeSidePeek: () =>
set({ activeSidePeek: null, hotelId: null, roomTypeCode: null }),
}))
export default useSidePeekStore

View File

@@ -1,9 +1,11 @@
import type { RoomData } from "@/types/hotel" import type { RoomData } from "@/types/hotel"
export interface RoomCardProps { export interface RoomCardProps {
hotelId: string
room: RoomData room: RoomData
} }
export type RoomsProps = { export type RoomsProps = {
hotelId: string
rooms: RoomData[] rooms: RoomData[]
} }

View File

@@ -0,0 +1,8 @@
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { Hotel } from "@/types/hotel"
export type HotelSidePeekProps = {
hotel: Hotel
activeSidePeek: SidePeekEnum
close: () => void
}

View File

@@ -12,4 +12,5 @@ export interface SelectHotelMapProps {
coordinates: Coordinates coordinates: Coordinates
pointsOfInterest: PointOfInterest[] pointsOfInterest: PointOfInterest[]
mapId: string mapId: string
isModal: boolean
} }

View File

@@ -13,6 +13,7 @@ import type { RoomData } from "@/types/hotel"
import type { RoomPackageCodes, RoomPackageData } from "./roomFilter" import type { RoomPackageCodes, RoomPackageData } from "./roomFilter"
export type RoomCardProps = { export type RoomCardProps = {
hotelId: string
roomConfiguration: RoomConfiguration roomConfiguration: RoomConfiguration
rateDefinitions: RateDefinition[] rateDefinitions: RateDefinition[]
roomCategories: RoomData[] roomCategories: RoomData[]

View File

@@ -2,6 +2,7 @@ import { Hotel } from "@/types/hotel"
export enum SidePeekEnum { export enum SidePeekEnum {
hotelDetails = "hotel-detail-side-peek", hotelDetails = "hotel-detail-side-peek",
roomDetails = "room-detail-side-peek",
} }
export type SidePeekProps = { export type SidePeekProps = {

View File

@@ -0,0 +1,4 @@
export type ToggleSidePeekProps = {
hotelId: string
roomTypeCode: string
}

View File

@@ -1,6 +1,9 @@
import { SidePeekEnum } from "../hotelReservation/sidePeek"
import type { RoomData } from "@/types/hotel" import type { RoomData } from "@/types/hotel"
export type RoomSidePeekProps = { export type RoomSidePeekProps = {
room: RoomData room: RoomData
buttonSize: "small" | "medium" activeSidePeek: SidePeekEnum | null
close: () => void
} }

15
utils/dateFormatting.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Lang } from "@/constants/languages"
/**
* Get the localized month name for a given month index and language
* @param monthIndex - The month index (1-12)
* @param lang - the language to use, Lang enum
* @returns The localized month name
*/
export function getLocalizedMonthName(monthIndex: number, lang: Lang) {
const monthName = new Date(2024, monthIndex - 1).toLocaleString(lang, {
month: "long",
})
return monthName.charAt(0).toUpperCase() + monthName.slice(1)
}