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:
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { cx } from "class-variance-authority"
|
import { cx } from "class-variance-authority"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import { useCarousel } from "./CarouselContext"
|
import { useCarousel } from "./CarouselContext"
|
||||||
|
|
||||||
@@ -11,11 +12,21 @@ export function CarouselContent({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { carouselRef } = useCarousel()
|
const { carouselRef, canScrollNext, canScrollPrev } = useCarousel()
|
||||||
|
const [isOneItem, setIsOneItem] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOneItem(!canScrollPrev() && !canScrollNext())
|
||||||
|
}, [canScrollPrev, canScrollNext])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={carouselRef} className={styles.viewport}>
|
<div ref={carouselRef} className={styles.viewport}>
|
||||||
<div className={cx(styles.container, className)} {...props}>
|
<div
|
||||||
|
className={cx(styles.container, className, {
|
||||||
|
[styles.centerContent]: isOneItem,
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.centerContent {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,19 @@ function Carousel({
|
|||||||
plugins,
|
plugins,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
scrollToIdx = 0,
|
||||||
|
align = "start",
|
||||||
}: CarouselProps) {
|
}: CarouselProps) {
|
||||||
const [carouselRef, api] = useEmblaCarousel(
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
{
|
{
|
||||||
containScroll: "trimSnaps",
|
containScroll: "trimSnaps",
|
||||||
align: "start",
|
align,
|
||||||
axis: "x",
|
axis: "x",
|
||||||
...opts,
|
...opts,
|
||||||
},
|
},
|
||||||
plugins
|
plugins
|
||||||
)
|
)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(scrollToIdx)
|
||||||
|
|
||||||
const onSelect = useCallback((api: CarouselApi) => {
|
const onSelect = useCallback((api: CarouselApi) => {
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -62,7 +64,6 @@ function Carousel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api) return
|
if (!api) return
|
||||||
|
|
||||||
onSelect(api)
|
onSelect(api)
|
||||||
api.on("reInit", onSelect)
|
api.on("reInit", onSelect)
|
||||||
api.on("select", onSelect)
|
api.on("select", onSelect)
|
||||||
@@ -72,6 +73,11 @@ function Carousel({
|
|||||||
}
|
}
|
||||||
}, [api, onSelect])
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || scrollToIdx === -1) return
|
||||||
|
api.scrollTo(scrollToIdx)
|
||||||
|
}, [api, scrollToIdx])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CarouselContext.Provider
|
<CarouselContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export interface CarouselProps extends PropsWithChildren {
|
|||||||
plugins?: CarouselPlugin
|
plugins?: CarouselPlugin
|
||||||
setApi?: (api: CarouselApi) => void
|
setApi?: (api: CarouselApi) => void
|
||||||
className?: string
|
className?: string
|
||||||
|
handleScrollSelected?: (idx: number) => void
|
||||||
|
scrollToIdx?: number
|
||||||
|
align?: "start" | "center"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CarouselContextProps extends Omit<CarouselProps, "className"> {
|
export interface CarouselContextProps extends Omit<CarouselProps, "className"> {
|
||||||
|
|||||||
@@ -18,12 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 949px) {
|
@media screen and (max-width: 949px) {
|
||||||
.hotelList {
|
.hotelList,
|
||||||
flex-direction: row;
|
|
||||||
align-items: end;
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Alert from "@/components/TempDesignSystem/Alert"
|
|||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import { debounce } from "@/utils/debounce"
|
import { debounce } from "@/utils/debounce"
|
||||||
|
|
||||||
|
import HotelCardCarousel from "../../../HotelCardCarousel"
|
||||||
import HotelListItem from "../HotelListItem"
|
import HotelListItem from "../HotelListItem"
|
||||||
import HotelListSkeleton from "./HotelListSkeleton"
|
import HotelListSkeleton from "./HotelListSkeleton"
|
||||||
import { getVisibleHotels } from "./utils"
|
import { getVisibleHotels } from "./utils"
|
||||||
@@ -75,13 +76,16 @@ export default function HotelList() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className={styles.hotelList}>
|
<>
|
||||||
{visibleHotels.map(({ hotel, url }) => (
|
<HotelCardCarousel visibleHotels={visibleHotels} />
|
||||||
<li key={hotel.operaId}>
|
<ul className={styles.hotelList}>
|
||||||
<HotelListItem hotel={hotel} url={url} />
|
{visibleHotels.map(({ hotel, url }) => (
|
||||||
</li>
|
<li key={hotel.operaId}>
|
||||||
))}
|
<HotelListItem hotel={hotel} url={url} />
|
||||||
</ul>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #fff;
|
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, #000000 25%, transparent 25%),
|
||||||
linear-gradient(45deg, transparent 75%, #000000 75%),
|
linear-gradient(45deg, transparent 75%, #000000 75%),
|
||||||
linear-gradient(-45deg, transparent 75%, #000000 75%);
|
linear-gradient(-45deg, transparent 75%, #000000 75%);
|
||||||
@@ -31,3 +32,9 @@
|
|||||||
top: 7px;
|
top: 7px;
|
||||||
border-radius: var(--Corner-radius-Small);
|
border-radius: var(--Corner-radius-Small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
.imageContainer {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
.dialog {
|
.dialog {
|
||||||
bottom: 0;
|
display: none;
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialogContent {
|
.dialogContent {
|
||||||
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
border-radius: var(--Corner-radius-Medium);
|
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);
|
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialogContent::after {
|
.dialog .dialogContent {
|
||||||
|
min-width: 402px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog .dialogContent::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -55,7 +54,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 220px;
|
min-width: 150px;
|
||||||
padding: var(--Spacing-x-one-and-half);
|
padding: var(--Spacing-x-one-and-half);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -83,8 +82,21 @@
|
|||||||
padding-bottom: var(--Spacing-x1);
|
padding-bottom: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 950px) {
|
||||||
.iconFootnote {
|
.iconFootnote {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { cx } from "class-variance-authority"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||||
|
|
||||||
import CloseLargeIcon from "@/components/Icons/CloseLarge"
|
import CloseLargeIcon from "@/components/Icons/CloseLarge"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
@@ -18,37 +21,60 @@ import type { GalleryImage } from "@/types/components/imageGallery"
|
|||||||
import type { Amenities } from "@/types/hotel"
|
import type { Amenities } from "@/types/hotel"
|
||||||
|
|
||||||
interface HotelMapCardProps {
|
interface HotelMapCardProps {
|
||||||
isActive: boolean
|
isActive?: boolean
|
||||||
amenities: Amenities
|
amenities: Amenities
|
||||||
tripadvisorRating: number | undefined
|
tripadvisorRating: number | undefined
|
||||||
hotelName: string
|
hotelName: string
|
||||||
image: GalleryImage | null
|
image: GalleryImage | null
|
||||||
url: string
|
url: string
|
||||||
onClose: () => void
|
type?: "dialog" | "article"
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HotelMapCard({
|
export default function HotelMapCard({
|
||||||
isActive,
|
isActive = false,
|
||||||
amenities,
|
amenities,
|
||||||
tripadvisorRating,
|
tripadvisorRating,
|
||||||
hotelName,
|
hotelName,
|
||||||
image,
|
image,
|
||||||
url,
|
url,
|
||||||
onClose,
|
type = "dialog",
|
||||||
|
className,
|
||||||
}: HotelMapCardProps) {
|
}: HotelMapCardProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const pageType = usePageType()
|
const pageType = usePageType()
|
||||||
const [imageError, setImageError] = useState(false)
|
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 (
|
return (
|
||||||
<dialog open={isActive} className={styles.dialog}>
|
<Wrapper>
|
||||||
<div className={styles.dialogContent}>
|
<div className={styles.dialogContent}>
|
||||||
<Button
|
<Button
|
||||||
intent="text"
|
intent="text"
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
className={styles.closeButton}
|
className={styles.closeButton}
|
||||||
onClick={onClose}
|
onClick={handleClose}
|
||||||
aria-label={intl.formatMessage({ id: "Close" })}
|
aria-label={intl.formatMessage({ id: "Close" })}
|
||||||
>
|
>
|
||||||
<CloseLargeIcon className={styles.closeIcon} width={16} height={16} />
|
<CloseLargeIcon className={styles.closeIcon} width={16} height={16} />
|
||||||
@@ -97,7 +123,13 @@ export default function HotelMapCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{url && (
|
{url && (
|
||||||
<Button intent="tertiary" theme="base" asChild size="small">
|
<Button
|
||||||
|
disabled={url ? false : true}
|
||||||
|
intent="tertiary"
|
||||||
|
theme="base"
|
||||||
|
asChild
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
<Link href={url}>
|
<Link href={url}>
|
||||||
{intl.formatMessage({ id: "See hotel information" })}
|
{intl.formatMessage({ id: "See hotel information" })}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -105,6 +137,6 @@ export default function HotelMapCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
background:
|
background:
|
||||||
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
||||||
var(--Surface-Brand-Primary-2-Default);
|
var(--Surface-Brand-Primary-2-Default);
|
||||||
|
width: 46px !important;
|
||||||
|
height: 46px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export default function ClusterMarker({
|
|||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
hotelIds,
|
hotelIds,
|
||||||
}: ClusterMarkerProps) {
|
}: ClusterMarkerProps) {
|
||||||
const { hoveredHotel } = useDestinationPageHotelsMapStore()
|
const { hoveredHotel, clickedHotel } = useDestinationPageHotelsMapStore()
|
||||||
const isActive = hoveredHotel
|
const isActive =
|
||||||
? hotelIds.includes(Number(hoveredHotel))
|
hotelIds.includes(Number(hoveredHotel)) ||
|
||||||
: false
|
hotelIds.includes(Number(clickedHotel))
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (onMarkerClick) {
|
if (onMarkerClick) {
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ export default function Marker({ position, properties }: MarkerProps) {
|
|||||||
trackMapClick(properties.name)
|
trackMapClick(properties.name)
|
||||||
}, [setClickedHotel, properties])
|
}, [setClickedHotel, properties])
|
||||||
|
|
||||||
function handleCloseCard() {
|
|
||||||
setClickedHotel(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
setHoveredHotel(properties.id)
|
setHoveredHotel(properties.id)
|
||||||
}, [setHoveredHotel, properties.id])
|
}, [setHoveredHotel, properties.id])
|
||||||
@@ -70,7 +66,6 @@ export default function Marker({ position, properties }: MarkerProps) {
|
|||||||
hotelName={properties.name}
|
hotelName={properties.name}
|
||||||
image={properties.image}
|
image={properties.image}
|
||||||
url={properties.url}
|
url={properties.url}
|
||||||
onClose={handleCloseCard}
|
|
||||||
/>
|
/>
|
||||||
</AdvancedMarker>
|
</AdvancedMarker>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
padding: 0 var(--Spacing-x2) var(--Spacing-x3);
|
padding: 0 0 var(--Spacing-x2) 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
Reference in New Issue
Block a user