diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/[...paths]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/page.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/[...paths]/page.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/page.tsx diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/loading.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/loading.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/loading.tsx diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx new file mode 100644 index 000000000..a73eb305e --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx @@ -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) { + const search = new URLSearchParams(searchParams) + const { hotel: hotelId } = getQueryParamsForEnterDetails(search) + + if (!hotelId) { + return + } + + const hotel = await getHotelData({ + hotelId: hotelId, + language: params.lang, + }) + + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/page.tsx deleted file mode 100644 index deca843c3..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@sidePeek/page.tsx +++ /dev/null @@ -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) { - if (!searchParams.hotel) { - redirect(`/${params.lang}`) - } - const hotel = await getHotelData({ - hotelId: searchParams.hotel, - language: params.lang, - }) - if (!hotel?.data) { - redirect(`/${params.lang}`) - } - return -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts new file mode 100644 index 000000000..6013a49cc --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts @@ -0,0 +1,9 @@ +import { + getCreditCardsSafely, + getProfileSafely, +} from "@/lib/trpc/memoizedRequests" + +export function preload() { + void getProfileSafely() + void getCreditCardsSafely() +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx index c33d65975..3e277f4a0 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx @@ -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 SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom" import { setLang } from "@/i18n/serverContext" -import { preload } from "./page" +import { preload } from "./_preload" import styles from "./layout.module.css" @@ -16,11 +18,9 @@ export default async function StepLayout({ children, hotelHeader, params, - sidePeek, }: React.PropsWithChildren< LayoutArgs & { hotelHeader: React.ReactNode - sidePeek: React.ReactNode summary: React.ReactNode } >) { @@ -34,7 +34,6 @@ export default async function StepLayout({
{hotelHeader}
- {children}
- {sidePeek}
) diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx index e3168f890..7b8d2da86 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx @@ -14,6 +14,7 @@ import Details from "@/components/HotelReservation/EnterDetails/Details" import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager" import Payment from "@/components/HotelReservation/EnterDetails/Payment" import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion" +import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom" import { generateChildrenString, getQueryParamsForEnterDetails, @@ -24,11 +25,6 @@ import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { LangParams, PageArgs } from "@/types/params" -export function preload() { - void getProfileSafely() - void getCreditCardsSafely() -} - function isValidStep(step: string): step is StepEnum { return Object.values(StepEnum).includes(step as StepEnum) } @@ -104,6 +100,9 @@ export default async function StepPage({ return (
+ + + {/* TODO: How to handle no beds found? */} {roomAvailability.bedTypes ? ( >) { + sidePeek, +}: React.PropsWithChildren> & { + sidePeek: React.ReactNode +}) { if (env.HIDE_FOR_NEXT_RELEASE) { return notFound() } - return
{children}
+ return ( +
+ {children} + {sidePeek} +
+ ) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx new file mode 100644 index 000000000..667d43ad6 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx @@ -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) { + 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 ( + + + + ) +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/default.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/default.tsx new file mode 100644 index 000000000..86b9e9a38 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.module.css new file mode 100644 index 000000000..b86e58a72 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.module.css @@ -0,0 +1,5 @@ +.layout { + min-height: 100dvh; + background-color: var(--Base-Background-Primary-Normal); + position: relative; +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx new file mode 100644 index 000000000..ab5f62674 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx @@ -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 & { modal: React.ReactNode } +>) { + if (env.HIDE_FOR_NEXT_RELEASE) { + return notFound() + } + return ( +
+ {children} + {modal} +
+ ) +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.module.css index ec9b9ce4f..d0a692d8b 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.module.css @@ -2,5 +2,5 @@ display: grid; background-color: var(--Scandic-Brand-Warm-White); min-height: 100dvh; - grid-template-columns: 420px 1fr; + position: relative; } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.tsx index 8dca8f4c7..bfd164880 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/map/page.tsx @@ -1,58 +1 @@ -import { env } from "@/env/server" - -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) { - 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 ( -
- -
- ) -} +export { default } from "../@modal/(.)map/page" diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css index acc942e21..800ffe8f9 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css @@ -1,6 +1,6 @@ .main { display: flex; - gap: var(--Spacing-x4); + gap: var(--Spacing-x3); padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4); background-color: var(--Scandic-Brand-Warm-White); min-height: 100dvh; @@ -19,8 +19,28 @@ 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) { + .mapContainer { + display: block; + } .main { flex-direction: row; } + .buttonContainer { + display: none; + } } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx index 3ebe54958..f3d920fb7 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx @@ -9,6 +9,7 @@ import { } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" import HotelCardListing from "@/components/HotelReservation/HotelCardListing" import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter" +import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer" import { generateChildrenString, getHotelReservationQueryParams, @@ -62,25 +63,28 @@ export default async function SelectHotelPage({ return (
- - - - - {intl.formatMessage({ id: "Show map" })} - - +
+ + + + + {intl.formatMessage({ id: "Show map" })} + + +
+
diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts index 11de17bee..c3dc16c49 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts @@ -3,9 +3,16 @@ import { serverClient } from "@/lib/trpc/server" import { getLang } from "@/i18n/serverContext" -import { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" -import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" -import { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFilters" +import { BedTypeEnum } from "@/types/components/bookingWidget/enums" +import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" +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( input: AvailabilityInput @@ -42,3 +49,49 @@ export function getFiltersFromHotels(hotels: HotelData[]) { return filterList } + +const bedTypeMap: Record = { + [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 +} diff --git a/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx b/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx index e73c2c6b4..db979bf47 100644 --- a/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx +++ b/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx @@ -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({ + ) +} diff --git a/components/ContentType/HotelPage/Rooms/index.tsx b/components/ContentType/HotelPage/Rooms/index.tsx index e976ee8af..325f60eb6 100644 --- a/components/ContentType/HotelPage/Rooms/index.tsx +++ b/components/ContentType/HotelPage/Rooms/index.tsx @@ -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) => (
- +
))} diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index 0b13baa2a..97969867b 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -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() { ) : null} - + {faq.accordions.length > 0 && ( @@ -166,6 +168,7 @@ export default async function HotelPage() { {/* eslint-enable import/no-named-as-default-member */} + ) } diff --git a/components/Forms/Signup/schema.ts b/components/Forms/Signup/schema.ts index 6a8eecc22..2962d9b90 100644 --- a/components/Forms/Signup/schema.ts +++ b/components/Forms/Signup/schema.ts @@ -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({ diff --git a/components/HotelReservation/EnterDetails/Details/schema.ts b/components/HotelReservation/EnterDetails/Details/schema.ts index 2e01eefe3..e6bd0dd41 100644 --- a/components/HotelReservation/EnterDetails/Details/schema.ts +++ b/components/HotelReservation/EnterDetails/Details/schema.ts @@ -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) { diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx new file mode 100644 index 000000000..cf14b995b --- /dev/null +++ b/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx @@ -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 ( + + ) +} diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx index 7812c6be6..523c06c1c 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx @@ -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 (
@@ -22,42 +32,50 @@ export default function SelectedRoom() { />
-
- - {intl.formatMessage({ id: "Your room" })} - -
- {/** - * [TEMP] - * No translation on Subtitles as they will be derived - * from Room selection. - */} - +
+ - Cozy cabin - - - Free rebooking - - - Pay now - + {intl.formatMessage({ id: "Your room" })} + +
+ {/** + * [TEMP] + * No translation on Subtitles as they will be derived + * from Room selection. + */} + + {room.roomType} + + + Free rebooking + + + Pay now + +
+ {room?.roomTypeCode && ( + + )}
- ) -} diff --git a/components/HotelReservation/ReadMore/index.tsx b/components/HotelReservation/ReadMore/index.tsx index 9425b7c9f..a6ef09c06 100644 --- a/components/HotelReservation/ReadMore/index.tsx +++ b/components/HotelReservation/ReadMore/index.tsx @@ -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 ( - <> - - { - setSidePeekOpen(false) - }} - > -
- - {intl.formatMessage({ id: "Practical information" })} - - - - {/* parking */} - {hotel.parking.length ? ( - - {hotel.parking.map((p) => ( - - ))} - - ) : null} - - TODO: What content should be in the accessibility section? - - {amenitiesList.map((amenity) => { - return ( -
- {amenity.name} -
- ) - })} -
- {/* TODO: handle linking to Hotel Page */} - -
-
- - ) -} - -function Parking({ parking }: ParkingProps) { - const intl = useIntl() - return ( -
- {`${intl.formatMessage({ id: parking.type })} (${parking.name})`} -
    -
  • - {`${intl.formatMessage({ - id: "Number of charging points for electric cars", - })}: ${parking.numberOfChargingSpaces}`} -
  • -
  • {`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}
  • -
  • {`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}
  • -
  • {`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel} m`}
  • -
  • {`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}
  • -
-
+ ) } diff --git a/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css b/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css index 737eca4d9..156874b54 100644 --- a/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css +++ b/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css @@ -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; + } +} diff --git a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx new file mode 100644 index 000000000..8fba327b3 --- /dev/null +++ b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx @@ -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 ( +
+ + {/* TODO: Add filter toggle */} + +
+ ) +} diff --git a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/mobileMapButtonContainer.module.css b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/mobileMapButtonContainer.module.css new file mode 100644 index 000000000..5d0610d8f --- /dev/null +++ b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/mobileMapButtonContainer.module.css @@ -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; + } +} diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css b/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css new file mode 100644 index 000000000..9ebb0678e --- /dev/null +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css @@ -0,0 +1,9 @@ +.hotelListing { + display: none; +} + +@media (min-width: 768px) { + .hotelListing { + display: block; + } +} diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/index.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/index.tsx index 4bcdb2d68..ecb639250 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/index.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/index.tsx @@ -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
Hotel listing TBI
+ return
Hotel listing TBI
} diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx index b14538812..0dc82d7ab 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx @@ -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(null) + function handleModalDismiss() { + router.back() + } + + function handlePageRedirect() { + router.push(`${selectHotel[lang]}?${searchParams.toString()}`) + } + const closeButton = ( ) return ( - - +
+
+ + Filter and sort + {/* TODO: Add filter and sort button */} +
+ + +
) } diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/selectHotelMap.module.css b/components/HotelReservation/SelectHotel/SelectHotelMap/selectHotelMap.module.css index 54f685aa8..c54a77ccf 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/selectHotelMap.module.css +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/selectHotelMap.module.css @@ -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; + } } diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index 530af45da..dfafe4927 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -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² - {selectedRoom && ( - + {roomConfiguration.roomTypeCode && ( + )}
diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index 112f69f76..89c4dc9a2 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -67,6 +67,7 @@ export default function RoomSelection({ {roomConfigurations.map((roomConfiguration) => (
  • 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 && ( + + )} + {selectedRoom && ( + + )} + + ) + } + + return null +} diff --git a/components/Icons/Filter.tsx b/components/Icons/Filter.tsx new file mode 100644 index 000000000..4b715059d --- /dev/null +++ b/components/Icons/Filter.tsx @@ -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 ( + + + + ) +} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index ce23296fe..a8be6f48f 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -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" diff --git a/components/MapModal/index.tsx b/components/MapModal/index.tsx new file mode 100644 index 000000000..282e08151 --- /dev/null +++ b/components/MapModal/index.tsx @@ -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(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 ( +
    + + + {children} + + +
    + ) +} diff --git a/components/MapModal/mapModal.module.css b/components/MapModal/mapModal.module.css new file mode 100644 index 000000000..500da73f5 --- /dev/null +++ b/components/MapModal/mapModal.module.css @@ -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; +} diff --git a/components/Maps/InteractiveMap/index.tsx b/components/Maps/InteractiveMap/index.tsx index d3c9b8667..ebc89de0c 100644 --- a/components/Maps/InteractiveMap/index.tsx +++ b/components/Maps/InteractiveMap/index.tsx @@ -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)} > !facility.name.startsWith("Parking") && facility.public + ) + return detailedAmenities +} + +export default function HotelSidePeek({ + hotel, + activeSidePeek, + close, +}: HotelSidePeekProps) { + const intl = useIntl() + const amenitiesList = getAmenitiesList(hotel) + + return ( + +
    + + {intl.formatMessage({ id: "Practical information" })} + + + + {/* parking */} + {hotel.parking.length ? ( + + {hotel.parking.map((p) => ( + + ))} + + ) : null} + + TODO: What content should be in the accessibility section? + + {amenitiesList.map((amenity) => { + return ( +
    + {amenity.name} +
    + ) + })} +
    + {/* TODO: handle linking to Hotel Page */} + +
    +
    + ) +} + +function Parking({ parking }: ParkingProps) { + const intl = useIntl() + return ( +
    + {`${intl.formatMessage({ id: parking.type })} (${parking.name})`} +
      +
    • + {`${intl.formatMessage({ + id: "Number of charging points for electric cars", + })}: ${parking.numberOfChargingSpaces}`} +
    • +
    • {`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}
    • +
    • {`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}
    • +
    • {`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel} m`}
    • +
    • {`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}
    • +
    +
    + ) +} diff --git a/components/SidePeeks/RoomSidePeek/index.tsx b/components/SidePeeks/RoomSidePeek/index.tsx index 1e27577c8..4a66117b8 100644 --- a/components/SidePeeks/RoomSidePeek/index.tsx +++ b/components/SidePeeks/RoomSidePeek/index.tsx @@ -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 ( -
    - - - setIsSidePeekOpen(false)} - > -
    -
    - - {roomSize.min === roomSize.max - ? roomSize.min - : `${roomSize.min} - ${roomSize.max}`} - m².{" "} - {intl.formatMessage( - { id: "booking.accommodatesUpTo" }, - { nrOfGuests: occupancy } - )} - - {images && ( -
    - -
    + +
    +
    + + {roomSize.min === roomSize.max + ? roomSize.min + : `${roomSize.min} - ${roomSize.max}`} + m².{" "} + {intl.formatMessage( + { id: "booking.accommodatesUpTo" }, + { nrOfGuests: occupancy } )} - {roomDescription} -
    -
    - - {intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })} - -
      - {room.roomFacilities - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((facility) => { - const Icon = getFacilityIcon(facility.name) - return ( -
    • - {Icon && } - - {facility.name} - -
    • - ) - })} -
    -
    -
    - - {intl.formatMessage({ id: "booking.bedOptions" })} - - - {intl.formatMessage({ id: "booking.basedOnAvailability" })} - - {/* TODO: Get data for bed options */} -
    + + {images && ( +
    + +
    + )} + {roomDescription}
    -
    - +
    + + {intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })} + +
      + {room.roomFacilities + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((facility) => { + const Icon = getFacilityIcon(facility.name) + return ( +
    • + {Icon && } + + {facility.name} + +
    • + ) + })} +
    - -
    +
    + + {intl.formatMessage({ id: "booking.bedOptions" })} + + + {intl.formatMessage({ id: "booking.basedOnAvailability" })} + + {/* TODO: Get data for bed options */} +
    +
    +
    + +
    + ) } diff --git a/components/TempDesignSystem/Card/card.module.css b/components/TempDesignSystem/Card/card.module.css index 8d0acdebe..f158696a8 100644 --- a/components/TempDesignSystem/Card/card.module.css +++ b/components/TempDesignSystem/Card/card.module.css @@ -15,6 +15,7 @@ .imageWrapper { display: flex; + width: 100%; } .imageWrapper::after { diff --git a/components/TempDesignSystem/Form/Date/date.module.css b/components/TempDesignSystem/Form/Date/date.module.css index 6c0549313..fde0b7e03 100644 --- a/components/TempDesignSystem/Form/Date/date.module.css +++ b/components/TempDesignSystem/Form/Date/date.module.css @@ -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); +} diff --git a/components/TempDesignSystem/Form/Date/date.ts b/components/TempDesignSystem/Form/Date/date.ts index 25eff31c7..8f17d4607 100644 --- a/components/TempDesignSystem/Form/Date/date.ts +++ b/components/TempDesignSystem/Form/Date/date.ts @@ -2,6 +2,7 @@ import type { RegisterOptions } from "react-hook-form" export const enum DateName { date = "date", + day = "day", month = "month", year = "year", } diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index 7441ff471..4e8f4a7af 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -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) { +
    + setValue(DateName.month, Number(key)) + } + placeholder={monthLabel} required tabIndex={2} - defaultSelectedKey={ - segment.isPlaceholder ? undefined : segment.value - } value={segment.isPlaceholder ? undefined : segment.value} />
    ) case "year": return ( -
    +