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 type { PageArgs } from "@/types/params"
|
||||
|
||||
export { generateMetadata } from "@/utils/metadata/generateMetadata"
|
||||
|
||||
export default async function DestinationCountryPagePage() {
|
||||
// props: PageArgs<{}, { view?: "map"; }>
|
||||
// const searchParams = await props.searchParams
|
||||
export default async function DestinationCountryPagePage(
|
||||
props: PageArgs<object, { view?: "map" }>
|
||||
) {
|
||||
const searchParams = await props.searchParams
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
||||
<DestinationCountryPage
|
||||
// isMapView={searchParams.view === "map"} // Disabled until further notice
|
||||
isMapView={false}
|
||||
/>
|
||||
<DestinationCountryPage isMapView={searchParams.view === "map"} />
|
||||
</Suspense>
|
||||
</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 styles from "./hotelCardCarousel.module.css"
|
||||
import styles from "./destinationCardCarousel.module.css"
|
||||
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface MapCardCarouselProps {
|
||||
interface HotelCardCarouselProps {
|
||||
visibleHotels: HotelListingHotelData[]
|
||||
}
|
||||
export default function HotelCardCarousel({
|
||||
visibleHotels,
|
||||
}: MapCardCarouselProps) {
|
||||
}: HotelCardCarouselProps) {
|
||||
const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
|
||||
|
||||
const selectedHotelIdx = visibleHotels.findIndex(
|
||||
@@ -36,18 +36,18 @@ export default function HotelCardCarousel({
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
className={styles.carousel}
|
||||
scrollToIdx={selectedHotelIdx}
|
||||
className={styles.carousel}
|
||||
align="center"
|
||||
opts={{ containScroll: false }}
|
||||
onScrollSelect={handleScrollSelect}
|
||||
>
|
||||
<Carousel.Content className={styles.carouselContent}>
|
||||
{visibleHotels.map(({ hotel, url }) => (
|
||||
<Carousel.Item key={hotel.id} className={styles.item}>
|
||||
<Carousel.Item key={hotel.id}>
|
||||
<HotelMapCard
|
||||
className={cx(styles.carouselCard, {
|
||||
[styles.noActiveHotel]: !activeMarker,
|
||||
className={cx({
|
||||
[styles.noActiveCard]: !activeMarker,
|
||||
})}
|
||||
tripadvisorRating={hotel.tripadvisor}
|
||||
hotelName={hotel.name}
|
||||
@@ -1,15 +1,15 @@
|
||||
.noActiveHotel,
|
||||
.noActiveCard,
|
||||
.carousel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 949px) {
|
||||
.carousel:not(.noActiveHotel) {
|
||||
.carousel {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.carouselContent {
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Space-x1);
|
||||
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 { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
|
||||
import HotelCardCarousel from "../../../HotelCardCarousel"
|
||||
import HotelCardCarousel from "../../../DestinationCardCarousel/HotelCardCarousel"
|
||||
import HotelListItem from "../HotelListItem"
|
||||
|
||||
import styles from "./hotelList.module.css"
|
||||
@@ -23,7 +20,6 @@ export default function HotelListContent({
|
||||
visibleHotels,
|
||||
}: HotelListContentProps) {
|
||||
const intl = useIntl()
|
||||
const isMobile = useMediaQuery("(max-width: 949px)")
|
||||
|
||||
if (hotelsCount === 0) {
|
||||
return (
|
||||
@@ -40,11 +36,10 @@ export default function HotelListContent({
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <HotelCardCarousel visibleHotels={visibleHotels} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HotelCardCarousel visibleHotels={visibleHotels} />
|
||||
|
||||
<ul className={styles.hotelList}>
|
||||
{visibleHotels.map(({ hotel, url }) => (
|
||||
<li key={hotel.id}>
|
||||
@@ -52,5 +47,6 @@ export default function HotelListContent({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
|
||||
<h3>{hotel.name}</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.captions}>
|
||||
<span className={styles.captions}>
|
||||
<Typography variant="Link/sm">
|
||||
<ButtonRAC
|
||||
className={styles.addressButton}
|
||||
@@ -120,7 +120,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||
|
||||
import Map from "../../Map"
|
||||
import { getHeadingText } from "../../utils"
|
||||
import CityMapContainer from "../../Map/CityMapContainer"
|
||||
import { getCityHeadingText } from "../../utils"
|
||||
import { BackToCities } from "./BackToCitiesLink"
|
||||
import HotelList from "./HotelList"
|
||||
|
||||
import styles from "./cityMap.module.css"
|
||||
@@ -36,21 +39,30 @@ export default function CityMap({
|
||||
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 (
|
||||
<Map
|
||||
<CityMapContainer
|
||||
hotels={activeHotels}
|
||||
mapId={mapId}
|
||||
apiKey={apiKey}
|
||||
pageType="city"
|
||||
defaultLocation={defaultLocation}
|
||||
>
|
||||
<span className="topSection">
|
||||
{fromCountryPage ? <BackToCities /> : null}
|
||||
<Typography variant="Title/sm">
|
||||
<h1 className={styles.title}>
|
||||
{getHeadingText(intl, city.name, allFilters, filterFromUrl)}
|
||||
{getCityHeadingText(intl, city.name, allFilters, filterFromUrl)}
|
||||
</h1>
|
||||
</Typography>
|
||||
</span>
|
||||
<HotelList />
|
||||
</Map>
|
||||
</CityMapContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
|
||||
import HotelListingSkeleton from "../DestinationListing/HotelListing/HotelListingSkeleton"
|
||||
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
||||
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
|
||||
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
||||
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||
import { getPathname } from "@/utils/getPathname"
|
||||
|
||||
import Blocks from "../Blocks"
|
||||
import HotelListing from "../DestinationListing/HotelListing"
|
||||
import ExperienceList from "../ExperienceList"
|
||||
import HotelListing from "../HotelListing"
|
||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||
import DestinationPageSidePeek from "../Sidepeek"
|
||||
import StaticMap from "../StaticMap"
|
||||
@@ -121,7 +121,11 @@ export default async function DestinationCityPage({
|
||||
<SeoFilters seoFilters={seo_filters} location={city.name} />
|
||||
</main>
|
||||
<aside className={styles.sidebar}>
|
||||
<SidebarContentWrapper preamble={preamble} location={city.name}>
|
||||
<SidebarContentWrapper
|
||||
preamble={preamble}
|
||||
location={city.name}
|
||||
pageType="city"
|
||||
>
|
||||
<ExperienceList experiences={experiences} />
|
||||
{has_sidepeek && sidepeek_content ? (
|
||||
<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" />
|
||||
</div>
|
||||
<ul className={styles.cityList}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<CityListItemSkeleton key={index} />
|
||||
))}
|
||||
</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 {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -13,15 +13,13 @@
|
||||
.cityList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
gap: var(--Space-x15);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 949px) {
|
||||
.cityList {
|
||||
flex-direction: row;
|
||||
align-items: end;
|
||||
overflow-x: scroll;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||
|
||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||
|
||||
import CityListItem from "../CityListItem"
|
||||
import CityListSkeleton from "./CityListSkeleton"
|
||||
import { CityListContent } from "./Content"
|
||||
|
||||
import styles from "./cityList.module.css"
|
||||
|
||||
@@ -27,36 +25,20 @@ export default function CityList() {
|
||||
) : (
|
||||
<div className={styles.cityListWrapper}>
|
||||
<div className={styles.header}>
|
||||
<Body>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{count} destinations",
|
||||
},
|
||||
{ count: activeCities.length }
|
||||
)}
|
||||
</Body>
|
||||
</p>
|
||||
</Typography>
|
||||
<DestinationFilterAndSort listType="city" />
|
||||
</div>
|
||||
{activeCities.length === 0 ? (
|
||||
<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.",
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<ul className={styles.cityList}>
|
||||
{activeCities.map((city) => (
|
||||
<li key={city.system.uid}>
|
||||
<CityListItem city={city} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<CityListContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,24 +2,16 @@
|
||||
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
|
||||
|
||||
import styles from "./cityListItem.module.css"
|
||||
|
||||
export default function CityListItemSkeleton() {
|
||||
return (
|
||||
<article className={styles.cityListItem}>
|
||||
<article className={styles.card}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<SkeletonShimmer width="100%" height="100%" />
|
||||
</div>
|
||||
<section className={styles.content}>
|
||||
<SkeletonShimmer height="52px" />
|
||||
<div className={styles.experienceList}>
|
||||
<ExperienceListSkeleton />
|
||||
</div>
|
||||
<div className={styles.ctaWrapper}>
|
||||
<SkeletonShimmer height="45px" width="100%" />
|
||||
</div>
|
||||
<SkeletonShimmer height="100%" />
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
.cityListItem {
|
||||
display: grid;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
.card {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
display: flex;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--Surface-Primary-Hover);
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
height: 80px;
|
||||
max-width: 80px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -17,32 +22,34 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
grid-template-columns: auto 1fr;
|
||||
margin: var(--Space-x15);
|
||||
margin-left: var(--Space-x2);
|
||||
color: var(--Text-Interactive-Default);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 949px) {
|
||||
.cityListItem {
|
||||
width: 360px;
|
||||
min-height: 120px;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
.left {
|
||||
display: grid;
|
||||
gap: var(--Space-x025);
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
.right {
|
||||
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 {
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.experienceList {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctaWrapper {
|
||||
margin-top: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
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 ExperienceList from "../../../ExperienceList"
|
||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||
|
||||
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 {
|
||||
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 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 (
|
||||
<article className={styles.cityListItem}>
|
||||
<article
|
||||
className={styles.card}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className={styles.imageWrapper}>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
fill
|
||||
title={intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{title} - Image gallery",
|
||||
},
|
||||
{ title: city.cityName }
|
||||
)}
|
||||
{image ? (
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.meta.alt || image.meta.caption || ""}
|
||||
focalPoint={image.focalPoint}
|
||||
width={80}
|
||||
height={80}
|
||||
title={cityName}
|
||||
/>
|
||||
</div>
|
||||
<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}>
|
||||
<Button intent="tertiary" theme="base" size="small" asChild>
|
||||
<Link href={city.url}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Explore {city}",
|
||||
},
|
||||
{ city: city.cityName }
|
||||
) : (
|
||||
<ImageFallback height="80px" />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.left}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<h3>{cityName}</h3>
|
||||
</Typography>
|
||||
<div>
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||
|
||||
import Map from "../../Map"
|
||||
import { getHeadingText } from "../../utils"
|
||||
import CountryMapContainer from "../../Map/CountryMapContainer"
|
||||
import { getCountryHeadingText } from "../../utils"
|
||||
import CityList from "./CityList"
|
||||
|
||||
import styles from "./countryMap.module.css"
|
||||
@@ -28,28 +28,27 @@ export default function CountryMap({
|
||||
defaultLocation,
|
||||
}: CountryMapProps) {
|
||||
const intl = useIntl()
|
||||
const { activeHotels, allFilters, filterFromUrl } = useDestinationDataStore(
|
||||
const { activeCities, allFilters, filterFromUrl } = useDestinationDataStore(
|
||||
(state) => ({
|
||||
activeHotels: state.activeHotels,
|
||||
activeCities: state.activeCities,
|
||||
allFilters: state.allFilters,
|
||||
filterFromUrl: state.filterFromUrl,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<Map
|
||||
hotels={activeHotels}
|
||||
<CountryMapContainer
|
||||
cities={activeCities}
|
||||
mapId={mapId}
|
||||
apiKey={apiKey}
|
||||
pageType="country"
|
||||
defaultLocation={defaultLocation}
|
||||
>
|
||||
<Typography variant="Title/sm">
|
||||
<h1 className={styles.title}>
|
||||
{getHeadingText(intl, country, allFilters, filterFromUrl)}
|
||||
{getCountryHeadingText(intl, country, allFilters, filterFromUrl)}
|
||||
</h1>
|
||||
</Typography>
|
||||
<CityList />
|
||||
</Map>
|
||||
</CountryMapContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
|
||||
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
|
||||
import CityListingSkeleton from "../DestinationListing/CityListing/CityListingSkeleton"
|
||||
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
||||
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
||||
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 Blocks from "../Blocks"
|
||||
import CityListing from "../CityListing"
|
||||
import CityListing from "../DestinationListing/CityListing"
|
||||
import ExperienceList from "../ExperienceList"
|
||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||
import DestinationPageSidePeek from "../Sidepeek"
|
||||
import StaticMap from "../StaticMap"
|
||||
import TopImages from "../TopImages"
|
||||
import DestinationTracking from "../Tracking"
|
||||
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 (
|
||||
<>
|
||||
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
||||
<DestinationDataProvider
|
||||
allHotels={allHotels}
|
||||
allCities={allCities}
|
||||
allCities={allCitiesWithCount}
|
||||
hotelFilters={hotelFilters}
|
||||
seoFilters={seo_filters}
|
||||
sortItems={sortItems}
|
||||
@@ -128,6 +143,7 @@ export default async function DestinationCountryPage({
|
||||
<SidebarContentWrapper
|
||||
preamble={preamble}
|
||||
location={translatedCountry}
|
||||
pageType="country"
|
||||
>
|
||||
<ExperienceList experiences={experiences} />
|
||||
{has_sidepeek && sidepeek_content ? (
|
||||
@@ -137,6 +153,13 @@ export default async function DestinationCountryPage({
|
||||
location={translatedCountry}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{destination_settings.country && (
|
||||
<StaticMap
|
||||
city={destination_settings.country}
|
||||
location={destination_settings.location}
|
||||
/>
|
||||
)}
|
||||
</SidebarContentWrapper>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import ExperienceListSkeleton from "../../ExperienceList/ExperienceListSkeleton"
|
||||
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
|
||||
|
||||
import styles from "./cityListingItem.module.css"
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x2) var(--Space-x3);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
@@ -11,7 +11,7 @@ import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import ExperienceList from "../../ExperienceList"
|
||||
import ExperienceList from "../../../ExperienceList"
|
||||
|
||||
import styles from "./cityListingItem.module.css"
|
||||
|
||||
@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
|
||||
|
||||
import styles from "./cityListing.module.css"
|
||||
import styles from "../destinationListing.module.css"
|
||||
|
||||
export default function CityListingSkeleton() {
|
||||
return (
|
||||
@@ -4,6 +4,7 @@ import { useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
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 { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
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 DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||
|
||||
import { SeeOnMapFilterWrapper } from "../../SeeOnMapFilterWrapper"
|
||||
import CityListingItem from "./CityListingItem"
|
||||
import CityListingSkeleton from "./CityListingSkeleton"
|
||||
|
||||
import styles from "./cityListing.module.css"
|
||||
import styles from "../destinationListing.module.css"
|
||||
|
||||
export default function CityListing() {
|
||||
const intl = useIntl()
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const mapUrl = useSetMapView()
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 300,
|
||||
elementRef: scrollRef,
|
||||
@@ -46,7 +47,7 @@ export default function CityListing() {
|
||||
)}
|
||||
</h2>
|
||||
</Typography>
|
||||
<DestinationFilterAndSort listType="city" />
|
||||
<SeeOnMapFilterWrapper mapUrl={mapUrl} listType="city" />
|
||||
</div>
|
||||
{activeCities.length === 0 ? (
|
||||
<Alert
|
||||
@@ -61,7 +62,7 @@ export default function CityListing() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ul className={styles.cityList}>
|
||||
<ul className={styles.list}>
|
||||
{activeCities.map((city) => (
|
||||
<li key={city.system.uid}>
|
||||
<CityListingItem city={city} />
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import NextLink from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
@@ -33,20 +32,12 @@ export default function HotelListingItem({
|
||||
url,
|
||||
}: HotelListingItemProps) {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const { setActiveMarker } = useDestinationPageHotelsMapStore()
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||
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}`
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("view", "map")
|
||||
setMapUrl(url.toString())
|
||||
}, [params, hotel.name])
|
||||
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
<div className={styles.imageWrapper}>
|
||||
@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
import styles from "../destinationListing.module.css"
|
||||
|
||||
export default function HotelListingSkeleton() {
|
||||
return (
|
||||
@@ -1,32 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
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 { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
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 { useDestinationDataStore } from "@/stores/destination-data"
|
||||
|
||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||
|
||||
import { SeeOnMapFilterWrapper } from "../../SeeOnMapFilterWrapper"
|
||||
import HotelListingItem from "./HotelListingItem"
|
||||
import HotelListingSkeleton from "./HotelListingSkeleton"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
import styles from "../destinationListing.module.css"
|
||||
|
||||
export default function HotelListing() {
|
||||
const intl = useIntl()
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const params = useParams()
|
||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
||||
const mapUrl = useSetMapView()
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 300,
|
||||
elementRef: scrollRef,
|
||||
@@ -36,12 +31,6 @@ export default function HotelListing() {
|
||||
isLoading: state.isLoading,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("view", "map")
|
||||
setMapUrl(url.toString())
|
||||
}, [params])
|
||||
|
||||
return isLoading ? (
|
||||
<HotelListingSkeleton />
|
||||
) : (
|
||||
@@ -58,26 +47,7 @@ export default function HotelListing() {
|
||||
)}
|
||||
</h2>
|
||||
</Typography>
|
||||
<div className={styles.cta}>
|
||||
{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>
|
||||
<SeeOnMapFilterWrapper mapUrl={mapUrl} listType="hotel" />
|
||||
</div>
|
||||
{activeHotels.length === 0 ? (
|
||||
<Alert
|
||||
@@ -92,7 +62,7 @@ export default function HotelListing() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ul className={styles.hotelList}>
|
||||
<ul className={styles.list}>
|
||||
{activeHotels.map(({ hotel, url }) => (
|
||||
<li key={hotel.id}>
|
||||
<HotelListingItem hotel={hotel} url={url} />
|
||||
@@ -1,10 +1,10 @@
|
||||
.container {
|
||||
--scroll-margin-top: calc(
|
||||
var(--booking-widget-mobile-height) + var(--Spacing-x2)
|
||||
var(--booking-widget-mobile-height) + var(--Space-x2)
|
||||
);
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
gap: var(--Space-x4);
|
||||
scroll-margin-top: var(--scroll-margin-top);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,10 @@
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotelList {
|
||||
.list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
@@ -33,13 +27,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@media screen and (min-width: 950px) {
|
||||
.listHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mapButton {
|
||||
display: none !important; /* Important to override button higher specificy */
|
||||
.container {
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default async function OverviewMapContainer() {
|
||||
boundsPadding={0}
|
||||
gestureHandling="cooperative"
|
||||
>
|
||||
<MapContent geojson={geoJson} />
|
||||
<MapContent geojson={geoJson} pageType="overview" />
|
||||
<ActiveMapCard markers={markers} />
|
||||
</DynamicMap>
|
||||
</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 {
|
||||
position: relative;
|
||||
min-width: 177px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Chip from "@scandic-hotels/design-system/Chip"
|
||||
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
|
||||
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 styles from "./dialogImage.module.css"
|
||||
@@ -22,7 +23,7 @@ export default function DialogImage({
|
||||
return (
|
||||
<div className={styles.imageContainer}>
|
||||
{!image || imageError ? (
|
||||
<div className={styles.imagePlaceholder} />
|
||||
<ImageFallback />
|
||||
) : (
|
||||
<Image
|
||||
src={image}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
.name {
|
||||
height: 48px;
|
||||
max-width: 180px;
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
margin-bottom: var(--Space-x05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -23,16 +23,10 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
min-width: 150px;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Space-x15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -46,18 +40,14 @@
|
||||
.facilitiesItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.content .button {
|
||||
margin-top: auto;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
gap: 0 var(--Space-x1);
|
||||
padding-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.mapWrapperWithCloseButton:after {
|
||||
.mapWrapperWithSeeAsListButton:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -23,39 +23,35 @@
|
||||
|
||||
.ctaButtons {
|
||||
position: absolute;
|
||||
top: var(--Spacing-x2);
|
||||
right: var(--Spacing-x2);
|
||||
top: var(--Space-x2);
|
||||
right: var(--Space-x2);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x7);
|
||||
gap: var(--Space-x7);
|
||||
align-items: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zoomButtons {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Space-x1);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
.seeAsListButton {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.zoomButton {
|
||||
width: var(--Spacing-x5);
|
||||
height: var(--Spacing-x5);
|
||||
padding: 0;
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.ctaButtons {
|
||||
top: var(--Spacing-x4);
|
||||
right: var(--Spacing-x4);
|
||||
bottom: var(--Spacing-x4);
|
||||
top: var(--Space-x4);
|
||||
right: var(--Space-x4);
|
||||
bottom: var(--Space-x4);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -64,11 +60,10 @@
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
.seeAsListButton {
|
||||
display: flex !important;
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
/* Overriding Google maps infoWindow styles */
|
||||
|
||||
@@ -7,9 +7,14 @@ import { cx } from "class-variance-authority"
|
||||
import { type PropsWithChildren, useEffect, useRef, useState } from "react"
|
||||
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 { MAP_RESTRICTIONS } from "@scandic-hotels/design-system/Map/mapConstants"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import {
|
||||
DESTINATION_PAGE,
|
||||
MAP_RESTRICTIONS,
|
||||
} from "@scandic-hotels/design-system/Map/mapConstants"
|
||||
|
||||
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||
|
||||
@@ -20,7 +25,10 @@ import { usePageType } from "../PageTypeProvider"
|
||||
|
||||
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 = {
|
||||
lat: 59.3293,
|
||||
@@ -28,7 +36,7 @@ const BACKUP_COORDINATES = {
|
||||
}
|
||||
|
||||
interface DynamicMapProps {
|
||||
markers: DestinationMarker[]
|
||||
markers: DestinationMarker[] | CityMarker[]
|
||||
mapId: string
|
||||
defaultCenter?: google.maps.LatLngLiteral
|
||||
defaultZoom?: number
|
||||
@@ -42,7 +50,7 @@ export default function DynamicMap({
|
||||
markers,
|
||||
mapId,
|
||||
defaultCenter = BACKUP_COORDINATES,
|
||||
defaultZoom = 3,
|
||||
defaultZoom = DESTINATION_PAGE.DEFAULT_ZOOM,
|
||||
fitBounds = true,
|
||||
boundsPadding = 100,
|
||||
onClose,
|
||||
@@ -55,6 +63,8 @@ export default function DynamicMap({
|
||||
const { activeMarker } = useDestinationPageHotelsMapStore()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [hasFittedBounds, setHasFittedBounds] = useState(!!activeMarker)
|
||||
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } =
|
||||
useZoomControls(DESTINATION_PAGE)
|
||||
|
||||
useEffect(() => {
|
||||
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 = {
|
||||
defaultCenter, // Default center will be overridden by the bounds
|
||||
minZoom: 3,
|
||||
maxZoom: 18,
|
||||
minZoom: DESTINATION_PAGE.MIN_ZOOM,
|
||||
maxZoom: DESTINATION_PAGE.MAX_ZOOM,
|
||||
defaultZoom,
|
||||
disableDefaultUI: true,
|
||||
clickableIcons: false,
|
||||
@@ -111,7 +108,7 @@ export default function DynamicMap({
|
||||
<div
|
||||
className={cx(
|
||||
styles.mapWrapper,
|
||||
onClose && styles.mapWrapperWithCloseButton
|
||||
onClose && styles.mapWrapperWithSeeAsListButton
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
@@ -129,48 +126,46 @@ export default function DynamicMap({
|
||||
<div className={styles.ctaButtons}>
|
||||
{onClose && (
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
className={styles.closeButton}
|
||||
color="Inverted"
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
className={styles.seeAsListButton}
|
||||
onClick={onClose}
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
<MaterialIcon icon="close" color="CurrentColor" />
|
||||
<MaterialIcon icon="format_list_bulleted" color="CurrentColor" />
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Close the map",
|
||||
defaultMessage: "See as list",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.zoomButtons}>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
<IconButton
|
||||
theme="Inverted"
|
||||
style="Elevated"
|
||||
className={styles.zoomButton}
|
||||
onClick={zoomIn}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Zoom out",
|
||||
defaultMessage: "Zoom in",
|
||||
})}
|
||||
isDisabled={isMaxZoom}
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
theme="base"
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
<MaterialIcon icon="add" color="CurrentColor" size={24} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
theme="Inverted"
|
||||
style="Elevated"
|
||||
className={styles.zoomButton}
|
||||
onClick={zoomOut}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Zoom in",
|
||||
defaultMessage: "Zoom out",
|
||||
})}
|
||||
isDisabled={isMinZoom}
|
||||
>
|
||||
<MaterialIcon icon="remove" color="CurrentColor" size={20} />
|
||||
</Button>
|
||||
<MaterialIcon icon="remove" color="CurrentColor" size={24} />
|
||||
</IconButton>
|
||||
</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"
|
||||
|
||||
interface ClusterMarkerProps {
|
||||
interface HotelClusterMarkerProps {
|
||||
position: google.maps.LatLngLiteral
|
||||
size: number
|
||||
sizeAsText: string
|
||||
onMarkerClick?: (position: google.maps.LatLngLiteral) => void
|
||||
hotelIds: number[]
|
||||
hotelIds: string[]
|
||||
}
|
||||
|
||||
export default function ClusterMarker({
|
||||
export default function HotelClusterMarker({
|
||||
position,
|
||||
size,
|
||||
sizeAsText,
|
||||
onMarkerClick,
|
||||
hotelIds,
|
||||
}: ClusterMarkerProps) {
|
||||
}: HotelClusterMarkerProps) {
|
||||
const { hoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
|
||||
|
||||
const isActive =
|
||||
hotelIds.includes(Number(hoveredMarker)) ||
|
||||
hotelIds.includes(Number(activeMarker))
|
||||
hotelIds.includes(String(hoveredMarker)) ||
|
||||
hotelIds.includes(String(activeMarker))
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (onMarkerClick) {
|
||||
@@ -20,12 +20,15 @@ import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
|
||||
|
||||
import type { MarkerProperties } from "@/types/components/maps/destinationMarkers"
|
||||
|
||||
interface MarkerProps {
|
||||
interface HotelMarkerProps {
|
||||
position: google.maps.LatLngLiteral
|
||||
properties: MarkerProperties
|
||||
}
|
||||
|
||||
export default function Marker({ position, properties }: MarkerProps) {
|
||||
export default function HotelMarker({
|
||||
position,
|
||||
properties,
|
||||
}: HotelMarkerProps) {
|
||||
const [markerRef] = useAdvancedMarkerRef()
|
||||
|
||||
const { setHoveredMarker, setActiveMarker, hoveredMarker, activeMarker } =
|
||||
@@ -2,24 +2,33 @@
|
||||
|
||||
import { useMap } from "@vis.gl/react-google-maps"
|
||||
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 { useSupercluster } from "@/hooks/maps/use-supercluster"
|
||||
|
||||
import ClusterMarker from "./ClusterMarker"
|
||||
import Marker from "./Marker"
|
||||
import CityClusterMarker from "./ClusterMarker/CityClusterMarker"
|
||||
import HotelClusterMarker from "./ClusterMarker/HotelClusterMarker"
|
||||
import CityMarker from "./CityMarker"
|
||||
import HotelMarker from "./HotelMarker"
|
||||
|
||||
import type { ClusterProperties } from "supercluster"
|
||||
|
||||
import type {
|
||||
CityMarkerGeojson,
|
||||
CityMarkerProperties,
|
||||
MarkerGeojson,
|
||||
MarkerProperties,
|
||||
} from "@/types/components/maps/destinationMarkers"
|
||||
|
||||
interface MapContentProps {
|
||||
geojson: MarkerGeojson
|
||||
geojson: MarkerGeojson | CityMarkerGeojson
|
||||
disableClustering?: boolean
|
||||
pageType: "city" | "country" | "overview"
|
||||
}
|
||||
|
||||
// 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({
|
||||
geojson,
|
||||
disableClustering,
|
||||
pageType,
|
||||
}: MapContentProps) {
|
||||
const { setActiveMarker, activeMarker } = useDestinationPageHotelsMapStore()
|
||||
const map = useMap()
|
||||
const { setActiveCityMarker, activeCityMarker } =
|
||||
useDestinationPageCitiesMapStore()
|
||||
|
||||
const { clusters, containedHotels, getClusterZoom } =
|
||||
useSupercluster<MarkerProperties>(geojson, CLUSTER_OPTIONS)
|
||||
const { clusters, containedHotelIds, containedCityIds, getClusterZoom } =
|
||||
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
|
||||
const markerList = disableClustering ? geojson.features : clusters
|
||||
@@ -51,6 +67,23 @@ export default function MapContent({
|
||||
})
|
||||
}, [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(
|
||||
position: google.maps.LatLngLiteral,
|
||||
clusterProperties: ClusterProperties
|
||||
@@ -67,11 +100,13 @@ export default function MapContent({
|
||||
return markerList.map((feature, idx) => {
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
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
|
||||
|
||||
if (pageType === "country") {
|
||||
return isCluster ? (
|
||||
<ClusterMarker
|
||||
<CityClusterMarker
|
||||
key={feature.id}
|
||||
position={{ lat, lng }}
|
||||
size={clusterProperties.point_count}
|
||||
@@ -79,13 +114,32 @@ export default function MapContent({
|
||||
onMarkerClick={(position) =>
|
||||
handleClusterClick(position, clusterProperties)
|
||||
}
|
||||
hotelIds={containedHotels[idx]}
|
||||
cities={containedCityIds[idx]}
|
||||
/>
|
||||
) : (
|
||||
<Marker
|
||||
<CityMarker
|
||||
key={feature.id}
|
||||
position={{ lat, lng }}
|
||||
properties={markerProperties}
|
||||
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"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
@@ -14,47 +14,59 @@ import { useIntl } from "react-intl"
|
||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
||||
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 { 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 { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||
|
||||
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||
|
||||
import DynamicMap from "./DynamicMap"
|
||||
import MapContent from "./MapContent"
|
||||
import MapProvider from "./MapProvider"
|
||||
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
|
||||
|
||||
import styles from "./map.module.css"
|
||||
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type { MapLocation } from "@/types/components/mapLocation"
|
||||
import type {
|
||||
CityMarker,
|
||||
CityMarkerGeojson,
|
||||
DestinationMarker,
|
||||
MarkerGeojson,
|
||||
} from "@/types/components/maps/destinationMarkers"
|
||||
|
||||
interface MapProps {
|
||||
hotels: HotelListingHotelData[]
|
||||
mapId: 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
|
||||
}
|
||||
|
||||
export default function Map({
|
||||
hotels,
|
||||
mapId,
|
||||
apiKey,
|
||||
defaultLocation,
|
||||
pageType,
|
||||
activeLocation,
|
||||
children,
|
||||
setActiveLocation,
|
||||
markers,
|
||||
geoJson,
|
||||
defaultLocation,
|
||||
}: PropsWithChildren<MapProps>) {
|
||||
const router = useRouter()
|
||||
const { activeMarker: activeHotelId, setActiveMarker } =
|
||||
useDestinationPageHotelsMapStore()
|
||||
const activeHotel = hotels.find(({ hotel }) => hotel.id === activeHotelId)
|
||||
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
const [mapHeight, setMapHeight] = useState("100dvh")
|
||||
const [fromCountryPage, setFromCountryPage] = useState(false)
|
||||
const params = useParams()
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 550,
|
||||
@@ -68,24 +80,14 @@ export default function Map({
|
||||
activeFilters: state.activeFilters,
|
||||
}))
|
||||
|
||||
const zoomConstants = pageType === "city" ? CITY_PAGE : COUNTRY_PAGE
|
||||
|
||||
const hasActiveFilters = activeFilters.length > 0
|
||||
|
||||
const markers = getHotelMapMarkers(hotels)
|
||||
const geoJson = mapMarkerDataToGeoJson(markers)
|
||||
const defaultCenter = activeHotel
|
||||
? {
|
||||
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))
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
setFromCountryPage(url.searchParams.has("fromCountry"))
|
||||
}, [params])
|
||||
|
||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
||||
const handleMapHeight = useCallback(() => {
|
||||
@@ -94,10 +96,22 @@ export default function Map({
|
||||
}, [])
|
||||
|
||||
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)
|
||||
url.searchParams.delete("view")
|
||||
url.searchParams.delete("fromCountry")
|
||||
router.push(url.toString())
|
||||
setActiveMarker(null)
|
||||
setActiveLocation(null)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -131,6 +145,22 @@ export default function Map({
|
||||
}
|
||||
}, [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 (
|
||||
<MapProvider apiKey={apiKey} pageType={pageType}>
|
||||
<div
|
||||
@@ -140,14 +170,23 @@ export default function Map({
|
||||
>
|
||||
<div className={styles.mobileNavigation}>
|
||||
<Button
|
||||
intent="text"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
variant="Text"
|
||||
size="Small"
|
||||
color="Primary"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<MaterialIcon icon="chevron_left" size={20} color="CurrentColor" />
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
<MaterialIcon
|
||||
icon="arrow_back_ios"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
{fromCountryPage
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Back to cities",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Back to list",
|
||||
})}
|
||||
</Button>
|
||||
<DestinationFilterAndSort
|
||||
@@ -169,13 +208,17 @@ export default function Map({
|
||||
<DynamicMap
|
||||
markers={markers}
|
||||
mapId={mapId}
|
||||
onClose={handleClose}
|
||||
onClose={backToListView}
|
||||
defaultCenter={defaultCenter}
|
||||
defaultZoom={defaultZoom}
|
||||
fitBounds={!activeHotel}
|
||||
fitBounds={!activeLocation}
|
||||
gestureHandling="greedy"
|
||||
>
|
||||
<MapContent geojson={geoJson} disableClustering={hasActiveFilters} />
|
||||
<MapContent
|
||||
geojson={geoJson}
|
||||
disableClustering={hasActiveFilters}
|
||||
pageType={pageType}
|
||||
/>
|
||||
</DynamicMap>
|
||||
</div>
|
||||
</MapProvider>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
.mobileNavigation {
|
||||
display: flex;
|
||||
padding: var(--Space-x2);
|
||||
padding: 0 var(--Space-x2);
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
CityMarker,
|
||||
CityMarkerFeature,
|
||||
CityMarkerGeojson,
|
||||
DestinationMarker,
|
||||
MarkerFeature,
|
||||
MarkerGeojson,
|
||||
@@ -52,6 +56,59 @@ export function getHotelMapMarkers(hotels: HotelListingHotelData[]) {
|
||||
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">) {
|
||||
if (hotel.galleryImages?.length) {
|
||||
const image = hotel.galleryImages[0]
|
||||
@@ -62,3 +119,13 @@ function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
||||
}
|
||||
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"
|
||||
|
||||
import { useRef } from "react"
|
||||
import React, { useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
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 { getHeadingText } from "@/components/ContentType/DestinationPage/utils"
|
||||
import { getCityHeadingText, getCountryHeadingText } from "../utils"
|
||||
|
||||
import styles from "./sidebarContentWrapper.module.css"
|
||||
|
||||
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
||||
preamble: string
|
||||
location: string
|
||||
pageType: "country" | "city"
|
||||
}
|
||||
|
||||
export default function SidebarContentWrapper({
|
||||
preamble,
|
||||
location,
|
||||
pageType,
|
||||
children,
|
||||
}: SidebarContentWrapperProps) {
|
||||
const intl = useIntl()
|
||||
@@ -33,19 +35,23 @@ export default function SidebarContentWrapper({
|
||||
ref: sidebarRef,
|
||||
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
||||
})
|
||||
|
||||
const heading = getHeadingText(intl, location, allFilters, filterFromUrl)
|
||||
const heading =
|
||||
pageType === "country"
|
||||
? getCountryHeadingText(intl, location, allFilters, filterFromUrl)
|
||||
: getCityHeadingText(intl, location, allFilters, filterFromUrl)
|
||||
|
||||
return (
|
||||
<div ref={sidebarRef} className={styles.sidebarContent}>
|
||||
<div className={styles.text}>
|
||||
<Typography variant="Title/md">
|
||||
<h1 className={styles.heading}>{heading}</h1>
|
||||
</Typography>
|
||||
{!filterFromUrl ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.text}>{preamble}</p>
|
||||
<p>{preamble}</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,20 +5,30 @@
|
||||
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 {
|
||||
color: var(--Text-Heading);
|
||||
hyphens: auto;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--Text-Default);
|
||||
max-width: var(--max-width-text-block);
|
||||
@media screen and (min-width: 950px) {
|
||||
.sidebarContent {
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: var(--Space-x4) var(--Space-x3);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.sidebarContent {
|
||||
position: sticky;
|
||||
padding: var(--Space-x4) var(--Space-x3);
|
||||
grid-template-columns: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
"use client"
|
||||
|
||||
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 styles from "./mapWrapper.module.css"
|
||||
|
||||
export default function MapWrapper({ children }: React.PropsWithChildren) {
|
||||
const params = useParams()
|
||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("view", "map")
|
||||
setMapUrl(url.toString())
|
||||
}, [params])
|
||||
const mapUrl = useSetMapView()
|
||||
|
||||
if (!mapUrl) {
|
||||
return null
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
@media (min-width: 950px) {
|
||||
.link {
|
||||
display: flex;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
export function getHeadingText(
|
||||
export function getCityHeadingText(
|
||||
intl: IntlShape,
|
||||
location: string,
|
||||
allFilters: CategorizedHotelFilters,
|
||||
@@ -41,3 +41,41 @@ export function getHeadingText(
|
||||
{ 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"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||
import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker"
|
||||
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) {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("view", "map")
|
||||
setMapUrl(url.toString())
|
||||
}, [params])
|
||||
const mapUrl = useSetMapView()
|
||||
|
||||
return (
|
||||
<div className={styles.mapCard}>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
import { cx } from "class-variance-authority"
|
||||
import NextLink from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
@@ -14,14 +13,7 @@ import styles from "./mobileToggle.module.css"
|
||||
|
||||
export default function MobileMapToggle() {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("view", "map")
|
||||
setMapUrl(url.toString())
|
||||
}, [params])
|
||||
const mapUrl = useSetMapView()
|
||||
|
||||
if (!mapUrl) {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
|
||||
@@ -12,20 +12,20 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/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 Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
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 { useDestinationDataStore } from "@/stores/destination-data"
|
||||
|
||||
import Filter from "./Filter"
|
||||
import { FilterAndSortButton } from "./FilterAndSortButton"
|
||||
import Sort from "./Sort"
|
||||
|
||||
import styles from "./destinationFilterAndSort.module.css"
|
||||
|
||||
interface HotelFilterAndSortProps {
|
||||
export interface HotelFilterAndSortProps {
|
||||
listType: "city" | "hotel"
|
||||
}
|
||||
|
||||
@@ -127,23 +127,10 @@ export default function DestinationFilterAndSort({
|
||||
resetPendingValues()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTrigger onOpenChange={handleClose}>
|
||||
<div className={styles.buttonWrapper}>
|
||||
<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>
|
||||
<FilterAndSortButton filterLength={activeFilters.length} />
|
||||
<ModalOverlay isDismissable className={styles.overlay}>
|
||||
<Modal>
|
||||
<Dialog
|
||||
@@ -193,19 +180,19 @@ export default function DestinationFilterAndSort({
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
onClick={clearPendingFilters}
|
||||
intent="text"
|
||||
size="medium"
|
||||
theme="base"
|
||||
variant="Text"
|
||||
size="Small"
|
||||
color="Primary"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Clear all filters",
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
intent="primary"
|
||||
size="large"
|
||||
theme="base"
|
||||
disabled={pendingCount === 0}
|
||||
variant="Primary"
|
||||
size="Medium"
|
||||
color="Primary"
|
||||
isDisabled={pendingCount === 0}
|
||||
onClick={() => submitAndClose(close)}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useMapViewport } from "./use-map-viewport"
|
||||
|
||||
import type { FeatureCollection, GeoJsonProperties, Point } from "geojson"
|
||||
|
||||
import type { CityMarkerProperties } from "@/types/components/maps/destinationMarkers"
|
||||
|
||||
export function useSupercluster<T extends GeoJsonProperties>(
|
||||
geojson: FeatureCollection<Point, T>,
|
||||
superclusterOptions: Supercluster.Options<T, ClusterProperties>
|
||||
@@ -45,16 +47,29 @@ export function useSupercluster<T extends GeoJsonProperties>(
|
||||
}
|
||||
|
||||
// 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") {
|
||||
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 {
|
||||
clusters,
|
||||
containedHotels,
|
||||
containedHotelIds,
|
||||
containedCityIds,
|
||||
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 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 {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
gap: var(--Space-x2);
|
||||
margin-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
.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 { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||
|
||||
import {
|
||||
DEFAULT_ZOOM,
|
||||
MAP_RESTRICTIONS,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
} from '../mapConstants'
|
||||
import { HOTEL_PAGE, MAP_RESTRICTIONS } from '../mapConstants'
|
||||
|
||||
import { useZoomControls } from './useZoomControls'
|
||||
import { useZoomControls } from '@scandic-hotels/common/hooks/map/useZoomControls'
|
||||
|
||||
import { HotelListingMapContent } from './HotelListingMapContent'
|
||||
import PoiMapMarkers from './PoiMapMarkers'
|
||||
@@ -87,12 +82,12 @@ export function InteractiveMap({
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
|
||||
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls()
|
||||
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls(HOTEL_PAGE)
|
||||
|
||||
const mapOptions: MapProps = {
|
||||
defaultZoom: DEFAULT_ZOOM,
|
||||
minZoom: MIN_ZOOM,
|
||||
maxZoom: MAX_ZOOM,
|
||||
defaultZoom: HOTEL_PAGE.DEFAULT_ZOOM,
|
||||
minZoom: HOTEL_PAGE.MIN_ZOOM,
|
||||
maxZoom: HOTEL_PAGE.MAX_ZOOM,
|
||||
defaultCenter: coordinates,
|
||||
disableDefaultUI: true,
|
||||
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 },
|
||||
}
|
||||
|
||||
export const DEFAULT_ZOOM = 14
|
||||
export const MAX_ZOOM = 18
|
||||
export const MIN_ZOOM = 8
|
||||
export const HOTEL_PAGE = {
|
||||
DEFAULT_ZOOM: 14,
|
||||
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-weight: 400;
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"./Alert": "./lib/components/Alert/index.tsx",
|
||||
"./Avatar": "./lib/components/Avatar/index.tsx",
|
||||
"./BackToTopButton": "./lib/components/BackToTopButton/index.tsx",
|
||||
"./Badge": "./lib/components/Badge/index.tsx",
|
||||
"./Body": "./lib/components/Body/index.tsx",
|
||||
"./BookingCodeChip": "./lib/components/BookingCodeChip/index.tsx",
|
||||
"./Button": "./lib/components/Button/index.tsx",
|
||||
|
||||
@@ -23,6 +23,10 @@ query GetDestinationCityListData($locale: String!, $cityIdentifier: String!) {
|
||||
city_norway
|
||||
city_poland
|
||||
city_sweden
|
||||
location {
|
||||
longitude
|
||||
latitude
|
||||
}
|
||||
}
|
||||
sort_order
|
||||
preamble
|
||||
|
||||
@@ -138,7 +138,9 @@ export async function getCityPages(
|
||||
lang,
|
||||
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({
|
||||
longitude: z.number().nullable(),
|
||||
latitude: z.number().nullable(),
|
||||
default_zoom: z.number().nullable(),
|
||||
default_zoom: z.number().nullish(),
|
||||
})
|
||||
.nullish()
|
||||
.transform((val) => {
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface CityPageUrlsData extends z.output<typeof cityPageUrlsSchema> {}
|
||||
|
||||
export interface DestinationCityListItem extends DestinationCityListData {
|
||||
cityName: string
|
||||
cityIdentifier: string
|
||||
hotelsCount?: number
|
||||
}
|
||||
export type Block = z.output<typeof blocksSchema>
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ const icons = [
|
||||
"festival",
|
||||
"filter_alt",
|
||||
"filter",
|
||||
"format_list_bulleted",
|
||||
"floor_lamp",
|
||||
"forest",
|
||||
"garage",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
|
||||
hash=f2d895e1
|
||||
created=2025-09-16T08:22:55.703Z
|
||||
hash=1db5531f
|
||||
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