Files
web/packages/design-system/lib/components/HotelCard/index.tsx
Bianca Widstam 3962ecd858 Merged in feat/BOOK-426-campaign-tag-select-hotel (pull request #3037)
Feat/BOOK-426 campaign tag select hotel

* fix(BOOK-426): do not show campaign tag if a regular booking code is used and the rate is a campaign

* fix(BOOK-426): if no availability show booking code striketrough as default


Approved-by: Erik Tiekstra
2025-10-30 07:15:12 +00:00

396 lines
12 KiB
TypeScript

'use client'
import { cx } from 'class-variance-authority'
import { type ReadonlyURLSearchParams, useSearchParams } from 'next/navigation'
import { memo } from 'react'
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
pointsCurrency?: CurrencyEnum
fullPrice: boolean
isCampaignWithBookingCode: boolean
lang: Lang
belowInfoSlot: React.ReactNode
onHover: () => void
onHoverEnd: () => void
onAddressClick: () => void
}
export const HotelCard = memo(
({
prices,
hotel,
distanceToCityCenter,
isUserLoggedIn,
state = 'default',
type = 'pageListing',
bookingCode = '',
isAlternative,
pointsCurrency,
images,
lang,
belowInfoSlot,
fullPrice,
isCampaignWithBookingCode,
onAddressClick,
onHover,
onHoverEnd,
}: HotelCardProps) => {
const searchParams = useSearchParams()
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
return (
<article
className={classNames}
onMouseEnter={() => onHover()}
onMouseLeave={() => onHoverEnd()}
>
<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}
className={styles.priceCard}
isCampaign={isCampaign}
/>
)}
{prices.member && (
<HotelPriceCard
productTypePrices={prices.member}
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>
)
}
)
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>
)
}