feat: adjust select rate ui to latest design

This commit is contained in:
Simon Emanuelsson
2025-02-17 15:10:48 +01:00
parent 2c72957dc6
commit 4c23700d52
76 changed files with 819 additions and 654 deletions

View File

@@ -11,10 +11,8 @@ import type {
HotelData, HotelData,
NullableHotelData, NullableHotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { import type { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
CategorizedFilters, import type { DetailedFacility } from "@/types/hotel"
Filter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { HotelsAvailabilityItem } from "@/types/trpc/routers/hotel/availability" import type { HotelsAvailabilityItem } from "@/types/trpc/routers/hotel/availability"
const hotelSurroundingsFilterNames = [ const hotelSurroundingsFilterNames = [
@@ -111,9 +109,9 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
}) })
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))] const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
const filterList: Filter[] = uniqueFilterIds const filterList: DetailedFacility[] = uniqueFilterIds
.map((filterId) => filters.find((filter) => filter.id === filterId)) .map((filterId) => filters.find((filter) => filter.id === filterId))
.filter((filter): filter is Filter => filter !== undefined) .filter((filter): filter is DetailedFacility => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder) .sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedFilters>( return filterList.reduce<CategorizedFilters>(

View File

@@ -8,7 +8,7 @@ export function getTypeSpecificInformation(
const { images } = hotel.hotelContent const { images } = hotel.hotelContent
const { descriptions, meetingDescription } = hotel.hotelContent.texts const { descriptions, meetingDescription } = hotel.hotelContent.texts
const hotelData = { const hotelData = {
description: descriptions.short, description: descriptions?.short,
imageSrc: images.imageSizes.small, imageSrc: images.imageSizes.small,
altText: images.metaData.altText, altText: images.metaData.altText,
} }

View File

@@ -64,7 +64,7 @@ export default function HotelListingItem({
</Caption> </Caption>
</div> </div>
</div> </div>
<Body>{hotel.hotelContent.texts.descriptions.short}</Body> <Body>{hotel.hotelContent.texts.descriptions?.short}</Body>
<ul className={styles.amenityList}> <ul className={styles.amenityList}>
{amenities.map((amenity) => { {amenities.map((amenity) => {
const IconComponent = mapFacilityToIcon(amenity.id) const IconComponent = mapFacilityToIcon(amenity.id)

View File

@@ -1,14 +1,13 @@
import type { import type {
Hotel, Hotel,
HotelAddress, HotelAddress,
HotelContent,
HotelLocation, HotelLocation,
HotelTripAdvisor, HotelTripAdvisor,
} from "@/types/hotel" } from "@/types/hotel"
export type IntroSectionProps = { export type IntroSectionProps = {
address: HotelAddress address: HotelAddress
hotelDescription: HotelContent["texts"]["descriptions"]["short"] hotelDescription: string | undefined
hotelName: Hotel["name"] hotelName: Hotel["name"]
location: HotelLocation location: HotelLocation
tripAdvisor: HotelTripAdvisor tripAdvisor: HotelTripAdvisor

View File

@@ -35,7 +35,7 @@ export default async function AboutTheHotelSidePeek({
ecoLabels={ecoLabels} ecoLabels={ecoLabels}
/> />
<Divider color="baseSurfaceSubtleHover" /> <Divider color="baseSurfaceSubtleHover" />
<Preamble>{descriptions.descriptions.medium}</Preamble> <Preamble>{descriptions.descriptions?.medium}</Preamble>
<Body>{descriptions.facilityInformation}</Body> <Body>{descriptions.facilityInformation}</Body>
<Body>{descriptions.surroundingInformation}</Body> <Body>{descriptions.surroundingInformation}</Body>
</section> </section>

View File

@@ -98,7 +98,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
} = hotelData.additionalData } = hotelData.additionalData
const images = gallery?.smallerImages const images = gallery?.smallerImages
const description = hotelContent.texts.descriptions.medium const description = hotelContent.texts.descriptions?.medium
const { spaPage, activitiesCards } = content const { spaPage, activitiesCards } = content
@@ -107,9 +107,9 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
const hasWellness = healthFacilities.length > 0 const hasWellness = healthFacilities.length > 0
const facilities = setFacilityCards( const facilities = setFacilityCards(
restaurantImages, restaurantImages ?? undefined,
conferencesAndMeetings, conferencesAndMeetings ?? undefined,
healthAndWellness, healthAndWellness ?? undefined,
hasRestaurants, hasRestaurants,
hasMeetingRooms, hasMeetingRooms,
hasWellness hasWellness

View File

@@ -42,7 +42,7 @@ export default function Header({
busyStatus: "FREE", busyStatus: "FREE",
categories: ["booking", "hotel", "stay"], categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime), created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions.medium, description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(booking.checkOutDate), end: generateDateTime(booking.checkOutDate),
endInputType: "utc", endInputType: "utc",
geo: { geo: {

View File

@@ -14,6 +14,7 @@ export default function ToggleSidePeek({
hotelId, hotelId,
roomTypeCode, roomTypeCode,
intent = "textInverted", intent = "textInverted",
title,
}: ToggleSidePeekProps) { }: ToggleSidePeekProps) {
const intl = useIntl() const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek) const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
@@ -29,7 +30,7 @@ export default function ToggleSidePeek({
intent={intent} intent={intent}
wrapping wrapping
> >
{intl.formatMessage({ id: "See room details" })} {title ? title : intl.formatMessage({ id: "See room details" })}
<ChevronRight height="14" /> <ChevronRight height="14" />
</Button> </Button>
) )

View File

@@ -137,7 +137,7 @@ function HotelCard({
</div> </div>
</div> </div>
<Body className={styles.hotelDescription}> <Body className={styles.hotelDescription}>
{hotelData.hotelContent.texts.descriptions.short} {hotelData.hotelContent.texts.descriptions?.short}
</Body> </Body>
<div className={styles.facilities}> <div className={styles.facilities}>
{amenities.map((facility) => { {amenities.map((facility) => {

View File

@@ -45,7 +45,7 @@ export default function ActionPanel({
busyStatus: "FREE", busyStatus: "FREE",
categories: ["booking", "hotel", "stay"], categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime), created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions.medium, description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(booking.checkOutDate), end: generateDateTime(booking.checkOutDate),
endInputType: "utc", endInputType: "utc",
geo: { geo: {

View File

@@ -58,7 +58,7 @@ export default async function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
)} )}
</Caption> </Caption>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{hotel.hotelContent.texts.descriptions.medium} {hotel.hotelContent.texts.descriptions?.medium}
</Body> </Body>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import { getRates } from "@/components/HotelReservation/SelectRate/utils"
import { EditIcon } from "@/components/Icons" import { EditIcon } from "@/components/Icons"
import Image from "@/components/Image" import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -72,7 +73,7 @@ export default function SelectedRoomPanel() {
return ( return (
<div className={styles.selectedRoomPanel}> <div className={styles.selectedRoomPanel}>
<div> <div className={styles.content}>
<Caption color="uiTextHighContrast"> <Caption color="uiTextHighContrast">
{intl.formatMessage( {intl.formatMessage(
{ id: "Room {roomIndex}" }, { id: "Room {roomIndex}" },
@@ -90,20 +91,22 @@ export default function SelectedRoomPanel() {
{intl.formatMessage({ id: "night" })} {intl.formatMessage({ id: "night" })}
</Body> </Body>
</div> </div>
<div className={styles.imageAndModifyButtonContainer}> <div className={styles.imageContainer}>
{images?.[0]?.imageSizes?.tiny && ( {images?.[0]?.imageSizes?.tiny ? (
<div className={styles.imageContainer}> <Image
<Image alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""}
alt={selectedRate?.roomType ?? images[0].metaData?.altText ?? ""} className={styles.img}
fill height={300}
src={images[0].imageSizes.tiny} src={images[0].imageSizes.tiny}
/> width={600}
</div> />
)} ) : null}
<div className={styles.modifyButtonContainer}> <div className={styles.modifyButtonContainer}>
<Button variant="icon" size="small" onClick={modifyRate}> <Button clean onClick={modifyRate}>
<EditIcon /> <Chip size="small" variant="uiTextHighContrast">
{intl.formatMessage({ id: "Modify" })} <EditIcon />
{intl.formatMessage({ id: "Modify" })}
</Chip>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,50 +1,53 @@
.selectedRoomPanel { .selectedRoomPanel {
display: flex; display: grid;
flex-direction: row; grid-template-areas: "content image";
justify-content: space-between; grid-template-columns: 1fr 190px;
position: relative; position: relative;
} }
.modifyButtonContainer { .content {
position: absolute; grid-area: content;
right: var(--Spacing-x2);
bottom: var(--Spacing-x2);
} }
.imageContainer { .imageContainer {
width: 187px;
height: 105px;
position: relative;
border-radius: var(--Corner-radius-Small); border-radius: var(--Corner-radius-Small);
overflow: hidden; display: flex;
grid-area: image;
} }
.titleContainer { .img {
display: flex; border-radius: var(--Corner-radius-Small);
flex-direction: column; height: auto;
gap: var(--Spacing-x1); max-height: 105px;
object-fit: fill;
width: 100%;
}
.modifyButtonContainer {
bottom: var(--Spacing-x1);
position: absolute;
right: var(--Spacing-x1);
} }
div.selectedRoomPanel p.subtitle { div.selectedRoomPanel p.subtitle {
padding-bottom: var(--Spacing-x1); padding-bottom: var(--Spacing-x1);
} }
@media (max-width: 768px) { @media screen and (max-width: 768px) {
.imageContainer { .selectedRoomPanel {
width: 120px;
height: 80px;
}
.imageAndModifyButtonContainer {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
grid-template-areas: "image" "content";
grid-template-columns: 1fr;
grid-template-rows: auto auto;
} }
.modifyButtonContainer { .img {
position: relative; max-height: 300px;
bottom: 0; }
right: 0; }
@media screen and (max-width: 500px) {
.img {
max-height: 190px;
} }
} }

View File

@@ -3,6 +3,8 @@ import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
import { ChevronUpIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room" import { useRoomContext } from "@/contexts/Room"
@@ -17,7 +19,13 @@ export default function MultiRoomWrapper({
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) { }: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
const intl = useIntl() const intl = useIntl()
const activeRoom = useRatesStore((state) => state.activeRoom) const activeRoom = useRatesStore((state) => state.activeRoom)
const { bookingRoom, isActiveRoom, roomNr, selectedRate } = useRoomContext() const {
actions: { closeSection },
bookingRoom,
isActiveRoom,
roomNr,
selectedRate,
} = useRoomContext()
const onlyAdultsMsg = intl.formatMessage( const onlyAdultsMsg = intl.formatMessage(
{ id: "{adults} adults" }, { id: "{adults} adults" },
@@ -56,19 +64,33 @@ export default function MultiRoomWrapper({
selected: !!selectedRate && !isActiveRoom, selected: !!selectedRate && !isActiveRoom,
}) })
return ( return (
<div className={styles.roomContainer}> <div className={styles.roomContainer} data-multiroom="true">
{selectedRate && !isActiveRoom ? null : ( <div className={styles.header}>
<Subtitle className={styles.subtitle} color="uiTextHighContrast"> {selectedRate && !isActiveRoom ? null : (
{intl.formatMessage( <Subtitle className={styles.subtitle} color="uiTextHighContrast">
{ id: "Room {roomIndex}" }, {intl.formatMessage(
{ roomIndex: roomNr } { id: "Room {roomIndex}" },
)} { roomIndex: roomNr }
,{" "} )}
{bookingRoom.childrenInRoom?.length ,{" "}
? adultsAndChildrenMsg {bookingRoom.childrenInRoom?.length
: onlyAdultsMsg} ? adultsAndChildrenMsg
</Subtitle> : onlyAdultsMsg}
)} </Subtitle>
)}
{selectedRate && isActiveRoom ? (
<Button
intent="text"
onClick={closeSection}
size="medium"
theme="base"
variant="icon"
>
{intl.formatMessage({ id: "Close" })}
<ChevronUpIcon height={20} width={20} />
</Button>
) : null}
</div>
<div className={classNames}> <div className={classNames}>
<div className={styles.roomPanel}> <div className={styles.roomPanel}>
<SelectedRoomPanel /> <SelectedRoomPanel />

View File

@@ -4,7 +4,13 @@
border-radius: var(--Corner-radius-Large); border-radius: var(--Corner-radius-Large);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--Spacing-x2); padding: var(--Spacing-x3);
}
.header {
align-items: center;
display: flex;
justify-content: space-between;
} }
.roomPanel, .roomPanel,
@@ -27,27 +33,21 @@
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
} }
.roomSelectionPanelContainer.active .roomSelectionPanel,
.roomSelectionPanelContainer.selected .roomPanel { .roomSelectionPanelContainer.selected .roomPanel {
grid-template-rows: 1fr; grid-template-rows: 1fr;
opacity: 1;
height: auto; height: auto;
opacity: 1;
}
.roomSelectionPanelContainer.active .roomPanel {
padding-top: var(--Spacing-x1);
} }
.roomSelectionPanelContainer.selected .roomSelectionPanel { .roomSelectionPanelContainer.selected .roomSelectionPanel {
display: none; display: none;
} }
.roomSelectionPanelContainer.active .roomSelectionPanel {
grid-template-rows: 1fr;
opacity: 1;
height: auto;
padding-top: var(--Spacing-x1);
}
div.roomContainer p.subtitle {
padding-bottom: var(--Spacing-x1);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.roomContainer { .roomContainer {
padding: var(--Spacing-x2); padding: var(--Spacing-x2);

View File

@@ -138,20 +138,20 @@ export default function PriceList({
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{isUserLoggedIn {isUserLoggedIn
? intl.formatMessage( ? intl.formatMessage(
{ id: "{memberPrice} {currency}" }, { id: "{memberPrice} {currency}" },
{ {
memberPrice: totalMemberRequestedPricePerNight, memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency, currency: publicRequestedPrice.currency,
} }
) )
: intl.formatMessage( : intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" }, { id: "{publicPrice}/{memberPrice} {currency}" },
{ {
publicPrice: totalPublicRequestedPricePerNight, publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight, memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency, currency: publicRequestedPrice.currency,
} }
)} )}
</Caption> </Caption>
</dd> </dd>
</div> </div>

View File

@@ -23,6 +23,7 @@
cursor: pointer; cursor: pointer;
background-color: var(--Base-Surface-Primary-light-Hover-alt); background-color: var(--Base-Surface-Primary-light-Hover-alt);
} }
.checkIcon { .checkIcon {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -33,10 +34,18 @@
align-items: center; align-items: center;
display: none; display: none;
} }
input[type="radio"].radio {
opacity: 0;
position: fixed;
width: 0;
}
input[type="radio"]:checked + .card { input[type="radio"]:checked + .card {
border: 1px solid var(--Primary-Dark-On-Surface-Divider); border: 1px solid var(--Primary-Dark-On-Surface-Divider);
background-color: var(--Base-Surface-Primary-light-Hover-alt); background-color: var(--Base-Surface-Primary-light-Hover-alt);
} }
input[type="radio"]:checked + .card .checkIcon { input[type="radio"]:checked + .card .checkIcon {
display: flex; display: flex;
position: absolute; position: absolute;
@@ -81,11 +90,13 @@ input[type="radio"]:checked + .card .checkIcon {
.terms { .terms {
padding-top: var(--Spacing-x3); padding-top: var(--Spacing-x3);
} }
.termsText:nth-child(n) { .termsText:nth-child(n) {
display: flex; display: flex;
align-items: center; align-items: center;
padding-bottom: var(--Spacing-x1); padding-bottom: var(--Spacing-x1);
} }
.termsIcon { .termsIcon {
padding-right: var(--Spacing-x1); padding-right: var(--Spacing-x1);
flex-shrink: 0; flex-shrink: 0;

View File

@@ -34,6 +34,7 @@ export default function FlexibilityOption({
const { const {
actions: { selectRate }, actions: { selectRate },
isMainRoom, isMainRoom,
roomNr,
} = useRoomContext() } = useRoomContext()
function handleSelect() { function handleSelect() {
@@ -74,7 +75,8 @@ export default function FlexibilityOption({
<label> <label>
<input <input
checked={isSelected} checked={isSelected}
name={`rateCode-${rate.rateCode}`} className={styles.radio}
name={`rateCode-${roomNr}-${rate.rateCode}`}
onChange={handleSelect} onChange={handleSelect}
type="radio" type="radio"
value={rate.rateCode} value={rate.rateCode}

View File

@@ -14,23 +14,29 @@ export default function RoomSize({ roomSize }: RoomSizeProps) {
if (roomSize.min === roomSize.max) { if (roomSize.min === roomSize.max) {
return ( return (
<Caption color="uiTextMediumContrast"> <>
{intl.formatMessage( <Caption color="uiTextMediumContrast"></Caption>
{ id: "{roomSize} m²" }, <Caption color="uiTextMediumContrast">
{ roomSize: roomSize.min } {intl.formatMessage(
)} { id: "{roomSize} m²" },
</Caption> { roomSize: roomSize.min }
)}
</Caption>
</>
) )
} }
return ( return (
<Caption color="uiTextMediumContrast"> <>
{intl.formatMessage( <Caption color="uiTextMediumContrast"></Caption>
{ id: "{roomSizeMin} - {roomSizeMax} m²" }, <Caption color="uiTextMediumContrast">
{ {intl.formatMessage(
roomSizeMin: roomSize.min, { id: "{roomSizeMin} - {roomSizeMax} m²" },
roomSizeMax: roomSize.max, {
} roomSizeMin: roomSize.min,
)} roomSizeMax: roomSize.max,
</Caption> }
)}
</Caption>
</>
) )
} }

View File

@@ -35,13 +35,17 @@ function getBreakfastMessage(
memberBreakfastIncluded: boolean, memberBreakfastIncluded: boolean,
hotelType: string | undefined, hotelType: string | undefined,
userIsLoggedIn: boolean, userIsLoggedIn: boolean,
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string> msgs: Record<
"included" | "noSelection" | "scandicgo" | "notIncluded",
string
>,
roomNr: number
) { ) {
if (hotelType === HotelTypeEnum.ScandicGo) { if (hotelType === HotelTypeEnum.ScandicGo) {
return msgs.scandicgo return msgs.scandicgo
} }
if (userIsLoggedIn && memberBreakfastIncluded) { if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
return msgs.included return msgs.included
} }
@@ -81,7 +85,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
rateDefinitions: state.roomsAvailability?.rateDefinitions, rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories, roomCategories: state.roomCategories,
})) }))
const { isMainRoom, selectedPackage, selectedRate } = useRoomContext() const { isMainRoom, roomNr, selectedPackage, selectedRate } = useRoomContext()
const classNames = cardVariants({ const classNames = cardVariants({
availability: availability:
@@ -105,7 +109,8 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
roomConfiguration.breakfastIncludedInAllRatesMember, roomConfiguration.breakfastIncludedInAllRatesMember,
hotelType, hotelType,
isUserLoggedIn, isUserLoggedIn,
breakfastMessages breakfastMessages,
roomNr
) )
if (!rateDefinitions) { if (!rateDefinitions) {
@@ -124,7 +129,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
) )
) )
const { name, roomSize, totalOccupancy, images } = selectedRoom || {} const { images, name, occupancy, roomSize } = selectedRoom || {}
const galleryImages = mapApiImagesToGalleryImages(images || []) const galleryImages = mapApiImagesToGalleryImages(images || [])
const freeCancelation = intl.formatMessage({ id: "Free cancellation" }) const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
@@ -203,86 +208,87 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
return ( return (
<li className={classNames}> <li className={classNames}>
<div> <div className={styles.imageContainer}>
<div className={styles.imageContainer}> <div className={styles.chipContainer}>
<div className={styles.chipContainer}> {lessThanFiveRoomsLeft ? (
{lessThanFiveRoomsLeft ? ( <span className={styles.chip}>
<span className={styles.chip}> <Footnote color="burgundy" textTransform="uppercase">
<Footnote color="burgundy" textTransform="uppercase"> {intl.formatMessage(
{intl.formatMessage( { id: "{amount, number} left" },
{ id: "{amount, number} left" }, { amount: roomConfiguration.roomsLeft }
{ amount: roomConfiguration.roomsLeft } )}
)} </Footnote>
</Footnote> </span>
) : null}
{roomConfiguration.features
.filter((feature) => selectedPackage === feature.code)
.map((feature) => (
<span className={styles.chip} key={feature.code}>
{createElement(getIconForFeatureCode(feature.code), {
color: "burgundy",
height: 16,
width: 16,
})}
</span> </span>
) : null} ))}
{roomConfiguration.features
.filter((feature) => selectedPackage === feature.code)
.map((feature) => (
<span className={styles.chip} key={feature.code}>
{createElement(getIconForFeatureCode(feature.code), {
color: "burgundy",
height: 16,
width: 16,
})}
</span>
))}
</div>
<ImageGallery
images={galleryImages}
title={roomConfiguration.roomType}
fill
/>
</div> </div>
<ImageGallery
images={galleryImages}
title={roomConfiguration.roomType}
fill
/>
</div>
<div className={styles.specification}> <div className={styles.specification}>
{totalOccupancy && ( {occupancy && (
<Caption color="uiTextMediumContrast" className={styles.guests}> <Caption color="uiTextMediumContrast">
{intl.formatMessage( {occupancy.max === occupancy.min
{ ? intl.formatMessage(
id: "Max {max, plural, one {{range} guest} other {{range} guests}}", { id: "guests.plural" },
}, { guests: occupancy.max }
{ max: totalOccupancy.max, range: totalOccupancy.range } )
)} : intl.formatMessage({ id: "guests.span" }, occupancy)}
</Caption> </Caption>
)}
<RoomSize roomSize={roomSize} />
<div className={styles.toggleSidePeek}>
{roomConfiguration.roomTypeCode && (
<ToggleSidePeek
hotelId={hotelId.toString()}
roomTypeCode={roomConfiguration.roomTypeCode}
title={intl.formatMessage({ id: "Room details" })}
/>
)} )}
<RoomSize roomSize={roomSize} />
<div className={styles.toggleSidePeek}>
{roomConfiguration.roomTypeCode && (
<ToggleSidePeek
hotelId={hotelId.toString()}
roomTypeCode={roomConfiguration.roomTypeCode}
/>
)}
</div>
</div> </div>
<div className={styles.roomDetails}> </div>
<Subtitle className={styles.name} type="two"> <div className={styles.roomDetails}>
{name} <Subtitle className={styles.name} type="two">
</Subtitle> {name}
{/* Out of scope for now </Subtitle>
{/* Out of scope for now
<Body>{descriptions?.short}</Body> <Body>{descriptions?.short}</Body>
*/} */}
</div>
</div> </div>
<div className={styles.container}> <div className={styles.container}>
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? ( {roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
<div className={styles.noRoomsContainer}> <>
<div className={styles.noRooms}> {/** The empty div is used to allow for subgrid to align rows */}
<ErrorCircleIcon color="red" width={16} /> <div></div>
<Caption color="uiTextHighContrast" type="bold"> <div className={styles.noRoomsContainer}>
{intl.formatMessage({ <div className={styles.noRooms}>
id: "This room is not available", <ErrorCircleIcon color="red" width={16} />
})} <Caption color="uiTextHighContrast" type="bold">
</Caption> {intl.formatMessage({
id: "This room is not available",
})}
</Caption>
</div>
</div> </div>
</div> </>
) : ( ) : (
<> <>
<Caption color="uiTextHighContrast" type="bold"> <Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
{breakfastMessage}
</Caption>
{roomConfiguration.products.map((product) => { {roomConfiguration.products.map((product) => {
const rate = getRateInfo(product) const rate = getRateInfo(product)
const isSelectedRateCode = const isSelectedRateCode =

View File

@@ -1,112 +1,100 @@
.card { .card {
font-size: 14px; background-color: #fff;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
display: grid; display: grid;
font-size: 14px;
gap: var(--Spacing-x-one-and-half);
grid-row: span 7;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: subgrid; grid-template-rows: subgrid;
background-color: #fff;
border-radius: var(--Corner-radius-Large);
border: 1px solid var(--Base-Border-Subtle);
position: relative;
justify-content: space-between; justify-content: space-between;
grid-row: span 5; padding: 0 var(--Spacing-x2) var(--Spacing-x2);
position: relative;
}
div[data-multiroom="true"] .card {
border: none;
padding: 0;
} }
.card.noAvailability { .card.noAvailability {
opacity: 0.6; opacity: 0.6;
} }
.specification { .imageContainer {
display: flex; margin: 0 calc(-1 * var(--Spacing-x2));
flex-direction: row; min-height: 190px;
align-items: center; position: relative;
justify-content: space-between;
gap: var(--Spacing-x1);
padding: 0 var(--Spacing-x1) 0 var(--Spacing-x-one-and-half);
} }
.specification .guests { div[data-multiroom="true"] .imageContainer {
border-right: 1px solid var(--Base-Border-Subtle); margin: 0;
padding-right: var(--Spacing-x1); }
.chipContainer {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
left: 12px;
position: absolute;
top: 12px;
z-index: 1;
}
.chip {
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x-half) var(--Spacing-x1);
}
.card .imageContainer img {
aspect-ratio: 16/9;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
max-width: 100%;
object-fit: cover;
}
.specification {
align-items: center;
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
justify-content: space-between;
} }
.toggleSidePeek { .toggleSidePeek {
margin-left: auto; margin-left: auto;
} }
.toggleSidePeek button { .specification .toggleSidePeek button {
padding: 0;
text-align: start; text-align: start;
} }
.container {
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x2);
display: grid;
grid-template-rows: subgrid;
gap: var(--Spacing-x-one-and-half);
grid-row: span 4;
}
/* Make sure rows with only unavailable rooms still has a min-height */
.container:has(.noRoomsContainer) {
min-height: 400px;
grid-template-rows: auto repeat(3, 1fr);
}
.roomDetails { .roomDetails {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x2) 0; padding-bottom: var(--Spacing-x-half);
} }
.name { .name {
display: inline-block; display: inline-block;
} }
.card img { .container {
max-width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
}
.flexibilityOptions {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-rows: repeat(3, 1fr); grid-row: span 4;
} grid-template-rows: subgrid;
.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);
}
.imageContainer {
min-height: 190px;
position: relative;
}
.noRoomsContainer {
height: 100%;
width: 100%;
} }
.noRooms { .noRooms {
padding: var(--Spacing-x2);
background-color: var(--Base-Surface-Secondary-light-Normal); background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium); border-radius: var(--Corner-radius-Medium);
margin: 0;
display: flex; display: flex;
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
} margin: 0;
padding: var(--Spacing-x2);
}

View File

@@ -41,16 +41,14 @@ export default function RoomSelectionPanel() {
</div> </div>
) : null} ) : null}
<RoomTypeFilter /> <RoomTypeFilter />
<div className={styles.wrapper}> <ul className={styles.roomList}>
<ul className={styles.roomList}> {rooms.map((roomConfiguration) => (
{rooms.map((roomConfiguration) => ( <RoomCard
<RoomCard key={roomConfiguration.roomTypeCode}
key={roomConfiguration.roomTypeCode} roomConfiguration={roomConfiguration}
roomConfiguration={roomConfiguration} />
/> ))}
))} </ul>
</ul>
</div>
</> </>
) )
} }

View File

@@ -9,12 +9,6 @@
width: 100%; width: 100%;
} }
.roomList input[type="radio"] {
opacity: 0;
position: fixed;
width: 0;
}
.hotelAlert { .hotelAlert {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;

View File

@@ -47,22 +47,26 @@ export default async function ParkingInformation({
{intl.formatMessage({ id: "Weekday prices" })} {intl.formatMessage({ id: "Weekday prices" })}
</Caption> </Caption>
<Divider color="baseSurfaceSubtleHover" /> <Divider color="baseSurfaceSubtleHover" />
<ParkingPrices {parking.pricing.localCurrency ? (
pricing={parking.pricing.localCurrency?.ordinary} <ParkingPrices
currency={parking.pricing.localCurrency?.currency} currency={parking.pricing.localCurrency.currency}
freeParking={parking.pricing.freeParking} freeParking={parking.pricing.freeParking}
/> pricing={parking.pricing.localCurrency.ordinary}
/>
) : null}
</div> </div>
<div className={styles.priceWrapper}> <div className={styles.priceWrapper}>
<Caption color="uiTextMediumContrast" textTransform="uppercase"> <Caption color="uiTextMediumContrast" textTransform="uppercase">
{intl.formatMessage({ id: "Weekend prices" })} {intl.formatMessage({ id: "Weekend prices" })}
</Caption> </Caption>
<Divider color="baseSurfaceSubtleHover" /> <Divider color="baseSurfaceSubtleHover" />
<ParkingPrices {parking.pricing.localCurrency ? (
pricing={parking.pricing.localCurrency?.weekend} <ParkingPrices
currency={parking.pricing.localCurrency?.currency} currency={parking.pricing.localCurrency.currency}
freeParking={parking.pricing.freeParking} freeParking={parking.pricing.freeParking}
/> pricing={parking.pricing.localCurrency.weekend}
/>
) : null}
</div> </div>
</div> </div>
{parking.externalParkingUrl && showExternalParkingButton && ( {parking.externalParkingUrl && showExternalParkingButton && (

View File

@@ -55,10 +55,12 @@ a.text {
border: none; border: none;
outline: none; outline: none;
} }
/* TODO: The variants for combinations of size/text/wrapping should be looked at and iterated on */ /* TODO: The variants for combinations of size/text/wrapping should be looked at and iterated on */
.text:not(.wrapping) { .text:not(.wrapping) {
padding: 0 !important; padding: 0 !important;
} }
/* VARIANTS */ /* VARIANTS */
.default, .default,
a.default { a.default {
@@ -827,3 +829,15 @@ a.default {
.icon.tertiaryLightSecondary:disabled svg * { .icon.tertiaryLightSecondary:disabled svg * {
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled); fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled);
} }
button.btn.clean {
background: none;
background-color: unset;
border: none;
border-color: unset;
border-radius: unset;
color: unset;
gap: unset;
margin: 0;
padding: 0;
}

View File

@@ -10,6 +10,7 @@ import type { ButtonProps } from "./button"
export default function Button(props: ButtonProps) { export default function Button(props: ButtonProps) {
const { const {
className, className,
clean,
intent, intent,
size, size,
theme, theme,
@@ -21,6 +22,7 @@ export default function Button(props: ButtonProps) {
const classNames = buttonVariants({ const classNames = buttonVariants({
className, className,
clean,
intent, intent,
size, size,
theme, theme,

View File

@@ -28,12 +28,16 @@ export const buttonVariants = cva(styles.btn, {
tertiaryDark: "", tertiaryDark: "",
}, },
variant: { variant: {
clean: styles.clean,
default: styles.default, default: styles.default,
icon: styles.icon, icon: styles.icon,
}, },
wrapping: { wrapping: {
true: styles.wrapping, true: styles.wrapping,
}, },
clean: {
true: styles.clean,
},
fullWidth: { fullWidth: {
true: styles.fullWidth, true: styles.fullWidth,
}, },

View File

@@ -1,14 +1,21 @@
div.chip { div.chip {
--chip-text-color: var(--Base-Text-High-contrast); --chip-text-color: var(--Base-Text-High-contrast);
--chip-background-color: var(--Base-Surface-Primary-light-Normal); --chip-background-color: var(--Base-Surface-Primary-light-Normal);
display: flex;
justify-content: center;
align-items: center; align-items: center;
padding: var(--Spacing-x-half) var(--Spacing-x1);
gap: var(--Spacing-x-half);
border-radius: var(--Corner-radius-Small);
color: var(--chip-text-color); color: var(--chip-text-color);
background-color: var(--chip-background-color); background-color: var(--chip-background-color);
border-radius: var(--Corner-radius-Small);
display: flex;
gap: var(--Spacing-x-half);
justify-content: center;
}
.chip.small {
padding: var(--Spacing-x-quarter) var(--Spacing-x-half);
}
.chip.medium {
padding: var(--Spacing-x-half) var(--Spacing-x1);
} }
.chip *, .chip *,
@@ -29,3 +36,8 @@ div.chip {
.chip.tag { .chip.tag {
--chip-background-color: var(--Base-Surface-Subtle-Hover); --chip-background-color: var(--Base-Surface-Subtle-Hover);
} }
.chip.uiTextHighContrast {
--chip-background-color: var(--UI-Text-High-contrast);
--chip-text-color: var(--UI-Input-Controls-On-Fill-Normal);
}

View File

@@ -4,9 +4,15 @@ import { chipVariants } from "./variants"
import type { ChipProps } from "./chip" import type { ChipProps } from "./chip"
export default function Chip({ children, className, variant }: ChipProps) { export default function Chip({
children,
className,
size,
variant,
}: ChipProps) {
const classNames = chipVariants({ const classNames = chipVariants({
className, className,
size,
variant, variant,
}) })
return ( return (

View File

@@ -4,14 +4,20 @@ import styles from "./chip.module.css"
export const chipVariants = cva(styles.chip, { export const chipVariants = cva(styles.chip, {
variants: { variants: {
size: {
small: styles.small,
medium: styles.medium,
},
variant: { variant: {
default: styles.default, default: styles.default,
burgundy: styles.burgundy, burgundy: styles.burgundy,
transparent: styles.transparent, transparent: styles.transparent,
tag: styles.tag, tag: styles.tag,
uiTextHighContrast: styles.uiTextHighContrast,
}, },
}, },
defaultVariants: { defaultVariants: {
size: "medium",
variant: "default", variant: "default",
}, },
}) })

View File

@@ -716,6 +716,8 @@
"booking.thisRoomIsEquippedWith": "Dette værelse er udstyret med", "booking.thisRoomIsEquippedWith": "Dette værelse er udstyret med",
"friday": "fredag", "friday": "fredag",
"from": "fra", "from": "fra",
"guests.plural": "{guests, plural, one {# gæst} other {# gæster}}",
"guests.span": "{min}-{max} gæster",
"max {seatings} pers": "max {seatings} pers", "max {seatings} pers": "max {seatings} pers",
"monday": "mandag", "monday": "mandag",
"next level: {nextLevel}": "next level: {nextLevel}", "next level: {nextLevel}": "next level: {nextLevel}",
@@ -782,4 +784,4 @@
"{value} persons": "{number} Personen", "{value} persons": "{number} Personen",
"{value} square meters": "{number} Quadratmeter", "{value} square meters": "{number} Quadratmeter",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle rettigheder forbeholdes" "© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle rettigheder forbeholdes"
} }

View File

@@ -717,6 +717,8 @@
"booking.thisRoomIsEquippedWith": "Dieses Zimmer ist ausgestattet mit", "booking.thisRoomIsEquippedWith": "Dieses Zimmer ist ausgestattet mit",
"friday": "freitag", "friday": "freitag",
"from": "von", "from": "von",
"guests.plural": "{guests, plural, one {# gast} other {# gäste}}",
"guests.span": "{min}-{max} gäste",
"max {seatings} pers": "max {seatings} pers", "max {seatings} pers": "max {seatings} pers",
"monday": "montag", "monday": "montag",
"next level: {nextLevel}": "next level: {nextLevel}", "next level: {nextLevel}": "next level: {nextLevel}",
@@ -783,4 +785,4 @@
"{value} persons": "{number} personer", "{value} persons": "{number} personer",
"{value} square meters": "{number} kvadratmeter", "{value} square meters": "{number} kvadratmeter",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle Rechte vorbehalten" "© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle Rechte vorbehalten"
} }

View File

@@ -521,6 +521,7 @@
"Room": "Room", "Room": "Room",
"Room & Terms": "Room & Terms", "Room & Terms": "Room & Terms",
"Room charge": "Room charge", "Room charge": "Room charge",
"Room details": "Room details",
"Room facilities": "Room facilities", "Room facilities": "Room facilities",
"Room total": "Room total", "Room total": "Room total",
"Room {roomIndex}": "Room {roomIndex}", "Room {roomIndex}": "Room {roomIndex}",
@@ -720,6 +721,8 @@
"cm": "cm", "cm": "cm",
"friday": "friday", "friday": "friday",
"from": "from", "from": "from",
"guests.plural": "{guests, plural, one {# guest} other {# guests}}",
"guests.span": "{min}-{max} guests",
"max {seatings} pers": "max {seatings} pers", "max {seatings} pers": "max {seatings} pers",
"monday": "monday", "monday": "monday",
"next level: {nextLevel}": "next level: {nextLevel}", "next level: {nextLevel}": "next level: {nextLevel}",
@@ -788,4 +791,4 @@
"{value} persons": "{value} persons", "{value} persons": "{value} persons",
"{value} square meters": "{value} square meters", "{value} square meters": "{value} square meters",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB All rights reserved" "© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB All rights reserved"
} }

View File

@@ -717,6 +717,8 @@
"booking.thisRoomIsEquippedWith": "Tämä huone on varustettu", "booking.thisRoomIsEquippedWith": "Tämä huone on varustettu",
"friday": "perjantai", "friday": "perjantai",
"from": "alkaa", "from": "alkaa",
"guests.plural": "{guests, plural, one {# vieras} other {# vieraita}}",
"guests.span": "{min}-{max} vieraita",
"max {seatings} pers": "max {seatings} pers", "max {seatings} pers": "max {seatings} pers",
"monday": "maanantai", "monday": "maanantai",
"next level: {nextLevel}": "next level: {nextLevel}", "next level: {nextLevel}": "next level: {nextLevel}",
@@ -783,4 +785,4 @@
"{value} persons": "{number} henkilöä", "{value} persons": "{number} henkilöä",
"{value} square meters": "{number} neliömetriä", "{value} square meters": "{number} neliömetriä",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Kaikki oikeudet pidätetään" "© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Kaikki oikeudet pidätetään"
} }

View File

@@ -715,6 +715,8 @@
"booking.thisRoomIsEquippedWith": "Dette rommet er utstyrt med", "booking.thisRoomIsEquippedWith": "Dette rommet er utstyrt med",
"friday": "fredag", "friday": "fredag",
"from": "fra", "from": "fra",
"guests.plural": "{guests, plural, one {# gjest} other {# gjester}}",
"guests.span": "{min}-{max} gjester",
"max {seatings} pers": "max {seatings} pers", "max {seatings} pers": "max {seatings} pers",
"monday": "mandag", "monday": "mandag",
"next level: {nextLevel}": "next level: {nextLevel}", "next level: {nextLevel}": "next level: {nextLevel}",
@@ -781,4 +783,4 @@
"{value} persons": "{number} personer", "{value} persons": "{number} personer",
"{value} square meters": "{number} kvadratmeter", "{value} square meters": "{number} kvadratmeter",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle rettigheter forbeholdt" "© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle rettigheter forbeholdt"
} }

View File

@@ -715,6 +715,8 @@
"booking.thisRoomIsEquippedWith": "Detta rum är utrustat med", "booking.thisRoomIsEquippedWith": "Detta rum är utrustat med",
"friday": "fredag", "friday": "fredag",
"from": "från", "from": "från",
"guests.plural": "{guests, plural, one {# gäst} other {# gäster}}",
"guests.span": "{min}-{max} gäster",
"max {seatings} pers": "max {seatings} pers", "max {seatings} pers": "max {seatings} pers",
"monday": "måndag", "monday": "måndag",
"next level: {nextLevel}": "next level: {nextLevel}", "next level: {nextLevel}": "next level: {nextLevel}",
@@ -783,4 +785,4 @@
"{value} persons": "{number} personer", "{value} persons": "{number} personer",
"{value} square meters": "{number} kvadratmeter", "{value} square meters": "{number} kvadratmeter",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alla rättigheter förbehålls" "© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alla rättigheter förbehålls"
} }

View File

@@ -12,6 +12,7 @@ export default function RoomProvider({
room, room,
}: RoomProviderProps) { }: RoomProviderProps) {
const activeRoom = useRatesStore((state) => state.activeRoom) const activeRoom = useRatesStore((state) => state.activeRoom)
const closeSection = useRatesStore((state) => state.actions.closeSection(idx))
const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx)) const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx))
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx)) const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
const selectRate = useRatesStore((state) => state.actions.selectRate(idx)) const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
@@ -21,6 +22,7 @@ export default function RoomProvider({
value={{ value={{
...room, ...room,
actions: { actions: {
closeSection,
modifyRate, modifyRate,
selectFilter, selectFilter,
selectRate, selectRate,

View File

@@ -93,7 +93,7 @@ export function getDescription(data: RawMetadataSchema) {
return metadata.description return metadata.description
} }
if (data.hotelData) { if (data.hotelData) {
return data.hotelData.hotelContent.texts.descriptions.short return data.hotelData.hotelContent.texts.descriptions?.short
} }
if (data.preamble) { if (data.preamble) {
return truncateTextAfterLastPeriod(data.preamble) return truncateTextAfterLastPeriod(data.preamble)

View File

@@ -18,7 +18,7 @@ import { cache } from "@/utils/cache"
import { getHotelPageUrl } from "../contentstack/hotelPage/utils" import { getHotelPageUrl } from "../contentstack/hotelPage/utils"
import { getVerifiedUser, parsedUser } from "../user/query" import { getVerifiedUser, parsedUser } from "../user/query"
import { additionalDataSchema } from "./schemas/additionalData" import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
import { meetingRoomsSchema } from "./schemas/meetingRoom" import { meetingRoomsSchema } from "./schemas/meetingRoom"
import { import {
ancillaryPackageInputSchema, ancillaryPackageInputSchema,
@@ -510,8 +510,8 @@ export const hotelQueryRouter = router({
adults: adultCount, adults: adultCount,
...(childArray && ...(childArray &&
childArray.length > 0 && { childArray.length > 0 && {
children: childArray.join(","), children: childArray.join(","),
}), }),
...(bookingCode && { bookingCode }), ...(bookingCode && { bookingCode }),
language: apiLang, language: apiLang,
} }
@@ -754,9 +754,9 @@ export const hotelQueryRouter = router({
type: matchingRoom.mainBed.type, type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed extraBed: matchingRoom.fixedExtraBed
? { ? {
type: matchingRoom.fixedExtraBed.type, type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description, description: matchingRoom.fixedExtraBed.description,
} }
: undefined, : undefined,
} }
} }
@@ -855,7 +855,7 @@ export const hotelQueryRouter = router({
}), }),
}), }),
rates: router({ rates: router({
get: publicProcedure.input(ratesInputSchema).query(async ({}) => { get: publicProcedure.input(ratesInputSchema).query(async () => {
// TODO: Do a real API call when the endpoint is ready // TODO: Do a real API call when the endpoint is ready
// const { hotelId } = input // const { hotelId } = input
@@ -1112,9 +1112,9 @@ export const hotelQueryRouter = router({
return hotelData return hotelData
? { ? {
...hotelData, ...hotelData,
url, url,
} }
: null : null
}) })
) )

View File

@@ -1,78 +0,0 @@
import { z } from "zod"
import { imageSchema } from "./image"
const specialNeedSchema = z.object({
name: z.string(),
details: z.string(),
})
const specialNeedGroupSchema = z.object({
name: z.string(),
specialNeeds: z.array(specialNeedSchema),
})
export const gallerySchema = z.object({
heroImages: z.array(imageSchema),
smallerImages: z.array(imageSchema),
})
export const facilitySchema = z.object({
headingText: z.string().default(""),
heroImages: z.array(imageSchema),
})
export const restaurantsOverviewPageSchema = z.object({
restaurantsOverviewPageLinkText: z.string().optional(),
restaurantsOverviewPageLink: z.string().optional(),
restaurantsContentDescriptionShort: z.string().optional(),
restaurantsContentDescriptionMedium: z.string().optional(),
})
export const extraPageSchema = z.object({
elevatorPitch: z.string().default(""),
mainBody: z.string().optional(),
nameInUrl: z.string().optional(),
})
export const accessibilitySchema = z.object({
headingText: z.string().default(""),
heroImages: z.array(imageSchema),
})
export const additionalDataSchema = z.object({
attributes: z.object({
name: z.string(),
id: z.string(),
displayWebPage: z.object({
healthGym: z.boolean(),
meetingRoom: z.boolean(),
parking: z.boolean(),
specialNeeds: z.boolean(),
}),
specialNeedGroups: z.array(specialNeedGroupSchema),
gallery: gallerySchema.optional(),
conferencesAndMeetings: facilitySchema.optional(),
healthAndWellness: facilitySchema.optional(),
restaurantImages: facilitySchema.optional(),
parkingImages: facilitySchema.optional(),
restaurantsOverviewPage: restaurantsOverviewPageSchema,
meetingRooms: extraPageSchema,
healthAndFitness: extraPageSchema,
hotelParking: extraPageSchema,
hotelSpecialNeeds: extraPageSchema,
hotelRoomElevatorPitchText: z.string().optional(),
accessibility: accessibilitySchema.optional(),
}),
type: z.literal("additionalData"),
})
export function transformAdditionalData(
data: z.output<typeof additionalDataSchema>
) {
return {
...data.attributes,
id: data.attributes.id,
type: data.type,
}
}

View File

@@ -1,14 +1,21 @@
import { z } from "zod" import { z } from "zod"
import {
nullableArrayObjectValidator,
nullableArrayStringValidator,
} from "@/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@/utils/zod/numberValidator" import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { addressSchema } from "./hotel/address" import { addressSchema } from "./hotel/address"
import { contactInformationSchema } from "./hotel/contactInformation" import { contactInformationSchema } from "./hotel/contactInformation"
import { hotelContentSchema } from "./hotel/content" import { hotelContentSchema } from "./hotel/content"
import { detailedFacilitiesSchema } from "./hotel/detailedFacility" import { detailedFacilitiesSchema } from "./hotel/detailedFacility"
import { hotelFactsSchema } from "./hotel/facts" import { hotelFactsSchema } from "./hotel/facts"
import { gallerySchema } from "./hotel/gallery" import { healthFacilitiesSchema } from "./hotel/healthFacilities"
import { healthFacilitySchema } from "./hotel/healthFacilities" import { displayWebPageSchema } from "./hotel/include/additionalData/displayWebPage"
import { facilitySchema } from "./hotel/include/additionalData/facility"
import { gallerySchema } from "./hotel/include/additionalData/gallery"
import { includeSchema } from "./hotel/include/include" import { includeSchema } from "./hotel/include/include"
import { locationSchema } from "./hotel/location" import { locationSchema } from "./hotel/location"
import { merchantInformationSchema } from "./hotel/merchantInformation" import { merchantInformationSchema } from "./hotel/merchantInformation"
@@ -18,41 +25,41 @@ import { ratingsSchema } from "./hotel/rating"
import { rewardNightSchema } from "./hotel/rewardNight" import { rewardNightSchema } from "./hotel/rewardNight"
import { socialMediaSchema } from "./hotel/socialMedia" import { socialMediaSchema } from "./hotel/socialMedia"
import { specialAlertsSchema } from "./hotel/specialAlerts" import { specialAlertsSchema } from "./hotel/specialAlerts"
import { specialNeedGroupSchema } from "./hotel/specialNeedGroups"
import { facilitySchema } from "./additionalData"
import { imageSchema } from "./image" import { imageSchema } from "./image"
export const attributesSchema = z.object({ export const attributesSchema = z.object({
id: z.string().optional(),
address: addressSchema, address: addressSchema,
cityId: z.string(), cityId: nullableStringValidator,
cityName: z.string(), cityName: nullableStringValidator,
conferencesAndMeetings: facilitySchema.optional(), conferencesAndMeetings: facilitySchema.nullish(),
contactInformation: contactInformationSchema, contactInformation: contactInformationSchema,
countryCode: nullableStringValidator,
detailedFacilities: detailedFacilitiesSchema, detailedFacilities: detailedFacilitiesSchema,
gallery: gallerySchema.optional(), displayWebPage: displayWebPageSchema,
galleryImages: z.array(imageSchema).optional(), gallery: gallerySchema.nullish(),
healthAndWellness: facilitySchema.optional(), galleryImages: z
healthFacilities: z.array(healthFacilitySchema), .array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
healthAndWellness: facilitySchema.nullish(),
healthFacilities: healthFacilitiesSchema,
hotelContent: hotelContentSchema, hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema, hotelFacts: hotelFactsSchema,
hotelRoomElevatorPitchText: z.string().optional(), hotelType: nullableStringValidator,
hotelType: z.string().optional(),
isActive: z.boolean(), isActive: z.boolean(),
isPublished: z.boolean(), isPublished: z.boolean(),
keywords: z.array(z.string()), keywords: nullableArrayStringValidator,
location: locationSchema, location: locationSchema,
merchantInformationData: merchantInformationSchema, merchantInformationData: merchantInformationSchema,
name: z.string(), name: nullableStringValidator,
operaId: z.string(), operaId: nullableStringValidator,
parking: z.array(parkingSchema), parking: nullableArrayObjectValidator(parkingSchema),
pointsOfInterest: pointOfInterestsSchema, pointsOfInterest: pointOfInterestsSchema,
ratings: ratingsSchema, ratings: ratingsSchema,
restaurantImages: facilitySchema.nullish(),
rewardNight: rewardNightSchema, rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
socialMedia: socialMediaSchema, socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema, specialAlerts: specialAlertsSchema,
specialNeedGroups: z.array(specialNeedGroupSchema),
vat: nullableNumberValidator, vat: nullableNumberValidator,
}) })

View File

@@ -1,8 +1,10 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const addressSchema = z.object({ export const addressSchema = z.object({
city: z.string(), city: nullableStringValidator,
country: z.string(), country: nullableStringValidator,
streetAddress: z.string(), streetAddress: nullableStringValidator,
zipCode: z.string(), zipCode: nullableStringValidator,
}) })

View File

@@ -1,8 +1,13 @@
import { z } from "zod" import { z } from "zod"
import {
nullableStringEmailValidator,
nullableStringValidator,
} from "@/utils/zod/stringValidator"
export const contactInformationSchema = z.object({ export const contactInformationSchema = z.object({
email: z.string(), email: nullableStringEmailValidator,
faxNumber: z.string().optional(), faxNumber: nullableStringValidator,
phoneNumber: z.string(), phoneNumber: nullableStringValidator,
websiteUrl: z.string(), websiteUrl: nullableStringValidator,
}) })

View File

@@ -1,40 +1,26 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { imageSchema } from "../image" import { imageSchema } from "../image"
import { restaurantsOverviewPageSchema } from "./include/additionalData/restaurantsOverviewPage"
const descriptionSchema = z
.object({
medium: nullableStringValidator,
short: nullableStringValidator,
})
.nullish()
const textsSchema = z.object({
descriptions: descriptionSchema,
facilityInformation: nullableStringValidator,
meetingDescription: descriptionSchema,
surroundingInformation: nullableStringValidator,
})
export const hotelContentSchema = z.object({ export const hotelContentSchema = z.object({
images: imageSchema.default({ images: imageSchema,
metaData: { restaurantsOverviewPage: restaurantsOverviewPageSchema,
altText: "default image", texts: textsSchema,
altText_En: "default image",
copyRight: "default image",
title: "default image",
},
imageSizes: {
large: "https://placehold.co/1280x720",
medium: "https://placehold.co/1280x720",
small: "https://placehold.co/1280x720",
tiny: "https://placehold.co/1280x720",
},
}),
restaurantsOverviewPage: z.object({
restaurantsContentDescriptionMedium: z.string().optional(),
restaurantsContentDescriptionShort: z.string().optional(),
restaurantsOverviewPageLink: z.string().optional(),
restaurantsOverviewPageLinkText: z.string().optional(),
}),
texts: z.object({
descriptions: z.object({
medium: z.string(),
short: z.string(),
}),
facilityInformation: z.string().optional(),
meetingDescription: z
.object({
medium: z.string().optional(),
short: z.string().optional(),
})
.optional(),
surroundingInformation: z.string(),
}),
}) })

View File

@@ -1,16 +1,21 @@
import { z } from "zod" import { z } from "zod"
const detailedFacilitySchema = z.object({ import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
filter: z.string().optional(), import { nullableStringValidator } from "@/utils/zod/stringValidator"
icon: z.string().optional(),
id: z.number(), import { FacilityEnum } from "@/types/enums/facilities"
name: z.string(),
export const detailedFacilitySchema = z.object({
filter: nullableStringValidator,
icon: nullableStringValidator,
id: z.nativeEnum(FacilityEnum),
name: nullableStringValidator,
public: z.boolean(), public: z.boolean(),
sortOrder: z.number(), sortOrder: z.number(),
}) })
export const detailedFacilitiesSchema = z export const detailedFacilitiesSchema = nullableArrayObjectValidator(
.array(detailedFacilitySchema) detailedFacilitySchema
.transform((facilities) => ).transform((facilities) =>
facilities.sort((a, b) => b.sortOrder - a.sortOrder) facilities.sort((a, b) => b.sortOrder - a.sortOrder)
) )

View File

@@ -1,50 +1,19 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const checkinSchema = z.object({
checkInTime: nullableStringValidator,
checkOutTime: nullableStringValidator,
onlineCheckout: z.boolean(),
onlineCheckOutAvailableFrom: nullableStringValidator,
})
const ecoLabelsSchema = z.object({ const ecoLabelsSchema = z.object({
euEcoLabel: z.boolean(), euEcoLabel: z.boolean(),
greenGlobeLabel: z.boolean(), greenGlobeLabel: z.boolean(),
nordicEcoLabel: z.boolean(), nordicEcoLabel: z.boolean(),
svanenEcoLabelCertificateNumber: z.string().optional(), svanenEcoLabelCertificateNumber: nullableStringValidator,
})
export const checkinSchema = z.object({
checkInTime: z.string(),
checkOutTime: z.string(),
onlineCheckout: z.boolean(),
onlineCheckOutAvailableFrom: z.string().nullable().optional(),
})
const hotelFacilityDetailSchema = z
.object({
description: z.string(),
heading: z.string(),
})
.optional()
/** Possibly more values */
const hotelFacilityDetailsSchema = z.object({
breakfast: hotelFacilityDetailSchema,
checkout: hotelFacilityDetailSchema,
gym: hotelFacilityDetailSchema,
internet: hotelFacilityDetailSchema,
laundry: hotelFacilityDetailSchema,
luggage: hotelFacilityDetailSchema,
shop: hotelFacilityDetailSchema,
telephone: hotelFacilityDetailSchema,
})
const hotelInformationSchema = z
.object({
description: z.string(),
heading: z.string(),
link: z.string().optional(),
})
.optional()
const hotelInformationsSchema = z.object({
accessibility: hotelInformationSchema,
safety: hotelInformationSchema,
sustainability: hotelInformationSchema,
}) })
const interiorSchema = z.object({ const interiorSchema = z.object({
@@ -53,7 +22,7 @@ const interiorSchema = z.object({
numberOfFloors: z.number(), numberOfFloors: z.number(),
numberOfRooms: z.object({ numberOfRooms: z.object({
connected: z.number(), connected: z.number(),
forAllergics: z.number().optional(), forAllergics: z.number(),
forDisabled: z.number(), forDisabled: z.number(),
nonSmoking: z.number(), nonSmoking: z.number(),
pet: z.number(), pet: z.number(),
@@ -64,17 +33,15 @@ const interiorSchema = z.object({
const receptionHoursSchema = z.object({ const receptionHoursSchema = z.object({
alwaysOpen: z.boolean(), alwaysOpen: z.boolean(),
closingTime: nullableStringValidator,
isClosed: z.boolean(), isClosed: z.boolean(),
openingTime: z.string().optional(), openingTime: nullableStringValidator,
closingTime: z.string().optional(),
}) })
export const hotelFactsSchema = z.object({ export const hotelFactsSchema = z.object({
checkin: checkinSchema, checkin: checkinSchema,
ecoLabels: ecoLabelsSchema, ecoLabels: ecoLabelsSchema,
hotelFacilityDetail: hotelFacilityDetailsSchema.default({}),
hotelInformation: hotelInformationsSchema.default({}),
interior: interiorSchema, interior: interiorSchema,
receptionHours: receptionHoursSchema, receptionHours: receptionHoursSchema,
yearBuilt: z.string(), yearBuilt: nullableStringValidator,
}) })

View File

@@ -1,8 +0,0 @@
import { z } from "zod"
import { imageSchema } from "../image"
export const gallerySchema = z.object({
heroImages: z.array(imageSchema),
smallerImages: z.array(imageSchema),
})

View File

@@ -1,41 +1,61 @@
import { z } from "zod" import { z } from "zod"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { imageSchema } from "../image" import { imageSchema } from "../image"
const healthFacilitiesOpenHoursSchema = z.object({ const healthFacilitiesOpenHoursSchema = z.object({
alwaysOpen: z.boolean(), alwaysOpen: z.boolean(),
closingTime: z.string().optional(), closingTime: nullableStringValidator,
isClosed: z.boolean(), isClosed: z.boolean(),
openingTime: z.string().optional(), openingTime: nullableStringValidator,
sortOrder: z.number().optional(), sortOrder: nullableNumberValidator,
})
const descriptionSchema = z
.object({
medium: nullableStringValidator,
short: nullableStringValidator,
})
.nullish()
const detailsSchema = z.object({
name: nullableStringValidator,
type: nullableStringValidator,
value: nullableStringValidator,
})
const textsSchema = z.object({
descriptions: descriptionSchema,
facilityInformation: nullableStringValidator,
meetingDescription: descriptionSchema,
surroundingInformation: nullableStringValidator,
}) })
export const healthFacilitySchema = z.object({ export const healthFacilitySchema = z.object({
content: z.object({ content: z.object({
images: z.array(imageSchema), images: z
texts: z.object({ .array(imageSchema)
descriptions: z.object({ .nullish()
short: z.string(), .transform((arr) => (arr ? arr.filter(Boolean) : [])),
medium: z.string(), texts: textsSchema,
}),
facilityInformation: z.string().optional(),
surroundingInformation: z.string().optional(),
}),
}), }),
details: z.array( details: nullableArrayObjectValidator(detailsSchema),
z.object({
name: z.string(),
type: z.string(),
value: z.string().optional(),
})
),
openingDetails: z.object({ openingDetails: z.object({
manualOpeningHours: z.string().optional(), manualOpeningHours: nullableStringValidator,
openingHours: z.object({ openingHours: z.object({
ordinary: healthFacilitiesOpenHoursSchema, ordinary: healthFacilitiesOpenHoursSchema,
weekends: healthFacilitiesOpenHoursSchema, weekends: healthFacilitiesOpenHoursSchema,
}), }),
useManualOpeningHours: z.boolean(), useManualOpeningHours: z
.boolean()
.nullish()
.transform((b) => !!b),
}), }),
type: z.string(), type: nullableStringValidator,
}) })
export const healthFacilitiesSchema =
nullableArrayObjectValidator(healthFacilitySchema)

View File

@@ -0,0 +1,48 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { displayWebPageSchema } from "./additionalData/displayWebPage"
import { facilitySchema } from "./additionalData/facility"
import { gallerySchema } from "./additionalData/gallery"
import { restaurantsOverviewPageSchema } from "./additionalData/restaurantsOverviewPage"
import { specialNeedGroupSchema } from "./additionalData/specialNeedGroups"
export const extraPageSchema = z.object({
elevatorPitch: nullableStringValidator,
mainBody: nullableStringValidator,
nameInUrl: nullableStringValidator,
})
export const additionalDataSchema = z.object({
attributes: z.object({
accessibility: facilitySchema.nullish(),
conferencesAndMeetings: facilitySchema.nullish(),
displayWebPage: displayWebPageSchema,
gallery: gallerySchema.nullish(),
healthAndFitness: extraPageSchema,
healthAndWellness: facilitySchema.nullish(),
hotelParking: extraPageSchema,
hotelRoomElevatorPitchText: nullableStringValidator,
hotelSpecialNeeds: extraPageSchema,
id: nullableStringValidator,
meetingRooms: extraPageSchema,
name: nullableStringValidator,
parkingImages: facilitySchema.nullish(),
restaurantImages: facilitySchema.nullish(),
restaurantsOverviewPage: restaurantsOverviewPageSchema,
specialNeedGroups: nullableArrayObjectValidator(specialNeedGroupSchema),
}),
type: z.literal("additionalData"),
})
export function transformAdditionalData(
data: z.output<typeof additionalDataSchema>
) {
return {
...data.attributes,
id: data.attributes.id,
type: data.type,
}
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const displayWebPageSchema = z.object({
healthGym: z.boolean(),
meetingRoom: z.boolean(),
parking: z.boolean(),
specialNeeds: z.boolean(),
})

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import { imageSchema } from "@/server/routers/hotels/schemas/image"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const facilitySchema = z.object({
headingText: nullableStringValidator,
heroImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
})

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import { imageSchema } from "@/server/routers/hotels/schemas/image"
const imagesSchema = z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
export const gallerySchema = z.object({
heroImages: imagesSchema,
smallerImages: imagesSchema,
})

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const restaurantsOverviewPageSchema = z.object({
restaurantsContentDescriptionMedium: nullableStringValidator,
restaurantsContentDescriptionShort: nullableStringValidator,
restaurantsOverviewPageLink: nullableStringValidator,
restaurantsOverviewPageLinkText: nullableStringValidator,
})

View File

@@ -0,0 +1,14 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
const specialNeedSchema = z.object({
details: nullableStringValidator,
name: nullableStringValidator,
})
export const specialNeedGroupSchema = z.object({
name: nullableStringValidator,
specialNeeds: nullableArrayObjectValidator(specialNeedSchema),
})

View File

@@ -8,10 +8,7 @@ import {
transformRoomCategories, transformRoomCategories,
} from "@/server/routers/hotels/schemas/hotel/include/roomCategories" } from "@/server/routers/hotels/schemas/hotel/include/roomCategories"
import { import { additionalDataSchema, transformAdditionalData } from "./additionalData"
additionalDataSchema,
transformAdditionalData,
} from "../../additionalData"
export const includeSchema = z export const includeSchema = z
.discriminatedUnion("type", [ .discriminatedUnion("type", [

View File

@@ -1,6 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { imageMetaDataSchema, imageSizesSchema } from "../../image" import { imageSchema } from "@/server/routers/hotels/schemas/image"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
const minMaxSchema = z.object({ const minMaxSchema = z.object({
max: z.number(), max: z.number(),
@@ -8,8 +11,8 @@ const minMaxSchema = z.object({
}) })
const bedTypeSchema = z.object({ const bedTypeSchema = z.object({
description: z.string().default(""), description: nullableStringValidator,
type: z.string(), type: nullableStringValidator,
widthRange: minMaxSchema, widthRange: minMaxSchema,
}) })
@@ -20,28 +23,26 @@ const occupancySchema = z.object({
}) })
const roomContentSchema = z.object({ const roomContentSchema = z.object({
images: z.array( images: z
z.object({ .array(imageSchema)
imageSizes: imageSizesSchema, .nullish()
metaData: imageMetaDataSchema, .transform((arr) => (arr ? arr.filter(Boolean) : [])),
})
),
texts: z.object({ texts: z.object({
descriptions: z.object({ descriptions: z.object({
medium: z.string().optional(), medium: nullableStringValidator,
short: z.string().optional(), short: nullableStringValidator,
}), }),
}), }),
}) })
const roomTypesSchema = z.object({ const roomTypesSchema = z.object({
code: z.string(), code: nullableStringValidator,
description: z.string(), description: nullableStringValidator,
fixedExtraBed: bedTypeSchema, fixedExtraBed: bedTypeSchema,
isLackingCribs: z.boolean(), isLackingCribs: z.boolean(),
isLackingExtraBeds: z.boolean(), isLackingExtraBeds: z.boolean(),
mainBed: bedTypeSchema, mainBed: bedTypeSchema,
name: z.string(), name: nullableStringValidator,
occupancy: occupancySchema, occupancy: occupancySchema,
roomCount: z.number(), roomCount: z.number(),
roomSize: minMaxSchema, roomSize: minMaxSchema,
@@ -58,11 +59,11 @@ const roomFacilitiesSchema = z.object({
export const roomCategoriesSchema = z.object({ export const roomCategoriesSchema = z.object({
attributes: z.object({ attributes: z.object({
content: roomContentSchema, content: roomContentSchema,
name: z.string(), name: nullableStringValidator,
occupancy: minMaxSchema, occupancy: minMaxSchema,
roomFacilities: z.array(roomFacilitiesSchema), roomFacilities: nullableArrayObjectValidator(roomFacilitiesSchema),
roomSize: minMaxSchema, roomSize: minMaxSchema,
roomTypes: z.array(roomTypesSchema), roomTypes: nullableArrayObjectValidator(roomTypesSchema),
sortOrder: z.number(), sortOrder: z.number(),
}), }),
id: z.string(), id: z.string(),

View File

@@ -1,5 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import type { PaymentMethodEnum } from "@/constants/booking" import type { PaymentMethodEnum } from "@/constants/booking"
export const merchantInformationSchema = z.object({ export const merchantInformationSchema = z.object({
@@ -17,5 +19,5 @@ export const merchantInformationSchema = z.object({
.map(([key]) => key) .map(([key]) => key)
.filter((key): key is PaymentMethodEnum => !!key) .filter((key): key is PaymentMethodEnum => !!key)
}), }),
webMerchantId: z.string().optional(), webMerchantId: nullableStringValidator,
}) })

View File

@@ -1,41 +1,45 @@
import { z } from "zod" import { z } from "zod"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
const periodSchema = z.object({ const periodSchema = z.object({
amount: z.number().optional(), amount: nullableNumberValidator,
endTime: z.string().optional(), endTime: nullableStringValidator,
period: z.string().optional(), period: nullableStringValidator,
startTime: z.string().optional(), startTime: nullableStringValidator,
}) })
const currencySchema = z const currencySchema = z
.object({ .object({
currency: z.string().optional(), currency: nullableStringValidator,
ordinary: z.array(periodSchema).optional(), ordinary: nullableArrayObjectValidator(periodSchema),
range: z range: z
.object({ .object({
min: z.number().optional(), min: nullableNumberValidator,
max: z.number().optional(), max: nullableNumberValidator,
}) })
.optional(), .nullish(),
weekend: z.array(periodSchema).optional(), weekend: nullableArrayObjectValidator(periodSchema),
}) })
.optional() .nullish()
const pricingSchema = z.object({ const pricingSchema = z.object({
freeParking: z.boolean(), freeParking: z.boolean(),
localCurrency: currencySchema, localCurrency: currencySchema,
paymentType: z.string().optional(), paymentType: nullableStringValidator,
requestedCurrency: currencySchema, requestedCurrency: currencySchema,
}) })
export const parkingSchema = z.object({ export const parkingSchema = z.object({
address: z.string().optional(), address: nullableStringValidator,
canMakeReservation: z.boolean(), canMakeReservation: z.boolean(),
distanceToHotel: z.number().optional(), distanceToHotel: nullableNumberValidator,
externalParkingUrl: z.string().optional(), externalParkingUrl: nullableStringValidator,
name: z.string().optional(), name: nullableStringValidator,
numberOfChargingSpaces: z.number().optional(), numberOfChargingSpaces: nullableNumberValidator,
numberOfParkingSpots: z.number().optional(), numberOfParkingSpots: nullableNumberValidator,
pricing: pricingSchema, pricing: pricingSchema,
type: z.string().optional(), type: nullableStringValidator,
}) })

View File

@@ -1,24 +1,25 @@
import { z } from "zod" import { z } from "zod"
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { getPoiGroupByCategoryName } from "../../utils" import { getPoiGroupByCategoryName } from "../../utils"
import { locationSchema } from "./location" import { locationSchema } from "./location"
export const pointOfInterestSchema = z export const pointOfInterestSchema = z
.object({ .object({
category: z.object({ category: z.object({
name: z.string().optional(), name: nullableStringValidator,
group: z.string().optional(),
}), }),
distance: z.number().optional(), distance: nullableNumberValidator,
isHighlighted: z.boolean().optional(), location: locationSchema,
location: locationSchema.optional(), name: nullableStringValidator,
name: z.string().optional(),
}) })
.transform((poi) => ({ .transform((poi) => ({
categoryName: poi.category.name, categoryName: poi.category.name,
coordinates: { coordinates: {
lat: poi.location?.latitude ?? 0, lat: poi.location.latitude,
lng: poi.location?.longitude ?? 0, lng: poi.location.longitude,
}, },
distance: poi.distance, distance: poi.distance,
group: getPoiGroupByCategoryName(poi.category.name), group: getPoiGroupByCategoryName(poi.category.name),
@@ -27,6 +28,8 @@ export const pointOfInterestSchema = z
export const pointOfInterestsSchema = z export const pointOfInterestsSchema = z
.array(pointOfInterestSchema) .array(pointOfInterestSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
.transform((pois) => .transform((pois) =>
pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0)) pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))
) )

View File

@@ -1,31 +1,57 @@
import { z } from "zod" import { z } from "zod"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import {
nullableStringUrlValidator,
nullableStringValidator,
} from "@/utils/zod/stringValidator"
const awardSchema = z.object({ const awardSchema = z.object({
displayName: z.string(), displayName: nullableStringValidator,
images: z.object({ images: z
large: z.string(), .object({
medium: z.string(), large: nullableStringValidator,
small: z.string(), medium: nullableStringValidator,
}), small: nullableStringValidator,
})
.nullish()
.transform((obj) =>
obj
? obj
: {
small: "",
medium: "",
large: "",
}
),
}) })
const reviewsSchema = z const reviewsSchema = z
.object({ .object({
widgetHtmlTagId: z.string(), widgetHtmlTagId: nullableStringValidator,
widgetScriptEmbedUrlIframe: z.string(), widgetScriptEmbedUrlIframe: nullableStringValidator,
widgetScriptEmbedUrlJavaScript: z.string(), widgetScriptEmbedUrlJavaScript: nullableStringValidator,
}) })
.optional() .nullish()
.transform((obj) =>
obj
? obj
: {
widgetHtmlTagId: "",
widgetScriptEmbedUrlIframe: "",
widgetScriptEmbedUrlJavaScript: "",
}
)
export const ratingsSchema = z export const ratingsSchema = z
.object({ .object({
tripAdvisor: z.object({ tripAdvisor: z.object({
awards: z.array(awardSchema), awards: nullableArrayObjectValidator(awardSchema),
numberOfReviews: z.number(), numberOfReviews: z.number(),
rating: z.number(), rating: z.number(),
ratingImageUrl: z.string(), ratingImageUrl: nullableStringUrlValidator,
reviews: reviewsSchema, reviews: reviewsSchema,
webUrl: z.string(), webUrl: nullableStringUrlValidator,
}), }),
}) })
.optional() .optional()

View File

@@ -1,10 +1,12 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const rewardNightSchema = z.object({ export const rewardNightSchema = z.object({
campaign: z.object({ campaign: z.object({
end: z.string(), end: nullableStringValidator,
points: z.number(), points: z.number(),
start: z.string(), start: nullableStringValidator,
}), }),
points: z.number(), points: z.number(),
}) })

View File

@@ -1,6 +1,8 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const socialMediaSchema = z.object({ export const socialMediaSchema = z.object({
facebook: z.string().optional(), facebook: nullableStringValidator,
instagram: z.string().optional(), instagram: nullableStringValidator,
}) })

View File

@@ -17,6 +17,8 @@ const specialAlertSchema = z.object({
export const specialAlertsSchema = z export const specialAlertsSchema = z
.array(specialAlertSchema) .array(specialAlertSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
.transform((data) => { .transform((data) => {
const now = dt().utc().format("YYYY-MM-DD") const now = dt().utc().format("YYYY-MM-DD")
const filteredAlerts = data.filter((alert) => { const filteredAlerts = data.filter((alert) => {
@@ -35,4 +37,3 @@ export const specialAlertsSchema = z
displayInBookingFlow: alert.displayInBookingFlow, displayInBookingFlow: alert.displayInBookingFlow,
})) }))
}) })
.default([])

View File

@@ -1,11 +0,0 @@
import { z } from "zod"
const specialNeedSchema = z.object({
details: z.string(),
name: z.string(),
})
export const specialNeedGroupSchema = z.object({
name: z.string(),
specialNeeds: z.array(specialNeedSchema),
})

View File

@@ -1,25 +1,27 @@
import { z } from "zod" import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
export const imageSizesSchema = z.object({ export const imageSizesSchema = z.object({
large: z.string(), large: nullableStringValidator,
medium: z.string(), medium: nullableStringValidator,
small: z.string(), small: nullableStringValidator,
tiny: z.string(), tiny: nullableStringValidator,
}) })
export const imageMetaDataSchema = z.object({ export const imageMetaDataSchema = z.object({
altText: z.string(), altText: nullableStringValidator,
altText_En: z.string(), altText_En: nullableStringValidator,
copyRight: z.string(), copyRight: nullableStringValidator,
title: z.string(), title: nullableStringValidator,
}) })
const DEFAULT_IMAGE_OBJ = { const DEFAULT_IMAGE_OBJ = {
metaData: { metaData: {
title: "Default image",
altText: "Default image", altText: "Default image",
altText_En: "Default image", altText_En: "Default image",
copyRight: "Default image", copyRight: "Default image",
title: "Default image",
}, },
imageSizes: { imageSizes: {
tiny: "https://placehold.co/1280x720", tiny: "https://placehold.co/1280x720",
@@ -31,11 +33,10 @@ const DEFAULT_IMAGE_OBJ = {
export const imageSchema = z export const imageSchema = z
.object({ .object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema, imageSizes: imageSizesSchema,
metaData: imageMetaDataSchema,
}) })
.default(DEFAULT_IMAGE_OBJ) .nullish()
.nullable()
.transform((val) => { .transform((val) => {
if (!val) { if (!val) {
return DEFAULT_IMAGE_OBJ return DEFAULT_IMAGE_OBJ

View File

@@ -17,7 +17,6 @@ import {
} from "./output" } from "./output"
import { getHotel } from "./query" import { getHotel } from "./query"
import type { Country } from "@/types/enums/country"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest" import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelDataWithUrl } from "@/types/hotel"

View File

@@ -120,6 +120,19 @@ export function createRatesStore({
return create<RatesState>()((set) => ({ return create<RatesState>()((set) => ({
actions: { actions: {
closeSection(idx) {
return function () {
return set(
produce((state: RatesState) => {
if (state.rateSummary.length === state.booking.rooms.length) {
state.activeRoom = -1
} else {
state.activeRoom = idx + 1
}
})
)
}
},
modifyRate(idx) { modifyRate(idx) {
return function () { return function () {
return set( return set(
@@ -201,10 +214,11 @@ export function createRatesStore({
selectedRate.roomTypeCode selectedRate.roomTypeCode
) )
state.activeRoom = if (state.rateSummary.length === state.booking.rooms.length) {
idx + 1 < state.booking.rooms.length state.activeRoom = -1
? idx + 1 } else {
: state.booking.rooms.length state.activeRoom = idx + 1
}
state.searchParams = new ReadonlyURLSearchParams(searchParams) state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState( window.history.pushState(

View File

@@ -1,4 +1,4 @@
import { Hotel } from "@/types/hotel" import type { Hotel } from "@/types/hotel"
export type CategorizedFilters = { export type CategorizedFilters = {
facilityFilters: Hotel["detailedFacilities"] facilityFilters: Hotel["detailedFacilities"]
@@ -9,15 +9,6 @@ export type HotelFiltersProps = {
className?: string className?: string
} }
export type Filter = {
name: string
id: number
public: boolean
sortOrder: number
filter?: string
icon: string
}
export type HotelFilterModalProps = { export type HotelFilterModalProps = {
filters: CategorizedFilters filters: CategorizedFilters
} }

View File

@@ -1,11 +1,10 @@
import type { z } from "zod" import type { z } from "zod"
import type { Coordinates } from "@/types/components/maps/coordinates" import type { Coordinates } from "@/types/components/maps/coordinates"
import type { Location } from "@/types/trpc/routers/hotel/locations" import type { Amenities } from "@/types/hotel"
import type { imageSchema } from "@/server/routers/hotels/schemas/image" import type { imageSchema } from "@/server/routers/hotels/schemas/image"
import type { Child } from "../selectRate/selectRate"
import type { HotelData } from "./hotelCardListingProps" import type { HotelData } from "./hotelCardListingProps"
import type { CategorizedFilters, Filter } from "./hotelFilters" import type { CategorizedFilters } from "./hotelFilters"
import type { import type {
AlternativeHotelsSearchParams, AlternativeHotelsSearchParams,
SelectHotelSearchParams, SelectHotelSearchParams,
@@ -39,7 +38,7 @@ export type HotelPin = {
imageSizes: ImageSizes imageSizes: ImageSizes
metaData: ImageMetaData metaData: ImageMetaData
}[] }[]
amenities: Filter[] amenities: Amenities
ratings: number | null ratings: number | null
operaId: string operaId: string
facilityIds: number[] facilityIds: number[]

View File

@@ -2,4 +2,5 @@ export type ToggleSidePeekProps = {
hotelId: string hotelId: string
roomTypeCode?: string roomTypeCode?: string
intent?: "text" | "textInverted" intent?: "text" | "textInverted"
title?: string
} }

View File

@@ -1,7 +1,5 @@
import type { ImageProps as NextImageProps } from "next/image" import type { ImageProps as NextImageProps } from "next/image"
import type { ImageVaultAsset } from "./imageVault"
export interface FocalPoint { export interface FocalPoint {
x: number x: number
y: number y: number

View File

@@ -3,6 +3,7 @@ import type { SelectedRate, SelectedRoom } from "@/types/stores/rates"
export interface RoomContextValue extends SelectedRoom { export interface RoomContextValue extends SelectedRoom {
actions: { actions: {
closeSection: () => void
modifyRate: () => void modifyRate: () => void
selectFilter: (code: RoomPackageCodeEnum | undefined) => void selectFilter: (code: RoomPackageCodeEnum | undefined) => void
selectRate: (rate: SelectedRate) => void selectRate: (rate: SelectedRate) => void

View File

@@ -1,18 +1,21 @@
import type { z } from "zod" import type { z } from "zod"
import type { hotelSchema } from "@/server/routers/hotels/output" import type { hotelSchema } from "@/server/routers/hotels/output"
import type {
extraPageSchema,
facilitySchema,
transformAdditionalData,
} from "@/server/routers/hotels/schemas/additionalData"
import type { citySchema } from "@/server/routers/hotels/schemas/city" import type { citySchema } from "@/server/routers/hotels/schemas/city"
import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel" import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel"
import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address" import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address"
import type { hotelContentSchema } from "@/server/routers/hotels/schemas/hotel/content" import type { hotelContentSchema } from "@/server/routers/hotels/schemas/hotel/content"
import type { detailedFacilitiesSchema } from "@/server/routers/hotels/schemas/hotel/detailedFacility" import type {
detailedFacilitiesSchema,
detailedFacilitySchema,
} from "@/server/routers/hotels/schemas/hotel/detailedFacility"
import type { checkinSchema } from "@/server/routers/hotels/schemas/hotel/facts" import type { checkinSchema } from "@/server/routers/hotels/schemas/hotel/facts"
import type { healthFacilitySchema } from "@/server/routers/hotels/schemas/hotel/healthFacilities" import type { healthFacilitySchema } from "@/server/routers/hotels/schemas/hotel/healthFacilities"
import type {
extraPageSchema,
transformAdditionalData,
} from "@/server/routers/hotels/schemas/hotel/include/additionalData"
import type { facilitySchema } from "@/server/routers/hotels/schemas/hotel/include/additionalData/facility"
import type { nearbyHotelsSchema } from "@/server/routers/hotels/schemas/hotel/include/nearbyHotels" import type { nearbyHotelsSchema } from "@/server/routers/hotels/schemas/hotel/include/nearbyHotels"
import type { import type {
openingHoursDetailsSchema, openingHoursDetailsSchema,
@@ -35,6 +38,7 @@ export type City = Pick<CitySchema, "id" | "type"> & CitySchema["attributes"]
export type FacilityData = z.output<typeof facilitySchema> export type FacilityData = z.output<typeof facilitySchema>
export type Facility = FacilityData & { id: string } export type Facility = FacilityData & { id: string }
export type ApiImage = z.output<typeof imageSchema> export type ApiImage = z.output<typeof imageSchema>
export type DetailedFacility = z.output<typeof detailedFacilitySchema>
export type HealthFacility = z.output<typeof healthFacilitySchema> export type HealthFacility = z.output<typeof healthFacilitySchema>
export type HealthFacilities = HealthFacility[] export type HealthFacilities = HealthFacility[]
export type Hotel = z.output<typeof attributesSchema> export type Hotel = z.output<typeof attributesSchema>

View File

@@ -18,6 +18,7 @@ import type {
} from "@/types/trpc/routers/hotel/roomAvailability" } from "@/types/trpc/routers/hotel/roomAvailability"
interface Actions { interface Actions {
closeSection: (idx: number) => () => void
modifyRate: (idx: number) => () => void modifyRate: (idx: number) => () => void
selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void
selectRate: (idx: number) => (rate: SelectedRate) => void selectRate: (idx: number) => (rate: SelectedRate) => void

View File

@@ -0,0 +1,17 @@
import { nullableStringValidator } from "./stringValidator"
import type { ZodObject, ZodRawShape } from "zod"
export function nullableArrayObjectValidator<T extends ZodRawShape>(
schema: ZodObject<T>
) {
return schema
.array()
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
}
export const nullableArrayStringValidator = nullableStringValidator
.array()
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))

View File

@@ -5,6 +5,12 @@ export const nullableStringValidator = z
.nullish() .nullish()
.transform((str) => (str ? str : "")) .transform((str) => (str ? str : ""))
export const nullableStringEmailValidator = z
.string()
.email()
.nullish()
.transform((str) => (str ? str : ""))
export const nullableStringUrlValidator = z export const nullableStringUrlValidator = z
.string() .string()
.url() .url()