Refactor TripadvisorChip * feat: create new StaticChip componeny * refactor tripadvisor chip to use ChipStatic * refactor: use TripadvisorChip everywhere * fix: use withChipStatic Approved-by: Erik Tiekstra
444 lines
14 KiB
TypeScript
444 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}
|
|
color="Neutral"
|
|
size="sm"
|
|
wrapper="x2"
|
|
/>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|