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:
Joakim Jäderberg
2025-08-25 11:26:16 +00:00
parent 4f8c51298f
commit c54c1ec540
139 changed files with 2511 additions and 1557 deletions

View File

@@ -1,6 +1,6 @@
import "@scandic-hotels/design-system/fonts.css"
import "@scandic-hotels/design-system/style.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/design-system-new-deprecated.css"
import "../../globals.css"
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"

View File

@@ -1,4 +1,4 @@
import { type NextRequest,NextResponse } from "next/server"
import { type NextRequest, NextResponse } from "next/server"
import { AuthError } from "next-auth"
import { logger } from "@scandic-hotels/common/logger"

View File

@@ -1,6 +1,6 @@
import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"

View File

@@ -1,6 +1,6 @@
import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"

View File

@@ -1,6 +1,6 @@
import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"

View File

@@ -1,7 +1,7 @@
import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import "@scandic-hotels/design-system/design-system-new-deprecated.css"
import Script from "next/script"
import { NuqsAdapter } from "nuqs/adapters/next/app"

View File

@@ -3,12 +3,12 @@ import { useIntl } from "react-intl"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
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 TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import styles from "./hotelListingItem.module.css"

View File

@@ -7,6 +7,7 @@ import { useIntl } from "react-intl"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
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 TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
@@ -14,7 +15,6 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import styles from "./hotelListItem.module.css"

View File

@@ -8,6 +8,7 @@ import { useIntl } from "react-intl"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
@@ -16,7 +17,6 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import styles from "./hotelListingItem.module.css"

View File

@@ -3,13 +3,13 @@ import { useState } from "react"
import { useIntl } from "react-intl"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import { FacilityToIcon } from "../../HotelPage/data"
import { usePageType } from "../Map/PageTypeProvider"
import DialogImage from "./DialogImage"

View File

@@ -8,9 +8,9 @@ import { type PropsWithChildren, useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { MAP_RESTRICTIONS } from "@scandic-hotels/design-system/Map/mapConstants"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { MAP_RESTRICTIONS } from "@/constants/map"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"

View File

@@ -8,9 +8,10 @@ import {
} from "@vis.gl/react-google-maps"
import { useMediaQuery } from "usehooks-ts"
import { HotelMarkerByType } from "@scandic-hotels/design-system/Map/Markers/HotelMarkerByType"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import HotelMarkerByType from "@/components/Maps/Markers"
import { trackMapClick } from "@/utils/tracking/destinationPage"
import HotelMapCard from "../../../HotelMapCard"

View File

@@ -13,8 +13,12 @@ import { useIntl } from "react-intl"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import useLang from "@/hooks/useLang"
import Sidebar from "./Sidebar"
@@ -43,11 +47,15 @@ export default function HotelMapPageClient({
mapId,
}: HotelMapPageClientProps) {
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const rootDiv = useRef<HTMLDivElement | null>(null)
const [mapHeight, setMapHeight] = useState("100dvh")
const [activePoi, setActivePoi] = useState<string | null>(null)
const isUserLoggedIn = useIsUserLoggedIn()
const hotelMapStore = useHotelsMapStore()
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
const handleMapHeight = useCallback(() => {
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
@@ -132,6 +140,15 @@ export default function HotelMapPageClient({
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
mapId={mapId}
markerInfo={markerInfo}
onHoverHotelPin={(args) => {
if (!args) {
hotelMapStore.disengage()
}
hotelMapStore.engage(hotelName)
}}
hoveredHotelPin={hotelMapStore.hoveredHotel}
lang={lang}
isUserLoggedIn={isUserLoggedIn}
/>
</div>
</APIProvider>

View File

@@ -6,10 +6,9 @@ import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker"
import { Typography } from "@scandic-hotels/design-system/Typography"
import PoiMarker from "@/components/Maps/Markers/Poi"
import { translatePOIGroup } from "./util"
import styles from "./sidebar.module.css"

View File

@@ -1,8 +1,8 @@
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { getIntl } from "@/i18n"
import styles from "./amenitiesList.module.css"

View File

@@ -5,9 +5,9 @@ import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker"
import { Typography } from "@scandic-hotels/design-system/Typography"
import PoiMarker from "@/components/Maps/Markers/Poi"
import { trackHotelMapClick } from "@/utils/tracking"
import styles from "./mapCard.module.css"

View File

@@ -1,6 +1,7 @@
import { HotelMarkerByType } from "@scandic-hotels/design-system/Map/Markers/HotelMarkerByType"
import { env } from "@/env/server"
import HotelMarkerByType from "@/components/Maps/Markers"
import StaticMapComp from "@/components/Maps/StaticMap"
import { getIntl } from "@/i18n"
import { calculateLatWithOffset } from "@/utils/map"

View File

@@ -3,14 +3,13 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import { Divider } from "@scandic-hotels/design-system/Divider"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetails from "../../PriceDetails"
import styles from "./totalPrice.module.css"

View File

@@ -9,12 +9,12 @@ import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Body from "@scandic-hotels/design-system/Body"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"

View File

@@ -1,34 +0,0 @@
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import styles from "./HotelCardSkeleton.module.css"
export function HotelCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
<div className={styles.content}>
<SkeletonShimmer height={"65px"} />
<div className={styles.text}>
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
</div>
<SkeletonShimmer height={"56px"} />
<SkeletonShimmer height={"52px"} width={"150px"} />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 2 }).map((_, index) => (
<SkeletonShimmer key={index} height={"100px"} />
))}
<SkeletonShimmer height={"40px"} />
</div>
</article>
)
}

View File

@@ -1,320 +0,0 @@
"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 {
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 HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
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, additionalData, restaurants, url },
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>
<HotelDetailsSidePeek
hotel={{ ...hotel, url: url }}
restaurants={restaurants}
additionalHotelData={additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See hotel details",
})}
buttonVariant="primary"
/>
</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)

View File

@@ -1,43 +0,0 @@
import Chip from "@scandic-hotels/design-system/Chip"
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import Image from "@scandic-hotels/design-system/Image"
import { hotelCardDialogImageVariants } from "./variants"
import styles from "./hotelCardDialogImage.module.css"
import type { HotelCardDialogImageProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialogImage({
firstImage,
altText,
rating,
imageError,
setImageError,
position,
}: HotelCardDialogImageProps) {
const classNames = hotelCardDialogImageVariants({ position })
return (
<div className={classNames}>
{!firstImage || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={firstImage}
alt={altText || ""}
fill
onError={() => setImageError(true)}
/>
)}
{rating ? (
<div className={styles.tripAdvisor}>
<Chip className={styles.tripAdvisor}>
<TripadvisorIcon color="Icon/Interactive/Default" />
{rating}
</Chip>
</div>
) : null}
</div>
)
}

View File

