Files
web/packages/design-system/lib/components/HotelCard/index.tsx
Anton Gunnarsson 16fbdb7ae0 Merged in fix/refactor-currency-display (pull request #3434)
fix(SW-3616): Handle EuroBonus point type everywhere

* Add tests to formatPrice

* formatPrice

* More work replacing config with api points type

* More work replacing config with api points type

* More fixing with currency

* maybe actually fixed it

* Fix MyStay

* Clean up

* Fix comments

* Merge branch 'master' into fix/refactor-currency-display

* Fix calculateTotalPrice for EB points + SF points + cash


Approved-by: Joakim Jäderberg
2026-01-15 09:32:17 +00:00

439 lines
14 KiB
TypeScript

"use client"
import NextLink from "next/link"
import { type ReadonlyURLSearchParams, useSearchParams } from "next/navigation"
import { memo, useState } from "react"
import { useFocusWithin } from "react-aria"
import { useIntl } from "react-intl"
import {
alternativeHotelsMap,
selectHotelMap,
selectRate,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "../Divider"
import { FacilityToIcon } from "../FacilityToIcon"
import HotelLogoIcon from "../Icons/Logos"
import ImageGallery, { GalleryImage } from "../ImageGallery"
import Link from "../OldDSLink"
import { Typography } from "../Typography"
import { HotelPointsRow } from "./HotelPointsRow"
import { NoPriceAvailableCard } from "./NoPriceAvailableCard"
import HotelChequeCard from "./HotelChequeCard"
import { HotelPriceCard } from "./HotelPriceCard"
import HotelVoucherCard from "./HotelVoucherCard"
import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
import { HotelType } from "@scandic-hotels/common/constants/hotelType"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { BookingCodeChip } from "../BookingCodeChip"
import { FakeButton } from "../FakeButton"
import { TripAdvisorChip } from "../TripAdvisorChip"
import { PointType } from "@scandic-hotels/common/constants/pointType"
type Price = {
pricePerStay: number
pricePerNight: number
currency: string
}
export type HotelCardProps = {
hotel: {
id: string
hotelType: HotelType
name: string
description?: string
detailedFacilities: { name: string; id: FacilityEnum }[]
address: {
city: string
streetAddress: string
}
ratings?: {
tripAdvisor?: number
}
}
prices:
| {
public?: {
rateType: RateTypeEnum
localPrice: Price
requestedPrice?: Price
}
member?: {
rateType: RateTypeEnum
localPrice: Price
requestedPrice?: Price
}
voucher?: {
numberOfVouchers: number
rateCode: string
rateType: RateTypeEnum
}
bonusCheque?: {
rateCode: string
rateType: RateTypeEnum
localPrice: {
additionalPricePerStay: number
currency: CurrencyEnum | null | undefined
numberOfCheques: number
}
requestedPrice?: {
additionalPricePerStay: number
currency: CurrencyEnum | null | undefined
numberOfCheques: number
}
}
redemptions?: {
rateCode: string
hasEnoughPoints: boolean
localPrice: {
additionalPricePerStay: number
pointsPerStay: number
currency: CurrencyEnum | null | undefined
pointsType?: PointType | null
}
}[]
}
| undefined
images: GalleryImage[]
distanceToCityCenter: number
isUserLoggedIn: boolean
type?: "mapListing" | "pageListing"
state?: "default" | "active"
bookingCode?: string | null
isAlternative?: boolean
isPartnerBrand: boolean
fullPrice: boolean
isCampaignWithBookingCode: boolean
lang: Lang
belowInfoSlot: React.ReactNode
onHover: () => void
onHoverEnd: () => void
onFocusIn: () => void
onFocusOut: () => void
onAddressClick: () => void
}
export const HotelCardComponent = memo(
({
prices,
hotel,
distanceToCityCenter,
isUserLoggedIn,
state = "default",
type = "pageListing",
bookingCode = "",
isAlternative,
isPartnerBrand,
images,
lang,
belowInfoSlot,
fullPrice,
isCampaignWithBookingCode,
onAddressClick,
onHover,
onHoverEnd,
onFocusIn,
onFocusOut,
}: HotelCardProps) => {
const searchParams = useSearchParams()
const [isFocusWithin, setIsFocusWithin] = useState(false)
const [isPricesHovered, setIsPricesHovered] = useState(false)
const { focusWithinProps } = useFocusWithin({
onFocusWithin: onFocusIn,
onBlurWithin: onFocusOut,
onFocusWithinChange: (isFocusWithin) => {
setIsFocusWithin(isFocusWithin)
},
})
const intl = useIntl()
const amenities = hotel.detailedFacilities.slice(0, 5)
const classNames = hotelCardVariants({
type,
state,
})
const mapUrl = isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
const handleAddressClick = (event: React.MouseEvent) => {
event.preventDefault()
onAddressClick()
}
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const hasInsufficientPoints = !prices?.redemptions?.some(
(r) => r.hasEnoughPoints
)
const notEnoughPointsLabel = intl.formatMessage({
id: "booking.notEnoughPoints",
defaultMessage: "Not enough points",
})
const isDisabled = prices?.redemptions?.length && hasInsufficientPoints
const isCampaign =
prices?.public?.rateType === RateTypeEnum.PublicPromotion ||
prices?.member?.rateType === RateTypeEnum.PublicPromotion
const showBookingCodeChip = bookingCode || isCampaign
function onMouseEnter() {
if (!isFocusWithin) {
onHover()
}
}
function onMouseLeave() {
if (!isFocusWithin) {
onHoverEnd()
}
}
return (
<article
{...focusWithinProps}
className={classNames}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div>
<div className={styles.imageContainer}>
<ImageGallery
title={hotel.name}
images={images}
fill
sizes="(min-width: 768px) calc(100vw - 340px), (min-width: 1367px) 33vw, 100vw"
/>
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor} />
)}
</div>
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogoIcon hotelId={hotel.id} hotelType={hotel.hotelType} />
<Typography variant="Title/Subtitle/lg">
<h2>{hotel.name}</h2>
</Typography>
<div className={styles.addressContainer}>
<address className={styles.address}>
{type == "mapListing" && (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{addressStr}</p>
</Typography>
)}
{type === "pageListing" && (
<Link
size="small"
textDecoration="underline"
onClick={handleAddressClick}
href={mapUrl}
keepSearchParams
aria-label={intl.formatMessage({
id: "destination.seeOnMap",
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(
{
id: "common.kmToCityCenter",
defaultMessage: "{number} km to city center",
},
{
number: getSingleDecimal(distanceToCityCenter / 1000),
}
)}
</span>
</Typography>
</div>
</div>
{hotel.description ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.hotelDescription}>{hotel.description}</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>
{belowInfoSlot}
</div>
<PricesWrapper
pathname={selectRate(lang)}
isClickable={prices && !isDisabled}
hotelId={hotel.id}
removeBookingCodeFromSearchParams={!!(bookingCode && fullPrice)}
searchParams={searchParams}
onHoverStart={() => setIsPricesHovered(true)}
onHoverEnd={() => setIsPricesHovered(false)}
>
{!prices ? (
<NoPriceAvailableCard />
) : (
<>
{showBookingCodeChip && (
<BookingCodeChip
bookingCode={bookingCode}
isUnavailable={fullPrice}
isCampaignUnavailable={
isCampaignWithBookingCode && fullPrice
}
isCampaign={isCampaign && !(fullPrice && bookingCode)}
/>
)}
{(!isUserLoggedIn ||
!prices?.member ||
(bookingCode && !fullPrice)) &&
prices?.public && (
<HotelPriceCard
productTypePrices={prices.public}
isPartnerBrand={isPartnerBrand}
className={styles.priceCard}
isCampaign={isCampaign}
/>
)}
{prices.member && (
<HotelPriceCard
productTypePrices={prices.member}
isPartnerBrand={isPartnerBrand}
className={styles.priceCard}
isMemberPrice
/>
)}
{prices?.voucher && (
<HotelVoucherCard productTypeVoucher={prices.voucher} />
)}
{prices?.bonusCheque && (
<HotelChequeCard productTypeCheque={prices.bonusCheque} />
)}
{prices?.redemptions?.length ? (
<div className={styles.pointsCard}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "hotelCard.availableRates",
defaultMessage: "Available rates",
})}
</p>
</Typography>
{prices.redemptions.map((redemption) => (
<HotelPointsRow
key={redemption.rateCode}
pointsPerStay={redemption.localPrice.pointsPerStay}
additionalPricePerStay={
redemption.localPrice.additionalPricePerStay
}
additionalPriceCurrency={
redemption.localPrice.currency ?? undefined
}
pointsType={redemption.localPrice.pointsType ?? null}
/>
))}
</div>
) : null}
<FakeButton
variant="Primary"
size="md"
isDisabled={!!isDisabled}
isHovered={isPricesHovered}
>
{isDisabled
? notEnoughPointsLabel
: intl.formatMessage({
id: "common.seeRooms",
defaultMessage: "See rooms",
})}
</FakeButton>
</>
)}
</PricesWrapper>
</div>
</article>
)
}
)
export const HotelCard = HotelCardComponent as React.MemoExoticComponent<
(props: HotelCardProps) => React.ReactElement
>
interface PricesWrapperProps {
children: React.ReactNode
isClickable?: boolean
hotelId: string
pathname: string
removeBookingCodeFromSearchParams: boolean
searchParams: ReadonlyURLSearchParams
onHoverStart: () => void
onHoverEnd: () => void
}
function PricesWrapper({
children,
hotelId,
isClickable,
pathname,
removeBookingCodeFromSearchParams,
searchParams,
onHoverStart,
onHoverEnd,
}: 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 (
<NextLink
href={href}
className={styles.link}
onMouseEnter={onHoverStart}
onMouseLeave={onHoverEnd}
>
{content}
</NextLink>
)
}