Merged in SW-3270-move-interactive-map-to-design-system-or-booking-flow (pull request #2681)

SW-3270 move interactive map to design system or booking flow

* wip

* wip

* merge

* wip

* add support for locales in design-system

* add story for HotelCard

* setup alias

* .

* remove tracking from design-system for hotelcard

* pass isUserLoggedIn

* export design-system-new-deprecated.css from design-system

* Add HotelMarkerByType to Storybook

* Add interactive map to Storybook

* fix reactintl in vitest

* rename env variables

* .

* fix background colors

* add storybook stories for <Link />

* merge

* fix tracking for when clicking 'See rooms' in InteractiveMap

* Merge branch 'master' of bitbucket.org:scandic-swap/web into SW-3270-move-interactive-map-to-design-system-or-booking-flow

* remove deprecated comment


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-08-25 11:26:16 +00:00
parent 4f8c51298f
commit c54c1ec540
139 changed files with 2511 additions and 1557 deletions

View File

@@ -3,14 +3,13 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
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 { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetails from "../../PriceDetails"
import styles from "./totalPrice.module.css"

View File

@@ -9,12 +9,12 @@ import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Body from "@scandic-hotels/design-system/Body"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"

View File

@@ -1,63 +0,0 @@
.card {
font-size: 14px;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: var(--Corner-radius-lg);
border: 1px solid var(--Base-Border-Subtle);
position: relative;
height: 100%;
justify-content: space-between;
min-height: 200px;
flex: 1;
overflow: hidden;
}
.imageContainer {
aspect-ratio: 16/9;
width: 100%;
height: 200px;
}
.priceVariants {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
flex: 1;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
}
.text {
display: none;
}
@media (min-width: 1367px) {
.content {
padding: var(--Spacing-x2) 0 var(--Spacing-x2) var(--Spacing-x2);
}
.text {
gap: 10px;
display: flex;
flex-direction: column;
}
.card {
flex-direction: row;
}
.imageContainer {
width: 315px;
height: 100%;
}
.priceVariants {
max-width: 260px;
}
}

View File

@@ -1,34 +0,0 @@
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import styles from "./HotelCardSkeleton.module.css"
export function HotelCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
<div className={styles.content}>
<SkeletonShimmer height={"65px"} />
<div className={styles.text}>
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
</div>
<SkeletonShimmer height={"56px"} />
<SkeletonShimmer height={"52px"} width={"150px"} />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 2 }).map((_, index) => (
<SkeletonShimmer key={index} height={"100px"} />
))}
<SkeletonShimmer height={"40px"} />
</div>
</article>
)
}

View File

@@ -1,22 +0,0 @@
.chequeCard {
padding: var(--Space-x15);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-md);
margin: 0;
width: 100%;
display: grid;
gap: var(--Space-x1);
}
.chequeRow,
.cheque {
display: flex;
gap: var(--Space-x05);
justify-content: space-between;
align-items: baseline;
color: var(--Text-Default);
}
.cheque {
justify-content: end;
}

View File

