Merged in feature/SW-3327-move-hotel-info-card-to-design-system (pull request #2730)

Feature/SW-3327 move hotel info card to design system

* wip

* wip

* wip

* wip moving hotelinfocard

* add controls for HotelInfoCard in storybook

* merge


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-08-29 10:09:48 +00:00
parent a0580de52f
commit 2a9313362f
14 changed files with 388 additions and 211 deletions

View File

@@ -16,7 +16,7 @@ import SidePanel from "@/components/HotelReservation/SidePanel"
import { getIntl } from "@/i18n"
import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider"
import { getHotelAlertsForBookingDates } from "../utils"
import { filterOverlappingDates } from "../utils"
import Confirmation from "./Confirmation"
import Tracking from "./Tracking"
import { mapRoomState } from "./utils"
@@ -79,7 +79,7 @@ export default async function BookingConfirmation({
<PaymentDetails />
<Divider color="Border/Divider/Subtle" />
<HotelDetails hotel={hotel} />
{getHotelAlertsForBookingDates(
{filterOverlappingDates(
hotel.specialAlerts,
dt(booking.checkInDate),
dt(booking.checkOutDate)

View File

@@ -39,7 +39,7 @@ import { getIntl } from "@/i18n"
import MyStayProvider from "@/providers/MyStay"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import { getHotelAlertsForBookingDates } from "../utils"
import { filterOverlappingDates } from "../utils"
import styles from "./index.module.css"
@@ -202,7 +202,7 @@ export default async function MyStay(props: {
} satisfies SafeUser)
: null
hotel.specialAlerts = getHotelAlertsForBookingDates(
hotel.specialAlerts = filterOverlappingDates(
hotel.specialAlerts,
dt(fromDate),
dt(toDate)

View File

@@ -1,74 +0,0 @@
.hotelDescription {
overflow: hidden;
text-align: left;
}
.descriptionWrapper {
display: flex;
flex-direction: column;
}
.collapsed {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
margin: var(--Space-x15) 0;
}
.expanded {
display: block;
max-height: none;
margin: var(--Space-x15) 0;
}
.expandedContent {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: var(--Space-x2);
}
.description {
display: flex;
gap: var(--Space-x025);
}
.showMoreButton {
display: flex;
background-color: transparent;
border-width: 0;
padding: 0;
color: var(--Text-Interactive-Secondary);
cursor: pointer;
&:hover {
color: var(--Text-Interactive-Secondary-Hover);
}
}
.facilities {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
@media screen and (min-width: 1367px) {
.descriptionWrapper {
display: none;
}
}

View File

@@ -1,92 +0,0 @@
"use client"
import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import styles from "./hotelDescription.module.css"
import type {
AdditionalData,
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
export default function HotelDescription({
description,
hotel,
sortedFacilities,
restaurants,
additionalData,
}: {
description?: string
hotel: Hotel & { url: string | null }
sortedFacilities: Hotel["detailedFacilities"]
restaurants: Restaurant[]
additionalData: AdditionalData | undefined
}) {
const intl = useIntl()
const [expanded, setExpanded] = useState(false)
const handleToggle = () => {
setExpanded((prev) => !prev)
}
const textShowMore = intl.formatMessage({
defaultMessage: "Show more",
})
const textShowLess = intl.formatMessage({
defaultMessage: "Show less",
})
return (
<div className={styles.descriptionWrapper}>
<div className={styles.facilityList}>
{sortedFacilities?.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="Icon/Default" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{facility.name}</p>
</Typography>
</div>
))}
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p
className={`${styles.hotelDescription} ${
expanded ? styles.expanded : styles.collapsed
}`}
>
{description}
</p>
</Typography>
<Typography variant="Link/md">
<ButtonRAC className={styles.showMoreButton} onPress={handleToggle}>
{expanded ? textShowLess : textShowMore}
</ButtonRAC>
</Typography>
{expanded && (
<div className={styles.expandedContent}>
<HotelDetailsSidePeek
hotel={hotel}
restaurants={restaurants}
additionalHotelData={additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See all amenities",
})}
buttonVariant="primary"
wrapping={false}
/>
</div>
)}
</div>
)
}

View File

@@ -1,134 +0,0 @@
.container {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x3) 0;
}
.hotelName {
color: var(--Text-Heading);
}
.hotelAddress {
color: var(--Text-Tertiary);
}
.wrapper {
display: flex;
margin: 0 auto;
max-width: var(--max-width-page);
position: relative;
flex-direction: column;
gap: var(--Space-x2);
}
.hotelDescription {
display: none;
}
.imageWrapper {
position: relative;
height: 200px;
width: 100%;
border-radius: var(--Corner-radius-md);
}
.hotelContent {
display: flex;
flex-direction: column;
align-items: center;
}
.hotelInformation {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
align-items: center;
text-align: center;
}
.hotelAddressDescription {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
align-items: center;
text-align: center;
}
.facilities {
display: none;
}
.hotelAlert {
max-width: var(--max-width-page);
margin: 0 auto;
padding-top: var(--Space-x15);
}
@media screen and (min-width: 768px) {
.container {
padding: var(--Space-x4) 0;
}
}
@media screen and (min-width: 1367px) {
.container {
padding: var(--Space-x4) var(--Space-x5);
}
.hotelDescription {
display: block;
}
.facilities {
display: flex;
flex-direction: column;
padding: var(--Space-x3) 0 var(--Space-x025);
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.imageWrapper {
max-width: 360px;
}
.hotelContent {
flex-direction: row;
gap: var(--Space-x6);
}
.hotelInformation {
padding-right: var(--Space-x3);
width: min(607px, 100%);
align-items: normal;
text-align: left;
}
.hotelAddressDescription {
align-items: normal;
text-align: left;
gap: var(--Space-x2);
}
.wrapper {
gap: var(--Space-x3);
flex-direction: row;
}
.facilityTitle {
display: none;
}
.imageWrapper {
align-self: center;
}
}

View File

@@ -1,196 +0,0 @@
import { dt } from "@scandic-hotels/common/dt"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import { Alert } from "@scandic-hotels/design-system/Alert"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { TripAdvisorChip } from "@scandic-hotels/design-system/TripAdvisorChip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import { getIntl } from "@/i18n"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getHotelAlertsForBookingDates } from "../../utils"
import HotelDescription from "./HotelDescription"
import styles from "./hotelInfoCard.module.css"
import type {
AdditionalData,
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
export type HotelInfoCardProps = {
booking: SelectRateBooking
hotel: Hotel & { url: string | null }
restaurants: Restaurant[]
additionalData: AdditionalData | undefined
}
export async function HotelInfoCard({
booking,
hotel,
restaurants,
additionalData,
}: HotelInfoCardProps) {
const intl = await getIntl()
const sortedFacilities = hotel.detailedFacilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.slice(0, 5)
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const bookingFromDate = dt(booking.fromDate)
const bookingToDate = dt(booking.toDate)
const specialAlerts = getHotelAlertsForBookingDates(
hotel.specialAlerts,
bookingFromDate,
bookingToDate
)
return (
<article className={styles.container}>
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<ImageGallery title={hotel.name} images={galleryImages} fill />
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)}
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<Typography variant="Title/md">
<h1 className={styles.hotelName}>{hotel.name}</h1>
</Typography>
<div className={styles.hotelAddressDescription}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.hotelAddress}>
{intl.formatMessage(
{
defaultMessage:
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center",
},
{
address: hotel.address.streetAddress,
city: hotel.address.city,
distanceToCityCenterInKm: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.hotelDescription}>
{hotel.hotelContent.texts.descriptions?.medium}
</p>
</Typography>
<HotelDescription
key={hotel.operaId}
description={hotel.hotelContent.texts.descriptions?.medium}
hotel={hotel}
restaurants={restaurants}
additionalData={additionalData}
sortedFacilities={sortedFacilities}
/>
</div>
</div>
<Divider variant="vertical" />
<div className={styles.facilities}>
<div className={styles.facilityList}>
{sortedFacilities?.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="Icon/Default" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{facility.name}</p>
</Typography>
</div>
))}
</div>
<HotelDetailsSidePeek
hotel={hotel}
restaurants={restaurants}
additionalHotelData={additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See all amenities",
})}
buttonVariant="primary"
/>
</div>
</div>
</section>
{specialAlerts.map((alert) => (
<SpecialAlert key={alert.id} alert={alert} />
))}
</article>
)
}
function SpecialAlert({ alert }: { alert: Hotel["specialAlerts"][number] }) {
return (
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
</div>
)
}
export function HotelInfoCardSkeleton() {
return (
<article className={styles.container}>
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<SkeletonShimmer height="100%" width="100%" />
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<SkeletonShimmer width="60ch" height="40px" />
<div className={styles.hotelAddressDescription}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<SkeletonShimmer width="40ch" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
<SkeletonShimmer width="60ch" />
<SkeletonShimmer width="58ch" />
<SkeletonShimmer width="45ch" />
</p>
</Typography>
</div>
</div>
<Divider variant="vertical" />
<div className={styles.facilities}>
<div className={styles.facilityList}>
<Typography
variant="Body/Paragraph/mdBold"
className={styles.facilityTitle}
>
<SkeletonShimmer width="20ch" />
</Typography>
{[1, 2, 3, 4, 5]?.map((id) => {
return (
<div className={styles.facilitiesItem} key={id}>
<SkeletonShimmer width="10ch" />
</div>
)
})}
</div>
<div className={styles.hotelAlert}>
<SkeletonShimmer width="18ch" />
</div>
</div>
</div>
</section>
</article>
)
}

View File

@@ -1,11 +1,17 @@
import { cookies } from "next/headers"
import { dt } from "@scandic-hotels/common/dt"
import { HotelInfoCard } from "@scandic-hotels/design-system/HotelInfoCard"
import { FamilyAndFriendsCodes } from "@/constants/booking"
import { HotelInfoCard } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import { getIntl } from "@/i18n"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import FnFNotAllowedAlert from "../FnFNotAllowedAlert/FnFNotAllowedAlert"
import { hasOverlappingDates } from "../utils"
import AvailabilityError from "./AvailabilityError"
import Tracking from "./Tracking"
@@ -20,6 +26,7 @@ export default async function SelectRatePage({
hotelData: NonNullable<RouterOutput["hotel"]["get"]>
booking: SelectRateBooking
}) {
const intl = await getIntl()
const bookingCode = booking.bookingCode
let isInValidFNF = false
@@ -27,13 +34,51 @@ export default async function SelectRatePage({
const cookieStore = await cookies()
isInValidFNF = cookieStore.get("sc")?.value !== "1"
}
const validAlerts = hotelData.hotel.specialAlerts.filter((alert) =>
hasOverlappingDates(alert, dt(booking.fromDate), dt(booking.toDate))
)
return (
<>
<HotelInfoCard
hotel={{ ...hotelData.hotel, url: hotelData.url }}
restaurants={hotelData.restaurants}
additionalData={hotelData.additionalData}
booking={booking}
hotel={{
id: hotelData.hotel.id,
name: hotelData.hotel.name,
url: hotelData.url,
ratings: hotelData.hotel.ratings,
}}
address={{
streetAddress: hotelData.hotel.address.streetAddress,
city: hotelData.hotel.address.city,
kilometersToCentre: hotelData.hotel.location.distanceToCentre / 1000,
}}
galleryImages={mapApiImagesToGalleryImages(
hotelData.hotel.galleryImages
)}
description={
hotelData.hotel.hotelContent.texts.descriptions?.medium ?? ""
}
alerts={validAlerts.map((alert) => ({
...alert,
heading: alert.heading ?? "",
text: alert.text ?? "",
}))}
facilities={hotelData.hotel.detailedFacilities}
slot={
<HotelDetailsSidePeek
hotel={{
...hotelData.hotel,
url: hotelData.url,
}}
restaurants={hotelData.restaurants}
additionalHotelData={hotelData.additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See all amenities",
})}
buttonVariant="primary"
/>
}
/>
{isInValidFNF ? (

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { dt } from "@scandic-hotels/common/dt"
import { getHotelAlertsForBookingDates } from "./index"
import { filterOverlappingDates } from "./index"
import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts"
import type { z } from "zod"
@@ -23,11 +23,11 @@ function makeAlert(start: string, end: string): Alert {
}
}
describe("getHotelAlertsForBookingDates", () => {
describe("filterOverlappingDates", () => {
const alert = makeAlert("2025-09-01", "2025-09-10")
it("shows alert if booking starts inside alert", () => {
const result = getHotelAlertsForBookingDates(
const result = filterOverlappingDates(
[alert],
dt("2025-09-05"),
dt("2025-09-12")
@@ -36,7 +36,7 @@ describe("getHotelAlertsForBookingDates", () => {
})
it("shows alert if booking ends inside alert", () => {
const result = getHotelAlertsForBookingDates(
const result = filterOverlappingDates(
[alert],
dt("2025-08-28"),
dt("2025-09-05")
@@ -45,7 +45,7 @@ describe("getHotelAlertsForBookingDates", () => {
})
it("shows alert if booking fully contains alert", () => {
const result = getHotelAlertsForBookingDates(
const result = filterOverlappingDates(
[alert],
dt("2025-08-28"),
dt("2025-09-15")
@@ -54,7 +54,7 @@ describe("getHotelAlertsForBookingDates", () => {
})
it("shows alert if alert fully contains booking", () => {
const result = getHotelAlertsForBookingDates(
const result = filterOverlappingDates(
[alert],
dt("2025-09-03"),
dt("2025-09-05")
@@ -63,7 +63,7 @@ describe("getHotelAlertsForBookingDates", () => {
})
it("does not show alert if no overlap", () => {
const result = getHotelAlertsForBookingDates(
const result = filterOverlappingDates(
[alert],
dt("2025-08-01"),
dt("2025-08-05")

View File

@@ -7,7 +7,6 @@ import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import { ChildBedTypeEnum } from "@scandic-hotels/trpc/enums/childBedTypeEnum"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts"
import type { Package, Packages } from "@scandic-hotels/trpc/types/packages"
import type { JSX } from "react"
@@ -88,41 +87,57 @@ export function calculateVat(priceInclVat: number, vat: number) {
}
}
export function getHotelAlertsForBookingDates(
specialAlerts: Zod.infer<typeof specialAlertsSchema>,
export function filterOverlappingDates<
T extends {
startDate: Date | Dayjs | string | undefined | null
endDate: Date | Dayjs | string | undefined | null
},
>(dateRangeItems: T[], fromDate: Date | Dayjs, toDate: Date | Dayjs) {
const startDate = dt(fromDate)
const endDate = dt(toDate)
return dateRangeItems.filter((item) =>
hasOverlappingDates(item, startDate, endDate)
)
}
export function hasOverlappingDates(
dateRangeItem: {
startDate: Date | Dayjs | string | undefined | null
endDate: Date | Dayjs | string | undefined | null
},
fromDate: Date | Dayjs,
toDate: Date | Dayjs
) {
return specialAlerts.filter((alert) => {
if (alert.endDate && alert.startDate) {
const alertStartDate = dt(alert.startDate)
const alertEndDate = dt(alert.endDate)
const bookingStart = dt(fromDate)
const bookingEnd = dt(toDate)
const startDate = dt(fromDate)
const endDate = dt(toDate)
const fromDateIsBetweenAlertDates = bookingStart.isBetween(
alertStartDate,
alertEndDate,
"date",
"[]"
)
const toDateIsBetweenAlertDates = bookingEnd.isBetween(
alertStartDate,
alertEndDate,
"date",
"[]"
)
if (dateRangeItem.endDate && dateRangeItem.startDate) {
const itemStartDate = dt(dateRangeItem.startDate)
const itemEndDate = dt(dateRangeItem.endDate)
const bookingFullyContainsAlert =
bookingStart.isSameOrBefore(alertStartDate, "date") &&
bookingEnd.isSameOrAfter(alertEndDate, "date")
const fromDateIsBetweenItemDates = startDate.isBetween(
itemStartDate,
itemEndDate,
"date",
"[]"
)
const toDateIsBetweenItemDates = endDate.isBetween(
itemStartDate,
itemEndDate,
"date",
"[]"
)
return (
fromDateIsBetweenAlertDates ||
toDateIsBetweenAlertDates ||
bookingFullyContainsAlert
)
}
return true
})
const itemFullyContained =
startDate.isSameOrBefore(itemStartDate, "date") &&
endDate.isSameOrAfter(itemEndDate, "date")
return (
fromDateIsBetweenItemDates ||
toDateIsBetweenItemDates ||
itemFullyContained
)
}
return true
}