Merged in fix/SW-2165-map-navigate-hotel-card (pull request #2246)

fix(SW-2165): map navigate on enter press 

* fix(SW-2165): navigate on enter press and refactor

* fix(SW-2165): responsive design

* fix(SW-2165): replace spacing variables

* fix(SW-2165): resolve pr comment

* fix(SW-2165): remove isOpen, hide/show logic already handled

* fix(SW-2165): remove dialog

* fix(SW-2165): use buttonicon

* fix(SW-2165): do not focus on close button without tab

* fix(SW-2165): remove unneccessary css


Approved-by: Christian Andolf
This commit is contained in:
Bianca Widstam
2025-06-02 11:10:27 +00:00
parent 6df8c75d2d
commit 47abd7d5ef
11 changed files with 382 additions and 435 deletions

View File

@@ -1,7 +1,10 @@
"use client"
import { useSession } from "next-auth/react"
import { 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 { Typography } from "@scandic-hotels/design-system/Typography"
import { selectRate } from "@/constants/routes/hotelReservation"
@@ -11,31 +14,31 @@ import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
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 "../hotelCardDialog.module.css"
import styles from "./listingHotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
interface ListingHotelCardProps {
data: HotelPin
lang: Lang
imageError: boolean
setImageError: (error: boolean) => void
handleClose: () => void
}
export default function ListingHotelCardDialog({
data,
lang,
imageError,
setImageError,
handleClose,
}: ListingHotelCardProps) {
const intl = useIntl()
const lang = useLang()
const [imageError, setImageError] = useState(false)
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const {
@@ -57,161 +60,174 @@ export default function ListingHotelCardDialog({
const altText = images[0]?.metaData?.altText
return (
<div className={styles.content}>
<div className={styles.header}>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
rating={ratings}
imageError={imageError}
setImageError={setImageError}
position="top"
/>
<div>
<div className={styles.name}>
<Subtitle type="two">{name}</Subtitle>
</div>
<div className={styles.facilities}>
{amenities.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon
id={facility.id}
size={20}
color="Icon/Default"
/>
</div>
))}
</div>
</div>
</div>
{publicPrice ||
memberPrice ||
redemptionPrice ||
voucherPrice ||
chequePrice ? (
<div className={styles.bottomContainer}>
<div className={styles.pricesContainer}>
{redemptionPrice ? (
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Available rates",
})}
</Caption>
) : (
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Per night from",
})}
</Caption>
)}
<div className={styles.listingPrices}>
{publicPrice && !isUserLoggedIn && memberPrice ? (
<>
<Subtitle type="two">
{publicPrice} {currency}
</Subtitle>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{memberPrice && <Caption>/</Caption>}
</>
) : (
bookingCode &&
publicPrice && (
<Subtitle type="two" color="red">
{publicPrice} {currency}
</Subtitle>
)
)}
{memberPrice && (
<Subtitle type="two" color="red">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: memberPrice,
currency,
}
)}
</Subtitle>
)}
{redemptionPrice && (
<HotelPointsRow pointsPerStay={redemptionPrice} />
)}
{chequePrice && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: chequePrice.numberOfCheques,
currency: "CC",
}
)}
{chequePrice.additionalPricePerStay > 0
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
" + " +
intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: chequePrice.additionalPricePerStay,
currency: chequePrice.currency,
}
)
: null}
<Typography variant="Body/Paragraph/mdRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Typography>
</Subtitle>
)}
{voucherPrice && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: voucherPrice,
currency,
}
)}
<Typography variant="Body/Paragraph/mdRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Typography>
</Subtitle>
)}
<div className={styles.container}>
<IconButton
theme="Black"
style="Muted"
className={styles.closeButton}
onPress={handleClose}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon icon="close" size={22} color="CurrentColor" />
</IconButton>
<div className={styles.content}>
<div className={styles.header}>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
rating={ratings}
imageError={imageError}
setImageError={setImageError}
position="top"
/>
<div>
<div className={styles.name}>
<Subtitle type="two">{name}</Subtitle>
</div>
<div className={styles.facilities}>
{amenities.map((facility) => (
<div key={facility.id}>
<FacilityToIcon
id={facility.id}
size={20}
color="Icon/Default"
/>
</div>
))}
</div>
</div>
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate(lang)}?hotel=${operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({
defaultMessage: "See rooms",
})}
</Link>
</Button>
</div>
) : (
<NoPriceAvailableCard />
)}
{publicPrice ||
memberPrice ||
redemptionPrice ||
voucherPrice ||
chequePrice ? (
<div className={styles.bottomContainer}>
<div className={styles.pricesContainer}>
{redemptionPrice ? (
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Available rates",
})}
</Caption>
) : (
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Per night from",
})}
</Caption>
)}
<div className={styles.listingPrices}>
{publicPrice && !isUserLoggedIn && memberPrice ? (
<>
<Subtitle type="two">
{publicPrice} {currency}
</Subtitle>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{memberPrice && <Caption>/</Caption>}
</>
) : (
bookingCode &&
publicPrice && (
<Subtitle type="two" color="red">
{publicPrice} {currency}
</Subtitle>
)
)}
{memberPrice && (
<Subtitle type="two" color="red">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: memberPrice,
currency,
}
)}
</Subtitle>
)}
{redemptionPrice && (
<HotelPointsRow pointsPerStay={redemptionPrice} />
)}
{chequePrice && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: chequePrice.numberOfCheques,
currency: "CC",
}
)}
{chequePrice.additionalPricePerStay > 0
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
" + " +
intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: chequePrice.additionalPricePerStay,
currency: chequePrice.currency,
}
)
: null}
<Typography variant="Body/Paragraph/mdRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Typography>
</Subtitle>
)}
{voucherPrice && (
<Subtitle type="two">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",
},
{
price: voucherPrice,
currency,
}
)}
<Typography variant="Body/Paragraph/mdRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>
/
{intl.formatMessage({
defaultMessage: "night",
})}
</span>
</Typography>
</Subtitle>
)}
</div>
</div>
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate(lang)}?hotel=${operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({
defaultMessage: "See rooms",
})}
</Link>
</Button>
</div>
) : (
<NoPriceAvailableCard />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
.container {
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-md);
min-width: 358px;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
position: relative;
}
.content {
padding: var(--Space-x15);
display: flex;
flex-direction: column;
gap: var(--Space-x15);
}
.header {
display: flex;
gap: var(--Space-x15);
}
.name {
height: 48px;
max-width: 180px;
margin-bottom: var(--Space-x05);
display: flex;
align-items: center;
}
.facilities {
display: flex;
gap: 0 var(--Space-x15);
}
.priceCard {
border-radius: var(--Corner-radius-md);
padding: var(--Space-x05) var(--Space-x1);
background: var(--Base-Surface-Secondary-light-Normal);
margin-top: var(--Space-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: space-between;
}
.bottomContainer {
display: flex;
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-top: var(--Space-x2);
padding-bottom: var(--Space-x05);
}
.pricesContainer {
display: flex;
flex-direction: column;
flex: 1;
height: 44px;
}
.listingPrices {
display: flex;
flex-direction: row;
gap: var(--Space-x1);
}
.content .button {
margin-top: auto;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
}

View File

@@ -1,7 +1,11 @@
"use client"
import { useSession } from "next-auth/react"
import { 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 { selectRate } from "@/constants/routes/hotelReservation"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
@@ -11,6 +15,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { isValidClientSession } from "@/utils/clientSession"
import { trackEvent } from "@/utils/tracking/base"
@@ -18,25 +23,22 @@ import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import styles from "../hotelCardDialog.module.css"
import styles from "./standaloneHotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
interface StandaloneHotelCardProps {
data: HotelPin
lang: Lang
imageError: boolean
setImageError: (error: boolean) => void
handleClose: () => void
}
export default function StandaloneHotelCardDialog({
data,
lang,
imageError,
setImageError,
handleClose,
}: StandaloneHotelCardProps) {
const intl = useIntl()
const lang = useLang()
const [imageError, setImageError] = useState(false)
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const {
@@ -57,7 +59,18 @@ export default function StandaloneHotelCardDialog({
const altText = images[0]?.metaData?.altText
return (
<>
<div className={styles.container}>
<IconButton
theme="Black"
style="Muted"
className={styles.closeButton}
onPress={handleClose}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon icon="close" size={22} color="CurrentColor" />
</IconButton>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
@@ -67,10 +80,8 @@ export default function StandaloneHotelCardDialog({
position="left"
/>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.name}>
<Body textTransform="bold">{name}</Body>
</div>
<div className={styles.name}>
<Body textTransform="bold">{name}</Body>
</div>
<div className={styles.facilities}>
{amenities.slice(0, 3).map((facility) => {
@@ -246,6 +257,6 @@ export default function StandaloneHotelCardDialog({
)}
</div>
</div>
</>
</div>
)
}

View File

@@ -0,0 +1,67 @@
.container {
flex-direction: row;
display: flex;
position: relative;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
}
.content {
width: 100%;
max-width: 220px;
padding: var(--Space-x15);
display: flex;
flex-direction: column;
}
.name {
height: 48px;
max-width: 180px;
margin-bottom: var(--Space-x05);
display: flex;
align-items: center;
padding-right: var(--Space-x1);
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: 0 var(--Space-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x05);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: space-between;
}
.priceCard {
border-radius: var(--Corner-radius-md);
padding: var(--Space-x05) var(--Space-x1);
background: var(--Base-Surface-Secondary-light-Normal);
margin-top: var(--Space-x1);
}
.pricesContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: space-between;
}
.content .button {
margin-top: auto;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}

View File

@@ -1,154 +0,0 @@
.dialog {
padding-bottom: var(--Spacing-x1);
bottom: 0;
left: 50%;
transform: translateX(-50%);
border: none;
background: transparent;
}
.dialogContainer {
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-md);
min-width: 402px;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
flex-direction: row;
display: flex;
position: relative;
}
.dialogContainer[data-type="listing"] {
min-width: 358px;
}
.dialogContainer[data-type="listing"] .header {
display: flex;
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
}
.name {
height: 48px;
max-width: 180px;
margin-bottom: var(--Spacing-x-half);
display: flex;
align-items: center;
}
.closeIcon {
position: absolute;
top: 8px;
right: 8px;
}
.content {
width: 100%;
min-width: 220px;
padding: var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
}
.dialogContainer[data-type="listing"] .content {
gap: var(--Spacing-x-one-and-half);
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: 0 var(--Spacing-x1);
}
.dialogContainer[data-type="listing"] .facilities {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
white-space: nowrap;
position: relative;
padding-right: 20px;
gap: 0 var(--Spacing-x-one-and-half);
max-width: 242px;
}
.dialogContainer[data-type="listing"] .facilities::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 100%;
background: linear-gradient(
to left,
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
pointer-events: none;
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.dialogContainer[data-type="listing"] .facilitiesItem {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--Spacing-x-half);
}
.priceCard {
border-radius: var(--Corner-radius-md);
padding: var(--Spacing-x-half) var(--Spacing-x1);
background: var(--Base-Surface-Secondary-light-Normal);
margin-top: var(--Spacing-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: space-between;
}
.bottomContainer {
display: flex;
flex-direction: row;
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-top: var(--Spacing-x2);
padding-bottom: var(--Spacing-x-half);
}
.pricesContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: space-between;
}
.dialogContainer[data-type="listing"] .pricesContainer {
flex: 1;
height: 44px;
gap: 0;
justify-content: flex-start;
}
.listingPrices {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
}
.perNight {
color: var(--Base-Text-Subtle-light-Normal);
}
.content .button {
margin-top: auto;
}
@media (min-width: 768px) {
.dialog {
bottom: 32px;
}
}

View File

@@ -1,55 +0,0 @@
"use client"
import { useParams } from "next/navigation"
import { useState } from "react"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import ListingHotelCardDialog from "./ListingHotelCardDialog"
import StandaloneHotelCardDialog from "./StandaloneHotelCardDialog"
import styles from "./hotelCardDialog.module.css"
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
export default function HotelCardDialog({
data,
isOpen,
type = "standalone",
handleClose,
}: HotelCardDialogProps) {
const params = useParams()
const lang = params.lang as Lang
const [imageError, setImageError] = useState(false)
if (!data) {
return null
}
return (
<dialog open={isOpen} className={styles.dialog}>
<div className={styles.dialogContainer} data-type={type}>
<div onClick={handleClose}>
<MaterialIcon icon="close" className={styles.closeIcon} size={22} />
</div>
{type === "standalone" ? (
<StandaloneHotelCardDialog
data={data}
lang={lang}
imageError={imageError}
setImageError={setImageError}
/>
) : (
<ListingHotelCardDialog
data={data}
lang={lang}
imageError={imageError}
setImageError={setImageError}
/>
)}
</div>
</dialog>
)
}

View File

@@ -1,6 +1,5 @@
.hotelCardDialogListing {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
align-items: flex-end;
overflow-x: scroll;
@@ -17,13 +16,7 @@
transform: translateZ(0);
}
.hotelCardDialogListing > div {
.hotelCard {
height: 100%;
scroll-snap-align: center;
}
.hotelCardDialogListing dialog {
position: relative;
padding: 0;
margin: 0;
}

View File

@@ -5,7 +5,7 @@ import { useIntl } from "react-intl"
import { useHotelsMapStore } from "@/stores/hotels-map"
import HotelCardDialog from "../HotelCardDialog"
import ListingHotelCardDialog from "../HotelCardDialog/ListingHotelCardDialog"
import { getHotelPins } from "./utils"
import styles from "./hotelCardDialogListing.module.css"
@@ -127,13 +127,9 @@ export default function HotelCardDialogListing({
key={data.name}
ref={isActive ? activeCardRef : null}
data-name={data.name}
className={styles.hotelCard}
>
<HotelCardDialog
data={data}
isOpen={!!activeHotel}
handleClose={deactivate}
type="listing"
/>
<ListingHotelCardDialog data={data} handleClose={deactivate} />
</div>
)
})}

View File

@@ -1,28 +1,3 @@
.advancedMarker {
height: 32px;
}
.dialogContainer {
display: none;
}
.card {
display: none;
position: absolute;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
width: 402px;
height: 181px;
background-color: var(--Base-Surface-Primary-light-Normal);
}
.card.active {
display: block;
}
@media (min-width: 768px) {
.dialogContainer {
display: block;
}
}

View File

@@ -1,12 +1,14 @@
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 HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
import StandaloneHotelCardDialog from "@/components/HotelReservation/HotelCardDialog/StandaloneHotelCardDialog"
import { trackEvent } from "@/utils/tracking/base"
import HotelPin from "./HotelPin"
@@ -18,6 +20,7 @@ import type { HotelListingMapContentProps } from "@/types/components/hotelReserv
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) => {
@@ -69,17 +72,22 @@ function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
onMouseLeave={() => disengage()}
onClick={() => toggleActiveHotelPin(pin.name, pin.operaId)}
>
<div className={styles.dialogContainer}>
<HotelCardDialog
isOpen={isActiveOrHovered}
handleClose={(event: { stopPropagation: () => void }) => {
event.stopPropagation()
deactivate()
disengage()
}}
data={pin}
/>
</div>
{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}

View File

@@ -6,6 +6,20 @@
z-index: 0;
}
.mapContainer :global(.gm-style .gm-style-iw-d) {
padding: 0 !important;
overflow: hidden !important;
max-height: none !important;
max-width: none !important;
}
.mapContainer :global(.gm-style .gm-style-iw-c) {
padding: 0 !important;
overflow: hidden !important;
max-height: none !important;
max-width: none !important;
}
.mapContainer::after {
content: "";
position: absolute;