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,
NullableHotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type {
CategorizedFilters,
Filter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { DetailedFacility } from "@/types/hotel"
import type { HotelsAvailabilityItem } from "@/types/trpc/routers/hotel/availability"
const hotelSurroundingsFilterNames = [
@@ -111,9 +109,9 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
})
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))
.filter((filter): filter is Filter => filter !== undefined)
.filter((filter): filter is DetailedFacility => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedFilters>(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,50 +1,53 @@
.selectedRoomPanel {
display: flex;
flex-direction: row;
justify-content: space-between;
display: grid;
grid-template-areas: "content image";
grid-template-columns: 1fr 190px;
position: relative;
}
.modifyButtonContainer {
position: absolute;
right: var(--Spacing-x2);
bottom: var(--Spacing-x2);
.content {
grid-area: content;
}
.imageContainer {
width: 187px;
height: 105px;
position: relative;
border-radius: var(--Corner-radius-Small);
overflow: hidden;
display: flex;
grid-area: image;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
.img {
border-radius: var(--Corner-radius-Small);
height: auto;
max-height: 105px;
object-fit: fill;
width: 100%;
}
.modifyButtonContainer {
bottom: var(--Spacing-x1);
position: absolute;
right: var(--Spacing-x1);
}
div.selectedRoomPanel p.subtitle {
padding-bottom: var(--Spacing-x1);
}
@media (max-width: 768px) {
.imageContainer {
width: 120px;
height: 80px;
}
.imageAndModifyButtonContainer {
display: flex;
flex-direction: column;
align-items: flex-end;
@media screen and (max-width: 768px) {
.selectedRoomPanel {
gap: var(--Spacing-x1);
grid-template-areas: "image" "content";
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.modifyButtonContainer {
position: relative;
bottom: 0;
right: 0;
.img {
max-height: 300px;
}
}
@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 { ChevronUpIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
@@ -17,7 +19,13 @@ export default function MultiRoomWrapper({
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
const intl = useIntl()
const activeRoom = useRatesStore((state) => state.activeRoom)
const { bookingRoom, isActiveRoom, roomNr, selectedRate } = useRoomContext()
const {
actions: { closeSection },
bookingRoom,
isActiveRoom,
roomNr,
selectedRate,
} = useRoomContext()
const onlyAdultsMsg = intl.formatMessage(
{ id: "{adults} adults" },
@@ -56,19 +64,33 @@ export default function MultiRoomWrapper({
selected: !!selectedRate && !isActiveRoom,
})
return (
<div className={styles.roomContainer}>
{selectedRate && !isActiveRoom ? null : (
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: roomNr }
)}
,{" "}
{bookingRoom.childrenInRoom?.length
? adultsAndChildrenMsg
: onlyAdultsMsg}
</Subtitle>
)}
<div className={styles.roomContainer} data-multiroom="true">
<div className={styles.header}>
{selectedRate && !isActiveRoom ? null : (
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: roomNr }
)}
,{" "}
{bookingRoom.childrenInRoom?.length
? adultsAndChildrenMsg
: 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={styles.roomPanel}>
<SelectedRoomPanel />

View File

@@ -4,7 +4,13 @@
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
padding: var(--Spacing-x2);
padding: var(--Spacing-x3);
}
.header {
align-items: center;
display: flex;
justify-content: space-between;
}
.roomPanel,
@@ -27,27 +33,21 @@
gap: var(--Spacing-x2);
}
.roomSelectionPanelContainer.active .roomSelectionPanel,
.roomSelectionPanelContainer.selected .roomPanel {
grid-template-rows: 1fr;
opacity: 1;
height: auto;
opacity: 1;
}
.roomSelectionPanelContainer.active .roomPanel {
padding-top: var(--Spacing-x1);
}
.roomSelectionPanelContainer.selected .roomSelectionPanel {
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) {
.roomContainer {
padding: var(--Spacing-x2);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,10 +55,12 @@ a.text {
border: none;
outline: none;
}
/* TODO: The variants for combinations of size/text/wrapping should be looked at and iterated on */
.text:not(.wrapping) {
padding: 0 !important;
}
/* VARIANTS */
.default,
a.default {
@@ -827,3 +829,15 @@ a.default {
.icon.tertiaryLightSecondary:disabled svg * {
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) {
const {
className,
clean,
intent,
size,
theme,
@@ -21,6 +22,7 @@ export default function Button(props: ButtonProps) {
const classNames = buttonVariants({
className,
clean,
intent,
size,
theme,

View File

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

View File

@@ -1,14 +1,21 @@
div.chip {
--chip-text-color: var(--Base-Text-High-contrast);
--chip-background-color: var(--Base-Surface-Primary-light-Normal);
display: flex;
justify-content: 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);
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 *,
@@ -29,3 +36,8 @@ div.chip {
.chip.tag {
--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"
export default function Chip({ children, className, variant }: ChipProps) {
export default function Chip({
children,
className,
size,
variant,
}: ChipProps) {
const classNames = chipVariants({
className,
size,
variant,
})
return (

View File

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

View File

@@ -716,6 +716,8 @@
"booking.thisRoomIsEquippedWith": "Dette værelse er udstyret med",
"friday": "fredag",
"from": "fra",
"guests.plural": "{guests, plural, one {# gæst} other {# gæster}}",
"guests.span": "{min}-{max} gæster",
"max {seatings} pers": "max {seatings} pers",
"monday": "mandag",
"next level: {nextLevel}": "next level: {nextLevel}",
@@ -782,4 +784,4 @@
"{value} persons": "{number} Personen",
"{value} square meters": "{number} Quadratmeter",
"© {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",
"friday": "freitag",
"from": "von",
"guests.plural": "{guests, plural, one {# gast} other {# gäste}}",
"guests.span": "{min}-{max} gäste",
"max {seatings} pers": "max {seatings} pers",
"monday": "montag",
"next level: {nextLevel}": "next level: {nextLevel}",
@@ -783,4 +785,4 @@
"{value} persons": "{number} personer",
"{value} square meters": "{number} kvadratmeter",
"© {currentYear} Scandic AB All rights reserved": "© {currentYear} Scandic AB Alle Rechte vorbehalten"
}
}

View File

@@ -521,6 +521,7 @@
"Room": "Room",
"Room & Terms": "Room & Terms",
"Room charge": "Room charge",
"Room details": "Room details",
"Room facilities": "Room facilities",
"Room total": "Room total",
"Room {roomIndex}": "Room {roomIndex}",
@@ -720,6 +721,8 @@
"cm": "cm",
"friday": "friday",
"from": "from",
"guests.plural": "{guests, plural, one {# guest} other {# guests}}",
"guests.span": "{min}-{max} guests",
"max {seatings} pers": "max {seatings} pers",
"monday": "monday",
"next level: {nextLevel}": "next level: {nextLevel}",
@@ -788,4 +791,4 @@
"{value} persons": "{value} persons",
"{value} square meters": "{value} square meters",
"© {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",
"friday": "perjantai",
"from": "alkaa",
"guests.plural": "{guests, plural, one {# vieras} other {# vieraita}}",
"guests.span": "{min}-{max} vieraita",
"max {seatings} pers": "max {seatings} pers",
"monday": "maanantai",
"next level: {nextLevel}": "next level: {nextLevel}",
@@ -783,4 +785,4 @@
"{value} persons": "{number} henkilöä",
"{value} square meters": "{number} neliömetriä",
"© {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",
"friday": "fredag",
"from": "fra",
"guests.plural": "{guests, plural, one {# gjest} other {# gjester}}",
"guests.span": "{min}-{max} gjester",
"max {seatings} pers": "max {seatings} pers",
"monday": "mandag",
"next level: {nextLevel}": "next level: {nextLevel}",
@@ -781,4 +783,4 @@
"{value} persons": "{number} personer",
"{value} square meters": "{number} kvadratmeter",
"© {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",
"friday": "fredag",
"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",
"monday": "måndag",
"next level: {nextLevel}": "next level: {nextLevel}",
@@ -783,4 +785,4 @@
"{value} persons": "{number} personer",
"{value} square meters": "{number} kvadratmeter",
"© {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,
}: RoomProviderProps) {
const activeRoom = useRatesStore((state) => state.activeRoom)
const closeSection = useRatesStore((state) => state.actions.closeSection(idx))
const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx))
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
@@ -21,6 +22,7 @@ export default function RoomProvider({
value={{
...room,
actions: {
closeSection,
modifyRate,
selectFilter,
selectRate,

View File

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

View File

@@ -18,7 +18,7 @@ import { cache } from "@/utils/cache"
import { getHotelPageUrl } from "../contentstack/hotelPage/utils"
import { getVerifiedUser, parsedUser } from "../user/query"
import { additionalDataSchema } from "./schemas/additionalData"
import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
import { meetingRoomsSchema } from "./schemas/meetingRoom"
import {
ancillaryPackageInputSchema,
@@ -510,8 +510,8 @@ export const hotelQueryRouter = router({
adults: adultCount,
...(childArray &&
childArray.length > 0 && {
children: childArray.join(","),
}),
children: childArray.join(","),
}),
...(bookingCode && { bookingCode }),
language: apiLang,
}
@@ -754,9 +754,9 @@ export const hotelQueryRouter = router({
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
@@ -855,7 +855,7 @@ export const hotelQueryRouter = 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
// const { hotelId } = input
@@ -1112,9 +1112,9 @@ export const hotelQueryRouter = router({
return hotelData
? {
...hotelData,
url,
}
...hotelData,
url,
}
: 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 {
nullableArrayObjectValidator,
nullableArrayStringValidator,
} from "@/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { addressSchema } from "./hotel/address"
import { contactInformationSchema } from "./hotel/contactInformation"
import { hotelContentSchema } from "./hotel/content"
import { detailedFacilitiesSchema } from "./hotel/detailedFacility"
import { hotelFactsSchema } from "./hotel/facts"
import { gallerySchema } from "./hotel/gallery"
import { healthFacilitySchema } from "./hotel/healthFacilities"
import { healthFacilitiesSchema } 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 { locationSchema } from "./hotel/location"
import { merchantInformationSchema } from "./hotel/merchantInformation"
@@ -18,41 +25,41 @@ import { ratingsSchema } from "./hotel/rating"
import { rewardNightSchema } from "./hotel/rewardNight"
import { socialMediaSchema } from "./hotel/socialMedia"
import { specialAlertsSchema } from "./hotel/specialAlerts"
import { specialNeedGroupSchema } from "./hotel/specialNeedGroups"
import { facilitySchema } from "./additionalData"
import { imageSchema } from "./image"
export const attributesSchema = z.object({
id: z.string().optional(),
address: addressSchema,
cityId: z.string(),
cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(),
cityId: nullableStringValidator,
cityName: nullableStringValidator,
conferencesAndMeetings: facilitySchema.nullish(),
contactInformation: contactInformationSchema,
countryCode: nullableStringValidator,
detailedFacilities: detailedFacilitiesSchema,
gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(),
healthFacilities: z.array(healthFacilitySchema),
displayWebPage: displayWebPageSchema,
gallery: gallerySchema.nullish(),
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
healthAndWellness: facilitySchema.nullish(),
healthFacilities: healthFacilitiesSchema,
hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema,
hotelRoomElevatorPitchText: z.string().optional(),
hotelType: z.string().optional(),
hotelType: nullableStringValidator,
isActive: z.boolean(),
isPublished: z.boolean(),
keywords: z.array(z.string()),
keywords: nullableArrayStringValidator,
location: locationSchema,
merchantInformationData: merchantInformationSchema,
name: z.string(),
operaId: z.string(),
parking: z.array(parkingSchema),
name: nullableStringValidator,
operaId: nullableStringValidator,
parking: nullableArrayObjectValidator(parkingSchema),
pointsOfInterest: pointOfInterestsSchema,
ratings: ratingsSchema,
restaurantImages: facilitySchema.nullish(),
rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema,
specialNeedGroups: z.array(specialNeedGroupSchema),
vat: nullableNumberValidator,
})

View File

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

View File

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

View File

@@ -1,40 +1,26 @@
import { z } from "zod"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
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({
images: imageSchema.default({
metaData: {
altText: "default image",
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(),
}),
images: imageSchema,
restaurantsOverviewPage: restaurantsOverviewPageSchema,
texts: textsSchema,
})

View File

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

View File

@@ -1,50 +1,19 @@
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({
euEcoLabel: z.boolean(),
greenGlobeLabel: z.boolean(),
nordicEcoLabel: z.boolean(),
svanenEcoLabelCertificateNumber: z.string().optional(),
})
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,
svanenEcoLabelCertificateNumber: nullableStringValidator,
})
const interiorSchema = z.object({
@@ -53,7 +22,7 @@ const interiorSchema = z.object({
numberOfFloors: z.number(),
numberOfRooms: z.object({
connected: z.number(),
forAllergics: z.number().optional(),
forAllergics: z.number(),
forDisabled: z.number(),
nonSmoking: z.number(),
pet: z.number(),
@@ -64,17 +33,15 @@ const interiorSchema = z.object({
const receptionHoursSchema = z.object({
alwaysOpen: z.boolean(),
closingTime: nullableStringValidator,
isClosed: z.boolean(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
openingTime: nullableStringValidator,
})
export const hotelFactsSchema = z.object({
checkin: checkinSchema,
ecoLabels: ecoLabelsSchema,
hotelFacilityDetail: hotelFacilityDetailsSchema.default({}),
hotelInformation: hotelInformationsSchema.default({}),
interior: interiorSchema,
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 { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { imageSchema } from "../image"
const healthFacilitiesOpenHoursSchema = z.object({
alwaysOpen: z.boolean(),
closingTime: z.string().optional(),
closingTime: nullableStringValidator,
isClosed: z.boolean(),
openingTime: z.string().optional(),
sortOrder: z.number().optional(),
openingTime: nullableStringValidator,
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({
content: z.object({
images: z.array(imageSchema),
texts: z.object({
descriptions: z.object({
short: z.string(),
medium: z.string(),
}),
facilityInformation: z.string().optional(),
surroundingInformation: z.string().optional(),
}),
images: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
texts: textsSchema,
}),
details: z.array(
z.object({
name: z.string(),
type: z.string(),
value: z.string().optional(),
})
),
details: nullableArrayObjectValidator(detailsSchema),
openingDetails: z.object({
manualOpeningHours: z.string().optional(),
manualOpeningHours: nullableStringValidator,
openingHours: z.object({
ordinary: 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,
} from "@/server/routers/hotels/schemas/hotel/include/roomCategories"
import {
additionalDataSchema,
transformAdditionalData,
} from "../../additionalData"
import { additionalDataSchema, transformAdditionalData } from "./additionalData"
export const includeSchema = z
.discriminatedUnion("type", [

View File

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

View File

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

View File

@@ -1,41 +1,45 @@
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({
amount: z.number().optional(),
endTime: z.string().optional(),
period: z.string().optional(),
startTime: z.string().optional(),
amount: nullableNumberValidator,
endTime: nullableStringValidator,
period: nullableStringValidator,
startTime: nullableStringValidator,
})
const currencySchema = z
.object({
currency: z.string().optional(),
ordinary: z.array(periodSchema).optional(),
currency: nullableStringValidator,
ordinary: nullableArrayObjectValidator(periodSchema),
range: z
.object({
min: z.number().optional(),
max: z.number().optional(),
min: nullableNumberValidator,
max: nullableNumberValidator,
})
.optional(),
weekend: z.array(periodSchema).optional(),
.nullish(),
weekend: nullableArrayObjectValidator(periodSchema),
})
.optional()
.nullish()
const pricingSchema = z.object({
freeParking: z.boolean(),
localCurrency: currencySchema,
paymentType: z.string().optional(),
paymentType: nullableStringValidator,
requestedCurrency: currencySchema,
})
export const parkingSchema = z.object({
address: z.string().optional(),
address: nullableStringValidator,
canMakeReservation: z.boolean(),
distanceToHotel: z.number().optional(),
externalParkingUrl: z.string().optional(),
name: z.string().optional(),
numberOfChargingSpaces: z.number().optional(),
numberOfParkingSpots: z.number().optional(),
distanceToHotel: nullableNumberValidator,
externalParkingUrl: nullableStringValidator,
name: nullableStringValidator,
numberOfChargingSpaces: nullableNumberValidator,
numberOfParkingSpots: nullableNumberValidator,
pricing: pricingSchema,
type: z.string().optional(),
type: nullableStringValidator,
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ const specialAlertSchema = z.object({
export const specialAlertsSchema = z
.array(specialAlertSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
.transform((data) => {
const now = dt().utc().format("YYYY-MM-DD")
const filteredAlerts = data.filter((alert) => {
@@ -35,4 +37,3 @@ export const specialAlertsSchema = z
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 { nullableStringValidator } from "@/utils/zod/stringValidator"
export const imageSizesSchema = z.object({
large: z.string(),
medium: z.string(),
small: z.string(),
tiny: z.string(),
large: nullableStringValidator,
medium: nullableStringValidator,
small: nullableStringValidator,
tiny: nullableStringValidator,
})
export const imageMetaDataSchema = z.object({
altText: z.string(),
altText_En: z.string(),
copyRight: z.string(),
title: z.string(),
altText: nullableStringValidator,
altText_En: nullableStringValidator,
copyRight: nullableStringValidator,
title: nullableStringValidator,
})
const DEFAULT_IMAGE_OBJ = {
metaData: {
title: "Default image",
altText: "Default image",
altText_En: "Default image",
copyRight: "Default image",
title: "Default image",
},
imageSizes: {
tiny: "https://placehold.co/1280x720",
@@ -31,11 +33,10 @@ const DEFAULT_IMAGE_OBJ = {
export const imageSchema = z
.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
metaData: imageMetaDataSchema,
})
.default(DEFAULT_IMAGE_OBJ)
.nullable()
.nullish()
.transform((val) => {
if (!val) {
return DEFAULT_IMAGE_OBJ

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import type { z } from "zod"
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 { Child } from "../selectRate/selectRate"
import type { HotelData } from "./hotelCardListingProps"
import type { CategorizedFilters, Filter } from "./hotelFilters"
import type { CategorizedFilters } from "./hotelFilters"
import type {
AlternativeHotelsSearchParams,
SelectHotelSearchParams,
@@ -39,7 +38,7 @@ export type HotelPin = {
imageSizes: ImageSizes
metaData: ImageMetaData
}[]
amenities: Filter[]
amenities: Amenities
ratings: number | null
operaId: string
facilityIds: number[]

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,21 @@
import type { z } from "zod"
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 { attributesSchema } from "@/server/routers/hotels/schemas/hotel"
import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address"
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 { 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 {
openingHoursDetailsSchema,
@@ -35,6 +38,7 @@ export type City = Pick<CitySchema, "id" | "type"> & CitySchema["attributes"]
export type FacilityData = z.output<typeof facilitySchema>
export type Facility = FacilityData & { id: string }
export type ApiImage = z.output<typeof imageSchema>
export type DetailedFacility = z.output<typeof detailedFacilitySchema>
export type HealthFacility = z.output<typeof healthFacilitySchema>
export type HealthFacilities = HealthFacility[]
export type Hotel = z.output<typeof attributesSchema>

View File

@@ -18,6 +18,7 @@ import type {
} from "@/types/trpc/routers/hotel/roomAvailability"
interface Actions {
closeSection: (idx: number) => () => void
modifyRate: (idx: number) => () => void
selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => 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()
.transform((str) => (str ? str : ""))
export const nullableStringEmailValidator = z
.string()
.email()
.nullish()
.transform((str) => (str ? str : ""))
export const nullableStringUrlValidator = z
.string()
.url()