@@ -1,64 +0,0 @@
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import Caption from "@scandic-hotels/design-system/Caption"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import styles from "./hotelChequeCard.module.css"
import type { ProductTypeCheque } from "@scandic-hotels/trpc/types/availability"
export default function HotelChequeCard({
productTypeCheque,
}: {
productTypeCheque: ProductTypeCheque
}) {
const intl = useIntl()
return (
<div className={styles.chequeCard}>
<div className={styles.chequeRow}>
<Caption>
{intl.formatMessage({
defaultMessage: "From",
})}
</Caption>
<div className={styles.cheque}>
<Subtitle type="two" color="uiTextHighContrast">
{productTypeCheque.localPrice.numberOfCheques}
</Subtitle>
<Caption color="uiTextHighContrast">{CurrencyEnum.CC}</Caption>
{productTypeCheque.localPrice.additionalPricePerStay > 0 ? (
<>
{"+"}
<Subtitle type="two" color="uiTextHighContrast">
{productTypeCheque.localPrice.additionalPricePerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{productTypeCheque.localPrice.currency}
</Caption>
</>
) : null}
</div>
</div>
{productTypeCheque.requestedPrice &&
productTypeCheque.requestedPrice.additionalPricePerStay > 0 ? (
<div className={styles.chequeRow}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Approx.",
})}
</Caption>
<Caption color={"uiTextMediumContrast"}>
{productTypeCheque.requestedPrice.numberOfCheques} {CurrencyEnum.CC}
{productTypeCheque.requestedPrice.additionalPricePerStay
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
" + "
: ""}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${productTypeCheque.requestedPrice.additionalPricePerStay} ${productTypeCheque.requestedPrice.currency}`}
</Caption>
</div>
) : null}
</div>
)
}

View File

@@ -1,6 +0,0 @@
.poinstRow {
display: flex;
gap: var(--Space-x1);
align-items: baseline;
color: var(--Text-Default);
}

View File

@@ -1,40 +0,0 @@
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import styles from "./hotelPointsRow.module.css"
import type { PointsRowProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPointsRow({
pointsPerStay,
additionalPricePerStay,
additionalPriceCurrency,
}: PointsRowProps) {
const intl = useIntl()
return (
<div className={styles.poinstRow}>
<Subtitle type="two" color="uiTextHighContrast">
{pointsPerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Points",
})}
</Caption>
{additionalPricePerStay ? (
<>
{"+"}
<Subtitle type="two" color="uiTextHighContrast">
{additionalPricePerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{additionalPriceCurrency}
</Caption>
</>
) : null}
</div>
)
}

View File

@@ -1,32 +0,0 @@
.priceCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-md);
margin: 0;
width: 100%;
}
.divider {
margin: var(--Spacing-x-half) 0;
}
.priceRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: var(--Spacing-x-quarter) 0;
}
.price {
display: flex;
gap: var(--Spacing-x-half);
}
.voucherChqRate {
justify-content: start;
align-items: baseline;
}
.perNight {
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);
}

View File

@@ -1,127 +0,0 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import styles from "./hotelPriceCard.module.css"
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPriceCard({
productTypePrices,
isMemberPrice = false,
className,
}: PriceCardProps) {
const intl = useIntl()
const isRegularOrPublicPromotionRate =
productTypePrices.rateType === RateTypeEnum.Regular ||
productTypePrices.rateType === RateTypeEnum.PublicPromotion
return (
<dl className={cx(styles.priceCard, className)}>
{isRegularOrPublicPromotionRate &&
(isMemberPrice ? (
<div className={styles.priceRow}>
<dt>
<Caption color="red">
{intl.formatMessage({
defaultMessage: "Member price",
})}
</Caption>
</dt>
</div>
) : (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Standard price",
})}
</Caption>
</dt>
</div>
))}
<div className={styles.priceRow}>
<dt>
<Caption
type="bold"
color={isMemberPrice ? "red" : "uiTextHighContrast"}
>
{intl.formatMessage({
defaultMessage: "From",
})}
</Caption>
</dt>
<dd>
<div className={styles.price}>
<Subtitle
type="two"
color={isMemberPrice ? "red" : "uiTextHighContrast"}
>
{productTypePrices.localPrice.pricePerNight}
</Subtitle>
<Body
color={isMemberPrice ? "red" : "uiTextHighContrast"}
textTransform="bold"
>
{productTypePrices.localPrice.currency}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span className={styles.perNight}>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Body>
</div>
</dd>
</div>
{productTypePrices?.requestedPrice && (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Approx.",
})}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${productTypePrices.requestedPrice.pricePerNight} `}
{productTypePrices.requestedPrice.currency}
</Caption>
</dd>
</div>
)}
{productTypePrices.localPrice.pricePerStay !==
productTypePrices.localPrice.pricePerNight &&
// Handle undefined scenarios
productTypePrices.localPrice.pricePerNight && (
<>
<Divider className={styles.divider} />
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Total",
})}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${productTypePrices.localPrice.pricePerStay} `}
{productTypePrices.localPrice.currency}
</Caption>
</dd>
</div>
</>
)}
</dl>
)
}

View File

@@ -1,19 +0,0 @@
.voucherCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-md);
margin: 0;
width: 100%;
}
.voucherRow,
.voucher {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
align-items: baseline;
}
.voucher {
justify-content: end;
}

View File

@@ -1,36 +0,0 @@
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import Caption from "@scandic-hotels/design-system/Caption"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import styles from "./hotelVoucherCard.module.css"
import type { ProductTypeVoucher } from "@scandic-hotels/trpc/types/availability"
export default function HotelVoucherCard({
productTypeVoucher,
}: {
productTypeVoucher: ProductTypeVoucher
}) {
const intl = useIntl()
return (
<div className={styles.voucherCard}>
<div className={styles.voucherRow}>
<Caption>
{intl.formatMessage({
defaultMessage: "From",
})}
</Caption>
<div className={styles.voucher}>
<Subtitle type="two" color="uiTextHighContrast">
{productTypeVoucher.numberOfVouchers}
</Subtitle>
<Caption color="uiTextHighContrast" className={styles.currency}>
{CurrencyEnum.Voucher}
</Caption>
</div>
</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./noPriceAvailable.module.css"
export default function NoPriceAvailableCard() {
const intl = useIntl()
return (
<div className={styles.priceCard}>
<div className={styles.noRooms}>
<MaterialIcon icon="error" color="Icon/Feedback/Error" />
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
defaultMessage:
"There are no rooms available that match your request.",
})}
</span>
</Typography>
</div>
</div>
)
}

View File

@@ -1,12 +0,0 @@
.priceCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-md);
margin: 0;
width: 100%;
}
.noRooms {
display: flex;
gap: var(--Spacing-x1);
}

View File

@@ -1,187 +0,0 @@
.card {
display: flex;
flex-direction: column;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-md);
width: 100%;
overflow: hidden;
color: var(--Text-Default);
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.imageContainer {
position: relative;
height: 200px;
width: 100%;
}
.imageContainer img {
object-fit: cover;
}
.hotelInformation {
margin-bottom: var(--Space-x05);
}
.hotelContent {
display: flex;
flex-direction: column;
padding: var(--Space-x2);
}
.hotelDescription {
display: none;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x05);
margin-top: var(--Space-x05);
}
.addressContainer {
display: flex;
flex-wrap: wrap;
gap: var(--Space-x1);
}
.address {
font-style: normal;
color: var(--Text-Tertiary);
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: var(--Space-x1);
margin-top: var(--Space-x15);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x05);
color: var(--Text-Secondary);
}
.specialAlerts {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
width: 100%;
}
.link:hover {
.fakeButton {
background-color: var(--Component-Button-Brand-Primary-Fill-Hover);
border-color: var(--Component-Button-Brand-Primary-Border-Hover);
color: var(--Component-Button-Brand-Primary-On-fill-Hover);
}
.priceCard {
background: linear-gradient(
0deg,
var(--Surface-Primary-Hover) 0%,
var(--Surface-Primary-Hover) 100%
);
}
}
.strikedText {
text-decoration: line-through;
}
.pointsCard {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Space-x15);
border-radius: var(--Corner-radius-md);
}
.fakeButton {
min-width: 160px;
border-radius: var(--Corner-radius-rounded);
border-width: 2px;
border-style: solid;
display: flex;
align-items: center;
justify-content: center;
gap: var(--Space-x05);
padding: 10px var(--Space-x2);
background-color: var(--Component-Button-Brand-Primary-Fill-Default);
border-color: var(--Component-Button-Brand-Primary-Border-Default);
color: var(--Component-Button-Brand-Primary-On-fill-Default);
}
.fakeButton.disabled {
background-color: var(--Component-Button-Brand-Primary-Fill-Disabled);
border-color: var(--Component-Button-Brand-Primary-Border-Disabled);
color: var(--Component-Button-Brand-Primary-On-fill-Disabled);
}
@media screen and (min-width: 768px) and (max-width: 1024px) {
.imageContainer {
height: 180px;
}
}
@media screen and (min-width: 1367px) {
.card.pageListing {
flex-direction: row;
overflow: hidden;
padding: 0;
}
.pageListing .hotelDescription {
display: block;
}
.pageListing .imageContainer {
position: relative;
height: 100%;
width: 314px;
}
.pageListing .hotelInformation {
width: min(422px, 100%);
padding-right: var(--Space-x2);
margin: 0;
}
.pageListing .facilities {
margin: var(--Space-x1) 0;
}
.pageListing .hotelContent {
flex-direction: row;
align-items: center;
gap: var(--Space-x2);
padding-left: var(--Space-x3);
}
.pageListing .titleContainer {
margin-bottom: var(--Space-x15);
}
.pageListing .fakeButton {
width: 100%;
}
.pageListing .prices {
width: 260px;
}
}

View File

@@ -1,320 +0,0 @@
"use client"
import { cx } from "class-variance-authority"
import {
type ReadonlyURLSearchParams,
useParams,
useRouter,
useSearchParams,
} from "next/navigation"
import { memo } from "react"
import { useIntl } from "react-intl"
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
import {
alternativeHotelsMap,
selectHotelMap,
selectRate,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelsMapStore } from "@/stores/hotels-map"
import BookingCodeChip from "@/components/BookingCodeChip"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import HotelChequeCard from "./HotelChequeCard"
import HotelPointsRow from "./HotelPointsRow"
import HotelPriceCard from "./HotelPriceCard"
import HotelVoucherCard from "./HotelVoucherCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
function HotelCard({
hotelData: { availability, hotel, additionalData, restaurants, url },
isUserLoggedIn,
state = "default",
type = HotelCardListingTypeEnum.PageListing,
bookingCode = "",
isAlternative,
}: HotelCardProps) {
const params = useParams()
const searchParams = useSearchParams()
const lang = params.lang as Lang
const intl = useIntl()
const { activate, engage, disengage, disengageAfterDelay } =
useHotelsMapStore()
const amenities = hotel.detailedFacilities.slice(0, 5)
const router = useRouter()
const classNames = hotelCardVariants({
type,
state,
})
const mapUrl = isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
const handleAddressClick = (event: React.MouseEvent) => {
event.preventDefault()
disengage() // Disengage the current hotel to avoid the hover state from being active when clicking on the address
activate(hotel.name)
router.push(`${mapUrl}?${searchParams.toString()}`)
}
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const fullPrice = !availability.bookingCode
const price = availability.productType
const hasInsufficientPoints = !price?.redemptions?.some(
(r) => r.hasEnoughPoints
)
const notEnoughPointsLabel = intl.formatMessage({
defaultMessage: "Not enough points",
})
const isDisabled = price?.redemptions?.length && hasInsufficientPoints
return (
<article
className={classNames}
onMouseEnter={() => engage(hotel.name)}
onMouseLeave={() => disengageAfterDelay()}
>
<div>
<div className={styles.imageContainer}>
<ImageGallery
title={hotel.name}
images={galleryImages}
fill
sizes="(min-width: 768px) calc(100vw - 340px), (min-width: 1367px) 33vw, 100vw"
/>
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)}
</div>
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogoIcon
hotelId={hotel.operaId}
hotelType={hotel.hotelType}
/>
<Typography variant="Title/Subtitle/lg">
<h2>{hotel.name}</h2>
</Typography>
<div className={styles.addressContainer}>
<address className={styles.address}>
{type == HotelCardListingTypeEnum.MapListing ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{addressStr}</p>
</Typography>
) : (
<Link
size="small"
textDecoration="underline"
onClick={handleAddressClick}
href={mapUrl}
keepSearchParams
aria-label={intl.formatMessage({
defaultMessage: "See on map",
})}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{addressStr}</p>
</Typography>
</Link>
)}
</address>
<div>
<Divider variant="vertical" />
</div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{intl.formatMessage(
{
defaultMessage: "{number} km to city center",
},
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</span>
</Typography>
</div>
</div>
{hotel.hotelContent.texts.descriptions ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.hotelDescription}>
{hotel.hotelContent.texts.descriptions.short}
</p>
</Typography>
) : null}
<div className={styles.facilities}>
{amenities.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="CurrentColor" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>{facility.name}</span>
</Typography>
</div>
))}
</div>
<HotelDetailsSidePeek
hotel={{ ...hotel, url: url }}
restaurants={restaurants}
additionalHotelData={additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See hotel details",
})}
buttonVariant="primary"
/>
</div>
<PricesWrapper
pathname={selectRate(lang)}
isClickable={availability.productType && !isDisabled}
hotelId={hotel.operaId}
removeBookingCodeFromSearchParams={!!(bookingCode && fullPrice)}
searchParams={searchParams}
>
{!availability.productType ? (
<NoPriceAvailableCard />
) : (
<>
{bookingCode && (
<BookingCodeChip
bookingCode={bookingCode}
isUnavailable={fullPrice}
/>
)}
{(!isUserLoggedIn ||
!price?.member ||
(bookingCode && !fullPrice)) &&
price?.public && (
<HotelPriceCard
productTypePrices={price.public}
className={styles.priceCard}
/>
)}
{availability.productType.member && (
<HotelPriceCard
productTypePrices={availability.productType.member}
className={styles.priceCard}
isMemberPrice
/>
)}
{price?.voucher && (
<HotelVoucherCard productTypeVoucher={price.voucher} />
)}
{price?.bonusCheque && (
<HotelChequeCard productTypeCheque={price.bonusCheque} />
)}
{price?.redemptions?.length ? (
<div className={styles.pointsCard}>
<Caption>
{intl.formatMessage({
defaultMessage: "Available rates",
})}
</Caption>
{price.redemptions.map((redemption) => (
<HotelPointsRow
key={redemption.rateCode}
pointsPerStay={redemption.localPrice.pointsPerStay}
additionalPricePerStay={
redemption.localPrice.additionalPricePerStay
}
additionalPriceCurrency={
redemption.localPrice.currency ?? undefined
}
/>
))}
</div>
) : null}
{isDisabled ? (
<div className={cx(styles.fakeButton, styles.disabled)}>
<Typography variant="Body/Paragraph/mdBold">
<span>{notEnoughPointsLabel}</span>
</Typography>
</div>
) : (
<div className={styles.fakeButton}>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({
defaultMessage: "See rooms",
})}
</span>
</Typography>
</div>
)}
</>
)}
</PricesWrapper>
</div>
</article>
)
}
interface PricesWrapperProps {
children: React.ReactNode
isClickable?: boolean
hotelId: string
pathname: string
removeBookingCodeFromSearchParams: boolean
searchParams: ReadonlyURLSearchParams
}
function PricesWrapper({
children,
hotelId,
isClickable,
pathname,
removeBookingCodeFromSearchParams,
searchParams,
}: PricesWrapperProps) {
const content = <div className={styles.prices}>{children}</div>
if (!isClickable) {
return content
}
const params = new URLSearchParams(searchParams)
params.delete("city")
params.set("hotel", hotelId)
if (removeBookingCodeFromSearchParams) {
params.delete("bookingCode")
}
const href = `${pathname}?${params.toString()}`
return (
<Link href={href} color="none" className={styles.link}>
{content}
</Link>
)
}
export default memo(HotelCard)

View File

@@ -1,20 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./hotelCard.module.css"
export const hotelCardVariants = cva(styles.card, {
variants: {
type: {
pageListing: styles.pageListing,
mapListing: styles.mapListing,
},
state: {
active: styles.active,
default: styles.default,
},
},
defaultVariants: {
type: "pageListing",
state: "default",
},
})

View File

@@ -1,46 +0,0 @@
.imagePlaceholder {
height: 100%;
width: 100%;
background-color: #fff;
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 120px 120px;
background-position:
0 0,
0 60px,
60px -60px,
-60px 0;
}
.imageContainer {
position: relative;
min-width: 177px;
border-radius: var(--Corner-radius-md) 0 0 var(--Corner-radius-md);
overflow: hidden;
}
.imageContainer.top {
width: 80px;
min-width: 80px;
height: 90px;
border-radius: var(--Corner-radius-md);
}
.imageContainer img {
object-fit: cover;
}
.imageContainer .tripAdvisor {
position: absolute;
left: 7px;
top: 7px;
border-radius: 2px;
}
.imageContainer.top .tripAdvisor {
left: 4px;
top: 4px;
}

View File

@@ -1,43 +0,0 @@
import Chip from "@scandic-hotels/design-system/Chip"
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import Image from "@scandic-hotels/design-system/Image"
import { hotelCardDialogImageVariants } from "./variants"
import styles from "./hotelCardDialogImage.module.css"
import type { HotelCardDialogImageProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialogImage({
firstImage,
altText,
rating,
imageError,
setImageError,
position,
}: HotelCardDialogImageProps) {
const classNames = hotelCardDialogImageVariants({ position })
return (
<div className={classNames}>
{!firstImage || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={firstImage}
alt={altText || ""}
fill
onError={() => setImageError(true)}
/>
)}
{rating ? (
<div className={styles.tripAdvisor}>
<Chip className={styles.tripAdvisor}>
<TripadvisorIcon color="Icon/Interactive/Default" />
{rating}
</Chip>
</div>
) : null}
</div>
)
}

View File

@@ -1,15 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./hotelCardDialogImage.module.css"
export const hotelCardDialogImageVariants = cva(styles.imageContainer, {
variants: {
position: {
top: styles.top,
left: styles.left,
},
},
defaultVariants: {
position: "top",
},
})

View File

@@ -1,10 +1,15 @@
"use client"
import { useSession } from "next-auth/react"
import { useState } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import Caption from "@scandic-hotels/design-system/Caption"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { HotelCardDialogImage } from "@scandic-hotels/design-system/HotelCard/HotelCardDialogImage"
import { HotelPointsRow } from "@scandic-hotels/design-system/HotelCard/HotelPointsRow"
import { NoPriceAvailableCard } from "@scandic-hotels/design-system/HotelCard/NoPriceAvailableCard"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
@@ -12,14 +17,9 @@ import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import useLang from "@/hooks/useLang"
import { isValidClientSession } from "@/utils/clientSession"
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import styles from "./listingHotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
@@ -81,7 +81,7 @@ export default function ListingHotelCardDialog({
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
rating={ratings}
rating={{ tripAdvisor: ratings }}
imageError={imageError}
setImageError={setImageError}
position="top"

View File

@@ -1,276 +0,0 @@
"use client"
import { useSession } from "next-auth/react"
import { useState } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import useLang from "@/hooks/useLang"
import { isValidClientSession } from "@/utils/clientSession"
import { trackEvent } from "@/utils/tracking/base"
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import styles from "./standaloneHotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
interface StandaloneHotelCardProps {
data: HotelPin
handleClose: () => void
}
export default function StandaloneHotelCardDialog({
data,
handleClose,
}: StandaloneHotelCardProps) {
const intl = useIntl()
const lang = useLang()
const [imageError, setImageError] = useState(false)
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const {
name,
chequePrice,
publicPrice,
memberPrice,
redemptionPrice,
voucherPrice,
currency,
amenities,
images,
ratings,
operaId,
hasEnoughPoints,
} = data
const firstImage = images[0]?.imageSizes?.small
const altText = images[0]?.metaData?.altText
const notEnoughPointsLabel = intl.formatMessage({
defaultMessage: "Not enough points",
})
const shouldShowNotEnoughPoints = redemptionPrice && !hasEnoughPoints
return (
<div className={styles.container}>
<IconButton
theme="Black"
style="Muted"
className={styles.closeButton}
onPress={handleClose}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon icon="close" size={22} color="CurrentColor" />
</IconButton>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
rating={ratings}
imageError={imageError}
setImageError={setImageError}
position="left"
/>
<div className={styles.content}>
<div className={styles.name}>
<Body textTransform="bold">{name}</Body>
</div>
<div className={styles.facilities}>
{amenities.slice(0, 3).map((facility) => {
const Icon = (
<FacilityToIcon id={facility.id} size={16} color="Icon/Default" />
)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{Icon && Icon}
<Footnote color="uiTextMediumContrast">
{facility.name}
</Footnote>
</div>
)
})}
</div>
<div className={styles.pricesContainer}>
{publicPrice ||
memberPrice ||
redemptionPrice ||
voucherPrice ||
chequePrice ? (
<>
<div className={styles.priceCard}>
{redemptionPrice ? (
<Caption>
{intl.formatMessage({
defaultMessage: "Available rates",
})}
</Caption>
) : (
<Caption type="bold">
{intl.formatMessage({
defaultMessage: "From",
})}
</Caption>
)}
{chequePrice && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: chequePrice.numberOfCheques,
currency: "CC",
}
)}
{chequePrice.additionalPricePerStay > 0
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
" + " +
intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: chequePrice.additionalPricePerStay,
currency: chequePrice.currency,
}
)
: null}
<Body asChild>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Body>
</Subtitle>
)}
{voucherPrice && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: voucherPrice,
currency,
}
)}
<Body asChild>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Body>
</Subtitle>
)}
{publicPrice && !isUserLoggedIn && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: publicPrice,
currency,
}
)}
<Body asChild>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Body>
</Subtitle>
)}
{memberPrice && (
<Subtitle type="two" color="red">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: memberPrice,
currency,
}
)}
<Body asChild color="red">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Body>
</Subtitle>
)}
{redemptionPrice && (
<HotelPointsRow pointsPerStay={redemptionPrice} />
)}
</div>
{shouldShowNotEnoughPoints ? (
<div className={styles.notEnoughPointsButton}>
<Typography variant="Body/Paragraph/mdBold">
<span>{notEnoughPointsLabel}</span>
</Typography>
</div>
) : (
<Button
asChild
theme="base"
size="small"
className={styles.button}
onClick={() =>
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId: operaId,
},
})
}
>
<Link
href={`${selectRate(lang)}?hotel=${operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({
defaultMessage: "See rooms",
})}
</Link>
</Button>
)}
</>
) : (
<NoPriceAvailableCard />
)}
</div>
</div>
</div>
)
}

