Feat/BOOK-61 refactor hotel page css variables * feat(BOOK-61): Breadcrumbs * feat(BOOK-61): intro section * feat(BOOK-61): show more button * feat(BOOK-61): rooms section * feat(BOOK-61): sidepeeks * feat(BOOK-61): deprecated old Link component * feat(BOOK-61): added new TextLink component to the design-system * feat(BOOK-61): replaced deprecated links with new TextLink component * feat(BOOK-61): miscellaneous changes Approved-by: Bianca Widstam Approved-by: Christel Westerberg
386 lines
12 KiB
TypeScript
386 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
|
|
|
|
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,
|
|
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
|
|
|
|
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 />
|
|
) : (
|
|
<>
|
|
{bookingCode && (
|
|
<BookingCodeChip
|
|
bookingCode={bookingCode}
|
|
isUnavailable={fullPrice}
|
|
/>
|
|
)}
|
|
{(!isUserLoggedIn ||
|
|
!prices?.member ||
|
|
(bookingCode && !fullPrice)) &&
|
|
prices?.public && (
|
|
<HotelPriceCard
|
|
productTypePrices={prices.public}
|
|
className={styles.priceCard}
|
|
/>
|
|
)}
|
|
{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>
|
|
)
|
|
}
|