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"
|
||||
|
||||
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>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.centerContent {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"> {
|
||||
|
||||
@@ -18,12 +18,7 @@
|
||||
}
|
||||
|
||||
@media screen and (max-width: 949px) {
|
||||
.hotelList {
|
||||
flex-direction: row;
|
||||
align-items: end;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.hotelList,
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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%;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user