View File

@@ -1,82 +0,0 @@
.container {
flex-direction: row;
display: flex;
position: relative;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
}
.content {
width: 100%;
max-width: 220px;
padding: var(--Space-x15);
display: flex;
flex-direction: column;
}
.name {
height: 48px;
max-width: 180px;
margin-bottom: var(--Space-x05);
display: flex;
align-items: center;
padding-right: var(--Space-x1);
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: 0 var(--Space-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x05);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: space-between;
}
.priceCard {
border-radius: var(--Corner-radius-md);
padding: var(--Space-x05) var(--Space-x1);
background: var(--Base-Surface-Secondary-light-Normal);
margin-top: var(--Space-x1);
}
.pricesContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: space-between;
}
.content .button {
margin-top: auto;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}
.notEnoughPointsButton {
border-radius: var(--Corner-radius-rounded);
border-width: 2px;
border-style: solid;
display: flex;
align-items: center;
justify-content: center;
gap: var(--Space-x05);
padding: 10px var(--Space-x2);
background-color: var(--Component-Button-Brand-Primary-Fill-Disabled);
border-color: var(--Component-Button-Brand-Primary-Border-Disabled);
color: var(--Component-Button-Brand-Primary-On-fill-Disabled);
}

View File

@@ -1,5 +1,6 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useEffect, useMemo, useRef } from "react"
import { useIntl } from "react-intl"
@@ -8,20 +9,29 @@ import {
BookingCodeFilterEnum,
useBookingCodeFilterStore,
} from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
import {
alternativeHotelsMap,
selectHotelMap,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { HotelCard } from "@scandic-hotels/design-system/HotelCard"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { isValidClientSession } from "@/utils/clientSession"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import HotelCard from "../HotelCard"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
import { getSortedHotels } from "./utils"
import styles from "./hotelCardListing.module.css"
import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
import {
type HotelCardListingProps,
HotelCardListingTypeEnum,
@@ -33,13 +43,15 @@ export default function HotelCardListing({
type = HotelCardListingTypeEnum.PageListing,
isAlternative,
}: HotelCardListingProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const { activeHotel } = useHotelsMapStore()
const { activeHotel, activate, disengage, engage } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const activeCardRef = useRef<HTMLDivElement | null>(null)
@@ -118,32 +130,79 @@ export default function HotelCardListing({
return (
<section className={styles.hotelCards}>
{hotels?.length
? hotels.map((hotel) => (
<div
key={hotel.hotel.operaId}
ref={
isHotelActiveInMapView(hotel.hotel.name) ? activeCardRef : null
}
data-active={
isHotelActiveInMapView(hotel.hotel.name) ? "true" : "false"
}
>
<HotelCard
hotelData={hotel}
isUserLoggedIn={isUserLoggedIn}
state={
isHotelActiveInMapView(hotel.hotel.name)
? "active"
: "default"
}
type={type}
bookingCode={bookingCode}
isAlternative={isAlternative}
{hotels.map((hotel) => (
<div
key={hotel.hotel.operaId}
ref={isHotelActiveInMapView(hotel.hotel.name) ? activeCardRef : null}
data-active={
isHotelActiveInMapView(hotel.hotel.name) ? "true" : "false"
}
>
<HotelCard
hotel={{
id: hotel.hotel.operaId,
name: hotel.hotel.name,
address: hotel.hotel.address,
description: hotel.hotel.hotelContent.texts.descriptions?.short,
hotelType: hotel.hotel.hotelType as HotelType,
detailedFacilities: hotel.hotel.detailedFacilities,
ratings: {
tripAdvisor: hotel.hotel.ratings?.tripAdvisor.rating,
},
}}
lang={lang}
prices={{
public: hotel.availability.productType?.public
? {
...hotel.availability.productType.public,
requestedPrice:
hotel.availability.productType?.public.requestedPrice ??
undefined,
}
: undefined,
member: hotel.availability.productType?.member
? {
...hotel.availability.productType.member,
requestedPrice:
hotel.availability.productType?.member.requestedPrice ??
undefined,
}
: undefined,
}}
onHover={() => engage(hotel.hotel.name)}
onHoverEnd={() => disengage()}
onAddressClick={() => {
const mapUrl = isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
disengage() // Disengage the current hotel to avoid the hover state from being active when clicking on the address
activate(hotel.hotel.name)
router.push(`${mapUrl}?${searchParams.toString()}`)
}}
belowInfoSlot={
<HotelDetailsSidePeek
hotel={{ ...hotel.hotel, url: "" }}
restaurants={hotel.restaurants}
additionalHotelData={hotel.additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See hotel details",
})}
buttonVariant="primary"
/>
</div>
))
: null}
}
distanceToCityCenter={hotel.hotel.location.distanceToCentre}
images={mapApiImagesToGalleryImages(hotel.hotel.galleryImages)}
isUserLoggedIn={isUserLoggedIn}
state={
isHotelActiveInMapView(hotel.hotel.name) ? "active" : "default"
}
type={type}
bookingCode={bookingCode}
isAlternative={isAlternative}
/>
</div>
))}
{showBackToTop && (
<BackToTopButton
position="right"

View File

@@ -1,6 +1,6 @@
"use client"
import BookingCodeChip from "@/components/BookingCodeChip"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import styles from "./row.module.css"

View File

@@ -1,4 +1,5 @@
"use client"
import { useMap } from "@vis.gl/react-google-maps"
import { useCallback, useMemo, useRef, useState } from "react"
import { useIntl } from "react-intl"
@@ -18,15 +19,18 @@ import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { trackEvent } from "@/utils/tracking/base"
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
import HotelListing from "../HotelListing"
@@ -39,7 +43,7 @@ import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/he
const SKELETON_LOAD_DELAY = 750
export default function SelectHotelContent({
export function SelectHotelMapContent({
hotelPins,
cityCoordinates,
mapId,
@@ -52,6 +56,7 @@ export default function SelectHotelContent({
const lang = useLang()
const intl = useIntl()
const map = useMap()
const isUserLoggedIn = useIsUserLoggedIn()
const isAboveMobile = useMediaQuery("(min-width: 900px)")
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
@@ -59,7 +64,7 @@ export default function SelectHotelContent({
const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const { activeHotel } = useHotelsMapStore()
const hotelMapStore = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
@@ -71,8 +76,10 @@ export default function SelectHotelContent({
)
const coordinates = useMemo(() => {
if (activeHotel) {
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotel)
if (hotelMapStore.activeHotel) {
const hotel = hotels.find(
(hotel) => hotel.hotel.name === hotelMapStore.activeHotel
)
if (hotel && hotel.hotel.location) {
return isAboveMobile
@@ -89,7 +96,7 @@ export default function SelectHotelContent({
return isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
}, [activeHotel, hotels, isAboveMobile, cityCoordinates])
}, [hotelMapStore.activeHotel, hotels, isAboveMobile, cityCoordinates])
const showOnlyBookingCodeRates =
bookingCode &&
@@ -231,10 +238,62 @@ export default function SelectHotelContent({
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
hotelPins={filteredHotelPins}
hotelPins={filteredHotelPins.map((pin) => {
const galleryImage = mapApiImagesToGalleryImages(pin.images).at(0)
return {
...pin,
ratings: {
tripAdvisor: pin.ratings ?? null,
},
image: {
alt: galleryImage?.alt ?? "",
url: galleryImage?.src ?? "",
},
}
})}
mapId={mapId}
onTilesLoaded={debouncedUpdateHotelCards}
fitBounds={isAboveMobile || !activeHotel}
fitBounds={isAboveMobile || !hotelMapStore.activeHotel}
onHoverHotelPin={(args) => {
if (!args) {
hotelMapStore.disengage()
return
}
hotelMapStore.engage(args.hotelName)
}}
hoveredHotelPin={hotelMapStore.hoveredHotel}
onSetActiveHotelPin={(args) => {
if (!args || args.hotelName === hotelMapStore.activeHotel) {
hotelMapStore.deactivate()
return
}
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId: args.hotelId,
},
})
hotelMapStore.activate(args.hotelName)
}}
onClickHotel={(hotelId) => {
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId,
},
})
}}
lang={lang}
isUserLoggedIn={isUserLoggedIn}
/>
</div>
)

View File

@@ -2,7 +2,7 @@
import { APIProvider } from "@vis.gl/react-google-maps"
import SelectHotelMapContent from "./SelectHotelMapContent"
import { SelectHotelMapContent } from "./SelectHotelMapContent"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"

View File

@@ -1,7 +1,6 @@
import { HotelCardSkeleton } from "@scandic-hotels/design-system/HotelCard/HotelCardSkeleton"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { HotelCardSkeleton } from "../HotelCard/HotelCardSkeleton"
import styles from "./selectHotel.module.css"
type Props = {

View File

@@ -4,9 +4,9 @@ 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 { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import Alert from "@/components/TempDesignSystem/Alert"

View File

@@ -1,12 +1,12 @@
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 { 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 { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"

View File

@@ -1,5 +1,5 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { sumPackages } from "@/components/HotelReservation/utils"

View File

@@ -17,7 +17,7 @@ import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useBreakpoint } from "@/hooks/useBreakpoint"

View File

@@ -1,6 +1,7 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import BookingCodeChip from "@/components/BookingCodeChip"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
export function RemoveBookingCodeButton() {