diff --git a/.gitignore b/.gitignore index 4663988fb..8a6f4e73e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,8 @@ certificates #vscode .vscode/ +#cursor +.cursorrules + # localfile with all the CSS variables exported from design system variables.css \ No newline at end of file diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.module.css deleted file mode 100644 index 464c8ce65..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.module.css +++ /dev/null @@ -1,24 +0,0 @@ -.page { - min-height: 100dvh; - padding-top: var(--Spacing-x6); - padding-left: var(--Spacing-x2); - padding-right: var(--Spacing-x2); - background-color: var(--Scandic-Brand-Warm-White); -} - -.content { - max-width: var(--max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--Spacing-x7); - padding: var(--Spacing-x2); -} - -.main { - flex-grow: 1; -} - -.summary { - max-width: 340px; -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx index 2d80356ac..b361aaa10 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx @@ -1,14 +1,15 @@ +import { notFound } from "next/navigation" + import { getProfileSafely } from "@/lib/trpc/memoizedRequests" import { serverClient } from "@/lib/trpc/server" import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard" -import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection" +import Rooms from "@/components/HotelReservation/SelectRate/Rooms" import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import { setLang } from "@/i18n/serverContext" -import styles from "./page.module.css" - -import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { LangParams, PageArgs } from "@/types/params" export default async function SelectRatePage({ @@ -20,10 +21,15 @@ export default async function SelectRatePage({ const selectRoomParams = new URLSearchParams(searchParams) const selectRoomParamsObject = getHotelReservationQueryParams(selectRoomParams) + + if (!selectRoomParamsObject.room) { + return notFound() + } + const adults = selectRoomParamsObject.room[0].adults // TODO: Handle multiple rooms const children = selectRoomParamsObject.room[0].child?.length // TODO: Handle multiple rooms - const [hotelData, roomConfigurations, user] = await Promise.all([ + const [hotelData, roomsAvailability, packages, user] = await Promise.all([ serverClient().hotel.hotelData.get({ hotelId: searchParams.hotel, language: params.lang, @@ -36,10 +42,22 @@ export default async function SelectRatePage({ adults, children, }), + serverClient().hotel.packages.get({ + hotelId: searchParams.hotel, + startDate: searchParams.fromDate, + endDate: searchParams.toDate, + adults: adults, + children: children, + packageCodes: [ + RoomPackageCodeEnum.ACCESSIBILITY_ROOM, + RoomPackageCodeEnum.PET_ROOM, + RoomPackageCodeEnum.ALLERGY_ROOM, + ], + }), getProfileSafely(), ]) - if (!roomConfigurations) { + if (!roomsAvailability) { return "No rooms found" // TODO: Add a proper error message } @@ -50,17 +68,14 @@ export default async function SelectRatePage({ const roomCategories = hotelData?.included return ( -
+ <> -
-
- -
-
-
+ + ) } diff --git a/components/BookingWidget/MobileToggleButton/index.tsx b/components/BookingWidget/MobileToggleButton/index.tsx index 3bc438c41..a58bdd1b2 100644 --- a/components/BookingWidget/MobileToggleButton/index.tsx +++ b/components/BookingWidget/MobileToggleButton/index.tsx @@ -68,7 +68,14 @@ export default function MobileToggleButton({ {`${selectedFromDate} - ${selectedToDate} (${intl.formatMessage( { id: "booking.nights" }, { totalNights: nights } - )}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${intl.formatMessage({ id: "booking.children" }, { totalChildren })}, ${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`} + )}) ${intl.formatMessage({ id: "booking.adults" }, { totalAdults })}, ${ + totalChildren > 0 + ? intl.formatMessage( + { id: "booking.children" }, + { totalChildren } + ) + ", " + : "" + }${intl.formatMessage({ id: "booking.rooms" }, { totalRooms })}`}
diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index 0da2b79e2..ba126c1f2 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -22,7 +22,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details" import LoadingSpinner from "@/components/LoadingSpinner" import Button from "@/components/TempDesignSystem/Button" -import Checkbox from "@/components/TempDesignSystem/Checkbox" +import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" import Link from "@/components/TempDesignSystem/Link" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" diff --git a/components/HotelReservation/SelectRate/RoomFilter/index.tsx b/components/HotelReservation/SelectRate/RoomFilter/index.tsx new file mode 100644 index 000000000..eb7c442b3 --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomFilter/index.tsx @@ -0,0 +1,124 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useCallback, useEffect, useMemo } from "react" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" +import { z } from "zod" + +import { InfoCircleIcon } from "@/components/Icons" +import CheckboxChip from "@/components/TempDesignSystem/Form/FilterChip/Checkbox" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import { Tooltip } from "@/components/TempDesignSystem/Tooltip" + +import { getIconForFeatureCode } from "../utils" + +import styles from "./roomFilter.module.css" + +import { + type RoomFilterProps, + RoomPackageCodeEnum, +} from "@/types/components/hotelReservation/selectRate/roomFilter" + +export default function RoomFilter({ + numberOfRooms, + onFilter, + filterOptions, +}: RoomFilterProps) { + const initialFilterValues = useMemo( + () => + filterOptions.reduce( + (acc, option) => { + acc[option.code] = false + return acc + }, + {} as Record + ), + [filterOptions] + ) + + const intl = useIntl() + const methods = useForm>({ + defaultValues: initialFilterValues, + mode: "all", + reValidateMode: "onChange", + resolver: zodResolver(z.object({})), + }) + + const { watch, getValues, handleSubmit } = methods + const petFriendly = watch(RoomPackageCodeEnum.PET_ROOM) + const allergyFriendly = watch(RoomPackageCodeEnum.ALLERGY_ROOM) + + const selectedFilters = getValues() + + const tooltipText = intl.formatMessage({ + id: "Pet-friendly rooms have an additional fee of 20 EUR per stay", + }) + + const submitFilter = useCallback(() => { + const data = getValues() + onFilter(data) + }, [onFilter, getValues]) + + useEffect(() => { + const subscription = watch(() => handleSubmit(submitFilter)()) + return () => subscription.unsubscribe() + }, [handleSubmit, watch, submitFilter]) + + return ( +
+
+ + {intl.formatMessage( + { id: "Room types available" }, + { numberOfRooms } + )} + +
+
+
+ + {intl.formatMessage({ id: "Filter" })} + + + {Object.entries(selectedFilters) + .filter(([_, value]) => value) + .map(([key]) => intl.formatMessage({ id: key })) + .join(", ")} + +
+ + {intl.formatMessage( + { id: "Room types available" }, + { numberOfRooms } + )} + +
+ +
+
+ {filterOptions.map((option) => ( + + ))} + + + +
+
+
+
+ ) +} diff --git a/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css b/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css new file mode 100644 index 000000000..9cce04e43 --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css @@ -0,0 +1,43 @@ +.container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.roomsFilter { + display: flex; + flex-direction: row; + gap: var(--Spacing-x1); + align-items: center; +} + +.roomsFilter .infoIcon, +.roomsFilter .infoIcon path { + stroke: var(--UI-Text-Medium-contrast); + fill: transparent; +} +.filterInfo { + display: flex; + flex-direction: row; + gap: var(--Spacing-x-half); + align-items: flex-end; +} + +.infoDesktop { + display: none; +} + +.infoMobile { + display: block; +} + +@media (min-width: 768px) { + .infoDesktop { + display: block; + } + + .infoMobile { + display: none; + } +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx index 76f366adb..dc7ca20fc 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx @@ -40,6 +40,9 @@ export default function PriceList({ {publicLocalPrice.currency} + + /{intl.formatMessage({ id: "night" })} +
) : ( @@ -64,6 +67,9 @@ export default function PriceList({ {memberLocalPrice.currency} + + /{intl.formatMessage({ id: "night" })} + ) : ( diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css index 7320cf1be..4f3431525 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css @@ -12,3 +12,8 @@ display: flex; gap: var(--Spacing-x-half); } + +.perNight { + font-weight: 400; + font-size: var(--typography-Caption-Regular-fontSize); +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx index a523305ae..a0a92bb66 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -19,6 +19,7 @@ export default function FlexibilityOption({ priceInformation, roomType, roomTypeCode, + features, handleSelectRate, }: FlexibilityOptionProps) { const [rootDiv, setRootDiv] = useState(undefined) @@ -52,6 +53,7 @@ export default function FlexibilityOption({ priceName: name, public: publicPrice, member: memberPrice, + features, } handleSelectRate(rate) } diff --git a/components/HotelReservation/SelectRate/RoomSelection/RateSummary/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RateSummary/index.tsx index b929bfe76..b7ecc3c63 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RateSummary/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RateSummary/index.tsx @@ -1,29 +1,56 @@ +import { differenceInCalendarDays } from "date-fns" import { useIntl } from "react-intl" import Button from "@/components/TempDesignSystem/Button" 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 styles from "./rateSummary.module.css" -import { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary" +import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" export default function RateSummary({ rateSummary, isUserLoggedIn, + packages, + roomsAvailability, }: RateSummaryProps) { const intl = useIntl() + const { + member, + public: publicRate, + features, + roomType, + priceName, + } = rateSummary + const priceToShow = isUserLoggedIn ? member : publicRate - const priceToShow = isUserLoggedIn ? rateSummary.member : rateSummary.public + const isPetRoomSelected = features.some( + (feature) => feature.code === RoomPackageCodeEnum.PET_ROOM + ) + + const petRoomPackage = packages.find( + (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM + ) + + const petRoomPrice = petRoomPackage?.calculatedPrice ?? null + const petRoomCurrency = petRoomPackage?.currency ?? null + + const checkInDate = new Date(roomsAvailability.checkInDate) + const checkOutDate = new Date(roomsAvailability.checkOutDate) + const nights = differenceInCalendarDays(checkOutDate, checkInDate) return (
- {rateSummary.roomType} - {rateSummary.priceName} + {roomType} + {priceName}
-
+
{priceToShow?.localPrice.pricePerStay}{" "} {priceToShow?.localPrice.currency} @@ -34,7 +61,49 @@ export default function RateSummary({ {priceToShow?.requestedPrice?.currency}
-
diff --git a/components/HotelReservation/SelectRate/RoomSelection/RateSummary/rateSummary.module.css b/components/HotelReservation/SelectRate/RoomSelection/RateSummary/rateSummary.module.css index c8352efb1..5cb5a4229 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RateSummary/rateSummary.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/RateSummary/rateSummary.module.css @@ -5,7 +5,7 @@ left: 0; right: 0; background-color: var(--Base-Surface-Primary-light-Normal); - padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5); + padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x5); display: flex; justify-content: space-between; align-items: center; @@ -13,5 +13,50 @@ .summaryPrice { display: flex; + width: 100%; gap: var(--Spacing-x4); } + +.petInfo { + border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); + padding-left: var(--Spacing-x2); + display: none; +} + +.summaryText { + display: none; +} + +.summaryPriceTextDesktop { + display: none; +} + +.continueButton { + margin-left: auto; + height: fit-content; + width: 100%; +} + +.summaryPriceTextMobile { + white-space: nowrap; +} + +@media (min-width: 768px) { + .summary { + padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5); + } + .petInfo, + .summaryText, + .summaryPriceTextDesktop { + display: block; + } + .summaryPriceTextMobile { + display: none; + } + .summaryPrice { + width: auto; + } + .continueButton { + width: auto; + } +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index 1afec6119..665d91b31 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -1,5 +1,6 @@ "use client" +import { createElement } from "react" import { useIntl } from "react-intl" import { RateDefinition } from "@/server/routers/hotels/output" @@ -11,6 +12,7 @@ import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import ImageGallery from "../../ImageGallery" +import { getIconForFeatureCode } from "../../utils" import RoomSidePeek from "../RoomSidePeek" import styles from "./roomCard.module.css" @@ -24,18 +26,18 @@ export default function RoomCard({ handleSelectRate, }: RoomCardProps) { const intl = useIntl() - const saveRate = rateDefinitions.find( - // TODO: Update string when API has decided - (rate) => rate.cancellationRule === "NonCancellable" - ) - const changeRate = rateDefinitions.find( - // TODO: Update string when API has decided - (rate) => rate.cancellationRule === "Modifiable" - ) - const flexRate = rateDefinitions.find( - // TODO: Update string when API has decided - (rate) => rate.cancellationRule === "CancellableBefore6PM" - ) + + const rates = { + saveRate: rateDefinitions.find( + (rate) => rate.cancellationRule === "NonCancellable" + ), + changeRate: rateDefinitions.find( + (rate) => rate.cancellationRule === "Modifiable" + ), + flexRate: rateDefinitions.find( + (rate) => rate.cancellationRule === "CancellableBefore6PM" + ), + } function findProductForRate(rate: RateDefinition | undefined) { return rate @@ -47,20 +49,15 @@ export default function RoomCard({ : undefined } - function getPriceForRate( - rate: typeof saveRate | typeof changeRate | typeof flexRate - ) { + function getPriceInformationForRate(rate: RateDefinition | undefined) { return rateDefinitions.find((def) => def.rateCode === rate?.rateCode) ?.generalTerms } + const selectedRoom = roomCategories.find( (room) => room.name === roomConfiguration.roomType ) - - const roomSize = selectedRoom?.roomSize - const occupancy = selectedRoom?.occupancy.total - const roomDescription = selectedRoom?.descriptions.short - const images = selectedRoom?.images + const { roomSize, occupancy, descriptions, images } = selectedRoom || {} const mainImage = images?.[0] return ( @@ -68,12 +65,11 @@ export default function RoomCard({
- {/*TODO: Handle pluralisation*/} {intl.formatMessage( { id: "booking.guests", }, - { nrOfGuests: occupancy } + { nrOfGuests: occupancy?.total } )} @@ -92,7 +88,7 @@ export default function RoomCard({ {roomConfiguration.roomType} - {roomDescription} + {descriptions?.short}
{intl.formatMessage({ @@ -100,49 +96,53 @@ export default function RoomCard({ })}
- - - + {Object.entries(rates).map(([key, rate]) => ( + + ))}
{mainImage && (
- {roomConfiguration.roomsLeft < 5 && ( - - {`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`} - - )} +
+ {roomConfiguration.roomsLeft < 5 && ( + + {`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`} + + )} + {roomConfiguration.features.map((feature) => ( + + {createElement(getIconForFeatureCode(feature.code), { + width: 16, + height: 16, + color: "burgundy", + })} + + ))} +
{/*NOTE: images from the test API are hosted on test3.scandichotels.com, which can't be accessed unless on Scandic's Wifi or using Citrix. */} {images && ( diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css index ef5d9b8fc..537c1b30a 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css @@ -64,10 +64,17 @@ gap: var(--Spacing-x2); } -.roomsLeft { +.chipContainer { position: absolute; + z-index: 1; top: 12px; left: 12px; + display: flex; + flex-direction: row; + gap: var(--Spacing-x1); +} + +.chip { background-color: var(--Main-Grey-White); padding: var(--Spacing-x-half) var(--Spacing-x1); border-radius: var(--Corner-radius-Small); diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index c4c5e2e87..9929a7451 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -1,6 +1,6 @@ "use client" import { useRouter, useSearchParams } from "next/navigation" -import { useState } from "react" +import { useMemo, useState } from "react" import RateSummary from "./RateSummary" import RoomCard from "./RoomCard" @@ -8,13 +8,14 @@ import getHotelReservationQueryParams from "./utils" import styles from "./roomSelection.module.css" -import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection" -import { Rate } from "@/types/components/hotelReservation/selectRate/selectRate" +import type { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection" +import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate" export default function RoomSelection({ - roomConfigurations, + roomsAvailability, roomCategories, user, + packages, }: RoomSelectionProps) { const [rateSummary, setRateSummary] = useState(null) @@ -22,27 +23,32 @@ export default function RoomSelection({ const searchParams = useSearchParams() const isUserLoggedIn = !!user - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const searchParamsObject = getHotelReservationQueryParams(searchParams) + const { roomConfigurations, rateDefinitions } = roomsAvailability - const queryParams = new URLSearchParams(searchParams) + const queryParams = useMemo(() => { + const params = new URLSearchParams(searchParams) + const searchParamsObject = getHotelReservationQueryParams(searchParams) searchParamsObject.room.forEach((item, index) => { if (rateSummary?.roomTypeCode) { - queryParams.set(`room[${index}].roomtype`, rateSummary.roomTypeCode) + params.set(`room[${index}].roomtype`, rateSummary.roomTypeCode) } if (rateSummary?.public?.rateCode) { - queryParams.set(`room[${index}].ratecode`, rateSummary.public.rateCode) + params.set(`room[${index}].ratecode`, rateSummary.public.rateCode) } if (rateSummary?.member?.rateCode) { - queryParams.set( + params.set( `room[${index}].counterratecode`, rateSummary.member.rateCode ) } }) + return params + }, [searchParams, rateSummary]) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() router.push(`select-bed?${queryParams}`) } @@ -54,10 +60,10 @@ export default function RoomSelection({ onSubmit={handleSubmit} >
    - {roomConfigurations.roomConfigurations.map((roomConfiguration) => ( -
  • + {roomConfigurations.map((roomConfiguration) => ( +
  • )} diff --git a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css index 66a27302e..1dab63afb 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css @@ -3,7 +3,6 @@ } .roomList { - margin-top: var(--Spacing-x4); list-style: none; display: grid; grid-template-columns: 1fr; diff --git a/components/HotelReservation/SelectRate/RoomSelection/utils.ts b/components/HotelReservation/SelectRate/RoomSelection/utils.ts index 0b1ab884a..1ae94cc9c 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/utils.ts +++ b/components/HotelReservation/SelectRate/RoomSelection/utils.ts @@ -1,6 +1,6 @@ import { getFormattedUrlQueryParams } from "@/utils/url" -import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" +import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" function getHotelReservationQueryParams(searchParams: URLSearchParams) { return getFormattedUrlQueryParams(searchParams, { diff --git a/components/HotelReservation/SelectRate/Rooms/index.tsx b/components/HotelReservation/SelectRate/Rooms/index.tsx new file mode 100644 index 000000000..8f9030149 --- /dev/null +++ b/components/HotelReservation/SelectRate/Rooms/index.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useCallback, useState } from "react" + +import { RoomsAvailability } from "@/server/routers/hotels/output" + +import RoomFilter from "../RoomFilter" +import RoomSelection from "../RoomSelection" + +import styles from "./rooms.module.css" + +import type { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection" + +export default function Rooms({ + roomsAvailability, + roomCategories = [], + user, + packages, +}: RoomSelectionProps) { + const defaultRooms = roomsAvailability.roomConfigurations.filter( + (room) => room.features.length === 0 + ) + const [rooms, setRooms] = useState({ + ...roomsAvailability, + roomConfigurations: defaultRooms, + }) + + const handleFilter = useCallback( + (filter: Record) => { + const selectedCodes = Object.keys(filter).filter((key) => filter[key]) + + if (selectedCodes.length === 0) { + setRooms({ + ...roomsAvailability, + roomConfigurations: defaultRooms, + }) + return + } + + const filteredRooms = roomsAvailability.roomConfigurations.filter( + (room) => + selectedCodes.every((selectedCode) => + room.features.some((feature) => feature.code === selectedCode) + ) + ) + setRooms({ ...roomsAvailability, roomConfigurations: filteredRooms }) + }, + [roomsAvailability, defaultRooms] + ) + + return ( +
    + + +
    + ) +} diff --git a/components/HotelReservation/SelectRate/Rooms/rooms.module.css b/components/HotelReservation/SelectRate/Rooms/rooms.module.css new file mode 100644 index 000000000..5e2bca00b --- /dev/null +++ b/components/HotelReservation/SelectRate/Rooms/rooms.module.css @@ -0,0 +1,8 @@ +.content { + max-width: var(--max-width); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); + padding: var(--Spacing-x2); +} diff --git a/components/HotelReservation/SelectRate/utils.ts b/components/HotelReservation/SelectRate/utils.ts new file mode 100644 index 000000000..d91476bb5 --- /dev/null +++ b/components/HotelReservation/SelectRate/utils.ts @@ -0,0 +1,19 @@ +import { AllergyIcon, PetsIcon, WheelchairIcon } from "@/components/Icons" + +import { + RoomPackageCodeEnum, + type RoomPackageCodes, +} from "@/types/components/hotelReservation/selectRate/roomFilter" + +export function getIconForFeatureCode(featureCode: RoomPackageCodes) { + switch (featureCode) { + case RoomPackageCodeEnum.ACCESSIBILITY_ROOM: + return WheelchairIcon + case RoomPackageCodeEnum.ALLERGY_ROOM: + return AllergyIcon + case RoomPackageCodeEnum.PET_ROOM: + return PetsIcon + default: + return PetsIcon + } +} diff --git a/components/Icons/Allergy.tsx b/components/Icons/Allergy.tsx new file mode 100644 index 000000000..0fe399445 --- /dev/null +++ b/components/Icons/Allergy.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function AllergyIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Pets.tsx b/components/Icons/Pets.tsx index 679a3afee..e5e475b2e 100644 --- a/components/Icons/Pets.tsx +++ b/components/Icons/Pets.tsx @@ -16,7 +16,7 @@ export default function PetsIcon({ className, color, ...props }: IconProps) { > ) diff --git a/components/Icons/Wheelchair.tsx b/components/Icons/Wheelchair.tsx new file mode 100644 index 000000000..991951761 --- /dev/null +++ b/components/Icons/Wheelchair.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function WheelchairIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/icon.module.css b/components/Icons/icon.module.css index ec5a15fd4..68ace50b3 100644 --- a/components/Icons/icon.module.css +++ b/components/Icons/icon.module.css @@ -76,3 +76,8 @@ .baseButtonTextOnFillNormal * { fill: var(--Base-Button-Text-On-Fill-Normal); } + +.disabled, +.disabled * { + fill: var(--Base-Text-Disabled); +} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index fc7407ce8..ce23296fe 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -4,6 +4,7 @@ export { default as AccessibilityIcon } from "./Accessibility" export { default as AccountCircleIcon } from "./AccountCircle" export { default as AirIcon } from "./Air" export { default as AirplaneIcon } from "./Airplane" +export { default as AllergyIcon } from "./Allergy" export { default as ArrowRightIcon } from "./ArrowRight" export { default as BarIcon } from "./Bar" export { default as BathtubIcon } from "./Bathtub" @@ -111,6 +112,7 @@ export { default as TshirtIcon } from "./Tshirt" export { default as TshirtWashIcon } from "./TshirtWash" export { default as TvCastingIcon } from "./TvCasting" export { default as WarningTriangle } from "./WarningTriangle" +export { default as WheelchairIcon } from "./Wheelchair" export { default as WifiIcon } from "./Wifi" export { default as WindowCurtainsAltIcon } from "./WindowCurtainsAlt" export { default as WindowNotAvailableIcon } from "./WindowNotAvailable" diff --git a/components/Icons/variants.ts b/components/Icons/variants.ts index d319c466e..09d69364f 100644 --- a/components/Icons/variants.ts +++ b/components/Icons/variants.ts @@ -20,6 +20,7 @@ const config = { white: styles.white, uiTextHighContrast: styles.uiTextHighContrast, uiTextMediumContrast: styles.uiTextMediumContrast, + disabled: styles.disabled, }, }, defaultVariants: { diff --git a/components/TempDesignSystem/Checkbox/checkbox.module.css b/components/TempDesignSystem/Checkbox/checkbox.module.css deleted file mode 100644 index c831ba525..000000000 --- a/components/TempDesignSystem/Checkbox/checkbox.module.css +++ /dev/null @@ -1,40 +0,0 @@ -.container { - display: flex; - flex-direction: column; - color: var(--text-color); -} - -.container[data-selected] .checkbox { - border: none; - background: var(--UI-Input-Controls-Fill-Selected); -} - -.checkboxContainer { - display: flex; - align-items: flex-start; - gap: var(--Spacing-x-one-and-half); -} - -.checkbox { - width: 24px; - height: 24px; - min-width: 24px; - background-color: var(--UI-Input-Controls-Surface-Normal); - border: 2px solid var(--UI-Input-Controls-Border-Normal); - border-radius: var(--Corner-radius-Small); - transition: all 200ms; - display: flex; - align-items: center; - justify-content: center; - transition: all 200ms; - forced-color-adjust: none; - cursor: pointer; -} - -.error { - align-items: center; - color: var(--Scandic-Red-60); - display: flex; - gap: var(--Spacing-x-half); - margin-top: var(--Spacing-x1); -} diff --git a/components/TempDesignSystem/Checkbox/checkbox.ts b/components/TempDesignSystem/Checkbox/checkbox.ts deleted file mode 100644 index 8588b7401..000000000 --- a/components/TempDesignSystem/Checkbox/checkbox.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { RegisterOptions } from "react-hook-form" - -export interface CheckboxProps - extends React.InputHTMLAttributes { - name: string - registerOptions?: RegisterOptions -} diff --git a/components/TempDesignSystem/Checkbox/index.tsx b/components/TempDesignSystem/Checkbox/index.tsx deleted file mode 100644 index fde1742ff..000000000 --- a/components/TempDesignSystem/Checkbox/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Checkbox as AriaCheckbox } from "react-aria-components" -import { useController, useFormContext } from "react-hook-form" - -import { InfoCircleIcon } from "@/components/Icons" -import CheckIcon from "@/components/Icons/Check" -import Caption from "@/components/TempDesignSystem/Text/Caption" - -import { CheckboxProps } from "./checkbox" - -import styles from "./checkbox.module.css" - -export default function Checkbox({ - name, - children, - registerOptions, -}: React.PropsWithChildren) { - const { control } = useFormContext() - const { field, fieldState } = useController({ - control, - name, - rules: registerOptions, - }) - - return ( - - {({ isSelected }) => ( - <> -
    -
    - {isSelected && } -
    - {children} -
    - {children && fieldState.error ? ( - - - {fieldState.error.message} - - ) : null} - - )} -
    - ) -} diff --git a/components/TempDesignSystem/Form/Checkbox/checkbox.module.css b/components/TempDesignSystem/Form/Checkbox/checkbox.module.css index 99077e212..2e924b226 100644 --- a/components/TempDesignSystem/Form/Checkbox/checkbox.module.css +++ b/components/TempDesignSystem/Form/Checkbox/checkbox.module.css @@ -16,7 +16,7 @@ .checkboxContainer { display: flex; - align-items: flex-start; + align-items: center; gap: var(--Spacing-x-one-and-half); } diff --git a/components/TempDesignSystem/Form/FilterChip/Checkbox.tsx b/components/TempDesignSystem/Form/FilterChip/Checkbox.tsx new file mode 100644 index 000000000..c2697d560 --- /dev/null +++ b/components/TempDesignSystem/Form/FilterChip/Checkbox.tsx @@ -0,0 +1,7 @@ +import Chip from "./_Chip" + +import type { FilterChipCheckboxProps } from "@/types/components/form/filterChip" + +export default function CheckboxChip(props: FilterChipCheckboxProps) { + return +} diff --git a/components/TempDesignSystem/Form/FilterChip/_Chip/chip.module.css b/components/TempDesignSystem/Form/FilterChip/_Chip/chip.module.css new file mode 100644 index 000000000..44fa78a14 --- /dev/null +++ b/components/TempDesignSystem/Form/FilterChip/_Chip/chip.module.css @@ -0,0 +1,37 @@ +.label { + display: flex; + align-items: center; + gap: var(--Spacing-x-half); + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + border: 1px solid var(--Base-Border-Subtle); + border-radius: var(--Corner-radius-Small); + background-color: var(--Base-Surface-Secondary-light-Normal); + cursor: pointer; +} + +.label[data-selected="true"], +.label[data-selected="true"]:hover { + background-color: var(--Primary-Light-Surface-Normal); + border-color: var(--Base-Border-Hover); +} + +.label:hover { + background-color: var(--Base-Surface-Primary-light-Hover-alt); + border-color: var(--Base-Border-Subtle); +} + +.label[data-disabled="true"] { + background-color: var(--Base-Button-Primary-Fill-Disabled); + border-color: var(--Base-Button-Primary-Fill-Disabled); + cursor: not-allowed; +} + +.caption { + display: none; +} + +@media (min-width: 768px) { + .caption { + display: block; + } +} diff --git a/components/TempDesignSystem/Form/FilterChip/_Chip/index.tsx b/components/TempDesignSystem/Form/FilterChip/_Chip/index.tsx new file mode 100644 index 000000000..528469df1 --- /dev/null +++ b/components/TempDesignSystem/Form/FilterChip/_Chip/index.tsx @@ -0,0 +1,57 @@ +import { useMemo } from "react" +import { useFormContext } from "react-hook-form" + +import { HeartIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./chip.module.css" + +import { FilterChipProps } from "@/types/components/form/filterChip" + +export default function FilterChip({ + Icon = HeartIcon, + iconHeight = 20, + iconWidth = 20, + id, + name, + label, + type, + value, + selected, + disabled, +}: FilterChipProps) { + const { register } = useFormContext() + + const color = useMemo(() => { + if (selected) return "burgundy" + if (disabled) return "disabled" + return "uiTextPlaceholder" + }, [selected, disabled]) + + return ( + + ) +} diff --git a/components/TempDesignSystem/Tooltip/tooltip.module.css b/components/TempDesignSystem/Tooltip/tooltip.module.css index 2a6b00b43..da8e50cbd 100644 --- a/components/TempDesignSystem/Tooltip/tooltip.module.css +++ b/components/TempDesignSystem/Tooltip/tooltip.module.css @@ -15,6 +15,7 @@ opacity: 0; transition: opacity 0.3s; max-width: 200px; + min-width: 150px; } .tooltipContainer:hover .tooltip { @@ -31,11 +32,15 @@ } .top { - bottom: 100%; + bottom: calc(100% + 8px); } .bottom { - top: 100%; + top: calc(100% + 8px); +} + +.bottom.arrowRight { + right: 0; } .tooltip::before { diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 889555466..e47484466 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -3,15 +3,21 @@ "{amount} {currency}/night per adult": "{amount} {currency}/nat pr. voksen", "A destination or hotel name is needed to be able to search for a hotel room.": "Et destinations- eller hotelnavn er nødvendigt for at kunne søge efter et hotelværelse.", "A photo of the room": "Et foto af værelset", + "ACCE": "Tilgængelighed", + "ALLG": "Allergi", "About meetings & conferences": "About meetings & conferences", "About the hotel": "About the hotel", + "Accessibility": "Tilgængelighed", + "Accessible Room": "Tilgængelighedsrum", "Activities": "Aktiviteter", "Add code": "Tilføj kode", "Add new card": "Tilføj nyt kort", + "Add room": "Tilføj værelse", "Address": "Adresse", "Adults": "voksne", "Airport": "Lufthavn", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle vores morgenmadsbuffeter tilbyder glutenfrie, veganske og allergivenlige muligheder.", + "Allergy Room": "Allergirum", "Already a friend?": "Allerede en ven?", "Amenities": "Faciliteter", "Amusement park": "Forlystelsespark", @@ -104,6 +110,7 @@ "FAQ": "Ofte stillede spørgsmål", "Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.", "Fair": "Messe", + "Filter": "Filter", "Find booking": "Find booking", "Find hotels": "Find hotel", "First name": "Fornavn", @@ -208,12 +215,15 @@ "Open menu": "Åbn menuen", "Open my pages menu": "Åbn mine sider menuen", "Overview": "Oversigt", + "PETR": "Kæledyr", "Parking": "Parkering", "Parking / Garage": "Parkering / Garage", "Password": "Adgangskode", "Pay later": "Betal senere", "Pay now": "Betal nu", "Payment info": "Betalingsoplysninger", + "Pet Room": "Kæledyrsrum", + "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kæledyrsrum har en ekstra gebyr på 20 EUR per ophold", "Phone": "Telefon", "Phone is required": "Telefonnummer er påkrævet", "Phone number": "Telefonnummer", @@ -241,9 +251,9 @@ "Restaurant & Bar": "Restaurant & Bar", "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Gentag den nye adgangskode", - "Room": "Værelse", "Room & Terms": "Værelse & Vilkår", "Room facilities": "Værelsesfaciliteter", + "Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} tilgængelig", "Rooms": "Værelser", "Rooms & Guests": "Værelser & gæster", "Sauna and gym": "Sauna and gym", @@ -295,6 +305,7 @@ "Things nearby HOTEL_NAME": "Ting i nærheden af {hotelName}", "Total Points": "Samlet antal point", "Total incl VAT": "Inkl. moms", + "Total price": "Samlet pris", "Tourist": "Turist", "Transaction date": "Overførselsdato", "Transactions": "Transaktioner", @@ -371,6 +382,8 @@ "number": "nummer", "or": "eller", "points": "Point", + "room type": "værelsestype", + "room types": "værelsestyper", "special character": "speciel karakter", "spendable points expiring by": "{points} Brugbare point udløber den {date}", "to": "til", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index e092862df..88a5092e5 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -3,15 +3,21 @@ "{amount} {currency}/night per adult": "{amount} {currency}/Nacht pro Erwachsener", "A destination or hotel name is needed to be able to search for a hotel room.": "Ein Reiseziel oder Hotelname wird benötigt, um nach einem Hotelzimmer suchen zu können.", "A photo of the room": "Ein Foto des Zimmers", + "ACCE": "Zugänglichkeit", + "ALLG": "Allergie", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Über das Hotel", + "Accessibility": "Zugänglichkeit", + "Accessible Room": "Barrierefreies Zimmer", "Activities": "Aktivitäten", "Add code": "Code hinzufügen", "Add new card": "Neue Karte hinzufügen", + "Add room": "Zimmer hinzufügen", "Address": "Adresse", "Adults": "Erwachsene", "Airport": "Flughafen", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle unsere Frühstücksbuffets bieten glutenfreie, vegane und allergikerfreundliche Speisen.", + "Allergy Room": "Allergikerzimmer", "Already a friend?": "Sind wir schon Freunde?", "Amenities": "Annehmlichkeiten", "Amusement park": "Vergnügungspark", @@ -104,6 +110,7 @@ "FAQ": "Häufig gestellte Fragen", "Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.", "Fair": "Messe", + "Filter": "Filter", "Find booking": "Buchung finden", "Find hotels": "Hotels finden", "First name": "Vorname", @@ -208,12 +215,15 @@ "Open menu": "Menü öffnen", "Open my pages menu": "Meine Seiten Menü öffnen", "Overview": "Übersicht", + "PETR": "Haustier", "Parking": "Parken", "Parking / Garage": "Parken / Garage", "Password": "Passwort", "Pay later": "Später bezahlen", "Pay now": "Jetzt bezahlen", "Payment info": "Zahlungsinformationen", + "Pet Room": "Haustierzimmer", + "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Haustierzimmer haben einen zusätzlichen Preis von 20 EUR pro Aufenthalt", "Phone": "Telefon", "Phone is required": "Telefon ist erforderlich", "Phone number": "Telefonnummer", @@ -244,6 +254,7 @@ "Room": "Zimmer", "Room & Terms": "Zimmer & Bedingungen", "Room facilities": "Zimmerausstattung", + "Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} verfügbar", "Rooms": "Räume", "Rooms & Guests": "Zimmer & Gäste", "Sauna and gym": "Sauna and gym", @@ -295,6 +306,7 @@ "Things nearby HOTEL_NAME": "Dinge in der Nähe von {hotelName}", "Total Points": "Gesamtpunktzahl", "Total incl VAT": "Gesamt inkl. MwSt.", + "Total price": "Gesamtpreis", "Tourist": "Tourist", "Transaction date": "Transaktionsdatum", "Transactions": "Transaktionen", @@ -371,6 +383,8 @@ "number": "nummer", "or": "oder", "points": "Punkte", + "room type": "zimmerart", + "room types": "zimmerarten", "special character": "sonderzeichen", "spendable points expiring by": "{points} Einlösbare punkte verfallen bis zum {date}", "to": "zu", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index f52e13d8e..75fbbd805 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -3,18 +3,24 @@ "{amount} {currency}/night per adult": "{amount} {currency}/night per adult", "A destination or hotel name is needed to be able to search for a hotel room.": "A destination or hotel name is needed to be able to search for a hotel room.", "A photo of the room": "A photo of the room", + "ACCE": "Accessibility", + "ALLG": "Allergy", "About meetings & conferences": "About meetings & conferences", "About the hotel": "About the hotel", + "Accessibility": "Accessibility", + "Accessible Room": "Accessibility room", "Activities": "Activities", "Add Room": "Add room", "Add code": "Add code", "Add new card": "Add new card", + "Add room": "Add room", "Add to calendar": "Add to calendar", "Address": "Address", "Adults": "Adults", "Age": "Age", "Airport": "Airport", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.", + "Allergy Room": "Allergy room", "Already a friend?": "Already a friend?", "Amenities": "Amenities", "Amusement park": "Amusement park", @@ -52,8 +58,6 @@ "Check in": "Check in", "Check out": "Check out", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.", - "Check-in": "Check-in", - "Check-out": "Check-out", "Child age is required": "Child age is required", "Children": "Children", "Choose room": "Choose room", @@ -113,6 +117,7 @@ "FAQ": "FAQ", "Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.", "Fair": "Fair", + "Filter": "Filter", "Find booking": "Find booking", "Find hotels": "Find hotels", "First name": "First name", @@ -219,6 +224,7 @@ "Open menu": "Open menu", "Open my pages menu": "Open my pages menu", "Overview": "Overview", + "PETR": "Pet", "Parking": "Parking", "Parking / Garage": "Parking / Garage", "Password": "Password", @@ -226,6 +232,8 @@ "Pay now": "Pay now", "Payment info": "Payment info", "Payment received": "Payment received", + "Pet Room": "Pet room", + "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay", "Phone": "Phone", "Phone is required": "Phone is required", "Phone number": "Phone number", @@ -259,6 +267,7 @@ "Room": "Room", "Room & Terms": "Room & Terms", "Room facilities": "Room facilities", + "Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} available", "Rooms": "Rooms", "Rooms & Guests": "Rooms & Guests", "Sauna and gym": "Sauna and gym", @@ -311,6 +320,7 @@ "Total Points": "Total Points", "Total cost": "Total cost", "Total incl VAT": "Total incl VAT", + "Total price": "Total price", "Tourist": "Tourist", "Transaction date": "Transaction date", "Transactions": "Transactions", @@ -393,6 +403,8 @@ "number": "number", "or": "or", "points": "Points", + "room type": "room type", + "room types": "room types", "special character": "special character", "spendable points expiring by": "{points} spendable points expiring by {date}", "to": "to", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index afe70ea1b..6f2f28b63 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -3,15 +3,21 @@ "{amount} {currency}/night per adult": "{amount} {currency}/yö per aikuinen", "A destination or hotel name is needed to be able to search for a hotel room.": "Kohteen tai hotellin nimi tarvitaan, jotta hotellihuonetta voidaan hakea.", "A photo of the room": "Kuva huoneesta", + "ACCE": "Saavutettavuus", + "ALLG": "Allergia", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Tietoja hotellista", + "Accessibility": "Saavutettavuus", + "Accessible Room": "Esteetön huone", "Activities": "Aktiviteetit", "Add code": "Lisää koodi", "Add new card": "Lisää uusi kortti", + "Add room": "Lisää huone", "Address": "Osoite", "Adults": "Aikuista", "Airport": "Lentokenttä", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Kaikki aamiaisbuffettimme tarjoavat gluteenittomia, vegaanisia ja allergiaystävällisiä vaihtoehtoja.", + "Allergy Room": "Allergiahuone", "Already a friend?": "Oletko jo ystävä?", "Amenities": "Mukavuudet", "Amusement park": "Huvipuisto", @@ -104,6 +110,7 @@ "FAQ": "Usein kysytyt kysymykset", "Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.", "Fair": "Messukeskus", + "Filter": "Suodatin", "Find booking": "Etsi varaus", "Find hotels": "Etsi hotelleja", "First name": "Etunimi", @@ -208,12 +215,15 @@ "Open menu": "Avaa valikko", "Open my pages menu": "Avaa omat sivut -valikko", "Overview": "Yleiskatsaus", + "PETR": "Lemmikki", "Parking": "Pysäköinti", "Parking / Garage": "Pysäköinti / Autotalli", "Password": "Salasana", "Pay later": "Maksa myöhemmin", "Pay now": "Maksa nyt", "Payment info": "Maksutiedot", + "Pet Room": "Lemmikkihuone", + "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Lemmikkihuoneen lisäkustannus on 20 EUR per majoitus", "Phone": "Puhelin", "Phone is required": "Puhelin vaaditaan", "Phone number": "Puhelinnumero", @@ -241,9 +251,9 @@ "Restaurant & Bar": "Ravintola & Baari", "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Kirjoita uusi salasana uudelleen", - "Room": "Huone", "Room & Terms": "Huone & Ehdot", "Room facilities": "Huoneen varustelu", + "Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} saatavilla", "Rooms": "Huoneet", "Rooms & Guests": "Huoneet & Vieraat", "Rooms & Guestss": "Huoneet & Vieraat", @@ -296,6 +306,7 @@ "Things nearby HOTEL_NAME": "Lähellä olevia asioita {hotelName}", "Total Points": "Kokonaispisteet", "Total incl VAT": "Yhteensä sis. alv", + "Total price": "Kokonaishinta", "Tourist": "Turisti", "Transaction date": "Tapahtuman päivämäärä", "Transactions": "Tapahtumat", @@ -372,6 +383,8 @@ "number": "määrä", "or": "tai", "points": "pistettä", + "room type": "huonetyyppi", + "room types": "huonetyypit", "special character": "erikoishahmo", "spendable points expiring by": "{points} pistettä vanhenee {date} mennessä", "to": "to", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index fab5ce670..0b45a4673 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -3,15 +3,21 @@ "{amount} {currency}/night per adult": "{amount} {currency}/natt per voksen", "A destination or hotel name is needed to be able to search for a hotel room.": "Et reisemål eller hotellnavn er nødvendig for å kunne søke etter et hotellrom.", "A photo of the room": "Et bilde av rommet", + "ACCE": "Tilgjengelighet", + "ALLG": "Allergi", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Om hotellet", + "Accessibility": "Tilgjengelighet", + "Accessible Room": "Tilgjengelighetsrom", "Activities": "Aktiviteter", "Add code": "Legg til kode", "Add new card": "Legg til nytt kort", + "Add room": "Legg til rom", "Address": "Adresse", "Adults": "Voksne", "Airport": "Flyplass", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle våre frokostbufféer tilbyr glutenfrie, veganske og allergivennlige alternativer.", + "Allergy Room": "Allergirom", "Already a friend?": "Allerede Friend?", "Amenities": "Fasiliteter", "Amusement park": "Tivoli", @@ -103,6 +109,7 @@ "FAQ": "Ofte stilte spørsmål", "Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.", "Fair": "Messe", + "Filter": "Filter", "Find booking": "Finn booking", "Find hotels": "Finn hotell", "First name": "Fornavn", @@ -206,12 +213,15 @@ "Open menu": "Åpne menyen", "Open my pages menu": "Åpne mine sider menyen", "Overview": "Oversikt", + "PETR": "Kjæledyr", "Parking": "Parkering", "Parking / Garage": "Parkering / Garasje", "Password": "Passord", "Pay later": "Betal senere", "Pay now": "Betal nå", "Payment info": "Betalingsinformasjon", + "Pet Room": "Kjæledyrsrom", + "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kjæledyrsrom har en tilleggsavgift på 20 EUR per opphold", "Phone": "Telefon", "Phone is required": "Telefon kreves", "Phone number": "Telefonnummer", @@ -239,9 +249,9 @@ "Restaurant & Bar": "Restaurant & Bar", "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Skriv inn nytt passord på nytt", - "Room": "Rom", "Room & Terms": "Rom & Vilkår", "Room facilities": "Romfasiliteter", + "Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} tilgjengelig", "Rooms": "Rom", "Rooms & Guests": "Rom og gjester", "Sauna and gym": "Sauna and gym", @@ -293,6 +303,7 @@ "Things nearby HOTEL_NAME": "Ting i nærheten av {hotelName}", "Total Points": "Totale poeng", "Total incl VAT": "Sum inkl mva", + "Total price": "Totalpris", "Tourist": "Turist", "Transaction date": "Transaksjonsdato", "Transactions": "Transaksjoner", @@ -368,6 +379,8 @@ "number": "antall", "or": "eller", "points": "poeng", + "room type": "romtype", + "room types": "romtyper", "special character": "spesiell karakter", "spendable points expiring by": "{points} Brukbare poeng utløper innen {date}", "to": "til", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index be22979a5..406aa4cb4 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -3,15 +3,21 @@ "{amount} {currency}/night per adult": "{amount} {currency}/natt per vuxen", "A destination or hotel name is needed to be able to search for a hotel room.": "Ett destinations- eller hotellnamn behövs för att kunna söka efter ett hotellrum.", "A photo of the room": "Ett foto av rummet", + "ACCE": "Tillgänglighet", + "ALLG": "Allergi", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Om hotellet", + "Accessibility": "Tillgänglighet", + "Accessible Room": "Tillgänglighetsrum", "Activities": "Aktiviteter", "Add code": "Lägg till kod", "Add new card": "Lägg till nytt kort", + "Add room": "Lägg till rum", "Address": "Adress", "Adults": "Vuxna", "Airport": "Flygplats", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alla våra frukostbufféer erbjuder glutenfria, veganska och allergivänliga alternativ.", + "Allergy Room": "Allergirum", "Already a friend?": "Är du redan en vän?", "Amenities": "Bekvämligheter", "Amusement park": "Nöjespark", @@ -103,6 +109,7 @@ "FAQ": "FAQ", "Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.", "Fair": "Mässa", + "Filter": "Filter", "Find booking": "Hitta bokning", "Find hotels": "Hitta hotell", "First name": "Förnamn", @@ -206,12 +213,15 @@ "Open menu": "Öppna menyn", "Open my pages menu": "Öppna mina sidor menyn", "Overview": "Översikt", + "PETR": "Husdjur", "Parking": "Parkering", "Parking / Garage": "Parkering / Garage", "Password": "Lösenord", "Pay later": "Betala senare", "Pay now": "Betala nu", "Payment info": "Betalningsinformation", + "Pet Room": "Husdjursrum", + "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Husdjursrum har en extra avgift på 20 EUR per vistelse", "Phone": "Telefon", "Phone is required": "Telefonnummer är obligatorisk", "Phone number": "Telefonnummer", @@ -239,9 +249,9 @@ "Restaurant & Bar": "Restaurang & Bar", "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Upprepa nytt lösenord", - "Room": "Rum", "Room & Terms": "Rum & Villkor", "Room facilities": "Rumfaciliteter", + "Room types available": "{numberOfRooms, plural, one {# room type} other {# room types}} tillgängliga", "Rooms": "Rum", "Rooms & Guests": "Rum och gäster", "Sauna and gym": "Sauna and gym", @@ -293,6 +303,7 @@ "Things nearby HOTEL_NAME": "Saker i närheten av {hotelName}", "Total Points": "Poäng totalt", "Total incl VAT": "Totalt inkl moms", + "Total price": "Totalpris", "Tourist": "Turist", "Transaction date": "Transaktionsdatum", "Transactions": "Transaktioner", @@ -369,9 +380,13 @@ "number": "nummer", "or": "eller", "points": "poäng", + "room type": "rumtyp", + "room types": "rumstyper", "special character": "speciell karaktär", "spendable points expiring by": "{points} poäng förfaller {date}", "to": "till", + "type": "typ", + "types": "typer", "uppercase letter": "stor bokstav", "{amount} out of {total}": "{amount} av {total}", "{amount} {currency}": "{amount} {currency}", diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 66bc36ec3..be1aee2fd 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -23,6 +23,7 @@ export namespace endpoints { rewards = `${profile}/reward`, tierRewards = `${profile}/TierRewards`, subscriberId = `${profile}/SubscriberId`, + packages = "package/v1/packages/hotel", } } diff --git a/lib/api/index.ts b/lib/api/index.ts index 9e32ac0cc..475e0da4e 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -37,7 +37,7 @@ export async function get( const searchParams = new URLSearchParams(params) if (searchParams.size) { searchParams.forEach((value, key) => { - url.searchParams.set(key, value) + url.searchParams.append(key, value) }) url.searchParams.sort() } diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index 0f545d673..182575c2d 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -7,6 +7,7 @@ import { imageMetaDataSchema, imageSizesSchema } from "./schemas/image" import { roomSchema } from "./schemas/room" import { getPoiGroupByCategoryName } from "./utils" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { AlertTypeEnum } from "@/types/enums/alert" import { FacilityEnum } from "@/types/enums/facilities" import { PointOfInterestCategoryNameEnum } from "@/types/hotel" @@ -545,7 +546,16 @@ const roomConfigurationSchema = z.object({ roomTypeCode: z.string().optional(), roomType: z.string(), roomsLeft: z.number(), - features: z.array(z.object({ inventory: z.number(), code: z.string() })), + features: z.array( + z.object({ + inventory: z.number(), + code: z.enum([ + RoomPackageCodeEnum.PET_ROOM, + RoomPackageCodeEnum.ALLERGY_ROOM, + RoomPackageCodeEnum.ACCESSIBILITY_ROOM, + ]), + }) + ), products: z.array(productSchema), }) diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index b5b5ff34b..883b3a3ee 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -25,6 +25,10 @@ import { getHotelPageCounter, validateHotelPageRefs, } from "../contentstack/hotelPage/utils" +import { + getRoomPackagesInputSchema, + getRoomPackagesSchema, +} from "./schemas/packages" import { getHotelInputSchema, getHotelsAvailabilityInputSchema, @@ -57,6 +61,14 @@ const getHotelCounter = meter.createCounter("trpc.hotel.get") const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success") const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail") +const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get") +const getPackagesSuccessCounter = meter.createCounter( + "trpc.hotel.packages.get-success" +) +const getPackagesFailCounter = meter.createCounter( + "trpc.hotel.packages.get-fail" +) + const hotelsAvailabilityCounter = meter.createCounter( "trpc.hotel.availability.hotels" ) @@ -441,6 +453,7 @@ export const hotelQueryRouter = router({ }, params ) + if (!apiResponse.ok) { const text = await apiResponse.text() roomsAvailabilityFailCounter.add(1, { @@ -693,4 +706,89 @@ export const hotelQueryRouter = router({ return locations }), }), + packages: router({ + get: serviceProcedure + .input(getRoomPackagesInputSchema) + .query(async ({ input, ctx }) => { + const { hotelId, startDate, endDate, adults, children, packageCodes } = + input + + const searchParams = new URLSearchParams({ + startDate, + endDate, + adults: adults.toString(), + children: children.toString(), + }) + + packageCodes.forEach((code) => { + searchParams.append("packageCodes", code) + }) + + const params = searchParams.toString() + + getPackagesCounter.add(1, { + hotelId, + }) + console.info( + "api.hotels.packages start", + JSON.stringify({ query: { hotelId, params } }) + ) + + const apiResponse = await api.get( + `${api.endpoints.v1.packages}/${hotelId}`, + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + getPackagesFailCounter.add(1, { + hotelId, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + }), + }) + console.error( + "api.hotels.packages error", + JSON.stringify({ query: { hotelId, params } }) + ) + throw serverErrorByStatus(apiResponse.status, apiResponse) + } + + const apiJson = await apiResponse.json() + const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson) + + if (!validatedPackagesData.success) { + getHotelFailCounter.add(1, { + hotelId, + error_type: "validation_error", + error: JSON.stringify(validatedPackagesData.error), + }) + + console.error( + "api.hotels.packages validation error", + JSON.stringify({ + query: { hotelId, params }, + error: validatedPackagesData.error, + }) + ) + throw badRequestError() + } + + getPackagesSuccessCounter.add(1, { + hotelId, + }) + console.info( + "api.hotels.packages success", + JSON.stringify({ query: { hotelId, params: params } }) + ) + + return validatedPackagesData.data + }), + }), }) diff --git a/server/routers/hotels/schemas/packages.ts b/server/routers/hotels/schemas/packages.ts new file mode 100644 index 000000000..2f12f7255 --- /dev/null +++ b/server/routers/hotels/schemas/packages.ts @@ -0,0 +1,55 @@ +import { z } from "zod" + +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" + +export const getRoomPackagesInputSchema = z.object({ + hotelId: z.string(), + startDate: z.string(), + endDate: z.string(), + adults: z.number(), + children: z.number().optional().default(0), + packageCodes: z.array(z.string()).optional().default([]), +}) + +const packagesSchema = z.array( + z.object({ + code: z.enum([ + RoomPackageCodeEnum.PET_ROOM, + RoomPackageCodeEnum.ALLERGY_ROOM, + RoomPackageCodeEnum.ACCESSIBILITY_ROOM, + ]), + itemCode: z.string(), + description: z.string(), + currency: z.string(), + calculatedPrice: z.number(), + inventories: z.array( + z.object({ + date: z.string(), + total: z.number(), + available: z.number(), + }) + ), + }) +) + +export const getRoomPackagesSchema = z + .object({ + data: z.object({ + attributes: z.object({ + hotelId: z.number(), + packages: packagesSchema, + }), + relationships: z + .object({ + links: z.array( + z.object({ + url: z.string(), + type: z.string(), + }) + ), + }) + .optional(), + type: z.string(), + }), + }) + .transform((data) => data.data.attributes.packages) diff --git a/server/tokenManager.ts b/server/tokenManager.ts index 24180d017..980ca071d 100644 --- a/server/tokenManager.ts +++ b/server/tokenManager.ts @@ -74,7 +74,7 @@ export async function getServiceToken() { if (env.HIDE_FOR_NEXT_RELEASE) { scopes = ["profile"] } else { - scopes = ["profile", "hotel", "booking"] + scopes = ["profile", "hotel", "booking", "package"] } const tag = generateServiceTokenTag(scopes) const getCachedJwt = unstable_cache( diff --git a/types/components/form/filterChip.ts b/types/components/form/filterChip.ts new file mode 100644 index 000000000..3ff40673d --- /dev/null +++ b/types/components/form/filterChip.ts @@ -0,0 +1,16 @@ +type FilterChipType = "checkbox" | "radio" + +export interface FilterChipProps { + Icon?: React.ElementType + iconHeight?: number + iconWidth?: number + id?: string + label: string + name: string + type: FilterChipType + value?: string + selected?: boolean + disabled?: boolean +} + +export type FilterChipCheckboxProps = Omit diff --git a/types/components/hotelReservation/selectRate/flexibilityOption.ts b/types/components/hotelReservation/selectRate/flexibilityOption.ts index 1835c3b65..1a432dc32 100644 --- a/types/components/hotelReservation/selectRate/flexibilityOption.ts +++ b/types/components/hotelReservation/selectRate/flexibilityOption.ts @@ -18,6 +18,7 @@ export type FlexibilityOptionProps = { priceInformation?: Array roomType: RoomConfiguration["roomType"] roomTypeCode: RoomConfiguration["roomTypeCode"] + features: RoomConfiguration["features"] handleSelectRate: (rate: Rate) => void } diff --git a/types/components/hotelReservation/selectRate/rateSummary.ts b/types/components/hotelReservation/selectRate/rateSummary.ts index 672df21dd..f6c0f03b6 100644 --- a/types/components/hotelReservation/selectRate/rateSummary.ts +++ b/types/components/hotelReservation/selectRate/rateSummary.ts @@ -1,6 +1,10 @@ -import { Rate } from "./selectRate" +import type { RoomsAvailability } from "@/server/routers/hotels/output" +import type { RoomPackageData } from "./roomFilter" +import type { Rate } from "./selectRate" export interface RateSummaryProps { rateSummary: Rate isUserLoggedIn: boolean + packages: RoomPackageData + roomsAvailability: RoomsAvailability } diff --git a/types/components/hotelReservation/selectRate/roomFilter.ts b/types/components/hotelReservation/selectRate/roomFilter.ts new file mode 100644 index 000000000..d42669295 --- /dev/null +++ b/types/components/hotelReservation/selectRate/roomFilter.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { getRoomPackagesSchema } from "@/server/routers/hotels/schemas/packages" + +export enum RoomPackageCodeEnum { + PET_ROOM = "PETR", + ALLERGY_ROOM = "ALLG", + ACCESSIBILITY_ROOM = "ACCE", +} +export interface RoomFilterProps { + numberOfRooms: number + onFilter: (filter: Record) => void + filterOptions: RoomPackageData +} + +export interface RoomPackageData + extends z.output {} + +export type RoomPackageCodes = RoomPackageData[number]["code"] diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts index 9e944f5d8..8d006779c 100644 --- a/types/components/hotelReservation/selectRate/roomSelection.ts +++ b/types/components/hotelReservation/selectRate/roomSelection.ts @@ -1,10 +1,11 @@ -import { RoomsAvailability } from "@/server/routers/hotels/output" - -import { RoomData } from "@/types/hotel" -import { SafeUser } from "@/types/user" +import type { RoomData } from "@/types/hotel" +import type { SafeUser } from "@/types/user" +import type { RoomsAvailability } from "@/server/routers/hotels/output" +import type { RoomPackageData } from "./roomFilter" export interface RoomSelectionProps { - roomConfigurations: RoomsAvailability + roomsAvailability: RoomsAvailability roomCategories: RoomData[] user: SafeUser + packages: RoomPackageData } diff --git a/types/components/hotelReservation/selectRate/selectRate.ts b/types/components/hotelReservation/selectRate/selectRate.ts index da8792133..b22c010c0 100644 --- a/types/components/hotelReservation/selectRate/selectRate.ts +++ b/types/components/hotelReservation/selectRate/selectRate.ts @@ -26,4 +26,5 @@ export interface Rate { priceName: string public: Product["productType"]["public"] member: Product["productType"]["member"] + features: RoomConfiguration["features"] }