Merged in feat/SW-1790-mobile-city-map (pull request #1497)

Feat/SW-1790 : Mobile city destination map

Approved-by: Christian Andolf
Approved-by: Fredrik Thorsson
This commit is contained in:
Matilda Landström
2025-03-11 16:26:49 +00:00
parent 7563db9dbc
commit b3a3933a02
15 changed files with 208 additions and 48 deletions

View File

@@ -1,6 +1,7 @@
"use client"
import { cx } from "class-variance-authority"
import { useEffect, useState } from "react"
import { useCarousel } from "./CarouselContext"
@@ -11,11 +12,21 @@ export function CarouselContent({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const { carouselRef } = useCarousel()
const { carouselRef, canScrollNext, canScrollPrev } = useCarousel()
const [isOneItem, setIsOneItem] = useState(false)
useEffect(() => {
setIsOneItem(!canScrollPrev() && !canScrollNext())
}, [canScrollPrev, canScrollNext])
return (
<div ref={carouselRef} className={styles.viewport}>
<div className={cx(styles.container, className)} {...props}>
<div
className={cx(styles.container, className, {
[styles.centerContent]: isOneItem,
})}
{...props}
>
{children}
</div>
</div>

View File

@@ -13,6 +13,10 @@
gap: var(--Spacing-x2);
}
.centerContent {
justify-content: center;
}
.item {
min-width: 0;
}

View File

@@ -20,17 +20,19 @@ function Carousel({
plugins,
className,
children,
scrollToIdx = 0,
align = "start",
}: CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
containScroll: "trimSnaps",
align: "start",
align,
axis: "x",
...opts,
},
plugins
)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedIndex, setSelectedIndex] = useState(scrollToIdx)
const onSelect = useCallback((api: CarouselApi) => {
if (!api) return
@@ -62,7 +64,6 @@ function Carousel({
useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
@@ -72,6 +73,11 @@ function Carousel({
}
}, [api, onSelect])
useEffect(() => {
if (!api || scrollToIdx === -1) return
api.scrollTo(scrollToIdx)
}, [api, scrollToIdx])
return (
<CarouselContext.Provider
value={{

View File

@@ -16,6 +16,9 @@ export interface CarouselProps extends PropsWithChildren {
plugins?: CarouselPlugin
setApi?: (api: CarouselApi) => void
className?: string
handleScrollSelected?: (idx: number) => void
scrollToIdx?: number
align?: "start" | "center"
}
export interface CarouselContextProps extends Omit<CarouselProps, "className"> {

View File

@@ -18,12 +18,7 @@
}
@media screen and (max-width: 949px) {
.hotelList {
flex-direction: row;
align-items: end;
overflow-x: scroll;
}
.hotelList,
.header {
display: none;
}

View File

@@ -11,6 +11,7 @@ import Alert from "@/components/TempDesignSystem/Alert"
import Body from "@/components/TempDesignSystem/Text/Body"
import { debounce } from "@/utils/debounce"
import HotelCardCarousel from "../../../HotelCardCarousel"
import HotelListItem from "../HotelListItem"
import HotelListSkeleton from "./HotelListSkeleton"
import { getVisibleHotels } from "./utils"
@@ -75,13 +76,16 @@ export default function HotelList() {
})}
/>
) : (
<ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => (
<li key={hotel.operaId}>
<HotelListItem hotel={hotel} url={url} />
</li>
))}
</ul>
<>
<HotelCardCarousel visibleHotels={visibleHotels} />
<ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => (
<li key={hotel.operaId}>
<HotelListItem hotel={hotel} url={url} />
</li>
))}
</ul>
</>
)}
</div>
)

View File

@@ -0,0 +1,28 @@
.noActiveHotel,
.carousel {
display: none;
}
@media screen and (max-width: 949px) {
.carousel:not(.noActiveHotel) {
display: grid;
}
.carouselContent {
gap: var(--Spacing-x1);
}
}
.carouselContent .item:first-child:not(:only-child) {
padding-left: var(--Spacing-x2);
}
.carouselContent .item:last-child:not(:only-child) {
padding-right: var(--Spacing-x2);
}
@media screen and (max-width: 500px) {
.carouselContent {
grid-auto-columns: 90%;
}
}

View File

