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:
377
packages/design-system/lib/components/HotelCard/index.tsx
Normal file
377
packages/design-system/lib/components/HotelCard/index.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'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 '@scandic-hotels/design-system/Caption'
|
||||
import { Divider } from '@scandic-hotels/design-system/Divider'
|
||||
import { FacilityToIcon } from '@scandic-hotels/design-system/FacilityToIcon'
|
||||
import HotelLogoIcon from '@scandic-hotels/design-system/Icons/HotelLogoIcon'
|
||||
import ImageGallery, {
|
||||
GalleryImage,
|
||||
} from '@scandic-hotels/design-system/ImageGallery'
|
||||
import { HotelPointsRow } from './HotelPointsRow'
|
||||
import { NoPriceAvailableCard } from './NoPriceAvailableCard'
|
||||
import Link from '@scandic-hotels/design-system/Link'
|
||||
import { Typography } from '@scandic-hotels/design-system/Typography'
|
||||
|
||||
import HotelChequeCard from './HotelChequeCard'
|
||||
import { HotelPriceCard } from './HotelPriceCard'
|
||||
import HotelVoucherCard from './HotelVoucherCard'
|
||||
import { hotelCardVariants } from './variants'
|
||||
|
||||
import styles from './hotelCard.module.css'
|
||||
|
||||
import type { Lang } from '@scandic-hotels/common/constants/language'
|
||||
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
|
||||
import { RateTypeEnum } from '@scandic-hotels/common/constants/rateType'
|
||||
import { CurrencyEnum } from '@scandic-hotels/common/constants/currency'
|
||||
import { BookingCodeChip } from '../BookingCodeChip'
|
||||
import { HotelType } from '@scandic-hotels/common/constants/hotelType'
|
||||
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: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
images: GalleryImage[]
|
||||
distanceToCityCenter: number
|
||||
isUserLoggedIn: boolean
|
||||
type?: 'mapListing' | 'pageListing'
|
||||
state?: 'default' | 'active'
|
||||
bookingCode?: string | null
|
||||
isAlternative?: 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,
|
||||
images,
|
||||
lang,
|
||||
belowInfoSlot,
|
||||
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 fullPrice = !bookingCode
|
||||
|
||||
const hasInsufficientPoints = !prices.redemptions?.some(
|
||||
(r) => r.hasEnoughPoints
|
||||
)
|
||||
const notEnoughPointsLabel = intl.formatMessage({
|
||||
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({
|
||||
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(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({
|
||||
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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user