Files
web/packages/design-system/lib/components/HotelCard/index.tsx
Rasmus Langvad c65091b36a Merged in feat/SW-3644-storybook-v10 (pull request #3240)
feat(SW-3644): Storybook v10

* Auto update to Storybook v10

* Add scandic theme and logo

* Update yarn.lock

* Update formatting of package.json

* Update vitest config and playwright plugin

* Remove vitest 4 update

* Re-added comment

* Update the Typography component to explicitly return React.ReactNode

* Add an explicit type assertion to the export

* Add an explicit type assertion to the export for Checkbox

* Explicit return type assertion

* Add an explicit type assertion to the export

* Update @types/react and fix ts warnings

* Updated typings


Approved-by: Linus Flood
Approved-by: Matilda Landström
2025-11-28 08:05:40 +00:00

430 lines
13 KiB
TypeScript

'use client'
import { cx } from 'class-variance-authority'
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 Caption from '../Caption'
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 { TripAdvisorChip } from '../TripAdvisorChip'
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
}
}[]
}
| undefined
images: GalleryImage[]
distanceToCityCenter: number
isUserLoggedIn: boolean
type?: 'mapListing' | 'pageListing'
state?: 'default' | 'active'
bookingCode?: string | null
isAlternative?: boolean
isPartnerBrand: boolean
pointsCurrency?: CurrencyEnum
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,
pointsCurrency,
images,
lang,
belowInfoSlot,
fullPrice,
isCampaignWithBookingCode,
onAddressClick,
onHover,
onHoverEnd,
onFocusIn,
onFocusOut,
}: HotelCardProps) => {
const searchParams = useSearchParams()
const [isFocusWithin, setIsFocusWithin] = 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}
>
{!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}>
<Caption>
{intl.formatMessage({
id: 'hotelCard.availableRates',
defaultMessage: 'Available rates',
})}
</Caption>
{prices.redemptions.map((redemption) => (
<HotelPointsRow
key={redemption.rateCode}
pointsPerStay={redemption.localPrice.pointsPerStay}
additionalPricePerStay={
redemption.localPrice.additionalPricePerStay
}
additionalPriceCurrency={
redemption.localPrice.currency ?? undefined
}
pointsCurrency={pointsCurrency}
/>
))}
</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({
id: 'common.seeRooms',
defaultMessage: 'See rooms',
})}
</span>
</Typography>
</div>
)}
</>
)}
</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
}
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>
)
}