Merged in feat/SW-2241-country-map (pull request #2808)
Feat/SW-2241 country map Approved-by: Erik Tiekstra Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
@@ -5,18 +5,18 @@ import DestinationCountryPageSkeleton from "@/components/ContentType/Destination
|
|||||||
|
|
||||||
import styles from "./page.module.css"
|
import styles from "./page.module.css"
|
||||||
|
|
||||||
|
import type { PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export { generateMetadata } from "@/utils/metadata/generateMetadata"
|
export { generateMetadata } from "@/utils/metadata/generateMetadata"
|
||||||
|
|
||||||
export default async function DestinationCountryPagePage() {
|
export default async function DestinationCountryPagePage(
|
||||||
// props: PageArgs<{}, { view?: "map"; }>
|
props: PageArgs<object, { view?: "map" }>
|
||||||
// const searchParams = await props.searchParams
|
) {
|
||||||
|
const searchParams = await props.searchParams
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
||||||
<DestinationCountryPage
|
<DestinationCountryPage isMapView={searchParams.view === "map"} />
|
||||||
// isMapView={searchParams.view === "map"} // Disabled until further notice
|
|
||||||
isMapView={false}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
.container {
|
|
||||||
--scroll-margin-top: calc(
|
|
||||||
var(--booking-widget-mobile-height) + var(--Spacing-x2)
|
|
||||||
);
|
|
||||||
display: grid;
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
scroll-margin-top: var(--scroll-margin-top);
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cityList {
|
|
||||||
list-style: none;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
|
||||||
.container {
|
|
||||||
--scroll-margin-top: calc(
|
|
||||||
var(--booking-widget-desktop-height) + var(--Spacing-x2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cx } from "class-variance-authority"
|
||||||
|
import { useCallback } from "react"
|
||||||
|
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
|
import { Carousel } from "@/components/Carousel"
|
||||||
|
|
||||||
|
import CityMapCard from "../DestinationCountryPage/CityMapCard"
|
||||||
|
|
||||||
|
import styles from "./destinationCardCarousel.module.css"
|
||||||
|
|
||||||
|
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
||||||
|
|
||||||
|
interface CityCardCarouselProps {
|
||||||
|
activeCities: DestinationCityListItem[]
|
||||||
|
}
|
||||||
|
export default function CityCardCarousel({
|
||||||
|
activeCities,
|
||||||
|
}: CityCardCarouselProps) {
|
||||||
|
const { activeCityMarker, setActiveCityMarker } =
|
||||||
|
useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
|
const selectedCityIdx = activeCities.findIndex(
|
||||||
|
(city) => city.cityIdentifier === activeCityMarker?.cityId
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleScrollSelect = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
if (
|
||||||
|
selectedCityIdx !== -1 &&
|
||||||
|
activeCities[idx]?.destination_settings.location
|
||||||
|
) {
|
||||||
|
setActiveCityMarker({
|
||||||
|
cityId: activeCities[idx].cityIdentifier,
|
||||||
|
location: {
|
||||||
|
lat: activeCities[idx].destination_settings.location.latitude,
|
||||||
|
lng: activeCities[idx].destination_settings.location.longitude,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActiveCityMarker, activeCities, selectedCityIdx]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
scrollToIdx={selectedCityIdx}
|
||||||
|
className={styles.carousel}
|
||||||
|
align="center"
|
||||||
|
opts={{ containScroll: false }}
|
||||||
|
onScrollSelect={handleScrollSelect}
|
||||||
|
>
|
||||||
|
<Carousel.Content className={styles.carouselContent}>
|
||||||
|
{activeCities.map(({ cityIdentifier, cityName, url, images }) => (
|
||||||
|
<Carousel.Item key={cityIdentifier}>
|
||||||
|
<CityMapCard
|
||||||
|
className={cx({
|
||||||
|
[styles.noActiveCard]: !activeCityMarker,
|
||||||
|
})}
|
||||||
|
cityName={cityName}
|
||||||
|
url={url}
|
||||||
|
image={getImage(images)}
|
||||||
|
/>
|
||||||
|
</Carousel.Item>
|
||||||
|
))}
|
||||||
|
</Carousel.Content>
|
||||||
|
</Carousel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImage(images: DestinationCityListItem["images"]) {
|
||||||
|
if (images?.length) {
|
||||||
|
const image = images[0]
|
||||||
|
return {
|
||||||
|
src: image.url,
|
||||||
|
alt: image.meta.alt || image.meta.caption || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -9,16 +9,16 @@ import { Carousel } from "@/components/Carousel"
|
|||||||
|
|
||||||
import HotelMapCard from "../HotelMapCard"
|
import HotelMapCard from "../HotelMapCard"
|
||||||
|
|
||||||
import styles from "./hotelCardCarousel.module.css"
|
import styles from "./destinationCardCarousel.module.css"
|
||||||
|
|
||||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
interface MapCardCarouselProps {
|
interface HotelCardCarouselProps {
|
||||||
visibleHotels: HotelListingHotelData[]
|
visibleHotels: HotelListingHotelData[]
|
||||||
}
|
}
|
||||||
export default function HotelCardCarousel({
|
export default function HotelCardCarousel({
|
||||||
visibleHotels,
|
visibleHotels,
|
||||||
}: MapCardCarouselProps) {
|
}: HotelCardCarouselProps) {
|
||||||
const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
|
const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
|
||||||
|
|
||||||
const selectedHotelIdx = visibleHotels.findIndex(
|
const selectedHotelIdx = visibleHotels.findIndex(
|
||||||
@@ -36,18 +36,18 @@ export default function HotelCardCarousel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Carousel
|
<Carousel
|
||||||
className={styles.carousel}
|
|
||||||
scrollToIdx={selectedHotelIdx}
|
scrollToIdx={selectedHotelIdx}
|
||||||
|
className={styles.carousel}
|
||||||
align="center"
|
align="center"
|
||||||
opts={{ containScroll: false }}
|
opts={{ containScroll: false }}
|
||||||
onScrollSelect={handleScrollSelect}
|
onScrollSelect={handleScrollSelect}
|
||||||
>
|
>
|
||||||
<Carousel.Content className={styles.carouselContent}>
|
<Carousel.Content className={styles.carouselContent}>
|
||||||
{visibleHotels.map(({ hotel, url }) => (
|
{visibleHotels.map(({ hotel, url }) => (
|
||||||
<Carousel.Item key={hotel.id} className={styles.item}>
|
<Carousel.Item key={hotel.id}>
|
||||||
<HotelMapCard
|
<HotelMapCard
|
||||||
className={cx(styles.carouselCard, {
|
className={cx({
|
||||||
[styles.noActiveHotel]: !activeMarker,
|
[styles.noActiveCard]: !activeMarker,
|
||||||
})}
|
})}
|
||||||
tripadvisorRating={hotel.tripadvisor}
|
tripadvisorRating={hotel.tripadvisor}
|
||||||
hotelName={hotel.name}
|
hotelName={hotel.name}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
.noActiveHotel,
|
.noActiveCard,
|
||||||
.carousel {
|
.carousel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 949px) {
|
@media screen and (max-width: 949px) {
|
||||||
.carousel:not(.noActiveHotel) {
|
.carousel {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carouselContent {
|
.carouselContent {
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Space-x1);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.backToCities {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 950px) {
|
||||||
|
.backToCities {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: var(--Space-x2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import styles from "./backToCitiesLink.module.css"
|
||||||
|
|
||||||
|
export function BackToCities() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function handleGoBack() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
typography="Body/Paragraph/mdBold"
|
||||||
|
variant="Text"
|
||||||
|
color="Primary"
|
||||||
|
className={styles.backToCities}
|
||||||
|
onClick={handleGoBack}
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="arrow_back" color="CurrentColor" size={24} />
|
||||||
|
<span>{intl.formatMessage({ defaultMessage: "Back to cities" })}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
import { useMediaQuery } from "usehooks-ts"
|
|
||||||
|
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||||
|
|
||||||
import HotelCardCarousel from "../../../HotelCardCarousel"
|
import HotelCardCarousel from "../../../DestinationCardCarousel/HotelCardCarousel"
|
||||||
import HotelListItem from "../HotelListItem"
|
import HotelListItem from "../HotelListItem"
|
||||||
|
|
||||||
import styles from "./hotelList.module.css"
|
import styles from "./hotelList.module.css"
|
||||||
@@ -23,7 +20,6 @@ export default function HotelListContent({
|
|||||||
visibleHotels,
|
visibleHotels,
|
||||||
}: HotelListContentProps) {
|
}: HotelListContentProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const isMobile = useMediaQuery("(max-width: 949px)")
|
|
||||||
|
|
||||||
if (hotelsCount === 0) {
|
if (hotelsCount === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -40,17 +36,17 @@ export default function HotelListContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return <HotelCardCarousel visibleHotels={visibleHotels} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={styles.hotelList}>
|
<>
|
||||||
{visibleHotels.map(({ hotel, url }) => (
|
<HotelCardCarousel visibleHotels={visibleHotels} />
|
||||||
<li key={hotel.id}>
|
|
||||||
<HotelListItem hotel={hotel} url={url} />
|
<ul className={styles.hotelList}>
|
||||||
</li>
|
{visibleHotels.map(({ hotel, url }) => (
|
||||||
))}
|
<li key={hotel.id}>
|
||||||
</ul>
|
<HotelListItem hotel={hotel} url={url} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
|
|||||||
<h3>{hotel.name}</h3>
|
<h3>{hotel.name}</h3>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<p className={styles.captions}>
|
<span className={styles.captions}>
|
||||||
<Typography variant="Link/sm">
|
<Typography variant="Link/sm">
|
||||||
<ButtonRAC
|
<ButtonRAC
|
||||||
className={styles.addressButton}
|
className={styles.addressButton}
|
||||||
@@ -120,7 +120,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Map from "../../Map"
|
import CityMapContainer from "../../Map/CityMapContainer"
|
||||||
import { getHeadingText } from "../../utils"
|
import { getCityHeadingText } from "../../utils"
|
||||||
|
import { BackToCities } from "./BackToCitiesLink"
|
||||||
import HotelList from "./HotelList"
|
import HotelList from "./HotelList"
|
||||||
|
|
||||||
import styles from "./cityMap.module.css"
|
import styles from "./cityMap.module.css"
|
||||||
@@ -36,21 +39,30 @@ export default function CityMap({
|
|||||||
filterFromUrl: state.filterFromUrl,
|
filterFromUrl: state.filterFromUrl,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const [fromCountryPage, setIsFromCountryPage] = useState(false)
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
setIsFromCountryPage(url.searchParams.has("fromCountry"))
|
||||||
|
}, [params])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Map
|
<CityMapContainer
|
||||||
hotels={activeHotels}
|
hotels={activeHotels}
|
||||||
mapId={mapId}
|
mapId={mapId}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
pageType="city"
|
|
||||||
defaultLocation={defaultLocation}
|
defaultLocation={defaultLocation}
|
||||||
>
|
>
|
||||||
<Typography variant="Title/sm">
|
<span className="topSection">
|
||||||
<h1 className={styles.title}>
|
{fromCountryPage ? <BackToCities /> : null}
|
||||||
{getHeadingText(intl, city.name, allFilters, filterFromUrl)}
|
<Typography variant="Title/sm">
|
||||||
</h1>
|
<h1 className={styles.title}>
|
||||||
</Typography>
|
{getCityHeadingText(intl, city.name, allFilters, filterFromUrl)}
|
||||||
|
</h1>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
<HotelList />
|
<HotelList />
|
||||||
</Map>
|
</CityMapContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
|||||||
|
|
||||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
|
|
||||||
|
import HotelListingSkeleton from "../DestinationListing/HotelListing/HotelListingSkeleton"
|
||||||
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
||||||
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
|
|
||||||
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
||||||
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
|||||||
import { getPathname } from "@/utils/getPathname"
|
import { getPathname } from "@/utils/getPathname"
|
||||||
|
|
||||||
import Blocks from "../Blocks"
|
import Blocks from "../Blocks"
|
||||||
|
import HotelListing from "../DestinationListing/HotelListing"
|
||||||
import ExperienceList from "../ExperienceList"
|
import ExperienceList from "../ExperienceList"
|
||||||
import HotelListing from "../HotelListing"
|
|
||||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||||
import DestinationPageSidePeek from "../Sidepeek"
|
import DestinationPageSidePeek from "../Sidepeek"
|
||||||
import StaticMap from "../StaticMap"
|
import StaticMap from "../StaticMap"
|
||||||
@@ -121,7 +121,11 @@ export default async function DestinationCityPage({
|
|||||||
<SeoFilters seoFilters={seo_filters} location={city.name} />
|
<SeoFilters seoFilters={seo_filters} location={city.name} />
|
||||||
</main>
|
</main>
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
<SidebarContentWrapper preamble={preamble} location={city.name}>
|
<SidebarContentWrapper
|
||||||
|
preamble={preamble}
|
||||||
|
location={city.name}
|
||||||
|
pageType="city"
|
||||||
|
>
|
||||||
<ExperienceList experiences={experiences} />
|
<ExperienceList experiences={experiences} />
|
||||||
{has_sidepeek && sidepeek_content ? (
|
{has_sidepeek && sidepeek_content ? (
|
||||||
<DestinationPageSidePeek
|
<DestinationPageSidePeek
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.wrapper {
|
||||||
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
height: 48px;
|
||||||
|
max-width: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: var(--Space-x15);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 950px) {
|
||||||
|
.content {
|
||||||
|
min-width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { setMapUrlFromCountryPage } from "@scandic-hotels/common/hooks/map/useSetMapUrlFromCountryPage"
|
||||||
|
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||||
|
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import ImageFallback from "@scandic-hotels/design-system/ImageFallback"
|
||||||
|
import Link from "@scandic-hotels/design-system/Link"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
|
import DialogImage from "../../HotelMapCard/DialogImage"
|
||||||
|
|
||||||
|
import styles from "./cityMapCard.module.css"
|
||||||
|
|
||||||
|
import type { GalleryImage } from "@/types/components/imageGallery"
|
||||||
|
|
||||||
|
interface CityMapCardProps {
|
||||||
|
cityName: string
|
||||||
|
image?: GalleryImage | null
|
||||||
|
url: string | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CityMapCard({
|
||||||
|
cityName,
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
className,
|
||||||
|
}: CityMapCardProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const [imageError, setImageError] = useState(false)
|
||||||
|
const { setActiveCityMarker, setHoveredCityMarker } =
|
||||||
|
useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setActiveCityMarker(null)
|
||||||
|
setHoveredCityMarker(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cityMapUrl = setMapUrlFromCountryPage(url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={className}>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<IconButton
|
||||||
|
theme="Black"
|
||||||
|
style="Muted"
|
||||||
|
className={styles.closeButton}
|
||||||
|
onPress={handleClose}
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
defaultMessage: "Close",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="close"
|
||||||
|
size={16}
|
||||||
|
className={styles.closeIcon}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
{image ? (
|
||||||
|
<DialogImage
|
||||||
|
image={image.src}
|
||||||
|
altText={image.alt}
|
||||||
|
imageError={imageError}
|
||||||
|
setImageError={setImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageFallback />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<h4>{cityName}</h4>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{url && cityMapUrl && (
|
||||||
|
<span className={styles.links}>
|
||||||
|
<ButtonLink
|
||||||
|
href={cityMapUrl}
|
||||||
|
variant="Secondary"
|
||||||
|
color="Primary"
|
||||||
|
size="Small"
|
||||||
|
onClick={() => setActiveCityMarker(null)}
|
||||||
|
>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "See hotels on map",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</ButtonLink>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={url}
|
||||||
|
color="Text/Interactive/Secondary"
|
||||||
|
variant="icon"
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
<Typography variant="Link/sm">
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Explore city",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="open_in_new"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export default function CityListSkeleton() {
|
|||||||
<SkeletonShimmer height="30px" width="120px" />
|
<SkeletonShimmer height="30px" width="120px" />
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.cityList}>
|
<ul className={styles.cityList}>
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<CityListItemSkeleton key={index} />
|
<CityListItemSkeleton key={index} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||||
|
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||||
|
|
||||||
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
|
import CityCardCarousel from "../../../DestinationCardCarousel/CityCardCarousel"
|
||||||
|
import { CityListItem } from "../CityListItem"
|
||||||
|
|
||||||
|
import styles from "./cityList.module.css"
|
||||||
|
|
||||||
|
export function CityListContent() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { activeCities } = useDestinationDataStore((state) => ({
|
||||||
|
activeCities: state.activeCities,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (activeCities.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type={AlertTypeEnum.Info}
|
||||||
|
heading={intl.formatMessage({
|
||||||
|
defaultMessage: "No matching locations found",
|
||||||
|
})}
|
||||||
|
text={intl.formatMessage({
|
||||||
|
defaultMessage:
|
||||||
|
"It looks like no location match your filters. Try adjusting your search to find the perfect stay.",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CityCardCarousel activeCities={activeCities} />
|
||||||
|
|
||||||
|
<ul className={styles.cityList}>
|
||||||
|
{activeCities.map((city) => (
|
||||||
|
<li key={city.system.uid}>
|
||||||
|
<CityListItem
|
||||||
|
image={city.images?.length ? city.images[0] : null}
|
||||||
|
location={{
|
||||||
|
lat: city.destination_settings.location?.latitude || 0,
|
||||||
|
lng: city.destination_settings.location?.longitude || 0,
|
||||||
|
}}
|
||||||
|
url={city.url}
|
||||||
|
cityName={city.cityName}
|
||||||
|
cityIdentifier={city.cityIdentifier}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.cityListWrapper {
|
.cityListWrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Space-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -13,15 +13,13 @@
|
|||||||
.cityList {
|
.cityList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Space-x15);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 949px) {
|
@media screen and (max-width: 949px) {
|
||||||
.cityList {
|
.cityList {
|
||||||
flex-direction: row;
|
display: none;
|
||||||
align-items: end;
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|||||||
@@ -2,16 +2,14 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
|
||||||
import Body from "@scandic-hotels/design-system/Body"
|
|
||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||||
|
|
||||||
import CityListItem from "../CityListItem"
|
|
||||||
import CityListSkeleton from "./CityListSkeleton"
|
import CityListSkeleton from "./CityListSkeleton"
|
||||||
|
import { CityListContent } from "./Content"
|
||||||
|
|
||||||
import styles from "./cityList.module.css"
|
import styles from "./cityList.module.css"
|
||||||
|
|
||||||
@@ -27,36 +25,20 @@ export default function CityList() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={styles.cityListWrapper}>
|
<div className={styles.cityListWrapper}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<Body>
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
{intl.formatMessage(
|
<p>
|
||||||
{
|
{intl.formatMessage(
|
||||||
defaultMessage: "{count} destinations",
|
{
|
||||||
},
|
defaultMessage: "{count} destinations",
|
||||||
{ count: activeCities.length }
|
},
|
||||||
)}
|
{ count: activeCities.length }
|
||||||
</Body>
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
<DestinationFilterAndSort listType="city" />
|
<DestinationFilterAndSort listType="city" />
|
||||||
</div>
|
</div>
|
||||||
{activeCities.length === 0 ? (
|
|
||||||
<Alert
|
<CityListContent />
|
||||||
type={AlertTypeEnum.Info}
|
|
||||||
heading={intl.formatMessage({
|
|
||||||
defaultMessage: "No matching locations found",
|
|
||||||
})}
|
|
||||||
text={intl.formatMessage({
|
|
||||||
defaultMessage:
|
|
||||||
"It looks like no location match your filters. Try adjusting your search to find the perfect stay.",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className={styles.cityList}>
|
|
||||||
{activeCities.map((city) => (
|
|
||||||
<li key={city.system.uid}>
|
|
||||||
<CityListItem city={city} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,16 @@
|
|||||||
|
|
||||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||||
|
|
||||||
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
|
|
||||||
|
|
||||||
import styles from "./cityListItem.module.css"
|
import styles from "./cityListItem.module.css"
|
||||||
|
|
||||||
export default function CityListItemSkeleton() {
|
export default function CityListItemSkeleton() {
|
||||||
return (
|
return (
|
||||||
<article className={styles.cityListItem}>
|
<article className={styles.card}>
|
||||||
<div className={styles.imageWrapper}>
|
<div className={styles.imageWrapper}>
|
||||||
<SkeletonShimmer width="100%" height="100%" />
|
<SkeletonShimmer width="100%" height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<section className={styles.content}>
|
<section className={styles.content}>
|
||||||
<SkeletonShimmer height="52px" />
|
<SkeletonShimmer height="100%" />
|
||||||
<div className={styles.experienceList}>
|
|
||||||
<ExperienceListSkeleton />
|
|
||||||
</div>
|
|
||||||
<div className={styles.ctaWrapper}>
|
|
||||||
<SkeletonShimmer height="45px" width="100%" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
.cityListItem {
|
.card {
|
||||||
display: grid;
|
border-radius: var(--Corner-radius-Medium);
|
||||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
background-color: var(--Surface-Primary-Default);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
display: flex;
|
||||||
border-radius: var(--Corner-radius-md);
|
height: 80px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background: var(--Surface-Primary-Hover);
|
||||||
|
}
|
||||||
|
|
||||||
.imageWrapper {
|
.imageWrapper {
|
||||||
position: relative;
|
overflow: hidden;
|
||||||
height: 200px;
|
height: 80px;
|
||||||
|
max-width: 80px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,32 +22,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
grid-template-columns: auto 1fr;
|
||||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
margin: var(--Space-x15);
|
||||||
|
margin-left: var(--Space-x2);
|
||||||
|
color: var(--Text-Interactive-Default);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 949px) {
|
.left {
|
||||||
.cityListItem {
|
display: grid;
|
||||||
width: 360px;
|
gap: var(--Space-x025);
|
||||||
min-height: 120px;
|
}
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imageWrapper {
|
.right {
|
||||||
height: 100%;
|
display: flex;
|
||||||
}
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-right: var(--Space-x3);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 950px) {
|
||||||
.content {
|
.content {
|
||||||
padding: var(--Spacing-x-one-and-half);
|
min-width: 220px;
|
||||||
gap: var(--Spacing-x1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.experienceList {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctaWrapper {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,135 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import { useCallback } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
import Image from "@scandic-hotels/design-system/Image"
|
||||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
import ImageFallback from "@scandic-hotels/design-system/ImageFallback"
|
||||||
|
import Link from "@scandic-hotels/design-system/Link"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
import ExperienceList from "../../../ExperienceList"
|
|
||||||
|
|
||||||
import styles from "./cityListItem.module.css"
|
import styles from "./cityListItem.module.css"
|
||||||
|
|
||||||
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
import type { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
|
||||||
|
|
||||||
interface CityListItemProps {
|
interface CityListItemProps {
|
||||||
city: DestinationCityListItem
|
cityName: string
|
||||||
|
cityIdentifier: string
|
||||||
|
url: string
|
||||||
|
image: ImageVaultAsset | null
|
||||||
|
location: { lat: number; lng: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CityListItem({ city }: CityListItemProps) {
|
export function CityListItem({
|
||||||
|
cityName,
|
||||||
|
cityIdentifier,
|
||||||
|
url,
|
||||||
|
image,
|
||||||
|
location,
|
||||||
|
}: CityListItemProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const galleryImages = mapImageVaultImagesToGalleryImages(city.images || [])
|
const {
|
||||||
|
hoveredCityMarker,
|
||||||
|
setHoveredCityMarker,
|
||||||
|
activeCityMarker,
|
||||||
|
setActiveCityMarker,
|
||||||
|
} = useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
|
const { activeCities } = useDestinationDataStore((state) => ({
|
||||||
|
activeCities: state.activeCities,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (activeCityMarker?.cityId !== hoveredCityMarker) {
|
||||||
|
setActiveCityMarker(null)
|
||||||
|
}
|
||||||
|
if (cityIdentifier) {
|
||||||
|
setHoveredCityMarker(cityIdentifier)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
cityIdentifier,
|
||||||
|
activeCityMarker,
|
||||||
|
setActiveCityMarker,
|
||||||
|
hoveredCityMarker,
|
||||||
|
setHoveredCityMarker,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setHoveredCityMarker(null)
|
||||||
|
}, [setHoveredCityMarker])
|
||||||
|
|
||||||
|
const handleClickCard = useCallback(() => {
|
||||||
|
const clickedCity = activeCities.find(
|
||||||
|
(city) => city.cityIdentifier === cityIdentifier
|
||||||
|
)
|
||||||
|
if (clickedCity) {
|
||||||
|
setHoveredCityMarker(null)
|
||||||
|
setActiveCityMarker({ cityId: cityIdentifier, location })
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeCities,
|
||||||
|
cityIdentifier,
|
||||||
|
location,
|
||||||
|
setActiveCityMarker,
|
||||||
|
setHoveredCityMarker,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.cityListItem}>
|
<article
|
||||||
|
className={styles.card}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<div className={styles.imageWrapper}>
|
<div className={styles.imageWrapper}>
|
||||||
<ImageGallery
|
{image ? (
|
||||||
images={galleryImages}
|
<Image
|
||||||
fill
|
src={image.url}
|
||||||
title={intl.formatMessage(
|
alt={image.meta.alt || image.meta.caption || ""}
|
||||||
{
|
focalPoint={image.focalPoint}
|
||||||
defaultMessage: "{title} - Image gallery",
|
width={80}
|
||||||
},
|
height={80}
|
||||||
{ title: city.cityName }
|
title={cityName}
|
||||||
)}
|
/>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<ImageFallback height="80px" />
|
||||||
<section className={styles.content}>
|
|
||||||
<Subtitle asChild>
|
|
||||||
<h3>{city.heading}</h3>
|
|
||||||
</Subtitle>
|
|
||||||
{city.experiences?.length && (
|
|
||||||
<div className={styles.experienceList}>
|
|
||||||
<ExperienceList experiences={city.experiences} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className={styles.ctaWrapper}>
|
</div>
|
||||||
<Button intent="tertiary" theme="base" size="small" asChild>
|
<div className={styles.content}>
|
||||||
<Link href={city.url}>
|
<div className={styles.left}>
|
||||||
{intl.formatMessage(
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
{
|
<h3>{cityName}</h3>
|
||||||
defaultMessage: "Explore {city}",
|
</Typography>
|
||||||
},
|
<div>
|
||||||
{ city: city.cityName }
|
<Link href={url} color="Text/Interactive/Secondary" variant="icon">
|
||||||
)}
|
<Typography variant="Link/sm">
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Explore city",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
<MaterialIcon icon="open_in_new" size={20} color="CurrentColor" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<button
|
||||||
|
onClick={handleClickCard}
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
defaultMessage: "See on map",
|
||||||
|
})}
|
||||||
|
className={styles.right}
|
||||||
|
>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="arrow_forward"
|
||||||
|
size={24}
|
||||||
|
color="Icon/Interactive/Default"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
|||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Map from "../../Map"
|
import CountryMapContainer from "../../Map/CountryMapContainer"
|
||||||
import { getHeadingText } from "../../utils"
|
import { getCountryHeadingText } from "../../utils"
|
||||||
import CityList from "./CityList"
|
import CityList from "./CityList"
|
||||||
|
|
||||||
import styles from "./countryMap.module.css"
|
import styles from "./countryMap.module.css"
|
||||||
@@ -28,28 +28,27 @@ export default function CountryMap({
|
|||||||
defaultLocation,
|
defaultLocation,
|
||||||
}: CountryMapProps) {
|
}: CountryMapProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { activeHotels, allFilters, filterFromUrl } = useDestinationDataStore(
|
const { activeCities, allFilters, filterFromUrl } = useDestinationDataStore(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
activeHotels: state.activeHotels,
|
activeCities: state.activeCities,
|
||||||
allFilters: state.allFilters,
|
allFilters: state.allFilters,
|
||||||
filterFromUrl: state.filterFromUrl,
|
filterFromUrl: state.filterFromUrl,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Map
|
<CountryMapContainer
|
||||||
hotels={activeHotels}
|
cities={activeCities}
|
||||||
mapId={mapId}
|
mapId={mapId}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
pageType="country"
|
|
||||||
defaultLocation={defaultLocation}
|
defaultLocation={defaultLocation}
|
||||||
>
|
>
|
||||||
<Typography variant="Title/sm">
|
<Typography variant="Title/sm">
|
||||||
<h1 className={styles.title}>
|
<h1 className={styles.title}>
|
||||||
{getHeadingText(intl, country, allFilters, filterFromUrl)}
|
{getCountryHeadingText(intl, country, allFilters, filterFromUrl)}
|
||||||
</h1>
|
</h1>
|
||||||
</Typography>
|
</Typography>
|
||||||
<CityList />
|
<CityList />
|
||||||
</Map>
|
</CountryMapContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
|||||||
|
|
||||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
|
|
||||||
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
|
import CityListingSkeleton from "../DestinationListing/CityListing/CityListingSkeleton"
|
||||||
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
||||||
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
||||||
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/* eslint-disable formatjs/no-literal-string-in-jsx */
|
||||||
|
|
||||||
|
import { setMapUrlFromCountryPage } from "@scandic-hotels/common/hooks/map/useSetMapUrlFromCountryPage"
|
||||||
|
import Link from "@scandic-hotels/design-system/Link"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import styles from "./locationsList.module.css"
|
||||||
|
|
||||||
|
export interface LocationsListProps {
|
||||||
|
locations: {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
hotelsCount: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationsList({ locations }: LocationsListProps) {
|
||||||
|
return (
|
||||||
|
<ul className={styles.content}>
|
||||||
|
{locations.map((location) => {
|
||||||
|
const mapUrl = setMapUrlFromCountryPage(location.url)
|
||||||
|
return mapUrl ? (
|
||||||
|
<li key={location.name}>
|
||||||
|
<Link href={mapUrl.toString()} color="Text/Interactive/Secondary">
|
||||||
|
<Typography variant="Link/sm">
|
||||||
|
<span>{`${location.name} (${location.hotelsCount})`}</span>
|
||||||
|
</Typography>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.content {
|
||||||
|
padding: var(--Space-x15);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
list-style: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -23,10 +23,11 @@ import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
|||||||
import { getPathname } from "@/utils/getPathname"
|
import { getPathname } from "@/utils/getPathname"
|
||||||
|
|
||||||
import Blocks from "../Blocks"
|
import Blocks from "../Blocks"
|
||||||
import CityListing from "../CityListing"
|
import CityListing from "../DestinationListing/CityListing"
|
||||||
import ExperienceList from "../ExperienceList"
|
import ExperienceList from "../ExperienceList"
|
||||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||||
import DestinationPageSidePeek from "../Sidepeek"
|
import DestinationPageSidePeek from "../Sidepeek"
|
||||||
|
import StaticMap from "../StaticMap"
|
||||||
import TopImages from "../TopImages"
|
import TopImages from "../TopImages"
|
||||||
import DestinationTracking from "../Tracking"
|
import DestinationTracking from "../Tracking"
|
||||||
import CountryMap from "./CountryMap"
|
import CountryMap from "./CountryMap"
|
||||||
@@ -85,12 +86,26 @@ export default async function DestinationCountryPage({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/* Add hotel count to cities */
|
||||||
|
const hotelsCountMap = new Map<string, number>()
|
||||||
|
allHotels.forEach(({ hotel: { cityIdentifier } }) => {
|
||||||
|
if (cityIdentifier)
|
||||||
|
hotelsCountMap.set(
|
||||||
|
cityIdentifier,
|
||||||
|
(hotelsCountMap.get(cityIdentifier) || 0) + 1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const allCitiesWithCount = allCities.map((obj) => ({
|
||||||
|
...obj,
|
||||||
|
hotelsCount: hotelsCountMap.get(obj.cityIdentifier) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
||||||
<DestinationDataProvider
|
<DestinationDataProvider
|
||||||
allHotels={allHotels}
|
allHotels={allHotels}
|
||||||
allCities={allCities}
|
allCities={allCitiesWithCount}
|
||||||
hotelFilters={hotelFilters}
|
hotelFilters={hotelFilters}
|
||||||
seoFilters={seo_filters}
|
seoFilters={seo_filters}
|
||||||
sortItems={sortItems}
|
sortItems={sortItems}
|
||||||
@@ -128,6 +143,7 @@ export default async function DestinationCountryPage({
|
|||||||
<SidebarContentWrapper
|
<SidebarContentWrapper
|
||||||
preamble={preamble}
|
preamble={preamble}
|
||||||
location={translatedCountry}
|
location={translatedCountry}
|
||||||
|
pageType="country"
|
||||||
>
|
>
|
||||||
<ExperienceList experiences={experiences} />
|
<ExperienceList experiences={experiences} />
|
||||||
{has_sidepeek && sidepeek_content ? (
|
{has_sidepeek && sidepeek_content ? (
|
||||||
@@ -137,6 +153,13 @@ export default async function DestinationCountryPage({
|
|||||||
location={translatedCountry}
|
location={translatedCountry}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{destination_settings.country && (
|
||||||
|
<StaticMap
|
||||||
|
city={destination_settings.country}
|
||||||
|
location={destination_settings.location}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarContentWrapper>
|
</SidebarContentWrapper>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||||
|
|
||||||
import ExperienceListSkeleton from "../../ExperienceList/ExperienceListSkeleton"
|
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
|
||||||
|
|
||||||
import styles from "./cityListingItem.module.css"
|
import styles from "./cityListingItem.module.css"
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Space-x2);
|
||||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
padding: var(--Space-x2) var(--Space-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
@@ -11,7 +11,7 @@ import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
|||||||
|
|
||||||
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
import ExperienceList from "../../ExperienceList"
|
import ExperienceList from "../../../ExperienceList"
|
||||||
|
|
||||||
import styles from "./cityListingItem.module.css"
|
import styles from "./cityListingItem.module.css"
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
|||||||
|
|
||||||
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
|
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
|
||||||
|
|
||||||
import styles from "./cityListing.module.css"
|
import styles from "../destinationListing.module.css"
|
||||||
|
|
||||||
export default function CityListingSkeleton() {
|
export default function CityListingSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -4,6 +4,7 @@ import { useRef } from "react"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||||
|
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
||||||
@@ -11,16 +12,16 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
|||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
import { SeeOnMapFilterWrapper } from "../../SeeOnMapFilterWrapper"
|
||||||
|
|
||||||
import CityListingItem from "./CityListingItem"
|
import CityListingItem from "./CityListingItem"
|
||||||
import CityListingSkeleton from "./CityListingSkeleton"
|
import CityListingSkeleton from "./CityListingSkeleton"
|
||||||
|
|
||||||
import styles from "./cityListing.module.css"
|
import styles from "../destinationListing.module.css"
|
||||||
|
|
||||||
export default function CityListing() {
|
export default function CityListing() {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const scrollRef = useRef<HTMLElement>(null)
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
|
const mapUrl = useSetMapView()
|
||||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||||
threshold: 300,
|
threshold: 300,
|
||||||
elementRef: scrollRef,
|
elementRef: scrollRef,
|
||||||
@@ -46,7 +47,7 @@ export default function CityListing() {
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</Typography>
|
</Typography>
|
||||||
<DestinationFilterAndSort listType="city" />
|
<SeeOnMapFilterWrapper mapUrl={mapUrl} listType="city" />
|
||||||
</div>
|
</div>
|
||||||
{activeCities.length === 0 ? (
|
{activeCities.length === 0 ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -61,7 +62,7 @@ export default function CityListing() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className={styles.cityList}>
|
<ul className={styles.list}>
|
||||||
{activeCities.map((city) => (
|
{activeCities.map((city) => (
|
||||||
<li key={city.system.uid}>
|
<li key={city.system.uid}>
|
||||||
<CityListingItem city={city} />
|
<CityListingItem city={city} />
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import NextLink from "next/link"
|
import NextLink from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||||
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
|
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
@@ -33,20 +32,12 @@ export default function HotelListingItem({
|
|||||||
url,
|
url,
|
||||||
}: HotelListingItemProps) {
|
}: HotelListingItemProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const params = useParams()
|
|
||||||
const { setActiveMarker } = useDestinationPageHotelsMapStore()
|
const { setActiveMarker } = useDestinationPageHotelsMapStore()
|
||||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
const mapUrl = useSetMapView()
|
||||||
|
|
||||||
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set("view", "map")
|
|
||||||
setMapUrl(url.toString())
|
|
||||||
}, [params, hotel.name])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.container}>
|
<article className={styles.container}>
|
||||||
<div className={styles.imageWrapper}>
|
<div className={styles.imageWrapper}>
|
||||||
@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
|||||||
|
|
||||||
import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton"
|
import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton"
|
||||||
|
|
||||||
import styles from "./hotelListing.module.css"
|
import styles from "../destinationListing.module.css"
|
||||||
|
|
||||||
export default function HotelListingSkeleton() {
|
export default function HotelListingSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -1,32 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import { useRef } from "react"
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||||
|
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
import { SeeOnMapFilterWrapper } from "../../SeeOnMapFilterWrapper"
|
||||||
|
|
||||||
import HotelListingItem from "./HotelListingItem"
|
import HotelListingItem from "./HotelListingItem"
|
||||||
import HotelListingSkeleton from "./HotelListingSkeleton"
|
import HotelListingSkeleton from "./HotelListingSkeleton"
|
||||||
|
|
||||||
import styles from "./hotelListing.module.css"
|
import styles from "../destinationListing.module.css"
|
||||||
|
|
||||||
export default function HotelListing() {
|
export default function HotelListing() {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const scrollRef = useRef<HTMLElement>(null)
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
const params = useParams()
|
const mapUrl = useSetMapView()
|
||||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
|
||||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||||
threshold: 300,
|
threshold: 300,
|
||||||
elementRef: scrollRef,
|
elementRef: scrollRef,
|
||||||
@@ -36,12 +31,6 @@ export default function HotelListing() {
|
|||||||
isLoading: state.isLoading,
|
isLoading: state.isLoading,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set("view", "map")
|
|
||||||
setMapUrl(url.toString())
|
|
||||||
}, [params])
|
|
||||||
|
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
<HotelListingSkeleton />
|
<HotelListingSkeleton />
|
||||||
) : (
|
) : (
|
||||||
@@ -58,26 +47,7 @@ export default function HotelListing() {
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</Typography>
|
</Typography>
|
||||||
<div className={styles.cta}>
|
<SeeOnMapFilterWrapper mapUrl={mapUrl} listType="hotel" />
|
||||||
{mapUrl && (
|
|
||||||
<Button
|
|
||||||
className={styles.mapButton}
|
|
||||||
asChild
|
|
||||||
intent="secondary"
|
|
||||||
variant="icon"
|
|
||||||
size="small"
|
|
||||||
theme="base"
|
|
||||||
>
|
|
||||||
<Link href={mapUrl}>
|
|
||||||
<MaterialIcon icon="map" color="CurrentColor" />
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "See on map",
|
|
||||||
})}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<DestinationFilterAndSort listType="hotel" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{activeHotels.length === 0 ? (
|
{activeHotels.length === 0 ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -92,7 +62,7 @@ export default function HotelListing() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className={styles.hotelList}>
|
<ul className={styles.list}>
|
||||||
{activeHotels.map(({ hotel, url }) => (
|
{activeHotels.map(({ hotel, url }) => (
|
||||||
<li key={hotel.id}>
|
<li key={hotel.id}>
|
||||||
<HotelListingItem hotel={hotel} url={url} />
|
<HotelListingItem hotel={hotel} url={url} />
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
.container {
|
.container {
|
||||||
--scroll-margin-top: calc(
|
--scroll-margin-top: calc(
|
||||||
var(--booking-widget-mobile-height) + var(--Spacing-x2)
|
var(--booking-widget-mobile-height) + var(--Space-x2)
|
||||||
);
|
);
|
||||||
position: relative;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
position: relative;
|
||||||
|
gap: var(--Space-x4);
|
||||||
scroll-margin-top: var(--scroll-margin-top);
|
scroll-margin-top: var(--scroll-margin-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,16 +13,10 @@
|
|||||||
gap: var(--Space-x2);
|
gap: var(--Space-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta {
|
.list {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hotelList {
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Space-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
@@ -33,13 +27,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 950px) {
|
||||||
.listHeader {
|
.listHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapButton {
|
.container {
|
||||||
display: none !important; /* Important to override button higher specificy */
|
gap: var(--Space-x1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ export default async function OverviewMapContainer() {
|
|||||||
boundsPadding={0}
|
boundsPadding={0}
|
||||||
gestureHandling="cooperative"
|
gestureHandling="cooperative"
|
||||||
>
|
>
|
||||||
<MapContent geojson={geoJson} />
|
<MapContent geojson={geoJson} pageType="overview" />
|
||||||
<ActiveMapCard markers={markers} />
|
<ActiveMapCard markers={markers} />
|
||||||
</DynamicMap>
|
</DynamicMap>
|
||||||
</MapProvider>
|
</MapProvider>
|
||||||
|
|||||||
@@ -1,20 +1,3 @@
|
|||||||
.imagePlaceholder {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #fff;
|
|
||||||
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%);
|
|
||||||
background-size: 120px 120px;
|
|
||||||
background-position:
|
|
||||||
0 0,
|
|
||||||
0 60px,
|
|
||||||
60px -60px,
|
|
||||||
-60px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imageContainer {
|
.imageContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 177px;
|
min-width: 177px;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Chip from "@scandic-hotels/design-system/Chip"
|
import Chip from "@scandic-hotels/design-system/Chip"
|
||||||
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
|
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
|
||||||
import Image from "@scandic-hotels/design-system/Image"
|
import Image from "@scandic-hotels/design-system/Image"
|
||||||
|
import ImageFallback from "@scandic-hotels/design-system/ImageFallback"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import styles from "./dialogImage.module.css"
|
import styles from "./dialogImage.module.css"
|
||||||
@@ -22,7 +23,7 @@ export default function DialogImage({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
{!image || imageError ? (
|
{!image || imageError ? (
|
||||||
<div className={styles.imagePlaceholder} />
|
<ImageFallback />
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
.name {
|
.name {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
margin-bottom: var(--Spacing-x-half);
|
margin-bottom: var(--Space-x05);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -23,16 +23,10 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:hover .closeIcon {
|
|
||||||
background-color: var(--Component-Button-Muted-Fill-Hover-inverted);
|
|
||||||
color: var(--Component-Button-Muted-On-fill-Hover-Inverted);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
padding: var(--Spacing-x-one-and-half);
|
padding: var(--Space-x15);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -46,18 +40,14 @@
|
|||||||
.facilitiesItem {
|
.facilitiesItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--Spacing-x-half);
|
gap: var(--Space-x05);
|
||||||
}
|
|
||||||
|
|
||||||
.content .button {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.facilities {
|
.facilities {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0 var(--Spacing-x1);
|
gap: 0 var(--Space-x1);
|
||||||
padding-bottom: var(--Spacing-x1);
|
padding-bottom: var(--Space-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 950px) {
|
@media (min-width: 950px) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||||
|
|
||||||
|
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
|
||||||
|
import Map from "."
|
||||||
|
|
||||||
|
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
import type { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
import type { MapLocation } from "@/types/components/mapLocation"
|
||||||
|
|
||||||
|
interface MapProps {
|
||||||
|
hotels: HotelListingHotelData[]
|
||||||
|
mapId: string
|
||||||
|
apiKey: string
|
||||||
|
defaultLocation: MapLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CityMapContainer({
|
||||||
|
hotels,
|
||||||
|
mapId,
|
||||||
|
apiKey,
|
||||||
|
defaultLocation,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<MapProps>) {
|
||||||
|
const { activeMarker: activeHotelId, setActiveMarker } =
|
||||||
|
useDestinationPageHotelsMapStore()
|
||||||
|
|
||||||
|
const activeHotel = hotels.find(({ hotel }) => hotel.id === activeHotelId)
|
||||||
|
const markers = getHotelMapMarkers(hotels)
|
||||||
|
const geoJson = mapMarkerDataToGeoJson(markers)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Map
|
||||||
|
mapId={mapId}
|
||||||
|
apiKey={apiKey}
|
||||||
|
pageType="city"
|
||||||
|
activeLocation={activeHotel?.hotel.location}
|
||||||
|
setActiveLocation={setActiveMarker}
|
||||||
|
markers={markers}
|
||||||
|
geoJson={geoJson}
|
||||||
|
defaultLocation={defaultLocation}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Map>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
|
import { getCityMapMarkers, mapCityMarkerDataToGeoJson } from "./utils"
|
||||||
|
import Map from "."
|
||||||
|
|
||||||
|
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
||||||
|
|
||||||
|
import type { MapLocation } from "@/types/components/mapLocation"
|
||||||
|
|
||||||
|
interface MapProps {
|
||||||
|
cities: DestinationCityListItem[]
|
||||||
|
mapId: string
|
||||||
|
apiKey: string
|
||||||
|
defaultLocation: MapLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CountryMapContainer({
|
||||||
|
cities,
|
||||||
|
mapId,
|
||||||
|
apiKey,
|
||||||
|
defaultLocation,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<MapProps>) {
|
||||||
|
const { activeCityMarker, setActiveCityMarker } =
|
||||||
|
useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
|
const activeCity = cities.find(
|
||||||
|
(city) => city.cityIdentifier === activeCityMarker?.cityId
|
||||||
|
)
|
||||||
|
|
||||||
|
const markers = getCityMapMarkers(cities)
|
||||||
|
const geoJson = mapCityMarkerDataToGeoJson(markers)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Map
|
||||||
|
mapId={mapId}
|
||||||
|
apiKey={apiKey}
|
||||||
|
pageType="country"
|
||||||
|
activeLocation={activeCity?.destination_settings.location}
|
||||||
|
setActiveLocation={setActiveCityMarker}
|
||||||
|
markers={markers}
|
||||||
|
geoJson={geoJson}
|
||||||
|
defaultLocation={defaultLocation}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Map>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapWrapperWithCloseButton:after {
|
.mapWrapperWithSeeAsListButton:after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -23,39 +23,35 @@
|
|||||||
|
|
||||||
.ctaButtons {
|
.ctaButtons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--Spacing-x2);
|
top: var(--Space-x2);
|
||||||
right: var(--Spacing-x2);
|
right: var(--Space-x2);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x7);
|
gap: var(--Space-x7);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoomButtons {
|
.zoomButtons {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Space-x1);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.seeAsListButton {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoomButton {
|
.zoomButton {
|
||||||
width: var(--Spacing-x5);
|
|
||||||
height: var(--Spacing-x5);
|
|
||||||
padding: 0;
|
|
||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
box-shadow: var(--button-box-shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 950px) {
|
@media screen and (min-width: 950px) {
|
||||||
.ctaButtons {
|
.ctaButtons {
|
||||||
top: var(--Spacing-x4);
|
top: var(--Space-x4);
|
||||||
right: var(--Spacing-x4);
|
right: var(--Space-x4);
|
||||||
bottom: var(--Spacing-x4);
|
bottom: var(--Space-x4);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +60,10 @@
|
|||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.seeAsListButton {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
box-shadow: var(--button-box-shadow);
|
box-shadow: var(--button-box-shadow);
|
||||||
gap: var(--Spacing-x-half);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overriding Google maps infoWindow styles */
|
/* Overriding Google maps infoWindow styles */
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import { cx } from "class-variance-authority"
|
|||||||
import { type PropsWithChildren, useEffect, useRef, useState } from "react"
|
import { type PropsWithChildren, useEffect, useRef, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useZoomControls } from "@scandic-hotels/common/hooks/map/useZoomControls"
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { MAP_RESTRICTIONS } from "@scandic-hotels/design-system/Map/mapConstants"
|
import {
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
DESTINATION_PAGE,
|
||||||
|
MAP_RESTRICTIONS,
|
||||||
|
} from "@scandic-hotels/design-system/Map/mapConstants"
|
||||||
|
|
||||||
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||||
|
|
||||||
@@ -20,7 +25,10 @@ import { usePageType } from "../PageTypeProvider"
|
|||||||
|
|
||||||
import styles from "./dynamicMap.module.css"
|
import styles from "./dynamicMap.module.css"
|
||||||
|
|
||||||
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
|
import type {
|
||||||
|
CityMarker,
|
||||||
|
DestinationMarker,
|
||||||
|
} from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
const BACKUP_COORDINATES = {
|
const BACKUP_COORDINATES = {
|
||||||
lat: 59.3293,
|
lat: 59.3293,
|
||||||
@@ -28,7 +36,7 @@ const BACKUP_COORDINATES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DynamicMapProps {
|
interface DynamicMapProps {
|
||||||
markers: DestinationMarker[]
|
markers: DestinationMarker[] | CityMarker[]
|
||||||
mapId: string
|
mapId: string
|
||||||
defaultCenter?: google.maps.LatLngLiteral
|
defaultCenter?: google.maps.LatLngLiteral
|
||||||
defaultZoom?: number
|
defaultZoom?: number
|
||||||
@@ -42,7 +50,7 @@ export default function DynamicMap({
|
|||||||
markers,
|
markers,
|
||||||
mapId,
|
mapId,
|
||||||
defaultCenter = BACKUP_COORDINATES,
|
defaultCenter = BACKUP_COORDINATES,
|
||||||
defaultZoom = 3,
|
defaultZoom = DESTINATION_PAGE.DEFAULT_ZOOM,
|
||||||
fitBounds = true,
|
fitBounds = true,
|
||||||
boundsPadding = 100,
|
boundsPadding = 100,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -55,6 +63,8 @@ export default function DynamicMap({
|
|||||||
const { activeMarker } = useDestinationPageHotelsMapStore()
|
const { activeMarker } = useDestinationPageHotelsMapStore()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const [hasFittedBounds, setHasFittedBounds] = useState(!!activeMarker)
|
const [hasFittedBounds, setHasFittedBounds] = useState(!!activeMarker)
|
||||||
|
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } =
|
||||||
|
useZoomControls(DESTINATION_PAGE)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current && activeMarker && pageType === "overview") {
|
if (ref.current && activeMarker && pageType === "overview") {
|
||||||
@@ -82,23 +92,10 @@ export default function DynamicMap({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function zoomIn() {
|
|
||||||
const currentZoom = map && map.getZoom()
|
|
||||||
if (currentZoom) {
|
|
||||||
map.setZoom(currentZoom + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function zoomOut() {
|
|
||||||
const currentZoom = map && map.getZoom()
|
|
||||||
if (currentZoom) {
|
|
||||||
map.setZoom(currentZoom - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapOptions: MapProps = {
|
const mapOptions: MapProps = {
|
||||||
defaultCenter, // Default center will be overridden by the bounds
|
defaultCenter, // Default center will be overridden by the bounds
|
||||||
minZoom: 3,
|
minZoom: DESTINATION_PAGE.MIN_ZOOM,
|
||||||
maxZoom: 18,
|
maxZoom: DESTINATION_PAGE.MAX_ZOOM,
|
||||||
defaultZoom,
|
defaultZoom,
|
||||||
disableDefaultUI: true,
|
disableDefaultUI: true,
|
||||||
clickableIcons: false,
|
clickableIcons: false,
|
||||||
@@ -111,7 +108,7 @@ export default function DynamicMap({
|
|||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
styles.mapWrapper,
|
styles.mapWrapper,
|
||||||
onClose && styles.mapWrapperWithCloseButton
|
onClose && styles.mapWrapperWithSeeAsListButton
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
@@ -129,48 +126,46 @@ export default function DynamicMap({
|
|||||||
<div className={styles.ctaButtons}>
|
<div className={styles.ctaButtons}>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<Button
|
<Button
|
||||||
theme="base"
|
color="Inverted"
|
||||||
intent="inverted"
|
variant="Primary"
|
||||||
variant="icon"
|
size="Small"
|
||||||
size="small"
|
className={styles.seeAsListButton}
|
||||||
className={styles.closeButton}
|
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
typography="Body/Supporting text (caption)/smBold"
|
||||||
>
|
>
|
||||||
<MaterialIcon icon="close" color="CurrentColor" />
|
<MaterialIcon icon="format_list_bulleted" color="CurrentColor" />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
defaultMessage: "Close the map",
|
defaultMessage: "See as list",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className={styles.zoomButtons}>
|
<div className={styles.zoomButtons}>
|
||||||
<Button
|
<IconButton
|
||||||
theme="base"
|
theme="Inverted"
|
||||||
intent="inverted"
|
style="Elevated"
|
||||||
variant="icon"
|
|
||||||
size="small"
|
|
||||||
className={styles.zoomButton}
|
className={styles.zoomButton}
|
||||||
onClick={zoomIn}
|
onClick={zoomIn}
|
||||||
aria-label={intl.formatMessage({
|
aria-label={intl.formatMessage({
|
||||||
defaultMessage: "Zoom out",
|
defaultMessage: "Zoom in",
|
||||||
})}
|
})}
|
||||||
|
isDisabled={isMaxZoom}
|
||||||
>
|
>
|
||||||
<MaterialIcon icon="add" color="CurrentColor" size={20} />
|
<MaterialIcon icon="add" color="CurrentColor" size={24} />
|
||||||
</Button>
|
</IconButton>
|
||||||
<Button
|
<IconButton
|
||||||
theme="base"
|
theme="Inverted"
|
||||||
intent="inverted"
|
style="Elevated"
|
||||||
variant="icon"
|
|
||||||
size="small"
|
|
||||||
className={styles.zoomButton}
|
className={styles.zoomButton}
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
aria-label={intl.formatMessage({
|
aria-label={intl.formatMessage({
|
||||||
defaultMessage: "Zoom in",
|
defaultMessage: "Zoom out",
|
||||||
})}
|
})}
|
||||||
|
isDisabled={isMinZoom}
|
||||||
>
|
>
|
||||||
<MaterialIcon icon="remove" color="CurrentColor" size={20} />
|
<MaterialIcon icon="remove" color="CurrentColor" size={24} />
|
||||||
</Button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.cityMarker {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
background-color: var(--Base-Text-High-contrast);
|
||||||
|
border: 4px solid var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border-radius: var(--Corner-radius-rounded);
|
||||||
|
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cityMarker:hover,
|
||||||
|
.hoveredChild {
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
||||||
|
var(--Surface-Brand-Primary-2-Default);
|
||||||
|
width: var(--Space-x4) !important;
|
||||||
|
height: var(--Space-x4) !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdvancedMarker,
|
||||||
|
AdvancedMarkerAnchorPoint,
|
||||||
|
InfoWindow,
|
||||||
|
} from "@vis.gl/react-google-maps"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { useMediaQuery } from "usehooks-ts"
|
||||||
|
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
|
import { trackMapClick } from "@/utils/tracking/destinationPage"
|
||||||
|
|
||||||
|
import CityMapCard from "../../../DestinationCountryPage/CityMapCard"
|
||||||
|
|
||||||
|
import styles from "./cityMarker.module.css"
|
||||||
|
|
||||||
|
import type { CityMarkerProperties } from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
|
interface CityMarkerProps {
|
||||||
|
position: google.maps.LatLngLiteral
|
||||||
|
properties: CityMarkerProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CityMarker({ position, properties }: CityMarkerProps) {
|
||||||
|
const {
|
||||||
|
hoveredCityMarker,
|
||||||
|
setHoveredCityMarker,
|
||||||
|
activeCityMarker,
|
||||||
|
setActiveCityMarker,
|
||||||
|
} = useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
|
const [infoWindowHovered, setInfoWindowHovered] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 950px)")
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setActiveCityMarker({ cityId: properties.id, location: position })
|
||||||
|
trackMapClick(`city with id: ${properties.id}`)
|
||||||
|
}, [position, properties.id, setActiveCityMarker])
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
if (activeCityMarker?.cityId !== hoveredCityMarker) {
|
||||||
|
setActiveCityMarker(null)
|
||||||
|
}
|
||||||
|
setHoveredCityMarker(properties.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!infoWindowHovered) {
|
||||||
|
setHoveredCityMarker(null)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHovered = hoveredCityMarker === properties.id || infoWindowHovered
|
||||||
|
const isActive = activeCityMarker?.cityId === properties.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdvancedMarker
|
||||||
|
position={position}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className={`${styles.cityMarker} ${isActive || isHovered ? styles.hoveredChild : ""}`}
|
||||||
|
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||||
|
>
|
||||||
|
{isDesktop && (isActive || isHovered) ? (
|
||||||
|
<span
|
||||||
|
onMouseEnter={() => setInfoWindowHovered(true)}
|
||||||
|
onMouseLeave={() => setInfoWindowHovered(false)}
|
||||||
|
>
|
||||||
|
<InfoWindow
|
||||||
|
position={position}
|
||||||
|
pixelOffset={[0, -24]}
|
||||||
|
headerDisabled={true}
|
||||||
|
minWidth={450}
|
||||||
|
>
|
||||||
|
<CityMapCard
|
||||||
|
cityName={properties.name}
|
||||||
|
image={properties.image}
|
||||||
|
url={properties.url}
|
||||||
|
/>
|
||||||
|
</InfoWindow>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</AdvancedMarker>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdvancedMarker,
|
||||||
|
AdvancedMarkerAnchorPoint,
|
||||||
|
InfoWindow,
|
||||||
|
} from "@vis.gl/react-google-maps"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { useMediaQuery } from "usehooks-ts"
|
||||||
|
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
|
import { trackMapClick } from "@/utils/tracking/destinationPage"
|
||||||
|
|
||||||
|
import { LocationsList } from "../../../DestinationCountryPage/LocationsList"
|
||||||
|
|
||||||
|
import styles from "./clusterMarker.module.css"
|
||||||
|
|
||||||
|
import type { CityMarkerProperties } from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
|
interface ClusterMarkerProps {
|
||||||
|
position: google.maps.LatLngLiteral
|
||||||
|
size: number
|
||||||
|
sizeAsText: string
|
||||||
|
onMarkerClick?: (position: google.maps.LatLngLiteral) => void
|
||||||
|
cities: CityMarkerProperties[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CityClusterMarker({
|
||||||
|
position,
|
||||||
|
size,
|
||||||
|
sizeAsText,
|
||||||
|
onMarkerClick,
|
||||||
|
cities,
|
||||||
|
}: ClusterMarkerProps) {
|
||||||
|
const { hoveredCityMarker, activeCityMarker, setActiveCityMarker } =
|
||||||
|
useDestinationPageCitiesMapStore()
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 950px)")
|
||||||
|
|
||||||
|
const [isHoveredOnMap, setIsHoveredOnMap] = useState(false)
|
||||||
|
const [infoWindowHovered, setInfoWindowHovered] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
cities.find(
|
||||||
|
(city) =>
|
||||||
|
city.id === hoveredCityMarker || city.id === activeCityMarker?.cityId
|
||||||
|
) || infoWindowHovered
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (onMarkerClick) {
|
||||||
|
onMarkerClick(position)
|
||||||
|
}
|
||||||
|
trackMapClick(
|
||||||
|
`cluster with cities: ${cities.map((city) => city.id).join(",")}`
|
||||||
|
)
|
||||||
|
}, [onMarkerClick, position, cities])
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
if (activeCityMarker?.cityId !== hoveredCityMarker) {
|
||||||
|
setActiveCityMarker(null)
|
||||||
|
}
|
||||||
|
setIsHoveredOnMap(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!infoWindowHovered) {
|
||||||
|
setIsHoveredOnMap(false)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdvancedMarker
|
||||||
|
position={position}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
zIndex={size}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`${styles.clusterMarker} ${isActive ? styles.hoveredChild : ""}`}
|
||||||
|
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||||
|
>
|
||||||
|
<span className={styles.count}>{sizeAsText}</span>
|
||||||
|
{isDesktop && (isHoveredOnMap || infoWindowHovered) ? (
|
||||||
|
<span
|
||||||
|
onMouseEnter={() => setInfoWindowHovered(true)}
|
||||||
|
onMouseLeave={() => setInfoWindowHovered(false)}
|
||||||
|
>
|
||||||
|
<InfoWindow
|
||||||
|
position={position}
|
||||||
|
pixelOffset={[0, -24]}
|
||||||
|
headerDisabled={true}
|
||||||
|
>
|
||||||
|
<LocationsList locations={cities} />
|
||||||
|
</InfoWindow>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</AdvancedMarker>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,25 +12,25 @@ import { trackMapClick } from "@/utils/tracking/destinationPage"
|
|||||||
|
|
||||||
import styles from "./clusterMarker.module.css"
|
import styles from "./clusterMarker.module.css"
|
||||||
|
|
||||||
interface ClusterMarkerProps {
|
interface HotelClusterMarkerProps {
|
||||||
position: google.maps.LatLngLiteral
|
position: google.maps.LatLngLiteral
|
||||||
size: number
|
size: number
|
||||||
sizeAsText: string
|
sizeAsText: string
|
||||||
onMarkerClick?: (position: google.maps.LatLngLiteral) => void
|
onMarkerClick?: (position: google.maps.LatLngLiteral) => void
|
||||||
hotelIds: number[]
|
hotelIds: string[]
|
||||||
}
|
}
|
||||||
|
export default function HotelClusterMarker({
|
||||||
export default function ClusterMarker({
|
|
||||||
position,
|
position,
|
||||||
size,
|
size,
|
||||||
sizeAsText,
|
sizeAsText,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
hotelIds,
|
hotelIds,
|
||||||
}: ClusterMarkerProps) {
|
}: HotelClusterMarkerProps) {
|
||||||
const { hoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
|
const { hoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
|
||||||
|
|
||||||
const isActive =
|
const isActive =
|
||||||
hotelIds.includes(Number(hoveredMarker)) ||
|
hotelIds.includes(String(hoveredMarker)) ||
|
||||||
hotelIds.includes(Number(activeMarker))
|
hotelIds.includes(String(activeMarker))
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (onMarkerClick) {
|
if (onMarkerClick) {
|
||||||
@@ -20,12 +20,15 @@ import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
|
|||||||
|
|
||||||
import type { MarkerProperties } from "@/types/components/maps/destinationMarkers"
|
import type { MarkerProperties } from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
interface MarkerProps {
|
interface HotelMarkerProps {
|
||||||
position: google.maps.LatLngLiteral
|
position: google.maps.LatLngLiteral
|
||||||
properties: MarkerProperties
|
properties: MarkerProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Marker({ position, properties }: MarkerProps) {
|
export default function HotelMarker({
|
||||||
|
position,
|
||||||
|
properties,
|
||||||
|
}: HotelMarkerProps) {
|
||||||
const [markerRef] = useAdvancedMarkerRef()
|
const [markerRef] = useAdvancedMarkerRef()
|
||||||
|
|
||||||
const { setHoveredMarker, setActiveMarker, hoveredMarker, activeMarker } =
|
const { setHoveredMarker, setActiveMarker, hoveredMarker, activeMarker } =
|
||||||
@@ -2,24 +2,33 @@
|
|||||||
|
|
||||||
import { useMap } from "@vis.gl/react-google-maps"
|
import { useMap } from "@vis.gl/react-google-maps"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { useMediaQuery } from "usehooks-ts"
|
||||||
|
|
||||||
|
import { CITY_PAGE } from "@scandic-hotels/design-system/Map/mapConstants"
|
||||||
|
|
||||||
|
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||||
|
|
||||||
import { useSupercluster } from "@/hooks/maps/use-supercluster"
|
import { useSupercluster } from "@/hooks/maps/use-supercluster"
|
||||||
|
|
||||||
import ClusterMarker from "./ClusterMarker"
|
import CityClusterMarker from "./ClusterMarker/CityClusterMarker"
|
||||||
import Marker from "./Marker"
|
import HotelClusterMarker from "./ClusterMarker/HotelClusterMarker"
|
||||||
|
import CityMarker from "./CityMarker"
|
||||||
|
import HotelMarker from "./HotelMarker"
|
||||||
|
|
||||||
import type { ClusterProperties } from "supercluster"
|
import type { ClusterProperties } from "supercluster"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CityMarkerGeojson,
|
||||||
|
CityMarkerProperties,
|
||||||
MarkerGeojson,
|
MarkerGeojson,
|
||||||
MarkerProperties,
|
MarkerProperties,
|
||||||
} from "@/types/components/maps/destinationMarkers"
|
} from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
interface MapContentProps {
|
interface MapContentProps {
|
||||||
geojson: MarkerGeojson
|
geojson: MarkerGeojson | CityMarkerGeojson
|
||||||
disableClustering?: boolean
|
disableClustering?: boolean
|
||||||
|
pageType: "city" | "country" | "overview"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Important this is outside the component to avoid re-creating the object on each render
|
// Important this is outside the component to avoid re-creating the object on each render
|
||||||
@@ -33,12 +42,19 @@ const CLUSTER_OPTIONS = {
|
|||||||
export default function MapContent({
|
export default function MapContent({
|
||||||
geojson,
|
geojson,
|
||||||
disableClustering,
|
disableClustering,
|
||||||
|
pageType,
|
||||||
}: MapContentProps) {
|
}: MapContentProps) {
|
||||||
const { setActiveMarker, activeMarker } = useDestinationPageHotelsMapStore()
|
const { setActiveMarker, activeMarker } = useDestinationPageHotelsMapStore()
|
||||||
const map = useMap()
|
const { setActiveCityMarker, activeCityMarker } =
|
||||||
|
useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
const { clusters, containedHotels, getClusterZoom } =
|
const { clusters, containedHotelIds, containedCityIds, getClusterZoom } =
|
||||||
useSupercluster<MarkerProperties>(geojson, CLUSTER_OPTIONS)
|
useSupercluster<MarkerProperties | CityMarkerProperties>(
|
||||||
|
geojson,
|
||||||
|
CLUSTER_OPTIONS
|
||||||
|
)
|
||||||
|
const map = useMap()
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 950px)")
|
||||||
|
|
||||||
// Based on the length of active filters, we decide if should show clusters or individual markers
|
// Based on the length of active filters, we decide if should show clusters or individual markers
|
||||||
const markerList = disableClustering ? geojson.features : clusters
|
const markerList = disableClustering ? geojson.features : clusters
|
||||||
@@ -51,6 +67,23 @@ export default function MapContent({
|
|||||||
})
|
})
|
||||||
}, [activeMarker, map, setActiveMarker])
|
}, [activeMarker, map, setActiveMarker])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map?.addListener("click", () => {
|
||||||
|
if (activeCityMarker) {
|
||||||
|
setActiveCityMarker(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [activeCityMarker, map, setActiveCityMarker])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return
|
||||||
|
const currentZoom = map && map.getZoom()
|
||||||
|
if (currentZoom && activeCityMarker?.location) {
|
||||||
|
map.panTo(activeCityMarker.location)
|
||||||
|
map.setZoom(CITY_PAGE.DEFAULT_ZOOM)
|
||||||
|
}
|
||||||
|
}, [activeCityMarker, map, isDesktop])
|
||||||
|
|
||||||
function handleClusterClick(
|
function handleClusterClick(
|
||||||
position: google.maps.LatLngLiteral,
|
position: google.maps.LatLngLiteral,
|
||||||
clusterProperties: ClusterProperties
|
clusterProperties: ClusterProperties
|
||||||
@@ -67,26 +100,47 @@ export default function MapContent({
|
|||||||
return markerList.map((feature, idx) => {
|
return markerList.map((feature, idx) => {
|
||||||
const [lng, lat] = feature.geometry.coordinates
|
const [lng, lat] = feature.geometry.coordinates
|
||||||
const clusterProperties = feature.properties as ClusterProperties
|
const clusterProperties = feature.properties as ClusterProperties
|
||||||
const markerProperties = feature.properties as MarkerProperties
|
const hotelMarkerProperties = feature.properties as MarkerProperties
|
||||||
|
const cityMarkerProperties = feature.properties as CityMarkerProperties
|
||||||
const isCluster = clusterProperties?.cluster
|
const isCluster = clusterProperties?.cluster
|
||||||
|
|
||||||
return isCluster ? (
|
if (pageType === "country") {
|
||||||
<ClusterMarker
|
return isCluster ? (
|
||||||
key={feature.id}
|
<CityClusterMarker
|
||||||
position={{ lat, lng }}
|
key={feature.id}
|
||||||
size={clusterProperties.point_count}
|
position={{ lat, lng }}
|
||||||
sizeAsText={String(clusterProperties.point_count_abbreviated)}
|
size={clusterProperties.point_count}
|
||||||
onMarkerClick={(position) =>
|
sizeAsText={String(clusterProperties.point_count_abbreviated)}
|
||||||
handleClusterClick(position, clusterProperties)
|
onMarkerClick={(position) =>
|
||||||
}
|
handleClusterClick(position, clusterProperties)
|
||||||
hotelIds={containedHotels[idx]}
|
}
|
||||||
/>
|
cities={containedCityIds[idx]}
|
||||||
) : (
|
/>
|
||||||
<Marker
|
) : (
|
||||||
key={feature.id}
|
<CityMarker
|
||||||
position={{ lat, lng }}
|
key={feature.id}
|
||||||
properties={markerProperties}
|
position={{ lat, lng }}
|
||||||
/>
|
properties={cityMarkerProperties}
|
||||||
)
|
/>
|
||||||
|
)
|
||||||
|
} else
|
||||||
|
return isCluster ? (
|
||||||
|
<HotelClusterMarker
|
||||||
|
key={feature.id}
|
||||||
|
position={{ lat, lng }}
|
||||||
|
size={clusterProperties.point_count}
|
||||||
|
sizeAsText={String(clusterProperties.point_count_abbreviated)}
|
||||||
|
onMarkerClick={(position) =>
|
||||||
|
handleClusterClick(position, clusterProperties)
|
||||||
|
}
|
||||||
|
hotelIds={containedHotelIds[idx]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HotelMarker
|
||||||
|
key={feature.id}
|
||||||
|
position={{ lat, lng }}
|
||||||
|
properties={hotelMarkerProperties}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -14,47 +14,59 @@ import { useIntl } from "react-intl"
|
|||||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||||
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
||||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
import {
|
||||||
|
CITY_PAGE,
|
||||||
|
COUNTRY_PAGE,
|
||||||
|
} from "@scandic-hotels/design-system/Map/mapConstants"
|
||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
|
||||||
|
|
||||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||||
|
|
||||||
import DynamicMap from "./DynamicMap"
|
import DynamicMap from "./DynamicMap"
|
||||||
import MapContent from "./MapContent"
|
import MapContent from "./MapContent"
|
||||||
import MapProvider from "./MapProvider"
|
import MapProvider from "./MapProvider"
|
||||||
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
|
|
||||||
|
|
||||||
import styles from "./map.module.css"
|
import styles from "./map.module.css"
|
||||||
|
|
||||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
|
||||||
|
|
||||||
import type { MapLocation } from "@/types/components/mapLocation"
|
import type { MapLocation } from "@/types/components/mapLocation"
|
||||||
|
import type {
|
||||||
|
CityMarker,
|
||||||
|
CityMarkerGeojson,
|
||||||
|
DestinationMarker,
|
||||||
|
MarkerGeojson,
|
||||||
|
} from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
hotels: HotelListingHotelData[]
|
|
||||||
mapId: string
|
mapId: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
pageType: "city" | "country"
|
pageType: "country" | "city"
|
||||||
|
activeLocation?: { latitude: number; longitude: number } | null
|
||||||
|
setActiveLocation: (id: null) => void
|
||||||
|
markers: DestinationMarker[] | CityMarker[]
|
||||||
|
geoJson: MarkerGeojson | CityMarkerGeojson
|
||||||
defaultLocation: MapLocation
|
defaultLocation: MapLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Map({
|
export default function Map({
|
||||||
hotels,
|
|
||||||
mapId,
|
mapId,
|
||||||
apiKey,
|
apiKey,
|
||||||
defaultLocation,
|
|
||||||
pageType,
|
pageType,
|
||||||
|
activeLocation,
|
||||||
children,
|
children,
|
||||||
|
setActiveLocation,
|
||||||
|
markers,
|
||||||
|
geoJson,
|
||||||
|
defaultLocation,
|
||||||
}: PropsWithChildren<MapProps>) {
|
}: PropsWithChildren<MapProps>) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { activeMarker: activeHotelId, setActiveMarker } =
|
|
||||||
useDestinationPageHotelsMapStore()
|
|
||||||
const activeHotel = hotels.find(({ hotel }) => hotel.id === activeHotelId)
|
|
||||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||||
const [mapHeight, setMapHeight] = useState("100dvh")
|
const [mapHeight, setMapHeight] = useState("100dvh")
|
||||||
|
const [fromCountryPage, setFromCountryPage] = useState(false)
|
||||||
|
const params = useParams()
|
||||||
const scrollRef = useRef<HTMLElement>(null)
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||||
threshold: 550,
|
threshold: 550,
|
||||||
@@ -68,24 +80,14 @@ export default function Map({
|
|||||||
activeFilters: state.activeFilters,
|
activeFilters: state.activeFilters,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const zoomConstants = pageType === "city" ? CITY_PAGE : COUNTRY_PAGE
|
||||||
|
|
||||||
const hasActiveFilters = activeFilters.length > 0
|
const hasActiveFilters = activeFilters.length > 0
|
||||||
|
|
||||||
const markers = getHotelMapMarkers(hotels)
|
useEffect(() => {
|
||||||
const geoJson = mapMarkerDataToGeoJson(markers)
|
const url = new URL(window.location.href)
|
||||||
const defaultCenter = activeHotel
|
setFromCountryPage(url.searchParams.has("fromCountry"))
|
||||||
? {
|
}, [params])
|
||||||
lat: activeHotel.hotel.location.latitude,
|
|
||||||
lng: activeHotel.hotel.location.longitude,
|
|
||||||
}
|
|
||||||
: defaultLocation
|
|
||||||
? {
|
|
||||||
lat: defaultLocation.latitude,
|
|
||||||
lng: defaultLocation.longitude,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
const defaultZoom = activeHotel
|
|
||||||
? 15
|
|
||||||
: (defaultLocation?.default_zoom ?? (pageType === "city" ? 10 : 3))
|
|
||||||
|
|
||||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
// 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 handleMapHeight = useCallback(() => {
|
||||||
@@ -94,10 +96,22 @@ export default function Map({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
if (fromCountryPage) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set("view", "map")
|
||||||
|
router.push(url.toString())
|
||||||
|
setActiveLocation(null)
|
||||||
|
} else {
|
||||||
|
backToListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToListView() {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.delete("view")
|
url.searchParams.delete("view")
|
||||||
|
url.searchParams.delete("fromCountry")
|
||||||
router.push(url.toString())
|
router.push(url.toString())
|
||||||
setActiveMarker(null)
|
setActiveLocation(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -131,6 +145,22 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
}, [rootDiv, handleMapHeight])
|
}, [rootDiv, handleMapHeight])
|
||||||
|
|
||||||
|
const defaultCenter = activeLocation
|
||||||
|
? {
|
||||||
|
lat: activeLocation.latitude,
|
||||||
|
lng: activeLocation.longitude,
|
||||||
|
}
|
||||||
|
: defaultLocation
|
||||||
|
? {
|
||||||
|
lat: defaultLocation.latitude,
|
||||||
|
lng: defaultLocation.longitude,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const defaultZoom = activeLocation
|
||||||
|
? zoomConstants.SELECTED_HOTEL_ZOOM
|
||||||
|
: (defaultLocation?.default_zoom ?? zoomConstants.DEFAULT_ZOOM)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapProvider apiKey={apiKey} pageType={pageType}>
|
<MapProvider apiKey={apiKey} pageType={pageType}>
|
||||||
<div
|
<div
|
||||||
@@ -140,15 +170,24 @@ export default function Map({
|
|||||||
>
|
>
|
||||||
<div className={styles.mobileNavigation}>
|
<div className={styles.mobileNavigation}>
|
||||||
<Button
|
<Button
|
||||||
intent="text"
|
variant="Text"
|
||||||
theme="base"
|
size="Small"
|
||||||
variant="icon"
|
color="Primary"
|
||||||
|
typography="Body/Supporting text (caption)/smBold"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<MaterialIcon icon="chevron_left" size={20} color="CurrentColor" />
|
<MaterialIcon
|
||||||
{intl.formatMessage({
|
icon="arrow_back_ios"
|
||||||
defaultMessage: "Back",
|
size={20}
|
||||||
})}
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
{fromCountryPage
|
||||||
|
? intl.formatMessage({
|
||||||
|
defaultMessage: "Back to cities",
|
||||||
|
})
|
||||||
|
: intl.formatMessage({
|
||||||
|
defaultMessage: "Back to list",
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
<DestinationFilterAndSort
|
<DestinationFilterAndSort
|
||||||
listType={pageType === "city" ? "hotel" : "city"}
|
listType={pageType === "city" ? "hotel" : "city"}
|
||||||
@@ -169,13 +208,17 @@ export default function Map({
|
|||||||
<DynamicMap
|
<DynamicMap
|
||||||
markers={markers}
|
markers={markers}
|
||||||
mapId={mapId}
|
mapId={mapId}
|
||||||
onClose={handleClose}
|
onClose={backToListView}
|
||||||
defaultCenter={defaultCenter}
|
defaultCenter={defaultCenter}
|
||||||
defaultZoom={defaultZoom}
|
defaultZoom={defaultZoom}
|
||||||
fitBounds={!activeHotel}
|
fitBounds={!activeLocation}
|
||||||
gestureHandling="greedy"
|
gestureHandling="greedy"
|
||||||
>
|
>
|
||||||
<MapContent geojson={geoJson} disableClustering={hasActiveFilters} />
|
<MapContent
|
||||||
|
geojson={geoJson}
|
||||||
|
disableClustering={hasActiveFilters}
|
||||||
|
pageType={pageType}
|
||||||
|
/>
|
||||||
</DynamicMap>
|
</DynamicMap>
|
||||||
</div>
|
</div>
|
||||||
</MapProvider>
|
</MapProvider>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
.mobileNavigation {
|
.mobileNavigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: var(--Space-x2);
|
padding: 0 var(--Space-x2);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background-color: var(--Surface-Primary-OnSurface-Default);
|
background-color: var(--Surface-Primary-OnSurface-Default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
||||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CityMarker,
|
||||||
|
CityMarkerFeature,
|
||||||
|
CityMarkerGeojson,
|
||||||
DestinationMarker,
|
DestinationMarker,
|
||||||
MarkerFeature,
|
MarkerFeature,
|
||||||
MarkerGeojson,
|
MarkerGeojson,
|
||||||
@@ -52,6 +56,59 @@ export function getHotelMapMarkers(hotels: HotelListingHotelData[]) {
|
|||||||
return markers
|
return markers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapCityMarkerDataToGeoJson(markers: CityMarker[]) {
|
||||||
|
const features = markers.map<CityMarkerFeature>(
|
||||||
|
({ coordinates, ...properties }) => {
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
id: properties.id,
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [coordinates.lng, coordinates.lat],
|
||||||
|
},
|
||||||
|
properties,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const geoJson: CityMarkerGeojson = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features,
|
||||||
|
}
|
||||||
|
|
||||||
|
return geoJson
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCityMapMarkers(cities: DestinationCityListItem[]) {
|
||||||
|
const markers = cities
|
||||||
|
.map(
|
||||||
|
({
|
||||||
|
cityName,
|
||||||
|
cityIdentifier,
|
||||||
|
url,
|
||||||
|
destination_settings,
|
||||||
|
images,
|
||||||
|
hotelsCount,
|
||||||
|
}) => ({
|
||||||
|
id: cityIdentifier,
|
||||||
|
name: cityName,
|
||||||
|
coordinates: destination_settings.location
|
||||||
|
? {
|
||||||
|
lat: destination_settings.location.latitude,
|
||||||
|
lng: destination_settings.location.longitude,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
hotelsCount,
|
||||||
|
url,
|
||||||
|
image: getCityImage({ images }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
.filter((item): item is CityMarker => !!item.coordinates)
|
||||||
|
|
||||||
|
return markers
|
||||||
|
}
|
||||||
|
|
||||||
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
||||||
if (hotel.galleryImages?.length) {
|
if (hotel.galleryImages?.length) {
|
||||||
const image = hotel.galleryImages[0]
|
const image = hotel.galleryImages[0]
|
||||||
@@ -62,3 +119,13 @@ function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
function getCityImage({ images }: Pick<DestinationCityListItem, "images">) {
|
||||||
|
if (images?.length) {
|
||||||
|
const image = images[0]
|
||||||
|
return {
|
||||||
|
src: image.url,
|
||||||
|
alt: image.meta.alt || image.meta.caption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import DestinationFilterAndSort, {
|
||||||
|
type HotelFilterAndSortProps,
|
||||||
|
} from "@/components/DestinationFilterAndSort"
|
||||||
|
|
||||||
|
import styles from "./seeOnMapFilterWrapper.module.css"
|
||||||
|
|
||||||
|
interface SeeOnMapFilterWrapperProps extends HotelFilterAndSortProps {
|
||||||
|
mapUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeeOnMapFilterWrapper({
|
||||||
|
listType,
|
||||||
|
mapUrl,
|
||||||
|
}: SeeOnMapFilterWrapperProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
return (
|
||||||
|
<div className={styles.cta}>
|
||||||
|
{mapUrl ? (
|
||||||
|
<ButtonLink
|
||||||
|
className={styles.mapButton}
|
||||||
|
typography="Body/Paragraph/mdBold"
|
||||||
|
variant="Secondary"
|
||||||
|
size="Small"
|
||||||
|
color="Primary"
|
||||||
|
wrapping
|
||||||
|
href={mapUrl}
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="map" color="CurrentColor" />
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "See on map",
|
||||||
|
})}
|
||||||
|
</ButtonLink>
|
||||||
|
) : null}
|
||||||
|
<DestinationFilterAndSort listType={listType} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.cta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 950px) {
|
||||||
|
.mapButton {
|
||||||
|
display: none !important; /* Important to override button higher specificy */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRef } from "react"
|
import React, { useRef } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||||
@@ -9,18 +9,20 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
|||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import { getHeadingText } from "@/components/ContentType/DestinationPage/utils"
|
import { getCityHeadingText, getCountryHeadingText } from "../utils"
|
||||||
|
|
||||||
import styles from "./sidebarContentWrapper.module.css"
|
import styles from "./sidebarContentWrapper.module.css"
|
||||||
|
|
||||||
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
||||||
preamble: string
|
preamble: string
|
||||||
location: string
|
location: string
|
||||||
|
pageType: "country" | "city"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SidebarContentWrapper({
|
export default function SidebarContentWrapper({
|
||||||
preamble,
|
preamble,
|
||||||
location,
|
location,
|
||||||
|
pageType,
|
||||||
children,
|
children,
|
||||||
}: SidebarContentWrapperProps) {
|
}: SidebarContentWrapperProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
@@ -33,19 +35,23 @@ export default function SidebarContentWrapper({
|
|||||||
ref: sidebarRef,
|
ref: sidebarRef,
|
||||||
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
||||||
})
|
})
|
||||||
|
const heading =
|
||||||
const heading = getHeadingText(intl, location, allFilters, filterFromUrl)
|
pageType === "country"
|
||||||
|
? getCountryHeadingText(intl, location, allFilters, filterFromUrl)
|
||||||
|
: getCityHeadingText(intl, location, allFilters, filterFromUrl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={sidebarRef} className={styles.sidebarContent}>
|
<div ref={sidebarRef} className={styles.sidebarContent}>
|
||||||
<Typography variant="Title/md">
|
<div className={styles.text}>
|
||||||
<h1 className={styles.heading}>{heading}</h1>
|
<Typography variant="Title/md">
|
||||||
</Typography>
|
<h1 className={styles.heading}>{heading}</h1>
|
||||||
{!filterFromUrl ? (
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p className={styles.text}>{preamble}</p>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
{!filterFromUrl ? (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>{preamble}</p>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,20 +5,30 @@
|
|||||||
padding: 0 var(--max-width-single-spacing) var(--Space-x3);
|
padding: 0 var(--max-width-single-spacing) var(--Space-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
color: var(--Text-Default);
|
||||||
|
max-width: var(--max-width-text-block);
|
||||||
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
color: var(--Text-Heading);
|
color: var(--Text-Heading);
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
@media screen and (min-width: 950px) {
|
||||||
color: var(--Text-Default);
|
.sidebarContent {
|
||||||
max-width: var(--max-width-text-block);
|
grid-template-columns: 1fr auto;
|
||||||
|
padding: var(--Space-x4) var(--Space-x3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.sidebarContent {
|
.sidebarContent {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
padding: var(--Space-x4) var(--Space-x3);
|
padding: var(--Space-x4) var(--Space-x3);
|
||||||
|
grid-template-columns: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useState } from "react"
|
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||||
|
|
||||||
import { MapWithButtonWrapper } from "@/components/Maps/MapWithButtonWrapper"
|
import { MapWithButtonWrapper } from "@/components/Maps/MapWithButtonWrapper"
|
||||||
|
|
||||||
import styles from "./mapWrapper.module.css"
|
import styles from "./mapWrapper.module.css"
|
||||||
|
|
||||||
export default function MapWrapper({ children }: React.PropsWithChildren) {
|
export default function MapWrapper({ children }: React.PropsWithChildren) {
|
||||||
const params = useParams()
|
const mapUrl = useSetMapView()
|
||||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set("view", "map")
|
|
||||||
setMapUrl(url.toString())
|
|
||||||
}, [params])
|
|
||||||
|
|
||||||
if (!mapUrl) {
|
if (!mapUrl) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1367px) {
|
@media (min-width: 950px) {
|
||||||
.link {
|
.link {
|
||||||
display: flex;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
} from "@scandic-hotels/trpc/types/hotel"
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
import type { IntlShape } from "react-intl"
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
export function getHeadingText(
|
export function getCityHeadingText(
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
location: string,
|
location: string,
|
||||||
allFilters: CategorizedHotelFilters,
|
allFilters: CategorizedHotelFilters,
|
||||||
@@ -41,3 +41,41 @@ export function getHeadingText(
|
|||||||
{ location }
|
{ location }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCountryHeadingText(
|
||||||
|
intl: IntlShape,
|
||||||
|
location: string,
|
||||||
|
allFilters: CategorizedHotelFilters,
|
||||||
|
filterFromUrl: HotelFilter | null
|
||||||
|
) {
|
||||||
|
if (filterFromUrl) {
|
||||||
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
|
(f) => f.id === filterFromUrl.id
|
||||||
|
)
|
||||||
|
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||||
|
(f) => f.id === filterFromUrl.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (facilityFilter) {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Destinations with {filter} in {location}",
|
||||||
|
},
|
||||||
|
{ location, filter: facilityFilter.name }
|
||||||
|
)
|
||||||
|
} else if (surroudingsFilter) {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Destinations near {filter} in {location}",
|
||||||
|
},
|
||||||
|
{ location, filter: surroudingsFilter.name }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Destinations in {location}",
|
||||||
|
},
|
||||||
|
{ location }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||||
import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker"
|
import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
@@ -16,14 +15,7 @@ import type { MapCardProps } from "@/types/components/hotelPage/map/mapCard"
|
|||||||
|
|
||||||
export default function MapCard({ hotelName, pois }: MapCardProps) {
|
export default function MapCard({ hotelName, pois }: MapCardProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const params = useParams()
|
const mapUrl = useSetMapView()
|
||||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set("view", "map")
|
|
||||||
setMapUrl(url.toString())
|
|
||||||
}, [params])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mapCard}>
|
<div className={styles.mapCard}>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { cx } from "class-variance-authority"
|
import { cx } from "class-variance-authority"
|
||||||
import NextLink from "next/link"
|
import NextLink from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
@@ -14,14 +13,7 @@ import styles from "./mobileToggle.module.css"
|
|||||||
|
|
||||||
export default function MobileMapToggle() {
|
export default function MobileMapToggle() {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const params = useParams()
|
const mapUrl = useSetMapView()
|
||||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set("view", "map")
|
|
||||||
setMapUrl(url.toString())
|
|
||||||
}, [params])
|
|
||||||
|
|
||||||
if (!mapUrl) {
|
if (!mapUrl) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.buttonWrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapView {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 949px) {
|
||||||
|
.buttonWrapper {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client"
|
||||||
|
import { cx } from "class-variance-authority"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
import { useMediaQuery } from "usehooks-ts"
|
||||||
|
|
||||||
|
import { Badge } from "@scandic-hotels/design-system/Badge"
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import styles from "./filterAndSort.module.css"
|
||||||
|
|
||||||
|
interface FilterAndSortButtonProps {
|
||||||
|
filterLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterAndSortButton({
|
||||||
|
filterLength,
|
||||||
|
}: FilterAndSortButtonProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [isMapView, setIsMapView] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isMapView = searchParams.get("view") === "map"
|
||||||
|
setIsMapView(isMapView)
|
||||||
|
setIsHydrated(true)
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false)
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 950px)")
|
||||||
|
if (!isHydrated) return null
|
||||||
|
|
||||||
|
const buttonProps: {
|
||||||
|
variant: "Text" | "Secondary"
|
||||||
|
size: "Small" | "Medium"
|
||||||
|
} = isDesktop
|
||||||
|
? {
|
||||||
|
//Desktop
|
||||||
|
variant: "Text",
|
||||||
|
size: "Medium",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
//Mobile
|
||||||
|
variant: isMapView ? "Text" : "Secondary",
|
||||||
|
size: "Small",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(styles.buttonWrapper, { [styles.mapView]: isMapView })}>
|
||||||
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
color="Primary"
|
||||||
|
typography="Body/Paragraph/mdBold"
|
||||||
|
wrapping
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="filter_alt" color="CurrentColor" />
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Filter and sort",
|
||||||
|
})}
|
||||||
|
{filterLength > 0 ? <Badge number={filterLength} size="20" /> : null}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,23 +25,6 @@
|
|||||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonWrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Spacing-x1);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background-color: var(--Base-Text-Accent);
|
|
||||||
border-radius: var(--Corner-radius-xl);
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: var(--Base-Surface-Primary-light-Normal);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x4);
|
gap: var(--Spacing-x4);
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
|
||||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Filter from "./Filter"
|
import Filter from "./Filter"
|
||||||
|
import { FilterAndSortButton } from "./FilterAndSortButton"
|
||||||
import Sort from "./Sort"
|
import Sort from "./Sort"
|
||||||
|
|
||||||
import styles from "./destinationFilterAndSort.module.css"
|
import styles from "./destinationFilterAndSort.module.css"
|
||||||
|
|
||||||
interface HotelFilterAndSortProps {
|
export interface HotelFilterAndSortProps {
|
||||||
listType: "city" | "hotel"
|
listType: "city" | "hotel"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,23 +127,10 @@ export default function DestinationFilterAndSort({
|
|||||||
resetPendingValues()
|
resetPendingValues()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogTrigger onOpenChange={handleClose}>
|
<DialogTrigger onOpenChange={handleClose}>
|
||||||
<div className={styles.buttonWrapper}>
|
<FilterAndSortButton filterLength={activeFilters.length} />
|
||||||
<Button intent="text" theme="base" variant="icon">
|
|
||||||
<MaterialIcon icon="filter_alt" color="CurrentColor" />
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Filter and sort",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
{activeFilters.length > 0 && (
|
|
||||||
<Footnote className={styles.badge} asChild>
|
|
||||||
<span>{activeFilters.length}</span>
|
|
||||||
</Footnote>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ModalOverlay isDismissable className={styles.overlay}>
|
<ModalOverlay isDismissable className={styles.overlay}>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -193,19 +180,19 @@ export default function DestinationFilterAndSort({
|
|||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Button
|
<Button
|
||||||
onClick={clearPendingFilters}
|
onClick={clearPendingFilters}
|
||||||
intent="text"
|
variant="Text"
|
||||||
size="medium"
|
size="Small"
|
||||||
theme="base"
|
color="Primary"
|
||||||
>
|
>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
defaultMessage: "Clear all filters",
|
defaultMessage: "Clear all filters",
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
intent="primary"
|
variant="Primary"
|
||||||
size="large"
|
size="Medium"
|
||||||
theme="base"
|
color="Primary"
|
||||||
disabled={pendingCount === 0}
|
isDisabled={pendingCount === 0}
|
||||||
onClick={() => submitAndClose(close)}
|
onClick={() => submitAndClose(close)}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useMapViewport } from "./use-map-viewport"
|
|||||||
|
|
||||||
import type { FeatureCollection, GeoJsonProperties, Point } from "geojson"
|
import type { FeatureCollection, GeoJsonProperties, Point } from "geojson"
|
||||||
|
|
||||||
|
import type { CityMarkerProperties } from "@/types/components/maps/destinationMarkers"
|
||||||
|
|
||||||
export function useSupercluster<T extends GeoJsonProperties>(
|
export function useSupercluster<T extends GeoJsonProperties>(
|
||||||
geojson: FeatureCollection<Point, T>,
|
geojson: FeatureCollection<Point, T>,
|
||||||
superclusterOptions: Supercluster.Options<T, ClusterProperties>
|
superclusterOptions: Supercluster.Options<T, ClusterProperties>
|
||||||
@@ -45,16 +47,29 @@ export function useSupercluster<T extends GeoJsonProperties>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retrieve the hotel ids included in the cluster
|
// retrieve the hotel ids included in the cluster
|
||||||
const containedHotels = clusters.map((cluster) => {
|
const containedHotelIds = clusters.map((cluster) => {
|
||||||
if (cluster.properties?.cluster && typeof cluster.id === "number") {
|
if (cluster.properties?.cluster && typeof cluster.id === "number") {
|
||||||
return clusterer.getLeaves(cluster.id).map((hotel) => Number(hotel.id))
|
return clusterer
|
||||||
|
.getLeaves(cluster.id)
|
||||||
|
.map((location) => String(location.id))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
// retrieve the city ids included in the cluster
|
||||||
|
const containedCityIds = clusters.map((cluster) => {
|
||||||
|
if (cluster.properties?.cluster && typeof cluster.id === "number") {
|
||||||
|
return clusterer
|
||||||
|
.getLeaves(cluster.id)
|
||||||
|
.map((city) => city.properties as CityMarkerProperties)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clusters,
|
clusters,
|
||||||
containedHotels,
|
containedHotelIds,
|
||||||
|
containedCityIds,
|
||||||
getClusterZoom,
|
getClusterZoom,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
22
apps/scandic-web/stores/destination-page-cities-map.ts
Normal file
22
apps/scandic-web/stores/destination-page-cities-map.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { create } from "zustand"
|
||||||
|
|
||||||
|
export type SelectedMarker = {
|
||||||
|
cityId: string
|
||||||
|
location: { lat: number; lng: number }
|
||||||
|
} | null
|
||||||
|
|
||||||
|
interface DestinationPageCitiesMapState {
|
||||||
|
hoveredCityMarker: string | null
|
||||||
|
activeCityMarker: SelectedMarker
|
||||||
|
setHoveredCityMarker: (cityId: string | null) => void
|
||||||
|
setActiveCityMarker: (marker: SelectedMarker) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDestinationPageCitiesMapStore =
|
||||||
|
create<DestinationPageCitiesMapState>((set) => ({
|
||||||
|
hoveredCityMarker: null,
|
||||||
|
activeCityMarker: null,
|
||||||
|
setHoveredCityMarker: (cityId) => set({ hoveredCityMarker: cityId }),
|
||||||
|
setActiveCityMarker: (selectedMarker) =>
|
||||||
|
set({ activeCityMarker: selectedMarker }),
|
||||||
|
}))
|
||||||
@@ -18,3 +18,19 @@ export type MarkerProperties = Omit<DestinationMarker, "coordinates">
|
|||||||
|
|
||||||
export type MarkerGeojson = FeatureCollection<Point, MarkerProperties>
|
export type MarkerGeojson = FeatureCollection<Point, MarkerProperties>
|
||||||
export type MarkerFeature = MarkerGeojson["features"][number]
|
export type MarkerFeature = MarkerGeojson["features"][number]
|
||||||
|
|
||||||
|
export interface CityMarker {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
coordinates: google.maps.LatLngLiteral
|
||||||
|
url: string
|
||||||
|
image: GalleryImage
|
||||||
|
|
||||||
|
hotelsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CityMarkerProperties = Omit<CityMarker, "coordinates">
|
||||||
|
export type CitiesClusterMarkerProperties = CityMarkerProperties[]
|
||||||
|
|
||||||
|
export type CityMarkerGeojson = FeatureCollection<Point, CityMarkerProperties>
|
||||||
|
export type CityMarkerFeature = CityMarkerGeojson["features"][number]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.buttonContainer {
|
.buttonContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Space-x2);
|
||||||
margin-bottom: var(--Spacing-x3);
|
margin-bottom: var(--Space-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonContainer > * {
|
.buttonContainer > * {
|
||||||
|
|||||||
17
packages/common/hooks/map/useSetMapUrlFromCountryPage.ts
Normal file
17
packages/common/hooks/map/useSetMapUrlFromCountryPage.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export function setMapUrlFromCountryPage(url: string | null) {
|
||||||
|
const [mapUrl, setMapUrl] = useState<URL | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || typeof window === "undefined") return
|
||||||
|
const cityMapUrl = new URL(url, window.location.origin)
|
||||||
|
if (cityMapUrl) {
|
||||||
|
cityMapUrl.searchParams.set("view", "map")
|
||||||
|
cityMapUrl.searchParams.set("fromCountry", "")
|
||||||
|
}
|
||||||
|
setMapUrl(cityMapUrl)
|
||||||
|
}, [])
|
||||||
|
return mapUrl
|
||||||
|
}
|
||||||
17
packages/common/hooks/map/useSetMapView.ts
Normal file
17
packages/common/hooks/map/useSetMapView.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export default function useSetMapView() {
|
||||||
|
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set("view", "map")
|
||||||
|
setMapUrl(url.toString())
|
||||||
|
}, [params])
|
||||||
|
|
||||||
|
return mapUrl
|
||||||
|
}
|
||||||
43
packages/common/hooks/map/useZoomControls.ts
Normal file
43
packages/common/hooks/map/useZoomControls.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMap } from "@vis.gl/react-google-maps"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import type { MapType } from "@scandic-hotels/design-system/Map/mapConstants"
|
||||||
|
|
||||||
|
export function useZoomControls(mapType: MapType) {
|
||||||
|
const map = useMap()
|
||||||
|
const [zoomLevel, setZoomLevel] = useState<number>(mapType.DEFAULT_ZOOM)
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
if (map && zoomLevel < mapType.MAX_ZOOM) {
|
||||||
|
map.setZoom(zoomLevel + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
if (map && zoomLevel > mapType.MIN_ZOOM) {
|
||||||
|
map.setZoom(zoomLevel - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const handleZoomChanged = () => {
|
||||||
|
const currentZoom = map.getZoom()
|
||||||
|
if (currentZoom != null) {
|
||||||
|
setZoomLevel(currentZoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = map.addListener("zoom_changed", handleZoomChanged)
|
||||||
|
return () => listener.remove()
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoomLevel,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
isMinZoom: zoomLevel <= mapType.MIN_ZOOM,
|
||||||
|
isMaxZoom: zoomLevel >= mapType.MAX_ZOOM,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
|
||||||
|
import { Badge } from './Badge.tsx'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Badge> = {
|
||||||
|
title: 'Components/Badge',
|
||||||
|
component: Badge,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Badge>
|
||||||
|
|
||||||
|
export const Default: Story = {}
|
||||||
|
|
||||||
|
export const XS: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<Badge number={3} color="primary" size="20" />
|
||||||
|
<Badge number={3} color="green" size="20" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Small: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<Badge number={3} color="primary" size="24" />
|
||||||
|
<Badge number={3} color="green" size="24" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
export const Medium: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<Badge number={3} color="primary" size="28" />
|
||||||
|
<Badge number={3} color="green" size="28" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
export const Large: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<Badge number={3} color="primary" size="32" />
|
||||||
|
<Badge number={3} color="green" size="32" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XL: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<Badge number={3} color="primary" size="36" />
|
||||||
|
<Badge number={3} color="green" size="36" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
35
packages/design-system/lib/components/Badge/Badge.tsx
Normal file
35
packages/design-system/lib/components/Badge/Badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { config } from './variants'
|
||||||
|
|
||||||
|
import { VariantProps } from 'class-variance-authority'
|
||||||
|
import { Typography } from '../Typography'
|
||||||
|
import { TypographyProps } from '../Typography/types'
|
||||||
|
|
||||||
|
interface BadgeProps extends VariantProps<typeof config> {
|
||||||
|
number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ number, color, size }: BadgeProps) {
|
||||||
|
const classNames = config({
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography variant={getTypography(size)}>
|
||||||
|
<span className={classNames}>{number}</span>
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypography(size: BadgeProps['size']): TypographyProps['variant'] {
|
||||||
|
switch (size) {
|
||||||
|
case '36':
|
||||||
|
case '32':
|
||||||
|
return 'Body/Paragraph/mdBold'
|
||||||
|
case '28':
|
||||||
|
case '24':
|
||||||
|
return 'Body/Supporting text (caption)/smBold'
|
||||||
|
case '20':
|
||||||
|
return 'Label/xsRegular'
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/design-system/lib/components/Badge/badge.module.css
Normal file
42
packages/design-system/lib/components/Badge/badge.module.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.badge {
|
||||||
|
border-radius: var(--Corner-radius-xl);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--Space-x025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background-color: var(--Surface-Brand-Primary-2-Default);
|
||||||
|
color: var(--Text-Inverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.green {
|
||||||
|
background-color: var(--Surface-Feedback-Succes);
|
||||||
|
color: var(--Text-Feedback-Succes-Accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
._36 {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
._32 {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
._28 {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
._24 {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
._20 {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
1
packages/design-system/lib/components/Badge/index.tsx
Normal file
1
packages/design-system/lib/components/Badge/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Badge } from './Badge'
|
||||||
23
packages/design-system/lib/components/Badge/variants.ts
Normal file
23
packages/design-system/lib/components/Badge/variants.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import styles from './badge.module.css'
|
||||||
|
|
||||||
|
export const config = cva(styles.badge, {
|
||||||
|
variants: {
|
||||||
|
color: {
|
||||||
|
primary: styles.primary,
|
||||||
|
green: styles.green,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
'36': styles._36,
|
||||||
|
'32': styles._32,
|
||||||
|
'28': styles._28,
|
||||||
|
'24': styles._24,
|
||||||
|
'20': styles._20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
color: 'primary',
|
||||||
|
size: '28',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -7,14 +7,9 @@ import { useIntl } from 'react-intl'
|
|||||||
import { IconButton } from '../../IconButton'
|
import { IconButton } from '../../IconButton'
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||||
|
|
||||||
import {
|
import { HOTEL_PAGE, MAP_RESTRICTIONS } from '../mapConstants'
|
||||||
DEFAULT_ZOOM,
|
|
||||||
MAP_RESTRICTIONS,
|
|
||||||
MAX_ZOOM,
|
|
||||||
MIN_ZOOM,
|
|
||||||
} from '../mapConstants'
|
|
||||||
|
|
||||||
import { useZoomControls } from './useZoomControls'
|
import { useZoomControls } from '@scandic-hotels/common/hooks/map/useZoomControls'
|
||||||
|
|
||||||
import { HotelListingMapContent } from './HotelListingMapContent'
|
import { HotelListingMapContent } from './HotelListingMapContent'
|
||||||
import PoiMapMarkers from './PoiMapMarkers'
|
import PoiMapMarkers from './PoiMapMarkers'
|
||||||
@@ -87,12 +82,12 @@ export function InteractiveMap({
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
|
const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
|
||||||
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls()
|
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls(HOTEL_PAGE)
|
||||||
|
|
||||||
const mapOptions: MapProps = {
|
const mapOptions: MapProps = {
|
||||||
defaultZoom: DEFAULT_ZOOM,
|
defaultZoom: HOTEL_PAGE.DEFAULT_ZOOM,
|
||||||
minZoom: MIN_ZOOM,
|
minZoom: HOTEL_PAGE.MIN_ZOOM,
|
||||||
maxZoom: MAX_ZOOM,
|
maxZoom: HOTEL_PAGE.MAX_ZOOM,
|
||||||
defaultCenter: coordinates,
|
defaultCenter: coordinates,
|
||||||
disableDefaultUI: true,
|
disableDefaultUI: true,
|
||||||
clickableIcons: false,
|
clickableIcons: false,
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useMap } from '@vis.gl/react-google-maps'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM } from '../mapConstants'
|
|
||||||
|
|
||||||
export function useZoomControls() {
|
|
||||||
const map = useMap()
|
|
||||||
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM)
|
|
||||||
|
|
||||||
const zoomIn = () => {
|
|
||||||
if (map && zoomLevel < MAX_ZOOM) {
|
|
||||||
map.setZoom(zoomLevel + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
if (map && zoomLevel > MIN_ZOOM) {
|
|
||||||
map.setZoom(zoomLevel - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return
|
|
||||||
|
|
||||||
const handleZoomChanged = () => {
|
|
||||||
const currentZoom = map.getZoom()
|
|
||||||
if (currentZoom != null) {
|
|
||||||
setZoomLevel(currentZoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listener = map.addListener('zoom_changed', handleZoomChanged)
|
|
||||||
return () => listener.remove()
|
|
||||||
}, [map])
|
|
||||||
|
|
||||||
return {
|
|
||||||
zoomLevel,
|
|
||||||
zoomIn,
|
|
||||||
zoomOut,
|
|
||||||
isMinZoom: zoomLevel <= MIN_ZOOM,
|
|
||||||
isMaxZoom: zoomLevel >= MAX_ZOOM,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,34 @@ export const MAP_RESTRICTIONS = {
|
|||||||
latLngBounds: { north: 85, south: -85, west: -180, east: 180 },
|
latLngBounds: { north: 85, south: -85, west: -180, east: 180 },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_ZOOM = 14
|
export const HOTEL_PAGE = {
|
||||||
export const MAX_ZOOM = 18
|
DEFAULT_ZOOM: 14,
|
||||||
export const MIN_ZOOM = 8
|
MAX_ZOOM: 18,
|
||||||
|
MIN_ZOOM: 8,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const DESTINATION_PAGE = {
|
||||||
|
DEFAULT_ZOOM: 10,
|
||||||
|
MAX_ZOOM: 18,
|
||||||
|
MIN_ZOOM: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CITY_PAGE = {
|
||||||
|
DEFAULT_ZOOM: 10,
|
||||||
|
SELECTED_HOTEL_ZOOM: 15,
|
||||||
|
MAX_ZOOM: 18,
|
||||||
|
MIN_ZOOM: 3,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const COUNTRY_PAGE = {
|
||||||
|
DEFAULT_ZOOM: 3,
|
||||||
|
SELECTED_HOTEL_ZOOM: 15,
|
||||||
|
MAX_ZOOM: 18,
|
||||||
|
MIN_ZOOM: 3,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type MapType =
|
||||||
|
| typeof HOTEL_PAGE
|
||||||
|
| typeof DESTINATION_PAGE
|
||||||
|
| typeof CITY_PAGE
|
||||||
|
| typeof COUNTRY_PAGE
|
||||||
|
|||||||
@@ -276,7 +276,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: block;
|
font-display: block;
|
||||||
src: url(/_static/shared/fonts/material-symbols/rounded-f2d895e1.woff2)
|
src: url(/_static/shared/fonts/material-symbols/rounded-1db5531f.woff2)
|
||||||
format('woff2');
|
format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"./Alert": "./lib/components/Alert/index.tsx",
|
"./Alert": "./lib/components/Alert/index.tsx",
|
||||||
"./Avatar": "./lib/components/Avatar/index.tsx",
|
"./Avatar": "./lib/components/Avatar/index.tsx",
|
||||||
"./BackToTopButton": "./lib/components/BackToTopButton/index.tsx",
|
"./BackToTopButton": "./lib/components/BackToTopButton/index.tsx",
|
||||||
|
"./Badge": "./lib/components/Badge/index.tsx",
|
||||||
"./Body": "./lib/components/Body/index.tsx",
|
"./Body": "./lib/components/Body/index.tsx",
|
||||||
"./BookingCodeChip": "./lib/components/BookingCodeChip/index.tsx",
|
"./BookingCodeChip": "./lib/components/BookingCodeChip/index.tsx",
|
||||||
"./Button": "./lib/components/Button/index.tsx",
|
"./Button": "./lib/components/Button/index.tsx",
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ query GetDestinationCityListData($locale: String!, $cityIdentifier: String!) {
|
|||||||
city_norway
|
city_norway
|
||||||
city_poland
|
city_poland
|
||||||
city_sweden
|
city_sweden
|
||||||
|
location {
|
||||||
|
longitude
|
||||||
|
latitude
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sort_order
|
sort_order
|
||||||
preamble
|
preamble
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ export async function getCityPages(
|
|||||||
lang,
|
lang,
|
||||||
city.cityIdentifier
|
city.cityIdentifier
|
||||||
)
|
)
|
||||||
return data ? { ...data, cityName: city.name } : null
|
return data
|
||||||
|
? { ...data, cityName: city.name, cityIdentifier: city.cityIdentifier }
|
||||||
|
: null
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const mapLocationSchema = z
|
|||||||
.object({
|
.object({
|
||||||
longitude: z.number().nullable(),
|
longitude: z.number().nullable(),
|
||||||
latitude: z.number().nullable(),
|
latitude: z.number().nullable(),
|
||||||
default_zoom: z.number().nullable(),
|
default_zoom: z.number().nullish(),
|
||||||
})
|
})
|
||||||
.nullish()
|
.nullish()
|
||||||
.transform((val) => {
|
.transform((val) => {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface CityPageUrlsData extends z.output<typeof cityPageUrlsSchema> {}
|
|||||||
|
|
||||||
export interface DestinationCityListItem extends DestinationCityListData {
|
export interface DestinationCityListItem extends DestinationCityListData {
|
||||||
cityName: string
|
cityName: string
|
||||||
|
cityIdentifier: string
|
||||||
|
hotelsCount?: number
|
||||||
}
|
}
|
||||||
export type Block = z.output<typeof blocksSchema>
|
export type Block = z.output<typeof blocksSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ const icons = [
|
|||||||
"festival",
|
"festival",
|
||||||
"filter_alt",
|
"filter_alt",
|
||||||
"filter",
|
"filter",
|
||||||
|
"format_list_bulleted",
|
||||||
"floor_lamp",
|
"floor_lamp",
|
||||||
"forest",
|
"forest",
|
||||||
"garage",
|
"garage",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
|
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
|
||||||
hash=f2d895e1
|
hash=1db5531f
|
||||||
created=2025-09-16T08:22:55.703Z
|
created=2025-09-17T06:58:37.841Z
|
||||||
|
|||||||
BIN
shared/fonts/material-symbols/rounded-1db5531f.woff2
Normal file
BIN
shared/fonts/material-symbols/rounded-1db5531f.woff2
Normal file
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user