@@ -1,10 +1,15 @@
"use client"
import { useSession } from "next-auth/react"
import { useState } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import Caption from "@scandic-hotels/design-system/Caption"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { HotelCardDialogImage } from "@scandic-hotels/design-system/HotelCard/HotelCardDialogImage"
import { HotelPointsRow } from "@scandic-hotels/design-system/HotelCard/HotelPointsRow"
import { NoPriceAvailableCard } from "@scandic-hotels/design-system/HotelCard/NoPriceAvailableCard"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
@@ -12,14 +17,9 @@ import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import useLang from "@/hooks/useLang"
import { isValidClientSession } from "@/utils/clientSession"
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import styles from "./listingHotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
@@ -81,7 +81,7 @@ export default function ListingHotelCardDialog({
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
rating={ratings}
rating={{ tripAdvisor: ratings }}
imageError={imageError}
setImageError={setImageError}
position="top"

View File

@@ -1,5 +1,6 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useEffect, useMemo, useRef } from "react"
import { useIntl } from "react-intl"
@@ -8,20 +9,29 @@ import {
BookingCodeFilterEnum,
useBookingCodeFilterStore,
} from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
import {
alternativeHotelsMap,
selectHotelMap,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { HotelCard } from "@scandic-hotels/design-system/HotelCard"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { isValidClientSession } from "@/utils/clientSession"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import HotelCard from "../HotelCard"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
import { getSortedHotels } from "./utils"
import styles from "./hotelCardListing.module.css"
import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
import {
type HotelCardListingProps,
HotelCardListingTypeEnum,
@@ -33,13 +43,15 @@ export default function HotelCardListing({
type = HotelCardListingTypeEnum.PageListing,
isAlternative,
}: HotelCardListingProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const { activeHotel } = useHotelsMapStore()
const { activeHotel, activate, disengage, engage } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const activeCardRef = useRef<HTMLDivElement | null>(null)
@@ -118,32 +130,79 @@ export default function HotelCardListing({
return (
<section className={styles.hotelCards}>
{hotels?.length
? hotels.map((hotel) => (
<div
key={hotel.hotel.operaId}
ref={
isHotelActiveInMapView(hotel.hotel.name) ? activeCardRef : null
}
data-active={
isHotelActiveInMapView(hotel.hotel.name) ? "true" : "false"
}
>
<HotelCard
hotelData={hotel}
isUserLoggedIn={isUserLoggedIn}
state={
isHotelActiveInMapView(hotel.hotel.name)
? "active"
: "default"
}
type={type}
bookingCode={bookingCode}
isAlternative={isAlternative}
{hotels.map((hotel) => (
<div
key={hotel.hotel.operaId}
ref={isHotelActiveInMapView(hotel.hotel.name) ? activeCardRef : null}
data-active={
isHotelActiveInMapView(hotel.hotel.name) ? "true" : "false"
}
>
<HotelCard
hotel={{
id: hotel.hotel.operaId,
name: hotel.hotel.name,
address: hotel.hotel.address,
description: hotel.hotel.hotelContent.texts.descriptions?.short,
hotelType: hotel.hotel.hotelType as HotelType,
detailedFacilities: hotel.hotel.detailedFacilities,
ratings: {
tripAdvisor: hotel.hotel.ratings?.tripAdvisor.rating,
},
}}
lang={lang}
prices={{
public: hotel.availability.productType?.public
? {
...hotel.availability.productType.public,
requestedPrice:
hotel.availability.productType?.public.requestedPrice ??
undefined,
}
: undefined,
member: hotel.availability.productType?.member
? {
...hotel.availability.productType.member,
requestedPrice:
hotel.availability.productType?.member.requestedPrice ??
undefined,
}
: undefined,
}}
onHover={() => engage(hotel.hotel.name)}
onHoverEnd={() => disengage()}
onAddressClick={() => {
const mapUrl = isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
disengage() // Disengage the current hotel to avoid the hover state from being active when clicking on the address
activate(hotel.hotel.name)
router.push(`${mapUrl}?${searchParams.toString()}`)
}}
belowInfoSlot={
<HotelDetailsSidePeek
hotel={{ ...hotel.hotel, url: "" }}
restaurants={hotel.restaurants}
additionalHotelData={hotel.additionalData}
triggerLabel={intl.formatMessage({
defaultMessage: "See hotel details",
})}
buttonVariant="primary"
/>
</div>
))
: null}
}
distanceToCityCenter={hotel.hotel.location.distanceToCentre}
images={mapApiImagesToGalleryImages(hotel.hotel.galleryImages)}
isUserLoggedIn={isUserLoggedIn}
state={
isHotelActiveInMapView(hotel.hotel.name) ? "active" : "default"
}
type={type}
bookingCode={bookingCode}
isAlternative={isAlternative}
/>
</div>
))}
{showBackToTop && (
<BackToTopButton
position="right"

View File

@@ -1,6 +1,6 @@
"use client"
import BookingCodeChip from "@/components/BookingCodeChip"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import styles from "./row.module.css"

View File

@@ -1,4 +1,5 @@
"use client"
import { useMap } from "@vis.gl/react-google-maps"
import { useCallback, useMemo, useRef, useState } from "react"
import { useIntl } from "react-intl"
@@ -18,15 +19,18 @@ import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { trackEvent } from "@/utils/tracking/base"
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
import HotelListing from "../HotelListing"
@@ -39,7 +43,7 @@ import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/he
const SKELETON_LOAD_DELAY = 750
export default function SelectHotelContent({
export function SelectHotelMapContent({
hotelPins,
cityCoordinates,
mapId,
@@ -52,6 +56,7 @@ export default function SelectHotelContent({
const lang = useLang()
const intl = useIntl()
const map = useMap()
const isUserLoggedIn = useIsUserLoggedIn()
const isAboveMobile = useMediaQuery("(min-width: 900px)")
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
@@ -59,7 +64,7 @@ export default function SelectHotelContent({
const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const { activeHotel } = useHotelsMapStore()
const hotelMapStore = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
@@ -71,8 +76,10 @@ export default function SelectHotelContent({
)
const coordinates = useMemo(() => {
if (activeHotel) {
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotel)
if (hotelMapStore.activeHotel) {
const hotel = hotels.find(
(hotel) => hotel.hotel.name === hotelMapStore.activeHotel
)
if (hotel && hotel.hotel.location) {
return isAboveMobile
@@ -89,7 +96,7 @@ export default function SelectHotelContent({
return isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
}, [activeHotel, hotels, isAboveMobile, cityCoordinates])
}, [hotelMapStore.activeHotel, hotels, isAboveMobile, cityCoordinates])
const showOnlyBookingCodeRates =
bookingCode &&
@@ -231,10 +238,62 @@ export default function SelectHotelContent({
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
hotelPins={filteredHotelPins}
hotelPins={filteredHotelPins.map((pin) => {
const galleryImage = mapApiImagesToGalleryImages(pin.images).at(0)
return {
...pin,
ratings: {
tripAdvisor: pin.ratings ?? null,
},
image: {
alt: galleryImage?.alt ?? "",
url: galleryImage?.src ?? "",
},
}
})}
mapId={mapId}
onTilesLoaded={debouncedUpdateHotelCards}
fitBounds={isAboveMobile || !activeHotel}
fitBounds={isAboveMobile || !hotelMapStore.activeHotel}
onHoverHotelPin={(args) => {
if (!args) {
hotelMapStore.disengage()
return
}
hotelMapStore.engage(args.hotelName)
}}
hoveredHotelPin={hotelMapStore.hoveredHotel}
onSetActiveHotelPin={(args) => {
if (!args || args.hotelName === hotelMapStore.activeHotel) {
hotelMapStore.deactivate()
return
}
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId: args.hotelId,
},
})
hotelMapStore.activate(args.hotelName)
}}
onClickHotel={(hotelId) => {
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId,
},
})
}}
lang={lang}
isUserLoggedIn={isUserLoggedIn}
/>
</div>
)

View File

@@ -2,7 +2,7 @@
import { APIProvider } from "@vis.gl/react-google-maps"
import SelectHotelMapContent from "./SelectHotelMapContent"
import { SelectHotelMapContent } from "./SelectHotelMapContent"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"

View File

@@ -1,7 +1,6 @@
import { HotelCardSkeleton } from "@scandic-hotels/design-system/HotelCard/HotelCardSkeleton"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { HotelCardSkeleton } from "../HotelCard/HotelCardSkeleton"
import styles from "./selectHotel.module.css"
type Props = {

View File

@@ -4,9 +4,9 @@ import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import Alert from "@/components/TempDesignSystem/Alert"

View File

@@ -1,12 +1,12 @@
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
import { dt } from "@scandic-hotels/common/dt"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { TripAdvisorChip } from "@scandic-hotels/design-system/TripAdvisorChip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"

View File

@@ -1,5 +1,5 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { sumPackages } from "@/components/HotelReservation/utils"

View File

@@ -17,7 +17,7 @@ import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useBreakpoint } from "@/hooks/useBreakpoint"

View File

@@ -1,6 +1,7 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import BookingCodeChip from "@/components/BookingCodeChip"
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
export function RemoveBookingCodeButton() {

View File

@@ -1,105 +0,0 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
InfoWindow,
} from "@vis.gl/react-google-maps"
import { useCallback } from "react"
import { useMediaQuery } from "usehooks-ts"
import { useHotelsMapStore } from "@/stores/hotels-map"
import StandaloneHotelCardDialog from "@/components/HotelReservation/HotelCardDialog/StandaloneHotelCardDialog"
import { trackEvent } from "@/utils/tracking/base"
import HotelPin from "./HotelPin"
import styles from "./hotelListingMapContent.module.css"
import type { HotelListingMapContentProps } from "@/types/components/hotelReservation/selectHotel/map"
function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
const { activeHotel, hoveredHotel, activate, deactivate, engage, disengage } =
useHotelsMapStore()
const isDesktop = useMediaQuery("(min-width: 768px)")
const toggleActiveHotelPin = useCallback(
(pinName: string | null, hotelId: string) => {
if (activeHotel === pinName || pinName === null) {
deactivate()
return
}
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId,
},
})
activate(pinName)
},
[activeHotel, activate, deactivate]
)
return (
<div>
{hotelPins.map((pin) => {
const isActiveOrHovered =
activeHotel === pin.name || hoveredHotel === pin.name
const hotelPrice =
pin.memberPrice ??
pin.publicPrice ??
pin.redemptionPrice ??
pin.voucherPrice ??
pin.chequePrice?.numberOfCheques ??
null
const hotelAdditionalPrice = pin.chequePrice
? pin.chequePrice.additionalPricePerStay
: undefined
const hotelAdditionalCurrency = pin.chequePrice
? pin.chequePrice.currency?.toString()
: undefined
return (
<AdvancedMarker
key={pin.name}
className={styles.advancedMarker}
position={pin.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={isActiveOrHovered ? 2 : 0}
onMouseEnter={() => engage(pin.name)}
onMouseLeave={() => disengage()}
onClick={() => toggleActiveHotelPin(pin.name, pin.operaId)}
>
{isActiveOrHovered && isDesktop && (
<InfoWindow
position={pin.coordinates}
pixelOffset={[0, -24]}
headerDisabled={true}
shouldFocus={false}
>
<StandaloneHotelCardDialog
data={pin}
handleClose={() => {
deactivate()
disengage()
}}
/>
</InfoWindow>
)}
<HotelPin
isActive={isActiveOrHovered}
hotelPrice={hotelPrice}
currency={pin.currency}
hotelAdditionalPrice={hotelAdditionalPrice}
hotelAdditionalCurrency={hotelAdditionalCurrency}
/>
</AdvancedMarker>
)
})}
</div>
)
}
export default HotelListingMapContent

View File

@@ -1,114 +0,0 @@
"use client"
import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import {
DEFAULT_ZOOM,
MAP_RESTRICTIONS,
MAX_ZOOM,
MIN_ZOOM,
} from "@/constants/map"
import { useZoomControls } from "@/hooks/maps/useZoomControls"
import HotelListingMapContent from "./HotelListingMapContent"
import PoiMapMarkers from "./PoiMapMarkers"
import styles from "./interactiveMap.module.css"
import type { InteractiveMapProps } from "@/types/components/hotelPage/map/interactiveMap"
export default function InteractiveMap({
coordinates,
pointsOfInterest,
activePoi,
hotelPins,
mapId,
closeButton,
markerInfo,
fitBounds = true,
onTilesLoaded,
onActivePoiChange,
}: InteractiveMapProps) {
const intl = useIntl()
const map = useMap()
const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls()
const mapOptions: MapProps = {
defaultZoom: DEFAULT_ZOOM,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
defaultCenter: coordinates,
disableDefaultUI: true,
clickableIcons: false,
mapId,
gestureHandling: "greedy",
restriction: MAP_RESTRICTIONS,
}
useEffect(() => {
if (map && hotelPins?.length && !hasInitializedBounds) {
if (fitBounds) {
const bounds = new google.maps.LatLngBounds()
hotelPins.forEach((marker) => {
bounds.extend(marker.coordinates)
})
map.fitBounds(bounds, 100)
}
setHasInitializedBounds(true)
}
}, [map, fitBounds, hotelPins, hasInitializedBounds])
return (
<div className={styles.mapContainer}>
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
{hotelPins && <HotelListingMapContent hotelPins={hotelPins} />}
{pointsOfInterest && markerInfo && (
<PoiMapMarkers
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
onActivePoiChange={onActivePoiChange}
activePoi={activePoi}
markerInfo={markerInfo}
/>
)}
</Map>
<div className={styles.ctaButtons}>
{closeButton}
<div className={styles.zoomButtons}>
<IconButton
theme="Inverted"
style="Elevated"
className={styles.zoomButton}
onClick={zoomOut}
aria-label={intl.formatMessage({
defaultMessage: "Zoom out",
})}
isDisabled={isMinZoom}
>
<MaterialIcon icon="remove" color="CurrentColor" />
</IconButton>
<IconButton
theme="Inverted"
style="Elevated"
className={styles.zoomButton}
onClick={zoomIn}
aria-label={intl.formatMessage({
defaultMessage: "Zoom in",
})}
isDisabled={isMaxZoom}
>
<MaterialIcon icon="add" color="CurrentColor" />
</IconButton>
</div>
</div>
</div>
)
}

View File

@@ -1,27 +0,0 @@
import { IconByIconName } from "@scandic-hotels/design-system/Icons/IconByIconName"
import { getIconByPoiGroupAndCategory } from "../utils"
import { poiVariants } from "./variants"
import type { PoiMarkerProps } from "@/types/components/maps/poiMarker"
export default function PoiMarker({
group,
categoryName,
skipBackground,
size = "small",
className = "",
}: PoiMarkerProps) {
const iconName = getIconByPoiGroupAndCategory(group, categoryName)
const classNames = poiVariants({ group, skipBackground, size, className })
return iconName ? (
<span className={classNames}>
<IconByIconName
iconName={iconName}
color={skipBackground ? "Icon/Feedback/Neutral" : "Icon/Inverted"}
size={size === "small" ? 16 : size === "large" ? 24 : 20}
/>
</span>
) : null
}

View File

@@ -1,31 +0,0 @@
import { cva } from "class-variance-authority"
import { PointOfInterestGroupEnum } from "@scandic-hotels/trpc/enums/pointOfInterest"
import styles from "./poi.module.css"
export const poiVariants = cva(styles.icon, {
variants: {
group: {
[PointOfInterestGroupEnum.ATTRACTIONS]: styles.attractions,
[PointOfInterestGroupEnum.BUSINESS]: styles.business,
[PointOfInterestGroupEnum.LOCATION]: styles.location,
[PointOfInterestGroupEnum.PARKING]: styles.parking,
[PointOfInterestGroupEnum.PUBLIC_TRANSPORT]: styles.publicTransport,
[PointOfInterestGroupEnum.SHOPPING_DINING]: styles.shoppingDining,
},
skipBackground: {
true: styles.transparent,
false: "",
},
size: {
small: styles.small,
medium: styles.small,
large: styles.large,
},
},
defaultVariants: {
skipBackground: false,
size: "small",
},
})

View File

@@ -1,89 +0,0 @@
import { HotelTypeEnum } from "@scandic-hotels/trpc/enums/hotelType"
import DowntownCamperMarker from "./DowntownCamper"
import DowntownCamperSmallMarker from "./DowntownCamperSmall"
import GrandHotelMarker from "./GrandHotel"
import GrandHotelSmallMarker from "./GrandHotelSmall"
import HaymarketMarker from "./Haymarket"
import HaymarketSmallMarker from "./HaymarketSmall"
import HotelNorgeMarker from "./HotelNorge"
import HotelNorgeSmallMarker from "./HotelNorgeSmall"
import MarskiMarker from "./Marski"
import MarskiSmallMarker from "./MarskiSmall"
import ScandicMarker from "./Scandic"
import ScandicGoMarker from "./ScandicGo"
import ScandicGoSmallMarker from "./ScandicGoSmall"
import ScandicSmallMarker from "./ScandicSmall"
import TheDockMarker from "./TheDock"
import TheDockSmallMarker from "./TheDockSmall"
import type { MarkerInfo } from "@scandic-hotels/trpc/types/marker"
import { SignatureHotelEnum } from "@/types/enums/signatureHotel"
interface HotelMarkerByTypeProps
extends MarkerInfo,
React.SVGAttributes<HTMLOrSVGElement> {
size?: "large" | "small"
}
export default function HotelMarkerByType({
hotelId,
hotelType,
size = "large",
...props
}: HotelMarkerByTypeProps) {
if (hotelType === HotelTypeEnum.ScandicGo) {
return size === "small" ? (
<ScandicGoSmallMarker {...props} />
) : (
<ScandicGoMarker {...props} />
)
}
switch (hotelId) {
case SignatureHotelEnum.Haymarket:
return size === "small" ? (
<HaymarketSmallMarker {...props} />
) : (
<HaymarketMarker {...props} />
)
case SignatureHotelEnum.HotelNorge:
return size === "small" ? (
<HotelNorgeSmallMarker {...props} />
) : (
<HotelNorgeMarker {...props} />
)
case SignatureHotelEnum.DowntownCamper:
return size === "small" ? (
<DowntownCamperSmallMarker {...props} />
) : (
<DowntownCamperMarker {...props} />
)
case SignatureHotelEnum.GrandHotelOslo:
return size === "small" ? (
<GrandHotelSmallMarker {...props} />
) : (
<GrandHotelMarker {...props} />
)
case SignatureHotelEnum.Marski:
return size === "small" ? (
<MarskiSmallMarker {...props} />
) : (
<MarskiMarker {...props} />
)
case SignatureHotelEnum.TheDock:
return size === "small" ? (
<TheDockSmallMarker {...props} />
) : (
<TheDockMarker {...props} />
)
default:
return size === "small" ? (
<ScandicSmallMarker {...props} />
) : (
<ScandicMarker {...props} />
)
}
}

View File

@@ -1,23 +0,0 @@
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
import { PointOfInterestGroupEnum } from "@scandic-hotels/trpc/enums/pointOfInterest"
export function getIconByPoiGroupAndCategory(
group: PointOfInterestGroupEnum,
category?: string
) {
switch (group) {
case PointOfInterestGroupEnum.PUBLIC_TRANSPORT:
return category === "Airport" ? IconName.Airplane : IconName.Train
case PointOfInterestGroupEnum.ATTRACTIONS:
return category === "Museum" ? IconName.Museum : IconName.Camera
case PointOfInterestGroupEnum.BUSINESS:
return IconName.Business
case PointOfInterestGroupEnum.PARKING:
return IconName.Parking
case PointOfInterestGroupEnum.SHOPPING_DINING:
return category === "Restaurant" ? IconName.Restaurant : IconName.Shopping
case PointOfInterestGroupEnum.LOCATION:
default:
return IconName.Location
}
}

View File

@@ -3,13 +3,13 @@ import { ArrowLeft } from "react-feather"
import Link from "@scandic-hotels/design-system/Link"
import { overview } from "@/constants/routes/webviews"
import { env } from "@/env/server"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { webviewSearchParams } from "@/utils/webviews"
import styles from "./linkToOverview.module.css"
import { env } from "@/env/server"
export default async function LinkToOverview() {
if (!env.WEBVIEW_SHOW_OVERVIEW) {

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@ import { parsePhoneNumberFromString } from "libphonenumber-js"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { logger } from "@scandic-hotels/common/logger"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import {
sumPackages,

View File

@@ -1,19 +0,0 @@
import type { PointOfInterest } from "@scandic-hotels/trpc/types/hotel"
import type { MarkerInfo } from "@scandic-hotels/trpc/types/marker"
import type { ReactElement } from "react"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { Coordinates } from "@/types/components/maps/coordinates"
export interface InteractiveMapProps {
coordinates: Coordinates
activePoi?: PointOfInterest["name"] | null
hotelPins?: HotelPin[]
pointsOfInterest?: PointOfInterest[]
markerInfo?: MarkerInfo
mapId: string
closeButton: ReactElement<any>
fitBounds?: boolean
onTilesLoaded?: () => void
onActivePoiChange?: (poi: PointOfInterest["name"] | null) => void
}

View File

@@ -50,10 +50,6 @@ export type HotelPin = {
hasEnoughPoints: boolean
}
export interface HotelListingMapContentProps {
hotelPins: HotelPin[]
}
export interface HotelCardDialogProps {
type?: "listing" | "standalone"
isOpen: boolean
@@ -61,15 +57,6 @@ export interface HotelCardDialogProps {
handleClose: (event: { stopPropagation: () => void }) => void
}
export interface HotelCardDialogImageProps {
firstImage?: string
altText?: string
rating?: number | null
imageError: boolean
setImageError: (error: boolean) => void
position: "top" | "left"
}
export interface HotelCardDialogListingProps {
hotels: HotelResponse[]
unfilteredHotelCount: number

View File

@@ -10,12 +10,6 @@ export type PriceCardProps = {
className?: string
}
export type PointsRowProps = {
pointsPerStay: number
additionalPricePerStay?: number
additionalPriceCurrency?: string
}
export type VoucherCardProps = {
productTypeVoucher: ProductTypeVoucher
}

View File

@@ -1,10 +0,0 @@
import type { PointOfInterestGroupEnum } from "@scandic-hotels/trpc/enums/pointOfInterest"
import type { VariantProps } from "class-variance-authority"
import type { poiVariants } from "@/components/Maps/Markers/Poi/variants"
export interface PoiMarkerProps extends VariantProps<typeof poiVariants> {
group: PointOfInterestGroupEnum
categoryName?: string
className?: string
}

View File

@@ -1,4 +1,4 @@
import { FacilityEnum } from "@scandic-hotels/trpc/enums/facilities"
import { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
import type {
Amenities,

View File

@@ -1,7 +1,6 @@
import { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityEnum } from "@scandic-hotels/trpc/enums/facilities"
import { FacilityToIcon } from "../TEMP/FacilityToIcon"
import styles from "./additionalAmenities.module.css"

View File

@@ -1,305 +0,0 @@
import { IconByIconName } from "@scandic-hotels/design-system/Icons/IconByIconName"
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
import { FacilityEnum } from "@scandic-hotels/trpc/enums/facilities"
import type {
IconProps,
NucleoIconProps,
} from "@scandic-hotels/design-system/Icons"
import type { MaterialIconSetIconProps } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import type { JSX } from "react"
const facilityToIconMap: Record<FacilityEnum, IconName> = {
[FacilityEnum.AccessibleBathingControls]: IconName.StarFilled,
[FacilityEnum.AccessibleBathtubs]: IconName.StarFilled,
[FacilityEnum.AccessibleElevators]: IconName.StarFilled,
[FacilityEnum.AccessibleLightSwitch]: IconName.StarFilled,
[FacilityEnum.AccessibleRoomsAtHotel1]: IconName.StarFilled,
[FacilityEnum.AccessibleRoomsAtHotel2]: IconName.StarFilled,
[FacilityEnum.AccessibleToilets]: IconName.StarFilled,
[FacilityEnum.AccessibleWashBasins]: IconName.StarFilled,
[FacilityEnum.AdaptedRoomDoors]: IconName.StarFilled,
[FacilityEnum.AdjoiningConventionCentre]: IconName.ConventionCentre,
[FacilityEnum.AirConAirCooling]: IconName.AirConAirCooling,
[FacilityEnum.AirConditioningInRoom]: IconName.AirConditioningInRoom,
[FacilityEnum.AirportMaxDistance8Km]: IconName.Airplane,
[FacilityEnum.AlarmsContinuouslyMonitored]: IconName.StarFilled,
[FacilityEnum.AlarmsHaveStrobeLightsForDeafHardHearingInAllGuestRooms]:
IconName.StarFilled,
[FacilityEnum.AlarmsHaveStrobeLightsForDeafHardHearingInAllHallways]:
IconName.StarFilled,
[FacilityEnum.AlarmsHaveStrobeLightsForDeafHardHearingInAllPublicAreas]:
IconName.StarFilled,
[FacilityEnum.AllAudibleSmokeAlarmsHardwired]: IconName.StarFilled,
[FacilityEnum.AllExteriorDoorsRequireKeyAccessAtNightOrAutomaticallyLock]:
IconName.StarFilled,
[FacilityEnum.AllGuestRoomDoorsHaveViewports]: IconName.StarFilled,
[FacilityEnum.AllGuestRoomDoorsSelfClosing]: IconName.StarFilled,
[FacilityEnum.AllParkingAreasPatrolled]: IconName.StarFilled,
[FacilityEnum.AllParkingAreasWellLit]: IconName.StarFilled,
[FacilityEnum.AllStairsWellsVentilated]: IconName.StarFilled,
[FacilityEnum.ArmchairBed]: IconName.ArmChair,
[FacilityEnum.AudibleAlarms]: IconName.StarFilled,
[FacilityEnum.AudibleSmokeAlarmsInAllHalls]: IconName.StarFilled,
[FacilityEnum.AudibleSmokeAlarmsInAllPublicAreas]: IconName.StarFilled,
[FacilityEnum.AudibleSmokeAlarmsInAllRooms]: IconName.StarFilled,
[FacilityEnum.AudioVisualEquipmentAvailable]: IconName.StarFilled,
[FacilityEnum.AutolinkFireDepartment]: IconName.StarFilled,
[FacilityEnum.AutomatedExternalDefibrillatorOnSiteAED]: IconName.StarFilled,
[FacilityEnum.AutomaticFireDoors]: IconName.StarFilled,
[FacilityEnum.AutoRecallElevators]: IconName.StarFilled,
[FacilityEnum.BalconiesAccessibleToAdjoiningRooms]: IconName.StarFilled,
[FacilityEnum.Ballroom]: IconName.StarFilled,
[FacilityEnum.Banquet]: IconName.StarFilled,
[FacilityEnum.Bar]: IconName.LocalBar,
[FacilityEnum.BasicMedicalEquipmentOnSite]: IconName.StarFilled,
[FacilityEnum.BathroomsAdaptedForDisabledGuests]: IconName.StarFilled,
[FacilityEnum.Beach]: IconName.Beach,
[FacilityEnum.Beach0To1Km]: IconName.Beach,
[FacilityEnum.BeautySalon]: IconName.BeautySalon,
[FacilityEnum.BedroomsWithWheelchairAccess]: IconName.StarFilled,
[FacilityEnum.BikesForLoan]: IconName.Bike,
[FacilityEnum.Bowling]: IconName.Bowling,
[FacilityEnum.BrailleLargePrintHotelLiterature]: IconName.StarFilled,
[FacilityEnum.BrailleLargePrintMenus]: IconName.StarFilled,
[FacilityEnum.Breakfast]: IconName.Breakfast,
[FacilityEnum.Business1]: IconName.BusinessCentre,
[FacilityEnum.Business2]: IconName.BusinessCentre,
[FacilityEnum.BusinessCentre]: IconName.BusinessCentre,
[FacilityEnum.Cafe]: IconName.Restaurant,
[FacilityEnum.CashFree8pmTill6am]: IconName.CashFree,
[FacilityEnum.CashFreeHotel]: IconName.CashFree,
[FacilityEnum.ChildrenWelcome]: IconName.StarFilled,
[FacilityEnum.City]: IconName.City,
[FacilityEnum.CoffeeInReceptionAtCharge]: IconName.CoffeeInReceptionAtCharge,
[FacilityEnum.CoffeeShop]: IconName.CoffeeShop,
[FacilityEnum.CoffeeTeaFacilities]: IconName.CoffeeAlt,
[FacilityEnum.ColourTVInRoomsAllScandicHotels]: IconName.StarFilled,
[FacilityEnum.ComplimentaryColdRefreshments]:
IconName.ComplimentaryColdRefreshments,
[FacilityEnum.CongressHall]: IconName.StarFilled,
[FacilityEnum.ConventionCentre]: IconName.ConventionCentre,
[FacilityEnum.Couples]: IconName.StarFilled,
[FacilityEnum.DeadboltsOnConnectingDoors]: IconName.StarFilled,
[FacilityEnum.DeadboltsSecondaryLocksOnAllGuestRoomDoors]:
IconName.StarFilled,
[FacilityEnum.Defibrillator]: IconName.StarFilled,
[FacilityEnum.Desk]: IconName.Desk,
[FacilityEnum.DirectDialPhoneInRoomsAllScandic]: IconName.DirectDial,
[FacilityEnum.DisabledEmergencyPlan1]: IconName.StarFilled,
[FacilityEnum.DisabledEmergencyPlan2]: IconName.StarFilled,
[FacilityEnum.DisabledParking]: IconName.Wheelchair,
[FacilityEnum.DiscoNightClub]: IconName.Nightlife,
[FacilityEnum.DJLiveMusic]: IconName.Nightlife,
[FacilityEnum.DO_NOT_USE_Restaurant]: IconName.StarFilled,
[FacilityEnum.Downtown]: IconName.StarFilled,
[FacilityEnum.DrinkableTapWater]: IconName.StarFilled,
[FacilityEnum.DVDPlayer]: IconName.StarFilled,
[FacilityEnum.EBikesChargingStation]: IconName.ElectricBike,
[FacilityEnum.ElectronicKeyCards]: IconName.StarFilled,
[FacilityEnum.Elevator]: IconName.Elevator,
[FacilityEnum.EmergencyBackUpGenerators]: IconName.StarFilled,
[FacilityEnum.EmergencyCallButtonOnPhone]: IconName.StarFilled,
[FacilityEnum.EmergencyCodesOrButtonsInRooms]: IconName.StarFilled,
[FacilityEnum.EmergencyEvacuationPlan1]: IconName.StarFilled,
[FacilityEnum.EmergencyEvacuationPlan2]: IconName.StarFilled,
[FacilityEnum.EmergencyEvaluationDrillFrequency]: IconName.StarFilled,
[FacilityEnum.EmergencyInfoInAllRooms]: IconName.StarFilled,
[FacilityEnum.EmergencyLightingAllScandic]: IconName.StarFilled,
[FacilityEnum.EmergencyLightningInAllPublicAreas]: IconName.StarFilled,
[FacilityEnum.EmergencyServiceResponseTimeInMinutes]: IconName.StarFilled,
[FacilityEnum.Entertainment]: IconName.Theatre,
[FacilityEnum.EventVenue]: IconName.StarFilled,
[FacilityEnum.ExchangeFacility]: IconName.StarFilled,
[FacilityEnum.ExitMapsInRooms]: IconName.StarFilled,
[FacilityEnum.ExitSignsLit]: IconName.StarFilled,
[FacilityEnum.ExtraFamilyFriendly]: IconName.ExtraFamilyFriendly,
[FacilityEnum.Families]: IconName.ExtraFamilyFriendly,
[FacilityEnum.FaxFacilityInRoom]: IconName.Fax,
[FacilityEnum.Financial]: IconName.StarFilled,
[FacilityEnum.FireDetectorsAllScandic]: IconName.StarFilled,
[FacilityEnum.FireDetectorsInAllHalls]: IconName.StarFilled,
[FacilityEnum.FireDetectorsInAllPublicAreas]: IconName.StarFilled,
[FacilityEnum.FireDetectorsInAllRooms]: IconName.StarFilled,
[FacilityEnum.FireExtinguishersInAllPublicAreas]: IconName.StarFilled,
[FacilityEnum.FireExtinguishersInPublicAreasAllScandic]: IconName.StarFilled,
[FacilityEnum.FireSafetyAllScandic]: IconName.StarFilled,
[FacilityEnum.FirstAidAvailable]: IconName.StarFilled,
[FacilityEnum.FoodDrinks247]: IconName.FoodDrinks247,
[FacilityEnum.FreeWiFi]: IconName.Wifi,
[FacilityEnum.GiftShop]: IconName.Gift,
[FacilityEnum.Golf]: IconName.Golf,
[FacilityEnum.GolfCourse0To30Km]: IconName.Golf,
[FacilityEnum.GuestRoomDoorsHaveASecondLock]: IconName.StarFilled,
[FacilityEnum.Gym]: IconName.Fitness,
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
[FacilityEnum.Hairdresser]: IconName.Hairdresser,
[FacilityEnum.HairdryerInRoomAllScandic]: IconName.HairdryerInRoomAllScandic,
[FacilityEnum.HandicapFacilities]: IconName.StarFilled,
[FacilityEnum.HandrailsInBathrooms]: IconName.StarFilled,
[FacilityEnum.HearingInductionLoops]: IconName.StarFilled,
[FacilityEnum.Highway1]: IconName.StarFilled,
[FacilityEnum.Highway2]: IconName.StarFilled,
[FacilityEnum.Hiking0To3Km]: IconName.Hiking,
[FacilityEnum.HotelCompliesWithAAASecurityStandards]: IconName.StarFilled,
[FacilityEnum.HotelIsFollowingScandicsSafetySecurityPolicy]:
IconName.StarFilled,
[FacilityEnum.HotelWorksAccordingToScandicsAccessibilityConcepts]:
IconName.StarFilled,
[FacilityEnum.IceMachine]: IconName.IceMachine,
[FacilityEnum.IceMachineReception]: IconName.IceMachine,
[FacilityEnum.IDRequiredToReplaceAGuestRoomKey]: IconName.StarFilled,
[FacilityEnum.IfNoWhatAreTheHoursUse24ClockEx0000To0600]: IconName.StarFilled,
[FacilityEnum.InCountry]: IconName.StarFilled,
[FacilityEnum.IndustrialPark]: IconName.StarFilled,
[FacilityEnum.InternetHighSpeedInternetConnectionAllScandic]:
IconName.StarFilled,
[FacilityEnum.InternetHotSpotsAllScandic]: IconName.StarFilled,
[FacilityEnum.IroningRoom]: IconName.Ironing,
[FacilityEnum.IronIroningBoardAllScandic]: IconName.Ironing,
[FacilityEnum.Jacuzzi]: IconName.Jacuzzi,
[FacilityEnum.JacuzziInRoom]: IconName.Jacuzzi,
[FacilityEnum.KayaksForLoan]: IconName.Kayaking,
[FacilityEnum.KeyAccessOnlySecuredFloorsAvailable]: IconName.StarFilled,
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
[FacilityEnum.KidsPlayRoom]: IconName.StarFilled,
[FacilityEnum.KidsUpToAndIncluding12YearsStayForFree]: IconName.StarFilled,
[FacilityEnum.KitchenInRoom]: IconName.Kitchen,
[FacilityEnum.Lake0To1Km]: IconName.Houseboat,
[FacilityEnum.LakeOrSea0To1Km]: IconName.Houseboat,
[FacilityEnum.LaptopSafe]: IconName.LaptopSafe,
[FacilityEnum.LateCheckOutUntil1400Guaranteed]: IconName.Business,
[FacilityEnum.LaundryRoom]: IconName.LaundryMachine,
[FacilityEnum.LaundryService]: IconName.LaundryMachine,
[FacilityEnum.LaundryServiceExpress]: IconName.TshirtWash,
[FacilityEnum.Leisure]: IconName.StarFilled,
[FacilityEnum.LifestyleConcierge]: IconName.Concierge,
[FacilityEnum.LuggageLockers]: IconName.LuggageLockers,
[FacilityEnum.LuggageStorageAdditionalCost]: IconName.Luggage,
[FacilityEnum.LuggageStorageNoCost]: IconName.Luggage,
[FacilityEnum.Massage]: IconName.Massage,
[FacilityEnum.MeetingArea]: IconName.Business,
[FacilityEnum.MeetingConferenceFacilities]: IconName.Business,
[FacilityEnum.MeetingRooms]: IconName.Business,
[FacilityEnum.MinibarInRoom]: IconName.Minibar,
[FacilityEnum.MobileLift]: IconName.StarFilled,
[FacilityEnum.Mountains0To1Km]: IconName.Landscape,
[FacilityEnum.MovieChannelsInRoomAllScandic]: IconName.TVRemote,
[FacilityEnum.MultipleExitsOnEachFloor]: IconName.StarFilled,
[FacilityEnum.NonSmokingRoomsAllScandic]: IconName.NonSmoking,
[FacilityEnum.OnSiteTrainingFacilities]: IconName.Fitness,
[FacilityEnum.OtherExplainInBriefDescription]: IconName.StarFilled,
[FacilityEnum.OutdoorTerrace]: IconName.Deck,
[FacilityEnum.OvernightSecurity]: IconName.Guard,
[FacilityEnum.ParkingAdditionalCost]: IconName.Parking,
[FacilityEnum.ParkingAttendant]: IconName.StarFilled,
[FacilityEnum.ParkingElectricCharging]: IconName.ElectricCar,
[FacilityEnum.ParkingFreeParking]: IconName.Parking,
[FacilityEnum.ParkingGarage]: IconName.Garage,
[FacilityEnum.ParkingOutdoor]: IconName.ParkingOutdoor,
[FacilityEnum.PCHookUpInRoom]: IconName.StarFilled,
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
[FacilityEnum.PillowAlarmsAvailable]: IconName.StarFilled,
[FacilityEnum.PlayStationInPlayArea]: IconName.Gaming,
[FacilityEnum.Pool]: IconName.Swim,
[FacilityEnum.PoolSwimmingPoolJacuzziAtHotel]: IconName.Swim,
[FacilityEnum.PrintingService]: IconName.StarFilled,
[FacilityEnum.PropertyMeetsRequirementsFireSafety]: IconName.StarFilled,
[FacilityEnum.PublicAddressSystem]: IconName.StarFilled,
[FacilityEnum.RelaxationSuite]: IconName.StarFilled,
[FacilityEnum.Restaurant]: IconName.Restaurant,
[FacilityEnum.RestrictedRoomAccessAllScandic]: IconName.StarFilled,
[FacilityEnum.RooftopBar]: IconName.Deck,
[FacilityEnum.RoomsAccessibleFromTheInterior]: IconName.StarFilled,
[FacilityEnum.RoomService]: IconName.RoomService,
[FacilityEnum.RoomWindowsOpen]: IconName.StarFilled,
[FacilityEnum.RoomWindowsThatOpenHaveLockingDevice]: IconName.StarFilled,
[FacilityEnum.Rural1]: IconName.StarFilled,
[FacilityEnum.Rural2]: IconName.StarFilled,
[FacilityEnum.SafeDepositBoxInRoomsAllScandic]: IconName.SafetyBox,
[FacilityEnum.SafeDepositBoxInRoomsCanHoldA17InchLaptop]: IconName.SafetyBox,
[FacilityEnum.SafeDepositBoxInRoomsCannotHoldALaptop]: IconName.SafetyBox,
[FacilityEnum.SafetyChainsOnGuestRoomDoor]: IconName.StarFilled,
[FacilityEnum.Sauna]: IconName.Sauna,
[FacilityEnum.ScandicShop24Hrs]: IconName.ConvenienceStore24h,
[FacilityEnum.SecondaryLocksOnSlidingGlassDoors]: IconName.StarFilled,
[FacilityEnum.SecondaryLocksOnWindows]: IconName.StarFilled,
[FacilityEnum.Security24Hours]: IconName.Guard,
[FacilityEnum.SecurityEscortsAvailableOnRequest]: IconName.Guard,
[FacilityEnum.SecurityPersonnelOnSite]: IconName.Guard,
[FacilityEnum.SeparateFloorsForWomen]: IconName.StarFilled,
[FacilityEnum.ServesBreakfastAlwaysIncluded]: IconName.Breakfast,
[FacilityEnum.ServesBreakfastNotAlwaysIncluded]: IconName.Breakfast,
[FacilityEnum.ServesOrganicBreakfastAlwaysIncluded]: IconName.Breakfast,
[FacilityEnum.ServesOrganicBreakfastNotAlwaysIncluded]: IconName.Breakfast,
[FacilityEnum.ServiceGuideDogsAllowed]: IconName.StarFilled,
[FacilityEnum.ServiceSecurity24Hrs]: IconName.Guard,
[FacilityEnum.Shopping]: IconName.Shopping,
[FacilityEnum.SkateboardsForLoan]: IconName.Skateboarding,
[FacilityEnum.Skiing0To1Km]: IconName.Skiing,
[FacilityEnum.Skybar]: IconName.LocalBar,
[FacilityEnum.SmokeDetectorsAllScandic]: IconName.StarFilled,
[FacilityEnum.Solarium]: IconName.StarFilled,
[FacilityEnum.SpecialNeedsMenus]: IconName.StarFilled,
[FacilityEnum.Sports]: IconName.Sports,
[FacilityEnum.SprinklersAllScandic]: IconName.StarFilled,
[FacilityEnum.SprinklersInAllHalls]: IconName.StarFilled,
[FacilityEnum.SprinklersInAllPublicAreas]: IconName.StarFilled,
[FacilityEnum.SprinklersInAllRooms]: IconName.StarFilled,
[FacilityEnum.StaffInDuplicateKeys]: IconName.StarFilled,
[FacilityEnum.StaffRedCrossCertifiedInCPR]: IconName.StarFilled,
[FacilityEnum.StaffTrainedForDisabledGuests]: IconName.StarFilled,
[FacilityEnum.StaffTrainedInAutomatedExternalDefibrillatorUsageAED]:
IconName.StarFilled,
[FacilityEnum.StaffTrainedInCPR]: IconName.StarFilled,
[FacilityEnum.StaffTrainedInFirstAid]: IconName.StarFilled,
[FacilityEnum.StaffTrainedInFirstAidTechniques]: IconName.StarFilled,
[FacilityEnum.StaffTrainedToCaterForDisabledGuestsAllScandic]:
IconName.StarFilled,
[FacilityEnum.Suburbs]: IconName.StarFilled,
[FacilityEnum.SwingboltLock]: IconName.StarFilled,
[FacilityEnum.TeleConferencingFacilitiesAvailable]: IconName.StarFilled,
[FacilityEnum.TelevisionsWithSubtitlesOrClosedCaptions]: IconName.StarFilled,
[FacilityEnum.Tennis1]: IconName.Sports,
[FacilityEnum.Tennis2]: IconName.Sports,
[FacilityEnum.TennisPadel]: IconName.Sports,
[FacilityEnum.Theatre]: IconName.Theatre,
[FacilityEnum.TrouserPress]: IconName.Ironing,
[FacilityEnum.TVWithChromecast1]: IconName.TvCasting,
[FacilityEnum.TVWithChromecast2]: IconName.TvCasting,
[FacilityEnum.UniformSecurityOnPremises]: IconName.StarFilled,
[FacilityEnum.UtilityRoomForIroning]: IconName.Ironing,
[FacilityEnum.VendingMachineWithNecessities]: IconName.Dining,
[FacilityEnum.VideoSurveillanceInHallways]: IconName.StarFilled,
[FacilityEnum.VideoSurveillanceInPublicAreas]: IconName.StarFilled,
[FacilityEnum.VideoSurveillanceMonitored24HrsADay]: IconName.StarFilled,
[FacilityEnum.VideoSurveillanceOfAllParkingAreas]: IconName.StarFilled,
[FacilityEnum.VideoSurveillanceOfExteriorFrontEntrance]: IconName.StarFilled,
[FacilityEnum.VideoSurveillanceRecorded24HrsADayParkingArea]:
IconName.StarFilled,
[FacilityEnum.WallMountedCycleRack]: IconName.Bike,
[FacilityEnum.WellLitWalkways]: IconName.StarFilled,
[FacilityEnum.WellnessAndSaunaEntranceFeeAdmission16PlusYears]: IconName.Spa,
[FacilityEnum.WellnessPoolSaunaEntranceFeeAdmission16PlusYears]: IconName.Spa,
[FacilityEnum.WheelchairAccess]: IconName.Wheelchair,
[FacilityEnum.WideCorridors]: IconName.StarFilled,
[FacilityEnum.WideEntrance]: IconName.StarFilled,
[FacilityEnum.WideRestaurantEntrance]: IconName.StarFilled,
[FacilityEnum.WiFiWirelessInternetAccessAllScandic]: IconName.StarFilled,
}
interface mapFacilityToIconProps {
id: FacilityEnum
}
export function FacilityToIcon({
id,
...props
}: mapFacilityToIconProps &
(
| MaterialIconSetIconProps
| NucleoIconProps
| IconProps
)): JSX.Element | null {
const iconName = facilityToIconMap[id]
return <IconByIconName iconName={iconName} {...props} />
}

View File

@@ -0,0 +1,7 @@
export const HotelTypes = {
Signature: "signature",
ScandicGo: "scandicgo",
Regular: "regular",
} as const
export type HotelType = (typeof HotelTypes)[keyof typeof HotelTypes]

View File

@@ -0,0 +1,8 @@
export enum SignatureHotelEnum {
DowntownCamper = "879",
GrandHotelOslo = "340",
Haymarket = "890",
HotelNorge = "785",
Marski = "605",
TheDock = "796",
}

View File

@@ -33,12 +33,16 @@
"./utils/isValidJson": "./utils/isValidJson.ts",
"./hooks/*": "./hooks/*.ts",
"./stores/*": "./stores/*.ts",
"./constants/currency": "./constants/currency.ts",
"./constants/dateFormats": "./constants/dateFormats.ts",
"./constants/facilities": "./constants/facilities.ts",
"./constants/hotelType": "./constants/hotelType.ts",
"./constants/language": "./constants/language.ts",
"./constants/membershipLevels": "./constants/membershipLevels.ts",
"./constants/paymentMethod": "./constants/paymentMethod.ts",
"./constants/currency": "./constants/currency.ts",
"./constants/dateFormats": "./constants/dateFormats.ts",
"./constants/routes/*": "./constants/routes/*.ts"
"./constants/rateType": "./constants/rateType.ts",
"./constants/routes/*": "./constants/routes/*.ts",
"./constants/signatureHotels": "./constants/signatureHotels.ts"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",

View File

@@ -1,5 +1,6 @@
import { dirname, join } from 'path'
import type { StorybookConfig } from '@storybook/nextjs-vite'
import { dirname, join } from 'path'
import { mergeConfig } from 'vite'
const config: StorybookConfig = {
stories: ['../lib/**/*.mdx', '../lib/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
@@ -9,11 +10,42 @@ const config: StorybookConfig = {
getAbsolutePath('@storybook/addon-vitest'),
getAbsolutePath('@storybook/addon-docs'),
getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('storybook-react-intl'),
],
framework: {
name: getAbsolutePath('@storybook/nextjs-vite'),
options: {},
},
async viteFinal(config) {
return mergeConfig(config, {
plugins: [
// Add babel plugin for react-intl transformation
{
name: 'formatjs-transform',
async transform(code, id) {
if (id.includes('node_modules')) return
if (!/\.(jsx?|tsx?)$/.test(id)) return
const babel = await import('@babel/core')
const result = babel.transformSync(code, {
plugins: [
[
'formatjs',
{
idInterpolationPattern: '[sha512:contenthash:base64:6]',
ast: true,
},
],
],
filename: id,
})
return result?.code
},
},
],
})
},
}
export default config

View File

@@ -1,9 +1,11 @@
import { withThemeByClassName } from '@storybook/addon-themes'
import { IntlProvider } from 'react-intl'
import type { Preview, ReactRenderer } from '@storybook/nextjs-vite'
import { reactIntl } from './reactIntl'
import '../lib/fonts.css'
import '../lib/style.css'
import '../lib/design-system-new-deprecated.css'
export const themes = {
themes: {
@@ -19,8 +21,33 @@ export const themes = {
}
const preview: Preview = {
decorators: [withThemeByClassName<ReactRenderer>(themes)],
decorators: [
// Theme decorator
withThemeByClassName<ReactRenderer>(themes),
(Story) => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<IntlProvider locale="en" {...(reactIntl as any)}>
<Story />
</IntlProvider>
),
],
initialGlobals: {
locale: reactIntl.defaultLocale,
locales: {
en: { icon: '🇬🇧', title: 'English', right: 'EN' },
sv: { icon: '🇸🇪', title: 'Svenska', right: 'SV' },
da: { icon: '🇩🇰', title: 'Dansk', right: 'DA' },
no: { icon: '🇳🇴', title: 'Norsk', right: 'NO' },
fi: { icon: '🇫🇮', title: 'Suomi', right: 'FI' },
de: { icon: '🇩🇪', title: 'Deutsch', right: 'DE' },
},
backgrounds: { value: 'scandicSubtle' },
},
parameters: {
reactIntl,
nextjs: {
appDirectory: true,
},
docs: {
toc: true,
},
@@ -31,18 +58,18 @@ const preview: Preview = {
},
},
backgrounds: {
values: [
options: {
// 👇 Scandic
{ name: 'Scandic Primary', value: '#FAF6F2' },
{ name: 'Scandic Subtle', value: '#F2ECE6' },
{ name: 'Scandic Primary Dark', value: '#4D001B' },
scandicPrimary: { name: 'Scandic Primary', value: '#FAF6F2' },
scandicSubtle: { name: 'Scandic Subtle', value: '#F2ECE6' },
scandicPrimaryDark: { name: 'Scandic Primary Dark', value: '#4D001B' },
// 👇 Default values
{ name: 'Storybook Dark', value: '#333' },
{ name: 'Storybook Light', value: '#F7F9F2' },
],
default: 'Scandic Primary',
storybookDark: { name: 'Storybook Dark', value: '#333' },
storybookLight: { name: 'Storybook Light', value: '#F7F9F2' },
},
},
},
tags: ['autodocs'],
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable import/no-relative-packages */
import en from '../../../apps/scandic-web/i18n/dictionaries/en.json'
import sv from '../../../apps/scandic-web/i18n/dictionaries/sv.json'
import da from '../../../apps/scandic-web/i18n/dictionaries/da.json'
import fi from '../../../apps/scandic-web/i18n/dictionaries/fi.json'
import de from '../../../apps/scandic-web/i18n/dictionaries/de.json'
import no from '../../../apps/scandic-web/i18n/dictionaries/no.json'
const locales = ['en', 'sv', 'da', 'fi', 'no', 'de']
const messages: Record<(typeof locales)[number], unknown> = {
en,
sv,
da,
fi,
no,
de,
}
const formats = {} // optional, if you have any formats
export const reactIntl = {
defaultLocale: 'en',
locales,
messages,
formats,
}

View File

@@ -5,6 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { FlatCompat } from '@eslint/eslintrc'
import js from '@eslint/js'
import importPlugin from 'eslint-plugin-import'
import formatjs from 'eslint-plugin-formatjs'
const compat = new FlatCompat({
recommendedConfig: js.configs.recommended,
@@ -28,6 +29,7 @@ export default defineConfig([
plugins: {
'react-refresh': reactRefresh,
import: importPlugin,
formatjs,
},
rules: {
'import/no-relative-packages': 'error',
@@ -37,6 +39,18 @@ export default defineConfig([
allowConstantExport: true,
},
],
'formatjs/enforce-default-message': ['error', 'literal'],
'formatjs/enforce-placeholders': ['error'],
'formatjs/enforce-plural-rules': ['error'],
'formatjs/no-literal-string-in-jsx': ['error'],
'formatjs/no-multiple-whitespaces': ['error'],
'formatjs/no-multiple-plurals': ['error'],
'formatjs/no-invalid-icu': ['error'],
'formatjs/no-id': ['error'],
'formatjs/no-complex-selectors': ['error'],
'formatjs/no-useless-message': ['error'],
'formatjs/prefer-pound-in-plural': ['error'],
},
},
globalIgnores(['**/dist', '**/.eslintrc.cjs']),

View File

@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { fn } from 'storybook/test'
import { BookingCodeChip } from './index'
const meta = {
title: 'Components/BookingCodeChip',
component: BookingCodeChip,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof BookingCodeChip>
export default meta
type Story = StoryObj<typeof BookingCodeChip>
export const Default: Story = {
args: {},
render: () => <BookingCodeChip bookingCode="ABC123" withText />,
}
export const WithoutText: Story = {
args: {},
render: () => <BookingCodeChip bookingCode="ABC123" withText={false} />,
}
export const FilledIcon: Story = {
args: {},
render: () => <BookingCodeChip bookingCode="ABC123" filledIcon />,
}
export const Unavailable: Story = {
args: {},
render: () => <BookingCodeChip bookingCode="ABC123" isUnavailable />,
}
export const AlignCenter: Story = {
args: {},
render: () => <BookingCodeChip bookingCode="ABC123" alignCenter />,
}
export const WithCloseButton: Story = {
args: {},
render: () => (
<BookingCodeChip bookingCode="ABC123" withCloseButton onClose={fn} />
),
}
export const CampaignBreakfastIncluded: Story = {
args: {},
render: () => (
<BookingCodeChip isCampaign bookingCode="SUMMER25" isBreakfastIncluded />
),
}
export const CampaignBreakfastExcluded: Story = {
args: {},
render: () => (
<BookingCodeChip
isCampaign
bookingCode="SUMMER25"
isBreakfastIncluded={false}
/>
),
}
export const CampaignFilledIcon: Story = {
args: {},
render: () => (
<BookingCodeChip
isCampaign
bookingCode="SUMMER25"
isBreakfastIncluded
filledIcon
/>
),
}

View File

@@ -1,13 +1,13 @@
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button as ButtonRAC } from 'react-aria-components'
import { useIntl } from 'react-intl'
import IconChip from "@scandic-hotels/design-system/IconChip"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import FilledDiscountIcon from "@scandic-hotels/design-system/Icons/FilledDiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import IconChip from '@scandic-hotels/design-system/IconChip'
import DiscountIcon from '@scandic-hotels/design-system/Icons/DiscountIcon'
import FilledDiscountIcon from '@scandic-hotels/design-system/Icons/FilledDiscountIcon'
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
import { Typography } from '@scandic-hotels/design-system/Typography'
import styles from "./bookingCodeChip.module.css"
import styles from './bookingCodeChip.module.css'
type BaseBookingCodeChipProps = {
alignCenter?: boolean
@@ -30,7 +30,7 @@ type BookingCodeChipProps =
| BookingCodeChipWithoutCloseButtonProps
| BookingCodeChipWithCloseButtonProps
export default function BookingCodeChip({
export function BookingCodeChip({
alignCenter,
bookingCode,
isBreakfastIncluded,
@@ -58,19 +58,19 @@ export default function BookingCodeChip({
<p className={styles.bookingCodeChip}>
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>
{intl.formatMessage({ defaultMessage: "Campaign" })}
{intl.formatMessage({ defaultMessage: 'Campaign' })}
</strong>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{isBreakfastIncluded
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${bookingCode ?? ""} ${intl.formatMessage({
defaultMessage: "Breakfast included",
`${bookingCode ?? ''} ${intl.formatMessage({
defaultMessage: 'Breakfast included',
})}`
: // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${bookingCode ?? ""} ${intl.formatMessage({
defaultMessage: "Breakfast excluded",
`${bookingCode ?? ''} ${intl.formatMessage({
defaultMessage: 'Breakfast excluded',
})}`}
</span>
</Typography>
@@ -96,12 +96,14 @@ export default function BookingCodeChip({
className={alignCenter ? styles.center : undefined}
>
<p
className={`${styles.bookingCodeChip} ${isUnavailable ? styles.unavailable : ""}`}
className={`${styles.bookingCodeChip} ${isUnavailable ? styles.unavailable : ''}`}
>
{withText && (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>
{intl.formatMessage({ defaultMessage: "Booking code" })}
{intl.formatMessage({
defaultMessage: 'Booking code',
})}
</strong>
</Typography>
)}

View File

@@ -556,6 +556,7 @@ export const TextWithIcon: Story = {
args: {
onPress: fn(),
children: (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<>
Text with icon
<MaterialIcon icon="chevron_right" size={24} color="CurrentColor" />
@@ -577,6 +578,7 @@ export const TextWithIconInverted: Story = {
args: {
onPress: fn(),
children: (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<>
Text with icon
<MaterialIcon icon="chevron_right" size={24} color="CurrentColor" />

View File

@@ -1,10 +1,9 @@
import Footnote from '../Footnote'
import Footnote from '@scandic-hotels/design-system/Footnote'
import { chipVariants } from './variants'
import { VariantProps } from 'class-variance-authority'
import type { VariantProps } from 'class-variance-authority'
interface ChipProps
export interface ChipProps
extends React.HtmlHTMLAttributes<HTMLDivElement>,
VariantProps<typeof chipVariants> {}

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
import { FacilityToIcon } from '.'
import { iconVariantConfig } from '../Icons/variants'
const facilityMapping: Record<string, FacilityEnum> = Object.fromEntries(
Object.entries(FacilityEnum).filter(([k]) => isNaN(Number(k)))
) as Record<string, FacilityEnum>
const colorOptions = Object.keys(iconVariantConfig.variants.color)
const meta: Meta<typeof FacilityToIcon> = {
title: 'Components/Facility To Icon',
component: FacilityToIcon,
argTypes: {
id: {
control: 'select',
options: Object.keys(FacilityEnum)
.map((key) => FacilityEnum[key as keyof typeof FacilityEnum])
.filter((x) => typeof x === 'string')
.toSorted(),
mapping: facilityMapping,
description: 'Facility identifier (mapped to the corresponding icon)',
},
color: {
control: 'select',
options: colorOptions,
description: 'Icon color variant',
},
size: {
control: 'number',
description: 'Icon pixel size',
},
},
}
export default meta
type Story = StoryObj<typeof FacilityToIcon>
export const Playground: Story = {
args: {
id: FacilityEnum.Bar,
size: 24,
color: 'Icon/Default',
},
}
const exampleFacilities = [
FacilityEnum.AirConAirCooling,
FacilityEnum.AirportMaxDistance8Km,
FacilityEnum.Bar,
FacilityEnum.CashFreeHotel,
FacilityEnum.ChildrenWelcome,
FacilityEnum.Elevator,
FacilityEnum.Gym,
FacilityEnum.ParkingGarage,
FacilityEnum.ParkingOutdoor,
FacilityEnum.Pool,
FacilityEnum.Tennis1,
]
export const Examples: Story = {
args: {
size: 24,
color: 'Icon/Default',
},
render: (args) => (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: 16,
}}
>
{exampleFacilities.map((key) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 8,
borderRadius: 8,
background: 'var(--ds-color-surface-subtle, #F2ECE6)',
}}
>
<FacilityToIcon id={key} size={args.size} color={args.color} />
<span style={{ fontSize: 12 }}>{FacilityEnum[key]}</span>
</div>
))}
</div>
),
}

View File

@@ -1,13 +1,26 @@
import { IconByIconName } from "@scandic-hotels/design-system/Icons/IconByIconName"
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
import { FacilityEnum } from "@scandic-hotels/trpc/enums/facilities"
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
import type {
IconProps,
NucleoIconProps,
} from "@scandic-hotels/design-system/Icons"
import type { MaterialIconSetIconProps } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import type { JSX } from "react"
import type { JSX } from 'react'
import { IconName } from '../Icons/iconName'
import { MaterialIconSetIconProps } from '../Icons/MaterialIcon'
import { IconProps, NucleoIconProps } from '../Icons'
import { IconByIconName } from '../Icons/IconByIconName'
interface mapFacilityToIconProps {
id: FacilityEnum
}
export function FacilityToIcon({
id,
...props
}: mapFacilityToIconProps &
(
| MaterialIconSetIconProps
| NucleoIconProps
| IconProps
)): JSX.Element | null {
const iconName = facilityToIconMap[id]
return <IconByIconName iconName={iconName} {...props} />
}
const facilityToIconMap: Record<FacilityEnum, IconName> = {
[FacilityEnum.AccessibleBathingControls]: IconName.StarFilled,
@@ -287,19 +300,3 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
[FacilityEnum.WideRestaurantEntrance]: IconName.StarFilled,
[FacilityEnum.WiFiWirelessInternetAccessAllScandic]: IconName.StarFilled,
}
interface mapFacilityToIconProps {
id: FacilityEnum
}
export function FacilityToIcon({
id,
...props
}: mapFacilityToIconProps &
(
| MaterialIconSetIconProps
| NucleoIconProps
| IconProps
)): JSX.Element | null {
const iconName = facilityToIconMap[id]
return <IconByIconName iconName={iconName} {...props} />
}

View File

@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { HotelCard } from './index'
import { fn } from 'storybook/test'
import { RateTypeEnum } from '@scandic-hotels/common/constants/rateType'
import { HotelTypeEnum } from '@scandic-hotels/trpc/enums/hotelType'
import { Button } from '@scandic-hotels/design-system/Button'
import { MaterialIcon } from '../Icons/MaterialIcon'
const meta: Meta<typeof HotelCard> = {
title: 'Components/HotelCard',
component: HotelCard,
argTypes: {
state: {
control: {
type: 'select',
options: ['default', 'active'],
},
},
type: {
control: {
type: 'select',
options: ['mapListing', 'pageListing'],
},
},
},
}
export default meta
type Story = StoryObj<typeof HotelCard>
export const Default: Story = {
args: {
hotel: {
id: '1',
name: 'Test Hotel',
address: { streetAddress: '123 Test Street', city: 'Test City' },
description: 'A great place to stay.',
hotelType: HotelTypeEnum.Signature,
detailedFacilities: [],
ratings: {
tripAdvisor: 4,
},
},
prices: {
public: {
rateType: RateTypeEnum.Regular,
localPrice: {
currency: 'SEK',
pricePerNight: 1000,
pricePerStay: 1000,
},
},
member: {
rateType: RateTypeEnum.Regular,
localPrice: {
currency: 'SEK',
pricePerNight: 800,
pricePerStay: 800,
},
},
},
state: 'default',
isAlternative: false,
type: 'pageListing',
isUserLoggedIn: false,
distanceToCityCenter: 0,
bookingCode: 'ABC123',
images: [
{
src: 'img/img2.jpg',
alt: 'Alt text',
smallSrc: 'img/img2.jpg',
caption: 'Caption',
},
],
belowInfoSlot: (
<Button
onPress={() => fn()}
variant="Text"
typography="Body/Paragraph/mdBold"
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
>
Read more
<MaterialIcon icon="chevron_right" size={24} color="CurrentColor" />
</Button>
),
onAddressClick: fn,
onHover: fn,
onHoverEnd: fn,
},
}
export const MapListing: Story = {
args: {
...Default.args,
type: 'mapListing',
},
}

View File

@@ -0,0 +1,51 @@
import TripadvisorIcon from '@scandic-hotels/design-system/Icons/TripadvisorIcon'
import Image from '@scandic-hotels/design-system/Image'
import Chip from '@scandic-hotels/design-system/Chip'
import { hotelCardDialogImageVariants } from './variants'
import styles from './hotelCardDialogImage.module.css'
export type HotelCardDialogImageProps = {
firstImage?: string
altText?: string
rating?: { tripAdvisor?: number | null }
imageError: boolean
setImageError: (error: boolean) => void
position: 'top' | 'left'
}
export function HotelCardDialogImage({
firstImage,
altText,
rating,
imageError,
setImageError,
position,
}: HotelCardDialogImageProps) {
const classNames = hotelCardDialogImageVariants({ position })
return (
<div className={classNames}>
{!firstImage || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={firstImage}
alt={altText || ''}
fill
onError={() => setImageError(true)}
/>
)}
{rating?.tripAdvisor && (
<div className={styles.tripAdvisor}>
<Chip className={styles.tripAdvisor}>
<TripadvisorIcon color="Icon/Interactive/Default" />
{rating.tripAdvisor}
</Chip>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { cva } from "class-variance-authority"
import { cva } from 'class-variance-authority'
import styles from "./hotelCardDialogImage.module.css"
import styles from './hotelCardDialogImage.module.css'
export const hotelCardDialogImageVariants = cva(styles.imageContainer, {
variants: {
@@ -10,6 +10,6 @@ export const hotelCardDialogImageVariants = cva(styles.imageContainer, {
},
},
defaultVariants: {
position: "top",
position: 'top',
},
})

View File

@@ -0,0 +1,34 @@
import SkeletonShimmer from '@scandic-hotels/design-system/SkeletonShimmer'
import styles from './HotelCardSkeleton.module.css'
export function HotelCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={'100%'} height="100%" />
</div>
<div className={styles.content}>
<SkeletonShimmer height={'65px'} />
<div className={styles.text}>
<SkeletonShimmer height={'20px'} />
<SkeletonShimmer height={'20px'} />
<SkeletonShimmer height={'20px'} />
<SkeletonShimmer height={'20px'} />
</div>
<SkeletonShimmer height={'56px'} />
<SkeletonShimmer height={'52px'} width={'150px'} />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 2 }).map((_, index) => (
<SkeletonShimmer key={index} height={'100px'} />
))}
<SkeletonShimmer height={'40px'} />
</div>
</article>
)
}

View File

@@ -1,12 +1,12 @@
import { useIntl } from "react-intl"
import { useIntl } from 'react-intl'
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import Caption from "@scandic-hotels/design-system/Caption"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { CurrencyEnum } from '@scandic-hotels/common/constants/currency'
import Caption from '@scandic-hotels/design-system/Caption'
import Subtitle from '@scandic-hotels/design-system/Subtitle'
import styles from "./hotelChequeCard.module.css"
import styles from './hotelChequeCard.module.css'
import type { ProductTypeCheque } from "@scandic-hotels/trpc/types/availability"
import type { ProductTypeCheque } from '@scandic-hotels/trpc/types/availability'
export default function HotelChequeCard({
productTypeCheque,
@@ -19,7 +19,7 @@ export default function HotelChequeCard({
<div className={styles.chequeRow}>
<Caption>
{intl.formatMessage({
defaultMessage: "From",
defaultMessage: 'From',
})}
</Caption>
<div className={styles.cheque}>
@@ -29,7 +29,7 @@ export default function HotelChequeCard({
<Caption color="uiTextHighContrast">{CurrencyEnum.CC}</Caption>
{productTypeCheque.localPrice.additionalPricePerStay > 0 ? (
<>
{"+"}
{'+'}
<Subtitle type="two" color="uiTextHighContrast">
{productTypeCheque.localPrice.additionalPricePerStay}
</Subtitle>
@@ -45,15 +45,15 @@ export default function HotelChequeCard({
<div className={styles.chequeRow}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Approx.",
defaultMessage: 'Approx.',
})}
</Caption>
<Caption color={"uiTextMediumContrast"}>
<Caption color={'uiTextMediumContrast'}>
{productTypeCheque.requestedPrice.numberOfCheques} {CurrencyEnum.CC}
{productTypeCheque.requestedPrice.additionalPricePerStay
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
" + "
: ""}
' + '
: ''}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${productTypeCheque.requestedPrice.additionalPricePerStay} ${productTypeCheque.requestedPrice.currency}`}
</Caption>

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { StandaloneHotelCardDialog } from './index'
import { fn } from 'storybook/test'
const meta: Meta<typeof StandaloneHotelCardDialog> = {
title: 'Components/StandaloneHotelCardDialog',
component: StandaloneHotelCardDialog,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof StandaloneHotelCardDialog>
export const Default: Story = {
args: {
data: {
name: 'Hotel Name',
image: {
url: 'img/img2.jpg',
alt: 'Alt text',
},
coordinates: {
lat: 0,
lng: 0,
},
chequePrice: null,
publicPrice: 100,
memberPrice: 200,
redemptionPrice: null,
voucherPrice: null,
rateType: null,
currency: 'SEK',
amenities: [],
ratings: { tripAdvisor: 5 },
operaId: '123',
facilityIds: [],
hasEnoughPoints: false,
},
handleClose: fn(),
},
}

View File

@@ -1,46 +1,45 @@
"use client"
import { useSession } from "next-auth/react"
import { useState } from "react"
import { useIntl } from "react-intl"
'use client'
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useState } from 'react'
import { useIntl } from 'react-intl'
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import useLang from "@/hooks/useLang"
import { isValidClientSession } from "@/utils/clientSession"
import { trackEvent } from "@/utils/tracking/base"
import { selectRate } from '@scandic-hotels/common/constants/routes/hotelReservation'
import Body from '@scandic-hotels/design-system/Body'
import Caption from '@scandic-hotels/design-system/Caption'
import Footnote from '@scandic-hotels/design-system/Footnote'
import { IconButton } from '@scandic-hotels/design-system/IconButton'
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
import Link from '@scandic-hotels/design-system/Link'
import { OldDSButton as Button } from '@scandic-hotels/design-system/OldDSButton'
import Subtitle from '@scandic-hotels/design-system/Subtitle'
import { Typography } from '@scandic-hotels/design-system/Typography'
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import { NoPriceAvailableCard } from '../../NoPriceAvailableCard'
import { HotelCardDialogImage } from '../../HotelCardDialogImage'
import styles from "./standaloneHotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import styles from './standaloneHotelCardDialog.module.css'
import { Lang } from '@scandic-hotels/common/constants/language'
import { HotelPin } from '../../../Map/types'
import { FacilityToIcon } from '@scandic-hotels/design-system/FacilityToIcon'
import { HotelPointsRow } from '../../HotelPointsRow'
interface StandaloneHotelCardProps {
data: HotelPin
lang: Lang
isUserLoggedIn: boolean
handleClose: () => void
onClick?: () => void
}
export default function StandaloneHotelCardDialog({
export function StandaloneHotelCardDialog({
data,
lang,
handleClose,
isUserLoggedIn,
onClick,
}: StandaloneHotelCardProps) {
const intl = useIntl()
const lang = useLang()
const [imageError, setImageError] = useState(false)
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const {
name,
chequePrice,
@@ -50,16 +49,14 @@ export default function StandaloneHotelCardDialog({
voucherPrice,
currency,
amenities,
images,
image,
ratings,
operaId,
hasEnoughPoints,
} = data
const firstImage = images[0]?.imageSizes?.small
const altText = images[0]?.metaData?.altText
const notEnoughPointsLabel = intl.formatMessage({
defaultMessage: "Not enough points",
defaultMessage: 'Not enough points',
})
const shouldShowNotEnoughPoints = redemptionPrice && !hasEnoughPoints
@@ -72,15 +69,15 @@ export default function StandaloneHotelCardDialog({
className={styles.closeButton}
onPress={handleClose}
aria-label={intl.formatMessage({
defaultMessage: "Close",
defaultMessage: 'Close',
})}
>
<MaterialIcon icon="close" size={22} color="CurrentColor" />
</IconButton>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
rating={ratings}
firstImage={image?.url}
altText={image?.alt}
rating={{ tripAdvisor: ratings?.tripAdvisor ?? null }}
imageError={imageError}
setImageError={setImageError}
position="left"
@@ -96,7 +93,7 @@ export default function StandaloneHotelCardDialog({
)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{Icon && Icon}
{Icon}
<Footnote color="uiTextMediumContrast">
{facility.name}
</Footnote>
@@ -115,13 +112,13 @@ export default function StandaloneHotelCardDialog({
{redemptionPrice ? (
<Caption>
{intl.formatMessage({
defaultMessage: "Available rates",
defaultMessage: 'Available rates',
})}
</Caption>
) : (
<Caption type="bold">
{intl.formatMessage({
defaultMessage: "From",
defaultMessage: 'From',
})}
</Caption>
)}
@@ -129,19 +126,19 @@ export default function StandaloneHotelCardDialog({
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
defaultMessage: '{price} {currency}',
},
{
price: chequePrice.numberOfCheques,
currency: "CC",
currency: 'CC',
}
)}
{chequePrice.additionalPricePerStay > 0
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
" + " +
' + ' +
intl.formatMessage(
{
defaultMessage: "{price} {currency}",
defaultMessage: '{price} {currency}',
},
{
price: chequePrice.additionalPricePerStay,
@@ -154,7 +151,7 @@ export default function StandaloneHotelCardDialog({
<span>
/
{intl.formatMessage({
defaultMessage: "night",
defaultMessage: 'night',
})}
</span>
</Body>
@@ -164,7 +161,7 @@ export default function StandaloneHotelCardDialog({
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
defaultMessage: '{price} {currency}',
},
{
price: voucherPrice,
@@ -176,7 +173,7 @@ export default function StandaloneHotelCardDialog({
<span>
/
{intl.formatMessage({
defaultMessage: "night",
defaultMessage: 'night',
})}
</span>
</Body>
@@ -186,7 +183,7 @@ export default function StandaloneHotelCardDialog({
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
defaultMessage: '{price} {currency}',
},
{
price: publicPrice,
@@ -198,7 +195,7 @@ export default function StandaloneHotelCardDialog({
<span>
/
{intl.formatMessage({
defaultMessage: "night",
defaultMessage: 'night',
})}
</span>
</Body>
@@ -208,7 +205,7 @@ export default function StandaloneHotelCardDialog({
<Subtitle type="two" color="red">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
defaultMessage: '{price} {currency}',
},
{
price: memberPrice,
@@ -220,7 +217,7 @@ export default function StandaloneHotelCardDialog({
<span>
/
{intl.formatMessage({
defaultMessage: "night",
defaultMessage: 'night',
})}
</span>
</Body>
@@ -242,17 +239,7 @@ export default function StandaloneHotelCardDialog({
theme="base"
size="small"
className={styles.button}
onClick={() =>
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId: operaId,
},
})
}
onClick={onClick}
>
<Link
href={`${selectRate(lang)}?hotel=${operaId}`}
@@ -260,7 +247,7 @@ export default function StandaloneHotelCardDialog({
keepSearchParams
>
{intl.formatMessage({
defaultMessage: "See rooms",
defaultMessage: 'See rooms',
})}
</Link>
</Button>

View File

@@ -1,13 +1,16 @@
import { useIntl } from "react-intl"
import { useIntl } from 'react-intl'
import Caption from "@scandic-hotels/design-system/Caption"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import Caption from '@scandic-hotels/design-system/Caption'
import Subtitle from '@scandic-hotels/design-system/Subtitle'
import styles from "./hotelPointsRow.module.css"
import styles from './hotelPointsRow.module.css'
import type { PointsRowProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPointsRow({
export type PointsRowProps = {
pointsPerStay: number
additionalPricePerStay?: number
additionalPriceCurrency?: string
}
export function HotelPointsRow({
pointsPerStay,
additionalPricePerStay,
additionalPriceCurrency,
@@ -21,12 +24,12 @@ export default function HotelPointsRow({
</Subtitle>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Points",
defaultMessage: 'Points',
})}
</Caption>
{additionalPricePerStay ? (
<>
{"+"}
{'+'}
<Subtitle type="two" color="uiTextHighContrast">
{additionalPricePerStay}
</Subtitle>

View File

@@ -1,17 +1,31 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { cx } from 'class-variance-authority'
import { useIntl } from 'react-intl'
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import Body from '@scandic-hotels/design-system/Body'
import Caption from '@scandic-hotels/design-system/Caption'
import { Divider } from '@scandic-hotels/design-system/Divider'
import Subtitle from '@scandic-hotels/design-system/Subtitle'
import { RateTypeEnum } from '@scandic-hotels/common/constants/rateType'
import styles from "./hotelPriceCard.module.css"
import styles from './hotelPriceCard.module.css'
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
type Price = {
pricePerStay: number
pricePerNight: number
currency: string
}
export default function HotelPriceCard({
export type PriceCardProps = {
productTypePrices: {
rateType: RateTypeEnum
localPrice: Price
requestedPrice?: Price
}
isMemberPrice?: boolean
className?: string
}
export function HotelPriceCard({
productTypePrices,
isMemberPrice = false,
className,
@@ -29,7 +43,7 @@ export default function HotelPriceCard({
<dt>
<Caption color="red">
{intl.formatMessage({
defaultMessage: "Member price",
defaultMessage: 'Member price',
})}
</Caption>
</dt>
@@ -39,7 +53,7 @@ export default function HotelPriceCard({
<dt>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Standard price",
defaultMessage: 'Standard price',
})}
</Caption>
</dt>
@@ -49,10 +63,10 @@ export default function HotelPriceCard({
<dt>
<Caption
type="bold"
color={isMemberPrice ? "red" : "uiTextHighContrast"}
color={isMemberPrice ? 'red' : 'uiTextHighContrast'}
>
{intl.formatMessage({
defaultMessage: "From",
defaultMessage: 'From',
})}
</Caption>
</dt>
@@ -60,12 +74,12 @@ export default function HotelPriceCard({
<div className={styles.price}>
<Subtitle
type="two"
color={isMemberPrice ? "red" : "uiTextHighContrast"}
color={isMemberPrice ? 'red' : 'uiTextHighContrast'}
>
{productTypePrices.localPrice.pricePerNight}
</Subtitle>
<Body
color={isMemberPrice ? "red" : "uiTextHighContrast"}
color={isMemberPrice ? 'red' : 'uiTextHighContrast'}
textTransform="bold"
>
{productTypePrices.localPrice.currency}
@@ -73,7 +87,7 @@ export default function HotelPriceCard({
<span className={styles.perNight}>
/
{intl.formatMessage({
defaultMessage: "night",
defaultMessage: 'night',
})}
</span>
</Body>
@@ -85,12 +99,12 @@ export default function HotelPriceCard({
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Approx.",
defaultMessage: 'Approx.',
})}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
<Caption color={'uiTextMediumContrast'}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${productTypePrices.requestedPrice.pricePerNight} `}
{productTypePrices.requestedPrice.currency}
@@ -108,12 +122,12 @@ export default function HotelPriceCard({
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Total",
defaultMessage: 'Total',
})}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
<Caption color={'uiTextMediumContrast'}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${productTypePrices.localPrice.pricePerStay} `}
{productTypePrices.localPrice.currency}

View File

@@ -1,12 +1,12 @@
import { useIntl } from "react-intl"
import { useIntl } from 'react-intl'
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import Caption from "@scandic-hotels/design-system/Caption"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { CurrencyEnum } from '@scandic-hotels/common/constants/currency'
import Caption from '@scandic-hotels/design-system/Caption'
import Subtitle from '@scandic-hotels/design-system/Subtitle'
import styles from "./hotelVoucherCard.module.css"
import styles from './hotelVoucherCard.module.css'
import type { ProductTypeVoucher } from "@scandic-hotels/trpc/types/availability"
import type { ProductTypeVoucher } from '@scandic-hotels/trpc/types/availability'
export default function HotelVoucherCard({
productTypeVoucher,
@@ -19,7 +19,7 @@ export default function HotelVoucherCard({
<div className={styles.voucherRow}>
<Caption>
{intl.formatMessage({
defaultMessage: "From",
defaultMessage: 'From',
})}
</Caption>
<div className={styles.voucher}>

View File

@@ -1,11 +1,11 @@
import { useIntl } from "react-intl"
import { useIntl } from 'react-intl'
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
import { Typography } from '@scandic-hotels/design-system/Typography'
import styles from "./noPriceAvailable.module.css"
import styles from './noPriceAvailable.module.css'
export default function NoPriceAvailableCard() {
export function NoPriceAvailableCard() {
const intl = useIntl()
return (
<div className={styles.priceCard}>
@@ -15,7 +15,7 @@ export default function NoPriceAvailableCard() {
<span>
{intl.formatMessage({
defaultMessage:
"There are no rooms available that match your request.",
'There are no rooms available that match your request.',
})}
</span>
</Typography>

View File

@@ -35,6 +35,7 @@
display: flex;
flex-direction: column;
padding: var(--Space-x2);
justify-content: space-between;
}
.hotelDescription {

View 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>
)
}

View File

@@ -1,6 +1,6 @@
import { cva } from "class-variance-authority"
import { cva } from 'class-variance-authority'
import styles from "./hotelCard.module.css"
import styles from './hotelCard.module.css'
export const hotelCardVariants = cva(styles.card, {
variants: {
@@ -14,7 +14,7 @@ export const hotelCardVariants = cva(styles.card, {
},
},
defaultVariants: {
type: "pageListing",
state: "default",
type: 'pageListing',
state: 'default',
},
})

View File

@@ -12,7 +12,7 @@ import Lightbox from '../Lightbox'
import styles from './imageGallery.module.css'
interface GalleryImage {
export interface GalleryImage {
src: string
alt: string
caption?: string | null

View File

@@ -77,7 +77,7 @@
.heart > .li::before,
.li:has(.heart)::before {
content: url("/_static/icons/heart.svg");
content: url('/_static/icons/heart.svg');
position: relative;
height: 8px;
top: 3px;
@@ -94,7 +94,7 @@
.check > .li::before,
.li:has(.check)::before {
content: url("/_static/icons/check-ring.svg");
content: url('/_static/icons/check-ring.svg');
position: relative;
height: 8px;
top: 3px;

View File

@@ -1,4 +1,4 @@
import type { RTERenderMark, RTERenderOptionComponent } from "./node"
import type { RTERenderMark, RTERenderOptionComponent } from './node'
export type RenderOptions = {
[type: string]: RTERenderOptionComponent | RTERenderMark

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { expect } from 'storybook/test'
import Link from '.'
const meta: Meta<typeof Link> = {
title: 'Components/Link',
component: Link,
argTypes: {
size: {
control: 'select',
options: ['small', 'regular', 'tiny', 'none'],
},
scroll: {
table: {
disable: true,
},
},
prefetch: {
table: {
disable: true,
},
},
partialMatch: {
table: {
disable: true,
},
},
},
}
export default meta
type Story = StoryObj<typeof Link>
export const Default: Story = {
args: {
active: false,
href: 'https://www.scandichotels.com/en',
},
render: (args) => <Link {...args}>{args.href}</Link>,
play: async ({ canvasElement }) => {
const link = canvasElement.querySelector('a')
if (!link) throw new Error('Link not found')
expect(link).toBeInTheDocument()
},
}
export const Focused: Story = {
args: {
...Default.args,
},
render: Default.render,
play: async ({ canvasElement }) => {
const link = canvasElement.querySelector('a')
if (!link) throw new Error('Link not found')
expect(link).toBeInTheDocument()
expect(link).not.toHaveFocus()
let styles = getComputedStyle(link)
expect(styles.outlineStyle).toBe('none')
expect(parseFloat(styles.outlineWidth)).toBe(0)
link?.focus()
expect(link).toHaveFocus()
styles = getComputedStyle(link)
expect(styles.outlineStyle).not.toBe('none')
expect(parseFloat(styles.outlineWidth)).toBeGreaterThan(0)
},
}

View File

@@ -1,12 +1,12 @@
import { useIntl } from "react-intl"
import { useIntl } from 'react-intl'
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { formatPrice } from '@scandic-hotels/common/utils/numberFormatting'
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
import { Typography } from '@scandic-hotels/design-system/Typography'
import HotelMarker from "@/components/Maps/Markers/HotelMarker"
import HotelMarker from '../../../Markers/HotelMarker'
import styles from "./hotelPin.module.css"
import styles from './hotelPin.module.css'
interface HotelPinProps {
isActive: boolean
@@ -16,7 +16,7 @@ interface HotelPinProps {
hotelAdditionalCurrency?: string
}
export default function HotelPin({
export function HotelPin({
isActive,
hotelPrice,
currency,
@@ -28,7 +28,7 @@ export default function HotelPin({
return (
<div
className={`${styles.pin} ${isActive ? styles.active : ""}`}
className={`${styles.pin} ${isActive ? styles.active : ''}`}
data-hotelpin
>
<span className={styles.pinIcon}>
@@ -36,17 +36,16 @@ export default function HotelPin({
<MaterialIcon
icon="calendar_clock"
size={16}
color={isActive ? "Icon/Interactive/Default" : "Icon/Inverted"}
color={isActive ? 'Icon/Interactive/Default' : 'Icon/Inverted'}
/>
) : (
<HotelMarker width={16} color={isActive ? "burgundy" : "white"} />
<HotelMarker width={16} color={isActive ? 'burgundy' : 'white'} />
)}
</span>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{isNotAvailable
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
"—"
? '—'
: formatPrice(
intl,
hotelPrice,

View File

@@ -0,0 +1,122 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
InfoWindow,
} from '@vis.gl/react-google-maps'
import { useMediaQuery } from 'usehooks-ts'
import { HotelPin } from './HotelPin'
import type { HotelPin as HotelPinType } from '../../types'
import styles from './hotelListingMapContent.module.css'
import { StandaloneHotelCardDialog } from '../../../HotelCard/HotelDialogCard/StandaloneHotelCardDialog'
import { Lang } from '@scandic-hotels/common/constants/language'
export type HotelListingMapContentProps = {
hotelPins: HotelPinType[]
activeHotel?: string | null
hoveredHotel?: string | null
lang: Lang
isUserLoggedIn: boolean
onClickHotel?: (hotelId: string) => void
setActiveHotel?: (args: { hotelName: string; hotelId: string } | null) => void
setHoveredHotel?: (
args: { hotelName: string; hotelId: string } | null
) => void
}
export function HotelListingMapContent({
hotelPins,
activeHotel,
hoveredHotel,
isUserLoggedIn,
setActiveHotel,
setHoveredHotel,
lang,
onClickHotel,
}: HotelListingMapContentProps) {
const isDesktop = useMediaQuery('(min-width: 768px)')
const toggleActiveHotelPin = (
args: { hotelName: string; hotelId: string } | null
) => {
if (!args) {
setActiveHotel?.(null)
return
}
setActiveHotel?.({ hotelName: args.hotelName, hotelId: args.hotelId })
}
return (
<div>
{hotelPins.map((pin) => {
const isActiveOrHovered =
activeHotel === pin.name || hoveredHotel === pin.name
const hotelPrice =
pin.memberPrice ??
pin.publicPrice ??
pin.redemptionPrice ??
pin.voucherPrice ??
pin.chequePrice?.numberOfCheques ??
null
const hotelAdditionalPrice = pin.chequePrice
? pin.chequePrice.additionalPricePerStay
: undefined
const hotelAdditionalCurrency = pin.chequePrice
? pin.chequePrice.currency?.toString()
: undefined
return (
<AdvancedMarker
key={pin.name}
className={styles.advancedMarker}
position={pin.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={isActiveOrHovered ? 2 : 0}
onMouseEnter={() => {
setHoveredHotel?.({ hotelName: pin.name, hotelId: pin.operaId })
}}
onMouseLeave={() => {
setHoveredHotel?.(null)
}}
onClick={() =>
toggleActiveHotelPin({
hotelName: pin.name,
hotelId: pin.operaId,
})
}
>
{isActiveOrHovered && isDesktop && (
<InfoWindow
position={pin.coordinates}
pixelOffset={[0, -24]}
headerDisabled={true}
shouldFocus={false}
>
<StandaloneHotelCardDialog
data={pin}
lang={lang}
isUserLoggedIn={isUserLoggedIn}
handleClose={() => {
setActiveHotel?.(null)
setHoveredHotel?.(null)
}}
onClick={() => {
onClickHotel?.(pin.operaId)
}}
/>
</InfoWindow>
)}
<HotelPin
isActive={isActiveOrHovered}
hotelPrice={hotelPrice}
currency={pin.currency}
hotelAdditionalPrice={hotelAdditionalPrice}
hotelAdditionalCurrency={hotelAdditionalCurrency}
/>
</AdvancedMarker>
)
})}
</div>
)
}

View File

@@ -0,0 +1,219 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
// import { expect, fn } from 'storybook/test'
import { InteractiveMap } from '.'
import { Lang } from '@scandic-hotels/common/constants/language'
import { APIProvider } from '@vis.gl/react-google-maps'
import { useState } from 'react'
const meta: Meta<typeof InteractiveMap> = {
title: 'Components/Map/Interactive Map',
component: InteractiveMap,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof InteractiveMap>
export const PrimaryDefault: Story = {
args: {
lang: Lang.en,
hotelPins: [
{
coordinates: {
lat: 59.331303,
lng: 18.065542,
},
name: 'Downtown Camper by Scandic',
chequePrice: null,
publicPrice: 1100,
memberPrice: 1067,
redemptionPrice: null,
voucherPrice: null,
rateType: 'Regular',
currency: 'SEK',
amenities: [
{
filter: 'Hotel facilities',
icon: 'Pool',
id: 1831,
name: 'Pool',
public: true,
sortOrder: 7000,
slug: 'pool',
},
{
filter: 'Hotel facilities',
icon: 'Restaurant',
id: 1383,
name: 'Restaurant',
public: true,
sortOrder: 6000,
slug: 'restaurant',
},
{
filter: 'None',
icon: 'KayaksForLoan',
id: 162585,
name: 'Kayaks for loan',
public: true,
sortOrder: 5000,
slug: 'kayaks-for-loan',
},
{
filter: 'Hotel facilities',
icon: 'None',
id: 239348,
name: 'Rooftop bar',
public: false,
sortOrder: 4000,
slug: 'rooftop-bar',
},
{
filter: 'None',
icon: 'BikesForLoan',
id: 5550,
name: 'Bikes for loan',
public: true,
sortOrder: 3000,
slug: 'bikes-for-loan',
},
],
ratings: {
tripAdvisor: 4.4,
},
operaId: '879',
facilityIds: [
1831, 1383, 162585, 239348, 5550, 162586, 5806, 1014, 1835, 1829,
1379, 1382, 162587, 1017, 1378, 1408, 1833, 971, 1834, 162584, 1381,
229144, 267806,
],
hasEnoughPoints: false,
image: {
alt: 'Bar of Downtown Camper by Scandic in Stockholm',
url: 'https://images-test.scandichotels.com/publishedmedia/z68596isempb61xm2ns9/Scandic_Downtown_Camper_spa_wellness_the_nest_swim.jpg',
},
},
{
coordinates: {
lat: 59.33469,
lng: 18.061586,
},
name: 'Haymarket by Scandic',
chequePrice: null,
publicPrice: null,
memberPrice: 9999,
redemptionPrice: null,
voucherPrice: null,
rateType: 'Regular',
currency: 'SEK',
amenities: [
{
filter: 'Hotel facilities',
icon: 'Restaurant',
id: 1383,
name: 'Restaurant',
public: true,
sortOrder: 6000,
slug: 'restaurant',
},
{
filter: 'None',
icon: 'None',
id: 5806,
name: 'Meeting / conference facilities',
public: true,
sortOrder: 1500,
slug: 'meeting-conference-facilities',
},
{
filter: 'Hotel facilities',
icon: 'Bar',
id: 1014,
name: 'Bar',
public: true,
sortOrder: 1401,
slug: 'bar',
},
{
filter: 'Hotel facilities',
icon: 'PetFriendlyRooms',
id: 1835,
name: 'Pet-friendly rooms',
public: true,
sortOrder: 1201,
slug: 'pet-friendly-rooms',
},
{
filter: 'Hotel facilities',
icon: 'Gym',
id: 1829,
name: 'Gym',
public: true,
sortOrder: 1101,
slug: 'gym',
},
],
ratings: {
tripAdvisor: 4.1,
},
operaId: '890',
facilityIds: [
1383, 5806, 1014, 1835, 1829, 1382, 162587, 1017, 1833, 971, 1834,
1381, 1406, 1913, 345180, 375885,
],
hasEnoughPoints: false,
image: {
alt: 'Bar',
url: 'https://images-test.scandichotels.com/publishedmedia/6wobp0j1ocvoopy1dmce/haymarket-by-scandic-bar-pauls_-3-.jpg',
},
},
],
isUserLoggedIn: false,
coordinates: {
lat: 59.32644916839965,
lng: 18.067759400301135,
},
},
render: (args) => {
const mapKey = import.meta.env.VITE_GOOGLE_STATIC_MAP_KEY
const mapId = import.meta.env.VITE_GOOGLE_DYNAMIC_MAP_ID
if (!mapKey || !mapId) {
throw new Error(
'VITE_GOOGLE_STATIC_MAP_KEY or VITE_GOOGLE_DYNAMIC_MAP_ID is not defined in your .env file. Please add it to run this story.'
)
}
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>()
const [activeHotelPin, setActiveHotelPin] = useState<string | null>()
return (
<APIProvider apiKey={mapKey}>
<div
style={
{
'--hotel-map-height': '300px',
height: 'max(500px, 90vh)',
} as React.CSSProperties
}
>
<InteractiveMap
{...args}
mapId={mapId}
hoveredHotelPin={hoveredHotelPin}
onHoverHotelPin={(args) => {
setHoveredHotelPin(args?.hotelName ?? null)
}}
activeHotelPin={activeHotelPin}
onSetActiveHotelPin={(args) => {
setActiveHotelPin(args?.hotelName ?? null)
}}
/>
</div>
</APIProvider>
)
},
}

View File

@@ -1,18 +1,18 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import { useIntl } from "react-intl"
} from '@vis.gl/react-google-maps'
import { useIntl } from 'react-intl'
import { Typography } from "@scandic-hotels/design-system/Typography"
import { Typography } from '@scandic-hotels/design-system/Typography'
import HotelMarkerByType from "../../Markers"
import PoiMarker from "../../Markers/Poi"
import { HotelMarkerByType } from '../../Markers/HotelMarkerByType'
import { PoiMarker } from '../../Markers/PoiMarker'
import styles from "./poiMapMarkers.module.css"
import styles from './poiMapMarkers.module.css'
import type { PointOfInterest } from "@scandic-hotels/trpc/types/hotel"
import type { MarkerInfo } from "@scandic-hotels/trpc/types/marker"
import type { PointOfInterest } from '@scandic-hotels/trpc/types/hotel'
import type { MarkerInfo } from '@scandic-hotels/trpc/types/marker'
export type PoiMapMarkersProps = {
activePoi?: string | null
@@ -52,15 +52,15 @@ export default function PoiMapMarkers({
zIndex={activePoi === poi.name ? 2 : 0}
onMouseEnter={() => onActivePoiChange?.(poi.name ?? null)}
onMouseLeave={() => onActivePoiChange?.(null)}
onClick={() => toggleActivePoi(poi.name ?? "")}
onClick={() => toggleActivePoi(poi.name ?? '')}
>
<span
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ""}`}
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ''}`}
>
<PoiMarker
group={poi.group}
categoryName={poi.categoryName}
size={activePoi === poi.name ? "large" : "small"}
size={activePoi === poi.name ? 'large' : 'small'}
/>
<span className={styles.poiLabel}>
<Typography variant="Body/Paragraph/mdRegular">
@@ -73,7 +73,7 @@ export default function PoiMapMarkers({
<span>
{intl.formatMessage(
{
defaultMessage: "{distanceInKm} km",
defaultMessage: '{distanceInKm} km',
},
{
distanceInKm: poi.distance,

View File

@@ -0,0 +1,176 @@
'use client'
import { Map, type MapProps, useMap } from '@vis.gl/react-google-maps'
import { useEffect, useState } from 'react'
import { useIntl } from 'react-intl'
import { IconButton } from '@scandic-hotels/design-system/IconButton'
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
import {
DEFAULT_ZOOM,
MAP_RESTRICTIONS,
MAX_ZOOM,
MIN_ZOOM,
} from '../mapConstants'
import { useZoomControls } from './useZoomControls'
import { HotelListingMapContent } from './HotelListingMapContent'
import PoiMapMarkers from './PoiMapMarkers'
import styles from './interactiveMap.module.css'
import type { PointOfInterest } from '@scandic-hotels/trpc/types/hotel'
import type { MarkerInfo } from '@scandic-hotels/trpc/types/marker'
import { HotelPin } from '../types'
import { Lang } from '@scandic-hotels/common/constants/language'
export type InteractiveMapProps = {
lang: Lang
coordinates: {
lat: number
lng: number
}
activePoi?: PointOfInterest['name'] | null
hotelPins?: HotelPin[]
pointsOfInterest?: PointOfInterest[]
markerInfo?: MarkerInfo
mapId: string
closeButton: React.ReactNode
fitBounds?: boolean
hoveredHotelPin?: string | null
activeHotelPin?: string | null
isUserLoggedIn: boolean
onTilesLoaded?: () => void
onActivePoiChange?: (poi: PointOfInterest['name'] | null) => void
onClickHotel?: (hotelId: string) => void
/**
* Called when a hotel pin is hovered.
* @param args when null, it means the hover has ended
* @returns
*/
onHoverHotelPin?: (
args: { hotelName: string; hotelId: string } | null
) => void
/**
* Called when a hotel pin is activated.
* @param args when null, it means nothing is active
* @returns
*/
onSetActiveHotelPin?: (
args: { hotelName: string; hotelId: string } | null
) => void
}
export function InteractiveMap({
lang,
coordinates,
pointsOfInterest,
activePoi,
hotelPins,
mapId,
closeButton,
markerInfo,
fitBounds = true,
hoveredHotelPin,
activeHotelPin,
isUserLoggedIn,
onClickHotel,
onHoverHotelPin,
onSetActiveHotelPin,
onTilesLoaded,
onActivePoiChange,
}: InteractiveMapProps) {
const intl = useIntl()
const map = useMap()
const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls()
const mapOptions: MapProps = {
defaultZoom: DEFAULT_ZOOM,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
defaultCenter: coordinates,
disableDefaultUI: true,
clickableIcons: false,
mapId,
gestureHandling: 'greedy',
restriction: MAP_RESTRICTIONS,
}
useEffect(() => {
if (map && hotelPins?.length && !hasInitializedBounds) {
if (fitBounds) {
const bounds = new google.maps.LatLngBounds()
hotelPins.forEach((marker) => {
bounds.extend(marker.coordinates)
})
map.fitBounds(bounds, 100)
}
setHasInitializedBounds(true)
}
}, [map, fitBounds, hotelPins, hasInitializedBounds])
return (
<div className={styles.mapContainer}>
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
{hotelPins && (
<HotelListingMapContent
lang={lang}
isUserLoggedIn={isUserLoggedIn}
hotelPins={hotelPins}
setActiveHotel={onSetActiveHotelPin}
setHoveredHotel={onHoverHotelPin}
activeHotel={activeHotelPin}
hoveredHotel={hoveredHotelPin}
onClickHotel={onClickHotel}
/>
)}
{pointsOfInterest && markerInfo && (
<PoiMapMarkers
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
onActivePoiChange={onActivePoiChange}
activePoi={activePoi}
markerInfo={markerInfo}
/>
)}
</Map>
<div className={styles.ctaButtons}>
{closeButton}
<div className={styles.zoomButtons}>
<IconButton
theme="Inverted"
style="Elevated"
className={styles.zoomButton}
onClick={zoomOut}
aria-label={intl.formatMessage({
defaultMessage: 'Zoom out',
})}
isDisabled={isMinZoom}
>
<MaterialIcon icon="remove" color="CurrentColor" />
</IconButton>
<IconButton
theme="Inverted"
style="Elevated"
className={styles.zoomButton}
onClick={zoomIn}
aria-label={intl.formatMessage({
defaultMessage: 'Zoom in',
})}
isDisabled={isMaxZoom}
>
<MaterialIcon icon="add" color="CurrentColor" />
</IconButton>
</div>
</div>
</div>
)
}

View File

@@ -21,7 +21,7 @@
}
.mapContainer::after {
content: "";
content: '';
position: absolute;
top: 0;
right: 0;

Some files were not shown because too many files have changed in this diff Show More