Merged in feature/select-rate-vertical-data-flow (pull request #2535)

Feature/select rate vertical data flow

* add fix from SW-2666

* use translations for room packages

* move types to it's own file

* Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow

* merge

* feature/select-rate: double rate for campaing rates

* revert NODE_ENV check in Cookiebot script

* revert testing values

* fix(SW-3171): fix all filter selected in price details

* fix(SW-3166): multiroom anchoring when changing filter

* fix(SW-3172): check hotelType, show correct breakfast message

* Merge branch 'feature/select-rate-vertical-data-flow' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow

* fix: show special needs icons for subsequent roomTypes SW-3167

* fix: Display strike through text when logged in SW-3168

* fix: Reinstate the scrollToView behaviour when selecting a rate SW-3169

* merge

* .

* PR fixes

* fix: don't return notFound()

* .

* always include defaults for room packages

* merge

* merge

* merge

* Remove floating h1 for new select-rate


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-08-13 12:45:40 +00:00
parent 706f2d8dfe
commit 68cd061c6d
126 changed files with 8751 additions and 315 deletions

View File

@@ -0,0 +1,75 @@
.hotelDescription {
overflow: hidden;
}
.descriptionWrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.collapsed {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.expanded {
display: block;
max-height: none;
}
.expandedContent {
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--Space-x2);
}
.description {
display: flex;
gap: var(--Space-x025);
}
.showMoreButton {
display: flex;
align-items: flex-end;
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;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
padding-bottom: var(--Space-x2);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
@media screen and (min-width: 1367px) {
.descriptionWrapper {
display: none;
}
}

View File

@@ -0,0 +1,93 @@
"use client"
import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ReadMore from "@/components/HotelReservation/ReadMore"
import Alert from "@/components/TempDesignSystem/Alert"
import styles from "./hotelDescription.module.css"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export default function HotelDescription({
description,
hotel,
sortedFacilities,
}: {
description?: string
hotel: Hotel
sortedFacilities: Hotel["detailedFacilities"]
}) {
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}>
<ReadMore
label={intl.formatMessage({
defaultMessage: "See all amenities",
})}
hotelId={hotel.operaId}
showCTA={false}
sidePeekKey={SidePeekEnum.hotelDetails}
/>
{hotel.specialAlerts.map((alert) => (
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,150 @@
.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-x2);
align-items: center;
text-align: center;
}
.hotelAlert {
display: none;
}
.facilities {
display: none;
}
@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;
}
.hotelAlert {
display: block;
max-width: var(--max-width-page);
margin: 0 auto;
padding-top: var(--Space-x15);
}
.facilities {
display: flex;
flex-direction: column;
padding: var(--Space-x3) 0 var(--Space-x025);
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
padding-bottom: var(--Space-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.imageWrapper {
max-width: 360px;
}
.hotelContent {
gap: var(--Space-x6);
align-items: normal;
}
.hotelInformation {
padding-right: var(--Space-x3);
width: min(607px, 100%);
align-items: normal;
text-align: left;
}
.hotelAddressDescription {
align-items: normal;
text-align: left;
}
.wrapper {
gap: var(--Space-x3);
flex-direction: row;
}
.facilities {
padding: var(--Space-x3) var(--Space-x3) var(--Space-x05);
}
.facilityList {
gap: var(--Space-x1);
padding-bottom: var(--Space-x05);
flex-direction: column;
}
.facilityTitle {
display: none;
}
.hotelContent {
flex-direction: row;
align-items: center;
}
.imageWrapper {
align-self: center;
}
}

View File

@@ -0,0 +1,183 @@
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
import { dt } from "@scandic-hotels/common/dt"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import ReadMore from "../../ReadMore"
import { getHotelAlertsForBookingDates } from "../../utils"
import HotelDescription from "./HotelDescription"
import styles from "./hotelInfoCard.module.css"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export type HotelInfoCardProps = {
booking: SelectRateBooking
hotel: Hotel
}
export async function HotelInfoCard({ booking, hotel }: 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}
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>
<ReadMore
label={intl.formatMessage({
defaultMessage: "See all amenities",
})}
hotelId={hotel.operaId}
showCTA={false}
sidePeekKey={SidePeekEnum.hotelDetails}
/>
</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>
)
}