Files
web/apps/scandic-web/components/HotelReservation/HotelCard/index.tsx
Anton Gunnarsson 322268595d Merged in feat/sw-3218-move-hotelreservationsidepeek-to-booking-flow (pull request #2600)
feat(SW-2873): Move HotelReservationSidePeek to booking-flow

* Move sidepeek store to booking-flow

* Begin move of HotelReservationSidePeek to booking-flow

* Copy Link

* Update AccessibilityAccordionItem

* Split AccessibilityAccordionItem into two components

* Fix tracking for Accordion

* Duplicate ButtonLink to booking-flow TEMP

* AdditionalAmeneties

* wip

* Move sidepeek accordion items

* Remove temp ButtonLink

* Merge branch 'master' into feat/sw-3218-move-hotelreservationsidepeek-to-booking-flow

* Fix accordion tracking

* Merge branch 'master' into feat/sw-3218-move-hotelreservationsidepeek-to-booking-flow

* Update exports

* Fix self-referencing import

* Merge branch 'master' into feat/sw-3218-move-hotelreservationsidepeek-to-booking-flow

* Add 'use client' to tracking function

* Merge branch 'master' into feat/sw-3218-move-hotelreservationsidepeek-to-booking-flow

* Fix TEMP folder

* Refactor sidepeek tracking

* Merge branch 'master' into feat/sw-3218-move-hotelreservationsidepeek-to-booking-flow


Approved-by: Joakim Jäderberg
2025-08-14 12:25:40 +00:00

321 lines
11 KiB
TypeScript

"use client"
import { cx } from "class-variance-authority"
import {
type ReadonlyURLSearchParams,
useParams,
useRouter,
useSearchParams,
} from "next/navigation"
import { memo } from "react"
import { useIntl } from "react-intl"
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
import { SidePeekEnum } from "@scandic-hotels/booking-flow/stores/sidepeek"
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 HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelsMapStore } from "@/stores/hotels-map"
import BookingCodeChip from "@/components/BookingCodeChip"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import ReadMore from "../ReadMore"
import HotelChequeCard from "./HotelChequeCard"
import HotelPointsRow from "./HotelPointsRow"
import HotelPriceCard from "./HotelPriceCard"
import HotelVoucherCard from "./HotelVoucherCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
function HotelCard({
hotelData: { availability, hotel },
isUserLoggedIn,
state = "default",
type = HotelCardListingTypeEnum.PageListing,
bookingCode = "",
isAlternative,
}: HotelCardProps) {
const params = useParams()
const searchParams = useSearchParams()
const lang = params.lang as Lang
const intl = useIntl()
const { activate, engage, disengage, disengageAfterDelay } =
useHotelsMapStore()
const amenities = hotel.detailedFacilities.slice(0, 5)
const router = useRouter()
const classNames = hotelCardVariants({
type,
state,
})
const mapUrl = isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
const handleAddressClick = (event: React.MouseEvent) => {
event.preventDefault()
disengage() // Disengage the current hotel to avoid the hover state from being active when clicking on the address
activate(hotel.name)
router.push(`${mapUrl}?${searchParams.toString()}`)
}
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const fullPrice = !availability.bookingCode
const price = availability.productType
const hasInsufficientPoints = !price?.redemptions?.some(
(r) => r.hasEnoughPoints
)
const notEnoughPointsLabel = intl.formatMessage({
defaultMessage: "Not enough points",
})
const isDisabled = price?.redemptions?.length && hasInsufficientPoints
return (
<article
className={classNames}
onMouseEnter={() => engage(hotel.name)}
onMouseLeave={() => disengageAfterDelay()}
>
<div>
<div className={styles.imageContainer}>
<ImageGallery
title={hotel.name}
images={galleryImages}
fill
sizes="(min-width: 768px) calc(100vw - 340px), (min-width: 1367px) 33vw, 100vw"
/>
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)}
</div>
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogoIcon
hotelId={hotel.operaId}
hotelType={hotel.hotelType}
/>
<Typography variant="Title/Subtitle/lg">
<h2>{hotel.name}</h2>
</Typography>
<div className={styles.addressContainer}>
<address className={styles.address}>
{type == HotelCardListingTypeEnum.MapListing ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{addressStr}</p>
</Typography>
) : (
<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(
hotel.location.distanceToCentre / 1000
),
}
)}
</span>
</Typography>
</div>
</div>
{hotel.hotelContent.texts.descriptions ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.hotelDescription}>
{hotel.hotelContent.texts.descriptions.short}
</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>
<ReadMore
label={intl.formatMessage({
defaultMessage: "See hotel details",
})}
hotelId={hotel.operaId}
showCTA={true}
sidePeekKey={SidePeekEnum.hotelDetails}
/>
</div>
<PricesWrapper
pathname={selectRate(lang)}
isClickable={availability.productType && !isDisabled}
hotelId={hotel.operaId}
removeBookingCodeFromSearchParams={!!(bookingCode && fullPrice)}
searchParams={searchParams}
>
{!availability.productType ? (
<NoPriceAvailableCard />
) : (
<>
{bookingCode && (
<BookingCodeChip
bookingCode={bookingCode}
isUnavailable={fullPrice}
/>
)}
{(!isUserLoggedIn ||
!price?.member ||
(bookingCode && !fullPrice)) &&
price?.public && (
<HotelPriceCard
productTypePrices={price.public}
className={styles.priceCard}
/>
)}
{availability.productType.member && (
<HotelPriceCard
productTypePrices={availability.productType.member}
className={styles.priceCard}
isMemberPrice
/>
)}
{price?.voucher && (
<HotelVoucherCard productTypeVoucher={price.voucher} />
)}
{price?.bonusCheque && (
<HotelChequeCard productTypeCheque={price.bonusCheque} />
)}
{price?.redemptions?.length ? (
<div className={styles.pointsCard}>
<Caption>
{intl.formatMessage({
defaultMessage: "Available rates",
})}
</Caption>
{price.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>
)
}
export default memo(HotelCard)