@@ -0,0 +1,61 @@
"use client"
import { cx } from "class-variance-authority"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import { Carousel } from "@/components/Carousel"
import HotelMapCard from "../HotelMapCard"
import styles from "./hotelCardCarousel.module.css"
import type { Hotel, HotelDataWithUrl } from "@/types/hotel"
interface MapCardCarouselProps {
visibleHotels: HotelDataWithUrl[] | []
}
export default function HotelCardCarousel({
visibleHotels,
}: MapCardCarouselProps) {
const { clickedHotel } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex(
(hotel) => hotel.hotel.operaId === clickedHotel
)
return (
<Carousel
className={styles.carousel}
scrollToIdx={selectedHotelIdx}
align="center"
>
<Carousel.Content className={styles.carouselContent}>
{visibleHotels.map(({ hotel, url }) => (
<Carousel.Item key={hotel.operaId} className={styles.item}>
<HotelMapCard
className={cx(styles.carouselCard, {
[styles.noActiveHotel]: !clickedHotel,
})}
tripadvisorRating={hotel.ratings?.tripAdvisor.rating}
hotelName={hotel.name}
url={url}
image={getImage(hotel)}
amenities={hotel.detailedFacilities.slice(0, 3)}
type="article"
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
)
}
function getImage(hotel: Hotel) {
return {
src: hotel.galleryImages[0].imageSizes.medium,
alt:
hotel.galleryImages[0].metaData.altText ||
hotel.galleryImages[0].metaData.altText_En,
}
}

View File

@@ -2,7 +2,8 @@
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
@@ -31,3 +32,9 @@
top: 7px;
border-radius: var(--Corner-radius-Small);
}
@media screen and (max-width: 500px) {
.imageContainer {
min-width: 120px;
}
}

View File

@@ -1,23 +1,22 @@
.dialog {
bottom: 0;
left: 50%;
transform: translateX(-50%);
border: none;
background: transparent;
display: none;
}
.dialogContent {
background: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
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;
}
.dialogContent::after {
.dialog .dialogContent {
min-width: 402px;
}
.dialog .dialogContent::after {
content: "";
display: block;
position: absolute;
@@ -55,7 +54,7 @@
.content {
width: 100%;
min-width: 220px;
min-width: 150px;
padding: var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
@@ -83,8 +82,21 @@
padding-bottom: var(--Spacing-x1);
}
@media (min-width: 768px) {
@media (min-width: 950px) {
.iconFootnote {
display: block;
}
.dialog {
bottom: 0;
left: 50%;
transform: translateX(-50%);
border: none;
background: transparent;
display: block;
}
.content {
min-width: 220px;
}
}

View File

@@ -1,8 +1,11 @@
"use client"
import { cx } from "class-variance-authority"
import Link from "next/link"
import { useState } from "react"
import { useCallback, useState } from "react"
import { useIntl } from "react-intl"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import CloseLargeIcon from "@/components/Icons/CloseLarge"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
@@ -18,37 +21,60 @@ import type { GalleryImage } from "@/types/components/imageGallery"
import type { Amenities } from "@/types/hotel"
interface HotelMapCardProps {
isActive: boolean
isActive?: boolean
amenities: Amenities
tripadvisorRating: number | undefined
hotelName: string
image: GalleryImage | null
url: string
onClose: () => void
type?: "dialog" | "article"
className?: string
}
export default function HotelMapCard({
isActive,
isActive = false,
amenities,
tripadvisorRating,
hotelName,
image,
url,
onClose,
type = "dialog",
className,
}: HotelMapCardProps) {
const intl = useIntl()
const pageType = usePageType()
const [imageError, setImageError] = useState(false)
const { setClickedHotel } = useDestinationPageHotelsMapStore()
const handleClose = useCallback(() => {
setClickedHotel(null)
}, [setClickedHotel])
function Wrapper({ children }: React.PropsWithChildren) {
if (type === "dialog") {
return (
<dialog
open={isActive}
className={cx({
[styles.dialog]: isActive,
})}
>
{children}
</dialog>
)
}
return <div className={className}>{children}</div>
}
return (
<dialog open={isActive} className={styles.dialog}>
<Wrapper>
<div className={styles.dialogContent}>
<Button
intent="text"
size="medium"
variant="icon"
className={styles.closeButton}
onClick={onClose}
onClick={handleClose}
aria-label={intl.formatMessage({ id: "Close" })}
>
<CloseLargeIcon className={styles.closeIcon} width={16} height={16} />
@@ -97,7 +123,13 @@ export default function HotelMapCard({
</div>
{url && (
<Button intent="tertiary" theme="base" asChild size="small">
<Button
disabled={url ? false : true}
intent="tertiary"
theme="base"
asChild
size="small"
>
<Link href={url}>
{intl.formatMessage({ id: "See hotel information" })}
</Link>
@@ -105,6 +137,6 @@ export default function HotelMapCard({
)}
</div>
</div>
</dialog>
</Wrapper>
)
}

View File

@@ -18,6 +18,8 @@
background:
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
var(--Surface-Brand-Primary-2-Default);
width: 46px !important;
height: 46px !important;
}
.count {

View File

@@ -27,10 +27,10 @@ export default function ClusterMarker({
onMarkerClick,
hotelIds,
}: ClusterMarkerProps) {
const { hoveredHotel } = useDestinationPageHotelsMapStore()
const isActive = hoveredHotel
? hotelIds.includes(Number(hoveredHotel))
: false
const { hoveredHotel, clickedHotel } = useDestinationPageHotelsMapStore()
const isActive =
hotelIds.includes(Number(hoveredHotel)) ||
hotelIds.includes(Number(clickedHotel))
const handleClick = useCallback(() => {
if (onMarkerClick) {

View File

@@ -32,10 +32,6 @@ export default function Marker({ position, properties }: MarkerProps) {
trackMapClick(properties.name)
}, [setClickedHotel, properties])
function handleCloseCard() {
setClickedHotel(null)
}
const handleMouseEnter = useCallback(() => {
setHoveredHotel(properties.id)
}, [setHoveredHotel, properties.id])
@@ -70,7 +66,6 @@ export default function Marker({ position, properties }: MarkerProps) {
hotelName={properties.name}
image={properties.image}
url={properties.url}
onClose={handleCloseCard}
/>
</AdvancedMarker>
)

View File

@@ -53,7 +53,7 @@
.sidebar {
position: absolute;
max-width: none;
padding: 0 var(--Spacing-x2) var(--Spacing-x3);
padding: 0 0 var(--Spacing-x2) 0;
overflow: hidden;
bottom: 0;
z-index: 3;