Merged in feat/sw-453-filter-select-room (pull request #772)
Feat/sw 453 filter select room Approved-by: Niclas Edenvin
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,5 +42,8 @@ certificates
|
||||
#vscode
|
||||
.vscode/
|
||||
|
||||
#cursor
|
||||
.cursorrules
|
||||
|
||||
# localfile with all the CSS variables exported from design system
|
||||
variables.css
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<>
|
||||
<HotelInfoCard hotelData={hotelData} />
|
||||
<div className={styles.content}>
|
||||
<div className={styles.main}>
|
||||
<RoomSelection
|
||||
roomConfigurations={roomConfigurations}
|
||||
roomCategories={roomCategories ?? []}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Rooms
|
||||
roomsAvailability={roomsAvailability}
|
||||
roomCategories={roomCategories ?? []}
|
||||
user={user}
|
||||
packages={packages ?? []}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.icon}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
124
components/HotelReservation/SelectRate/RoomFilter/index.tsx
Normal file
124
components/HotelReservation/SelectRate/RoomFilter/index.tsx
Normal file
@@ -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<string, boolean | undefined>
|
||||
),
|
||||
[filterOptions]
|
||||
)
|
||||
|
||||
const intl = useIntl()
|
||||
const methods = useForm<Record<string, boolean | undefined>>({
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.infoDesktop}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room types available" },
|
||||
{ numberOfRooms }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.infoMobile}>
|
||||
<div className={styles.filterInfo}>
|
||||
<Caption type="label" color="burgundy" textTransform="uppercase">
|
||||
{intl.formatMessage({ id: "Filter" })}
|
||||
</Caption>
|
||||
<Caption type="label" color="burgundy">
|
||||
{Object.entries(selectedFilters)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => intl.formatMessage({ id: key }))
|
||||
.join(", ")}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room types available" },
|
||||
{ numberOfRooms }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(submitFilter)}>
|
||||
<div className={styles.roomsFilter}>
|
||||
{filterOptions.map((option) => (
|
||||
<CheckboxChip
|
||||
name={option.code}
|
||||
key={option.code}
|
||||
label={intl.formatMessage({ id: option.description })}
|
||||
disabled={
|
||||
(option.code === RoomPackageCodeEnum.ALLERGY_ROOM &&
|
||||
petFriendly) ||
|
||||
(option.code === RoomPackageCodeEnum.PET_ROOM &&
|
||||
allergyFriendly)
|
||||
}
|
||||
selected={getValues(option.code)}
|
||||
Icon={getIconForFeatureCode(option.code)}
|
||||
/>
|
||||
))}
|
||||
<Tooltip text={tooltipText} position="bottom" arrow="right">
|
||||
<InfoCircleIcon className={styles.infoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export default function PriceList({
|
||||
</Subtitle>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{publicLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
@@ -64,6 +67,9 @@ export default function PriceList({
|
||||
</Subtitle>
|
||||
<Body color="red" textTransform="bold">
|
||||
{memberLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -12,3 +12,8 @@
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.perNight {
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function FlexibilityOption({
|
||||
priceInformation,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
features,
|
||||
handleSelectRate,
|
||||
}: FlexibilityOptionProps) {
|
||||
const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined)
|
||||
@@ -52,6 +53,7 @@ export default function FlexibilityOption({
|
||||
priceName: name,
|
||||
public: publicPrice,
|
||||
member: memberPrice,
|
||||
features,
|
||||
}
|
||||
handleSelectRate(rate)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
<Subtitle color="uiTextHighContrast">{rateSummary.roomType}</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{rateSummary.priceName}</Body>
|
||||
<Subtitle color="uiTextHighContrast">{roomType}</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{priceName}</Body>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceText}>
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||
{priceToShow?.localPrice.pricePerStay}{" "}
|
||||
{priceToShow?.localPrice.currency}
|
||||
@@ -34,7 +61,49 @@ export default function RateSummary({
|
||||
{priceToShow?.requestedPrice?.currency}
|
||||
</Body>
|
||||
</div>
|
||||
<Button type="submit" theme="base">
|
||||
<div className={styles.summaryPriceTextMobile}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Caption>
|
||||
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||
{priceToShow?.localPrice.pricePerStay}{" "}
|
||||
{priceToShow?.localPrice.currency}
|
||||
</Subtitle>
|
||||
<Footnote
|
||||
color="uiTextMediumContrast"
|
||||
className={styles.summaryPriceTextMobile}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: roomsAvailability.occupancy?.adults }
|
||||
)}
|
||||
{roomsAvailability.occupancy?.children && (
|
||||
<>
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: roomsAvailability.occupancy.children }
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
{isPetRoomSelected && (
|
||||
<div className={styles.petInfo}>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
+ {petRoomPrice} {petRoomCurrency}
|
||||
</Body>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Pet charge" })}
|
||||
</Body>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" theme="base" className={styles.continueButton}>
|
||||
{intl.formatMessage({ id: "Continue" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.specification}>
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{/*TODO: Handle pluralisation*/}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "booking.guests",
|
||||
},
|
||||
{ nrOfGuests: occupancy }
|
||||
{ nrOfGuests: occupancy?.total }
|
||||
)}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
@@ -92,7 +88,7 @@ export default function RoomCard({
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{roomConfiguration.roomType}
|
||||
</Subtitle>
|
||||
<Body>{roomDescription}</Body>
|
||||
<Body>{descriptions?.short}</Body>
|
||||
</div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
@@ -100,49 +96,53 @@ export default function RoomCard({
|
||||
})}
|
||||
</Caption>
|
||||
<div className={styles.flexibilityOptions}>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Non-refundable" })}
|
||||
value="non-refundable"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay now" })}
|
||||
product={findProductForRate(saveRate)}
|
||||
priceInformation={getPriceForRate(saveRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free rebooking" })}
|
||||
value="free-rebooking"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay now" })}
|
||||
product={findProductForRate(changeRate)}
|
||||
priceInformation={getPriceForRate(changeRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free cancellation" })}
|
||||
value="free-cancellation"
|
||||
paymentTerm={intl.formatMessage({ id: "Pay later" })}
|
||||
product={findProductForRate(flexRate)}
|
||||
priceInformation={getPriceForRate(flexRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
{Object.entries(rates).map(([key, rate]) => (
|
||||
<FlexibilityOption
|
||||
key={key}
|
||||
name={intl.formatMessage({
|
||||
id:
|
||||
key === "flexRate"
|
||||
? "Free cancellation"
|
||||
: key === "saveRate"
|
||||
? "Non-refundable"
|
||||
: "Free rebooking",
|
||||
})}
|
||||
value={key.toLowerCase()}
|
||||
paymentTerm={intl.formatMessage({
|
||||
id: key === "flexRate" ? "Pay later" : "Pay now",
|
||||
})}
|
||||
product={findProductForRate(rate)}
|
||||
priceInformation={getPriceInformationForRate(rate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
features={roomConfiguration.features}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mainImage && (
|
||||
<div className={styles.imageContainer}>
|
||||
{roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.roomsLeft}>
|
||||
<Footnote
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.chipContainer}>
|
||||
{roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.chip}>
|
||||
<Footnote
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
|
||||
</span>
|
||||
)}
|
||||
{roomConfiguration.features.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/*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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Rate | null>(null)
|
||||
|
||||
@@ -22,27 +23,32 @@ export default function RoomSelection({
|
||||
const searchParams = useSearchParams()
|
||||
const isUserLoggedIn = !!user
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
router.push(`select-bed?${queryParams}`)
|
||||
}
|
||||
|
||||
@@ -54,10 +60,10 @@ export default function RoomSelection({
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ul className={styles.roomList}>
|
||||
{roomConfigurations.roomConfigurations.map((roomConfiguration) => (
|
||||
<li key={roomConfiguration.roomType}>
|
||||
{roomConfigurations.map((roomConfiguration) => (
|
||||
<li key={roomConfiguration.roomTypeCode}>
|
||||
<RoomCard
|
||||
rateDefinitions={roomConfigurations.rateDefinitions}
|
||||
rateDefinitions={rateDefinitions}
|
||||
roomConfiguration={roomConfiguration}
|
||||
roomCategories={roomCategories}
|
||||
handleSelectRate={setRateSummary}
|
||||
@@ -69,6 +75,8 @@ export default function RoomSelection({
|
||||
<RateSummary
|
||||
rateSummary={rateSummary}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
packages={packages}
|
||||
roomsAvailability={roomsAvailability}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
}
|
||||
|
||||
.roomList {
|
||||
margin-top: var(--Spacing-x4);
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
66
components/HotelReservation/SelectRate/Rooms/index.tsx
Normal file
66
components/HotelReservation/SelectRate/Rooms/index.tsx
Normal file
@@ -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>({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: defaultRooms,
|
||||
})
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(filter: Record<string, boolean | undefined>) => {
|
||||
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 (
|
||||
<div className={styles.content}>
|
||||
<RoomFilter
|
||||
numberOfRooms={rooms.roomConfigurations.length}
|
||||
onFilter={handleFilter}
|
||||
filterOptions={packages}
|
||||
/>
|
||||
<RoomSelection
|
||||
roomsAvailability={rooms}
|
||||
roomCategories={roomCategories}
|
||||
user={user}
|
||||
packages={packages}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
19
components/HotelReservation/SelectRate/utils.ts
Normal file
19
components/HotelReservation/SelectRate/utils.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
36
components/Icons/Allergy.tsx
Normal file
36
components/Icons/Allergy.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ export default function PetsIcon({ className, color, ...props }: IconProps) {
|
||||
>
|
||||
<path
|
||||
d="M4.6251 12.05C3.95843 12.05 3.39176 11.8166 2.9251 11.35C2.45843 10.8833 2.2251 10.3166 2.2251 9.64995C2.2251 8.98328 2.45843 8.41662 2.9251 7.94995C3.39176 7.48328 3.95843 7.24995 4.6251 7.24995C5.29176 7.24995 5.85843 7.48328 6.3251 7.94995C6.79176 8.41662 7.0251 8.98328 7.0251 9.64995C7.0251 10.3166 6.79176 10.8833 6.3251 11.35C5.85843 11.8166 5.29176 12.05 4.6251 12.05ZM9.0501 8.12495C8.38343 8.12495 7.81676 7.89162 7.3501 7.42495C6.88343 6.95828 6.6501 6.39162 6.6501 5.72495C6.6501 5.05828 6.88343 4.49162 7.3501 4.02495C7.81676 3.55828 8.38343 3.32495 9.0501 3.32495C9.71676 3.32495 10.2834 3.55828 10.7501 4.02495C11.2168 4.49162 11.4501 5.05828 11.4501 5.72495C11.4501 6.39162 11.2168 6.95828 10.7501 7.42495C10.2834 7.89162 9.71676 8.12495 9.0501 8.12495ZM14.9751 8.12495C14.3084 8.12495 13.7418 7.89162 13.2751 7.42495C12.8084 6.95828 12.5751 6.39162 12.5751 5.72495C12.5751 5.05828 12.8084 4.49162 13.2751 4.02495C13.7418 3.55828 14.3084 3.32495 14.9751 3.32495C15.6418 3.32495 16.2084 3.55828 16.6751 4.02495C17.1418 4.49162 17.3751 5.05828 17.3751 5.72495C17.3751 6.39162 17.1418 6.95828 16.6751 7.42495C16.2084 7.89162 15.6418 8.12495 14.9751 8.12495ZM19.4001 12.05C18.7334 12.05 18.1668 11.8166 17.7001 11.35C17.2334 10.8833 17.0001 10.3166 17.0001 9.64995C17.0001 8.98328 17.2334 8.41662 17.7001 7.94995C18.1668 7.48328 18.7334 7.24995 19.4001 7.24995C20.0668 7.24995 20.6334 7.48328 21.1001 7.94995C21.5668 8.41662 21.8001 8.98328 21.8001 9.64995C21.8001 10.3166 21.5668 10.8833 21.1001 11.35C20.6334 11.8166 20.0668 12.05 19.4001 12.05ZM6.7559 21.925C6.0187 21.925 5.40426 21.6474 4.9126 21.0922C4.42093 20.537 4.1751 19.8813 4.1751 19.125C4.1751 18.2666 4.4626 17.5187 5.0376 16.8812C5.6126 16.2437 6.19176 15.615 6.7751 14.995C7.25843 14.4893 7.6751 13.9387 8.0251 13.3432C8.3751 12.7477 8.78343 12.1833 9.2501 11.65C9.60843 11.2416 10.0188 10.8979 10.4813 10.6187C10.9438 10.3395 11.4506 10.2 12.0015 10.2C12.5524 10.2 13.0628 10.3333 13.5327 10.6C14.0026 10.8666 14.4168 11.2083 14.7751 11.625C15.2418 12.15 15.6522 12.7125 16.0063 13.3125C16.3605 13.9125 16.7755 14.4753 17.2513 15.001C17.8255 15.6253 18.4022 16.2541 18.9813 16.8875C19.5605 17.5208 19.8501 18.2666 19.8501 19.125C19.8501 19.8813 19.6043 20.537 19.1126 21.0922C18.6209 21.6474 18.0073 21.925 17.2718 21.925C16.3892 21.925 15.5147 21.85 14.6484 21.7C13.7822 21.55 12.9077 21.475 12.0251 21.475C11.1334 21.475 10.2535 21.55 9.38522 21.7C8.51697 21.85 7.64053 21.925 6.7559 21.925Z"
|
||||
fill="#4D001B"
|
||||
fill="#787472"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
40
components/Icons/Wheelchair.tsx
Normal file
40
components/Icons/Wheelchair.tsx
Normal file
@@ -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 (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_7488_25219"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<rect width="16" height="16" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_7488_25219)">
|
||||
<path
|
||||
d="M5.41602 14.5C4.51046 14.5 3.7424 14.1847 3.11185 13.5541C2.48129 12.9236 2.16602 12.1555 2.16602 11.25C2.16602 10.3444 2.48129 9.57636 3.11185 8.94581C3.7424 8.31525 4.51324 7.99998 5.42435 7.99998V9.24998C4.86324 9.24998 4.38824 9.44442 3.99935 9.83331C3.61046 10.2222 3.41602 10.6944 3.41602 11.25C3.41602 11.8055 3.61046 12.2778 3.99935 12.6666C4.38824 13.0555 4.86046 13.25 5.41602 13.25C5.97157 13.25 6.44379 13.0541 6.83268 12.6625C7.22157 12.2708 7.41602 11.7916 7.41602 11.225H8.66602C8.66602 12.1416 8.35074 12.9166 7.72018 13.55C7.08963 14.1833 6.32157 14.5 5.41602 14.5ZM7.38268 10.6C6.89379 10.6 6.52018 10.3944 6.26185 9.98331C6.00352 9.5722 5.97157 9.14442 6.16602 8.69998L7.36602 6.03331H5.84935L5.64935 6.54998C5.59379 6.70553 5.49102 6.82081 5.34102 6.89581C5.19102 6.97081 5.03546 6.98053 4.87435 6.92498C4.70213 6.86942 4.57574 6.76109 4.49518 6.59998C4.41463 6.43886 4.40768 6.2722 4.47435 6.09998L4.68268 5.54998C4.77713 5.30553 4.92852 5.11664 5.13685 4.98331C5.34518 4.84998 5.5799 4.78331 5.84102 4.78331H9.21601C9.69379 4.78331 10.0618 4.97914 10.3202 5.37081C10.5785 5.76248 10.6105 6.17775 10.416 6.61664L9.31602 9.03331H11.2827C11.6438 9.03331 11.9507 9.1597 12.2035 9.41248C12.4563 9.66525 12.5827 9.9722 12.5827 10.3333V13.2083C12.5827 13.3805 12.5216 13.5278 12.3993 13.65C12.2771 13.7722 12.1299 13.8333 11.9577 13.8333C11.7855 13.8333 11.6382 13.7722 11.516 13.65C11.3938 13.5278 11.3327 13.3805 11.3327 13.2083V10.6H7.38268ZM10.5493 4.44998C10.1882 4.44998 9.88129 4.32359 9.62852 4.07081C9.37574 3.81803 9.24935 3.51109 9.24935 3.14998C9.24935 2.78886 9.37574 2.48192 9.62852 2.22914C9.88129 1.97636 10.1882 1.84998 10.5493 1.84998C10.9105 1.84998 11.2174 1.97636 11.4702 2.22914C11.723 2.48192 11.8493 2.78886 11.8493 3.14998C11.8493 3.51109 11.723 3.81803 11.4702 4.07081C11.2174 4.32359 10.9105 4.44998 10.5493 4.44998Z"
|
||||
fill="#787472"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -76,3 +76,8 @@
|
||||
.baseButtonTextOnFillNormal * {
|
||||
fill: var(--Base-Button-Text-On-Fill-Normal);
|
||||
}
|
||||
|
||||
.disabled,
|
||||
.disabled * {
|
||||
fill: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,6 +20,7 @@ const config = {
|
||||
white: styles.white,
|
||||
uiTextHighContrast: styles.uiTextHighContrast,
|
||||
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||
disabled: styles.disabled,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
@@ -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<CheckboxProps>) {
|
||||
const { control } = useFormContext()
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name,
|
||||
rules: registerOptions,
|
||||
})
|
||||
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.container}
|
||||
isSelected={field.value}
|
||||
onChange={field.onChange}
|
||||
data-testid={name}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<div className={styles.checkboxContainer}>
|
||||
<div className={styles.checkbox}>
|
||||
{isSelected && <CheckIcon color="white" />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children && fieldState.error ? (
|
||||
<Caption className={styles.error}>
|
||||
<InfoCircleIcon color="red" />
|
||||
{fieldState.error.message}
|
||||
</Caption>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
.checkboxContainer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
|
||||
7
components/TempDesignSystem/Form/FilterChip/Checkbox.tsx
Normal file
7
components/TempDesignSystem/Form/FilterChip/Checkbox.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Chip from "./_Chip"
|
||||
|
||||
import type { FilterChipCheckboxProps } from "@/types/components/form/filterChip"
|
||||
|
||||
export default function CheckboxChip(props: FilterChipCheckboxProps) {
|
||||
return <Chip {...props} type="checkbox" />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
57
components/TempDesignSystem/Form/FilterChip/_Chip/index.tsx
Normal file
57
components/TempDesignSystem/Form/FilterChip/_Chip/index.tsx
Normal file
@@ -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 (
|
||||
<label
|
||||
className={styles.label}
|
||||
data-selected={selected}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
className={styles.icon}
|
||||
color={color}
|
||||
height={iconHeight}
|
||||
width={iconWidth}
|
||||
/>
|
||||
<Caption type="bold" color={color} className={styles.caption}>
|
||||
{label}
|
||||
</Caption>
|
||||
<input
|
||||
aria-hidden
|
||||
id={id || name}
|
||||
hidden
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
{...register(name)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/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",
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/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",
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/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",
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/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",
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/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",
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/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}",
|
||||
|
||||
@@ -23,6 +23,7 @@ export namespace endpoints {
|
||||
rewards = `${profile}/reward`,
|
||||
tierRewards = `${profile}/TierRewards`,
|
||||
subscriberId = `${profile}/SubscriberId`,
|
||||
packages = "package/v1/packages/hotel",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
55
server/routers/hotels/schemas/packages.ts
Normal file
55
server/routers/hotels/schemas/packages.ts
Normal file
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
16
types/components/form/filterChip.ts
Normal file
16
types/components/form/filterChip.ts
Normal file
@@ -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<FilterChipProps, "type">
|
||||
@@ -18,6 +18,7 @@ export type FlexibilityOptionProps = {
|
||||
priceInformation?: Array<string>
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
features: RoomConfiguration["features"]
|
||||
handleSelectRate: (rate: Rate) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
19
types/components/hotelReservation/selectRate/roomFilter.ts
Normal file
19
types/components/hotelReservation/selectRate/roomFilter.ts
Normal file
@@ -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<string, boolean | undefined>) => void
|
||||
filterOptions: RoomPackageData
|
||||
}
|
||||
|
||||
export interface RoomPackageData
|
||||
extends z.output<typeof getRoomPackagesSchema> {}
|
||||
|
||||
export type RoomPackageCodes = RoomPackageData[number]["code"]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -26,4 +26,5 @@ export interface Rate {
|
||||
priceName: string
|
||||
public: Product["productType"]["public"]
|
||||
member: Product["productType"]["member"]
|
||||
features: RoomConfiguration["features"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user