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:
Matilda Landström
2025-09-24 12:04:01 +00:00
parent af4f544b8a
commit 00689607bc
93 changed files with 1876 additions and 600 deletions

View File

@@ -5,18 +5,18 @@ import DestinationCountryPageSkeleton from "@/components/ContentType/Destination
import styles from "./page.module.css" import styles from "./page.module.css"
import type { PageArgs } from "@/types/params"
export { generateMetadata } from "@/utils/metadata/generateMetadata" export { generateMetadata } from "@/utils/metadata/generateMetadata"
export default async function DestinationCountryPagePage() { export default async function DestinationCountryPagePage(
// props: PageArgs<{}, { view?: "map"; }> props: PageArgs<object, { view?: "map" }>
// const searchParams = await props.searchParams ) {
const searchParams = await props.searchParams
return ( return (
<div className={styles.page}> <div className={styles.page}>
<Suspense fallback={<DestinationCountryPageSkeleton />}> <Suspense fallback={<DestinationCountryPageSkeleton />}>
<DestinationCountryPage <DestinationCountryPage isMapView={searchParams.view === "map"} />
// isMapView={searchParams.view === "map"} // Disabled until further notice
isMapView={false}
/>
</Suspense> </Suspense>
</div> </div>
) )

View File

@@ -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)
);
}
}

View File

@@ -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
}

View File

@@ -9,16 +9,16 @@ import { Carousel } from "@/components/Carousel"
import HotelMapCard from "../HotelMapCard" import HotelMapCard from "../HotelMapCard"
import styles from "./hotelCardCarousel.module.css" import styles from "./destinationCardCarousel.module.css"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
interface MapCardCarouselProps { interface HotelCardCarouselProps {
visibleHotels: HotelListingHotelData[] visibleHotels: HotelListingHotelData[]
} }
export default function HotelCardCarousel({ export default function HotelCardCarousel({
visibleHotels, visibleHotels,
}: MapCardCarouselProps) { }: HotelCardCarouselProps) {
const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore() const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex( const selectedHotelIdx = visibleHotels.findIndex(
@@ -36,18 +36,18 @@ export default function HotelCardCarousel({
return ( return (
<Carousel <Carousel
className={styles.carousel}
scrollToIdx={selectedHotelIdx} scrollToIdx={selectedHotelIdx}
className={styles.carousel}
align="center" align="center"
opts={{ containScroll: false }} opts={{ containScroll: false }}
onScrollSelect={handleScrollSelect} onScrollSelect={handleScrollSelect}
> >
<Carousel.Content className={styles.carouselContent}> <Carousel.Content className={styles.carouselContent}>
{visibleHotels.map(({ hotel, url }) => ( {visibleHotels.map(({ hotel, url }) => (
<Carousel.Item key={hotel.id} className={styles.item}> <Carousel.Item key={hotel.id}>
<HotelMapCard <HotelMapCard
className={cx(styles.carouselCard, { className={cx({
[styles.noActiveHotel]: !activeMarker, [styles.noActiveCard]: !activeMarker,
})} })}
tripadvisorRating={hotel.tripadvisor} tripadvisorRating={hotel.tripadvisor}
hotelName={hotel.name} hotelName={hotel.name}

View File

@@ -1,15 +1,15 @@
.noActiveHotel, .noActiveCard,
.carousel { .carousel {
display: none; display: none;
} }
@media screen and (max-width: 949px) { @media screen and (max-width: 949px) {
.carousel:not(.noActiveHotel) { .carousel {
display: grid; display: grid;
} }
.carouselContent { .carouselContent {
gap: var(--Spacing-x1); gap: var(--Space-x1);
align-items: end; align-items: end;
} }
} }

View File

@@ -0,0 +1,10 @@
.backToCities {
display: none;
}
@media screen and (min-width: 950px) {
.backToCities {
display: flex;
margin-bottom: var(--Space-x2);
}
}

View File

@@ -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>
)
}

View File

@@ -1,12 +1,9 @@
"use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { Alert } from "@scandic-hotels/design-system/Alert" import { Alert } from "@scandic-hotels/design-system/Alert"
import HotelCardCarousel from "../../../HotelCardCarousel" import HotelCardCarousel from "../../../DestinationCardCarousel/HotelCardCarousel"
import HotelListItem from "../HotelListItem" import HotelListItem from "../HotelListItem"
import styles from "./hotelList.module.css" import styles from "./hotelList.module.css"
@@ -23,7 +20,6 @@ export default function HotelListContent({
visibleHotels, visibleHotels,
}: HotelListContentProps) { }: HotelListContentProps) {
const intl = useIntl() const intl = useIntl()
const isMobile = useMediaQuery("(max-width: 949px)")
if (hotelsCount === 0) { if (hotelsCount === 0) {
return ( return (
@@ -40,17 +36,17 @@ export default function HotelListContent({
) )
} }
if (isMobile) {
return <HotelCardCarousel visibleHotels={visibleHotels} />
}
return ( return (
<ul className={styles.hotelList}> <>
{visibleHotels.map(({ hotel, url }) => ( <HotelCardCarousel visibleHotels={visibleHotels} />
<li key={hotel.id}>
<HotelListItem hotel={hotel} url={url} /> <ul className={styles.hotelList}>
</li> {visibleHotels.map(({ hotel, url }) => (
))} <li key={hotel.id}>
</ul> <HotelListItem hotel={hotel} url={url} />
</li>
))}
</ul>
</>
) )
} }

View File

@@ -96,7 +96,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
<h3>{hotel.name}</h3> <h3>{hotel.name}</h3>
</Typography> </Typography>
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.captions}> <span className={styles.captions}>
<Typography variant="Link/sm"> <Typography variant="Link/sm">
<ButtonRAC <ButtonRAC
className={styles.addressButton} className={styles.addressButton}
@@ -120,7 +120,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
} }
)} )}
</span> </span>
</p> </span>
</Typography> </Typography>
</div> </div>
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">

View File

@@ -1,12 +1,15 @@
"use client" "use client"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import Map from "../../Map" import CityMapContainer from "../../Map/CityMapContainer"
import { getHeadingText } from "../../utils" import { getCityHeadingText } from "../../utils"
import { BackToCities } from "./BackToCitiesLink"
import HotelList from "./HotelList" import HotelList from "./HotelList"
import styles from "./cityMap.module.css" import styles from "./cityMap.module.css"
@@ -36,21 +39,30 @@ export default function CityMap({
filterFromUrl: state.filterFromUrl, filterFromUrl: state.filterFromUrl,
}) })
) )
const [fromCountryPage, setIsFromCountryPage] = useState(false)
const params = useParams()
useEffect(() => {
const url = new URL(window.location.href)
setIsFromCountryPage(url.searchParams.has("fromCountry"))
}, [params])
return ( return (
<Map <CityMapContainer
hotels={activeHotels} hotels={activeHotels}
mapId={mapId} mapId={mapId}
apiKey={apiKey} apiKey={apiKey}
pageType="city"
defaultLocation={defaultLocation} defaultLocation={defaultLocation}
> >
<Typography variant="Title/sm"> <span className="topSection">
<h1 className={styles.title}> {fromCountryPage ? <BackToCities /> : null}
{getHeadingText(intl, city.name, allFilters, filterFromUrl)} <Typography variant="Title/sm">
</h1> <h1 className={styles.title}>
</Typography> {getCityHeadingText(intl, city.name, allFilters, filterFromUrl)}
</h1>
</Typography>
</span>
<HotelList /> <HotelList />
</Map> </CityMapContainer>
) )
} }

View File

@@ -4,8 +4,8 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import HotelListingSkeleton from "../DestinationListing/HotelListing/HotelListingSkeleton"
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton" import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton" import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton" import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"

View File

@@ -22,8 +22,8 @@ import DestinationDataProvider from "@/providers/DestinationDataProvider"
import { getPathname } from "@/utils/getPathname" import { getPathname } from "@/utils/getPathname"
import Blocks from "../Blocks" import Blocks from "../Blocks"
import HotelListing from "../DestinationListing/HotelListing"
import ExperienceList from "../ExperienceList" import ExperienceList from "../ExperienceList"
import HotelListing from "../HotelListing"
import SidebarContentWrapper from "../SidebarContentWrapper" import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek" import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap" import StaticMap from "../StaticMap"
@@ -121,7 +121,11 @@ export default async function DestinationCityPage({
<SeoFilters seoFilters={seo_filters} location={city.name} /> <SeoFilters seoFilters={seo_filters} location={city.name} />
</main> </main>
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<SidebarContentWrapper preamble={preamble} location={city.name}> <SidebarContentWrapper
preamble={preamble}
location={city.name}
pageType="city"
>
<ExperienceList experiences={experiences} /> <ExperienceList experiences={experiences} />
{has_sidepeek && sidepeek_content ? ( {has_sidepeek && sidepeek_content ? (
<DestinationPageSidePeek <DestinationPageSidePeek

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -14,7 +14,7 @@ export default function CityListSkeleton() {
<SkeletonShimmer height="30px" width="120px" /> <SkeletonShimmer height="30px" width="120px" />
</div> </div>
<ul className={styles.cityList}> <ul className={styles.cityList}>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 5 }).map((_, index) => (
<CityListItemSkeleton key={index} /> <CityListItemSkeleton key={index} />
))} ))}
</ul> </ul>

View File

@@ -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>
</>
)
}

View File

@@ -1,6 +1,6 @@
.cityListWrapper { .cityListWrapper {
display: grid; display: grid;
gap: var(--Spacing-x3); gap: var(--Space-x3);
} }
.header { .header {
@@ -13,15 +13,13 @@
.cityList { .cityList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x3); gap: var(--Space-x15);
list-style: none; list-style: none;
} }
@media screen and (max-width: 949px) { @media screen and (max-width: 949px) {
.cityList { .cityList {
flex-direction: row; display: none;
align-items: end;
overflow-x: scroll;
} }
.header { .header {

View File

@@ -2,16 +2,14 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { Typography } from "@scandic-hotels/design-system/Typography"
import { Alert } from "@scandic-hotels/design-system/Alert"
import Body from "@scandic-hotels/design-system/Body"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort" import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import CityListItem from "../CityListItem"
import CityListSkeleton from "./CityListSkeleton" import CityListSkeleton from "./CityListSkeleton"
import { CityListContent } from "./Content"
import styles from "./cityList.module.css" import styles from "./cityList.module.css"
@@ -27,36 +25,20 @@ export default function CityList() {
) : ( ) : (
<div className={styles.cityListWrapper}> <div className={styles.cityListWrapper}>
<div className={styles.header}> <div className={styles.header}>
<Body> <Typography variant="Body/Paragraph/mdRegular">
{intl.formatMessage( <p>
{ {intl.formatMessage(
defaultMessage: "{count} destinations", {
}, defaultMessage: "{count} destinations",
{ count: activeCities.length } },
)} { count: activeCities.length }
</Body> )}
</p>
</Typography>
<DestinationFilterAndSort listType="city" /> <DestinationFilterAndSort listType="city" />
</div> </div>
{activeCities.length === 0 ? (
<Alert <CityListContent />
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No matching locations found",
})}
text={intl.formatMessage({
defaultMessage:
"It looks like no location match your filters. Try adjusting your search to find the perfect stay.",
})}
/>
) : (
<ul className={styles.cityList}>
{activeCities.map((city) => (
<li key={city.system.uid}>
<CityListItem city={city} />
</li>
))}
</ul>
)}
</div> </div>
) )
} }

View File

@@ -2,24 +2,16 @@
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer" import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
import styles from "./cityListItem.module.css" import styles from "./cityListItem.module.css"
export default function CityListItemSkeleton() { export default function CityListItemSkeleton() {
return ( return (
<article className={styles.cityListItem}> <article className={styles.card}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<SkeletonShimmer width="100%" height="100%" /> <SkeletonShimmer width="100%" height="100%" />
</div> </div>
<section className={styles.content}> <section className={styles.content}>
<SkeletonShimmer height="52px" /> <SkeletonShimmer height="100%" />
<div className={styles.experienceList}>
<ExperienceListSkeleton />
</div>
<div className={styles.ctaWrapper}>
<SkeletonShimmer height="45px" width="100%" />
</div>
</section> </section>
</article> </article>
) )

View File

@@ -1,14 +1,19 @@
.cityListItem { .card {
display: grid; border-radius: var(--Corner-radius-Medium);
background-color: var(--Base-Surface-Primary-light-Normal); background-color: var(--Surface-Primary-Default);
border: 1px solid var(--Base-Border-Subtle); display: flex;
border-radius: var(--Corner-radius-md); height: 80px;
overflow: hidden; overflow: hidden;
} }
.card:hover {
background: var(--Surface-Primary-Hover);
}
.imageWrapper { .imageWrapper {
position: relative; overflow: hidden;
height: 200px; height: 80px;
max-width: 80px;
width: 100%; width: 100%;
} }
@@ -17,32 +22,34 @@
} }
.content { .content {
align-items: center;
display: grid; display: grid;
gap: var(--Spacing-x2); grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3); margin: var(--Space-x15);
margin-left: var(--Space-x2);
color: var(--Text-Interactive-Default);
width: 100%;
} }
@media screen and (max-width: 949px) { .left {
.cityListItem { display: grid;
width: 360px; gap: var(--Space-x025);
min-height: 120px; }
grid-template-columns: 1fr 2fr;
}
.imageWrapper { .right {
height: 100%; display: flex;
} justify-content: flex-end;
align-items: center;
height: 100%;
cursor: pointer;
padding-right: var(--Space-x3);
border: none;
background-color: transparent;
}
@media (min-width: 950px) {
.content { .content {
padding: var(--Spacing-x-one-and-half); min-width: 220px;
gap: var(--Spacing-x1);
}
.experienceList {
display: none;
}
.ctaWrapper {
margin-top: auto;
} }
} }

View File

@@ -1,64 +1,135 @@
"use client" "use client"
import Link from "next/link" import { useCallback } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton" import Image from "@scandic-hotels/design-system/Image"
import Subtitle from "@scandic-hotels/design-system/Subtitle" import ImageFallback from "@scandic-hotels/design-system/ImageFallback"
import Link from "@scandic-hotels/design-system/Link"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery" import { useDestinationDataStore } from "@/stores/destination-data"
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
import ExperienceList from "../../../ExperienceList"
import styles from "./cityListItem.module.css" import styles from "./cityListItem.module.css"
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" import type { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
interface CityListItemProps { interface CityListItemProps {
city: DestinationCityListItem cityName: string
cityIdentifier: string
url: string
image: ImageVaultAsset | null
location: { lat: number; lng: number }
} }
export default function CityListItem({ city }: CityListItemProps) { export function CityListItem({
cityName,
cityIdentifier,
url,
image,
location,
}: CityListItemProps) {
const intl = useIntl() const intl = useIntl()
const galleryImages = mapImageVaultImagesToGalleryImages(city.images || []) const {
hoveredCityMarker,
setHoveredCityMarker,
activeCityMarker,
setActiveCityMarker,
} = useDestinationPageCitiesMapStore()
const { activeCities } = useDestinationDataStore((state) => ({
activeCities: state.activeCities,
}))
const handleMouseEnter = useCallback(() => {
if (activeCityMarker?.cityId !== hoveredCityMarker) {
setActiveCityMarker(null)
}
if (cityIdentifier) {
setHoveredCityMarker(cityIdentifier)
}
}, [
cityIdentifier,
activeCityMarker,
setActiveCityMarker,
hoveredCityMarker,
setHoveredCityMarker,
])
const handleMouseLeave = useCallback(() => {
setHoveredCityMarker(null)
}, [setHoveredCityMarker])
const handleClickCard = useCallback(() => {
const clickedCity = activeCities.find(
(city) => city.cityIdentifier === cityIdentifier
)
if (clickedCity) {
setHoveredCityMarker(null)
setActiveCityMarker({ cityId: cityIdentifier, location })
}
}, [
activeCities,
cityIdentifier,
location,
setActiveCityMarker,
setHoveredCityMarker,
])
return ( return (
<article className={styles.cityListItem}> <article
className={styles.card}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<ImageGallery {image ? (
images={galleryImages} <Image
fill src={image.url}
title={intl.formatMessage( alt={image.meta.alt || image.meta.caption || ""}
{ focalPoint={image.focalPoint}
defaultMessage: "{title} - Image gallery", width={80}
}, height={80}
{ title: city.cityName } title={cityName}
)} />
/> ) : (
</div> <ImageFallback height="80px" />
<section className={styles.content}>
<Subtitle asChild>
<h3>{city.heading}</h3>
</Subtitle>
{city.experiences?.length && (
<div className={styles.experienceList}>
<ExperienceList experiences={city.experiences} />
</div>
)} )}
<div className={styles.ctaWrapper}> </div>
<Button intent="tertiary" theme="base" size="small" asChild> <div className={styles.content}>
<Link href={city.url}> <div className={styles.left}>
{intl.formatMessage( <Typography variant="Body/Paragraph/mdBold">
{ <h3>{cityName}</h3>
defaultMessage: "Explore {city}", </Typography>
}, <div>
{ city: city.cityName } <Link href={url} color="Text/Interactive/Secondary" variant="icon">
)} <Typography variant="Link/sm">
<span>
{intl.formatMessage({
defaultMessage: "Explore city",
})}
</span>
</Typography>
<MaterialIcon icon="open_in_new" size={20} color="CurrentColor" />
</Link> </Link>
</Button> </div>
</div> </div>
</section> <button
onClick={handleClickCard}
aria-label={intl.formatMessage({
defaultMessage: "See on map",
})}
className={styles.right}
>
<MaterialIcon
icon="arrow_forward"
size={24}
color="Icon/Interactive/Default"
/>
</button>
</div>
</article> </article>
) )
} }

View File

@@ -6,8 +6,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import Map from "../../Map" import CountryMapContainer from "../../Map/CountryMapContainer"
import { getHeadingText } from "../../utils" import { getCountryHeadingText } from "../../utils"
import CityList from "./CityList" import CityList from "./CityList"
import styles from "./countryMap.module.css" import styles from "./countryMap.module.css"
@@ -28,28 +28,27 @@ export default function CountryMap({
defaultLocation, defaultLocation,
}: CountryMapProps) { }: CountryMapProps) {
const intl = useIntl() const intl = useIntl()
const { activeHotels, allFilters, filterFromUrl } = useDestinationDataStore( const { activeCities, allFilters, filterFromUrl } = useDestinationDataStore(
(state) => ({ (state) => ({
activeHotels: state.activeHotels, activeCities: state.activeCities,
allFilters: state.allFilters, allFilters: state.allFilters,
filterFromUrl: state.filterFromUrl, filterFromUrl: state.filterFromUrl,
}) })
) )
return ( return (
<Map <CountryMapContainer
hotels={activeHotels} cities={activeCities}
mapId={mapId} mapId={mapId}
apiKey={apiKey} apiKey={apiKey}
pageType="country"
defaultLocation={defaultLocation} defaultLocation={defaultLocation}
> >
<Typography variant="Title/sm"> <Typography variant="Title/sm">
<h1 className={styles.title}> <h1 className={styles.title}>
{getHeadingText(intl, country, allFilters, filterFromUrl)} {getCountryHeadingText(intl, country, allFilters, filterFromUrl)}
</h1> </h1>
</Typography> </Typography>
<CityList /> <CityList />
</Map> </CountryMapContainer>
) )
} }

View File

@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import CityListingSkeleton from "../CityListing/CityListingSkeleton" import CityListingSkeleton from "../DestinationListing/CityListing/CityListingSkeleton"
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton" import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton" import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton" import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"

View File

@@ -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>
)
}

View File

@@ -0,0 +1,7 @@
.content {
padding: var(--Space-x15);
display: grid;
gap: var(--Space-x1);
list-style: none;
white-space: nowrap;
}

View File

@@ -23,10 +23,11 @@ import DestinationDataProvider from "@/providers/DestinationDataProvider"
import { getPathname } from "@/utils/getPathname" import { getPathname } from "@/utils/getPathname"
import Blocks from "../Blocks" import Blocks from "../Blocks"
import CityListing from "../CityListing" import CityListing from "../DestinationListing/CityListing"
import ExperienceList from "../ExperienceList" import ExperienceList from "../ExperienceList"
import SidebarContentWrapper from "../SidebarContentWrapper" import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek" import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap"
import TopImages from "../TopImages" import TopImages from "../TopImages"
import DestinationTracking from "../Tracking" import DestinationTracking from "../Tracking"
import CountryMap from "./CountryMap" import CountryMap from "./CountryMap"
@@ -85,12 +86,26 @@ export default async function DestinationCountryPage({
}, },
] ]
/* Add hotel count to cities */
const hotelsCountMap = new Map<string, number>()
allHotels.forEach(({ hotel: { cityIdentifier } }) => {
if (cityIdentifier)
hotelsCountMap.set(
cityIdentifier,
(hotelsCountMap.get(cityIdentifier) || 0) + 1
)
})
const allCitiesWithCount = allCities.map((obj) => ({
...obj,
hotelsCount: hotelsCountMap.get(obj.cityIdentifier) || 0,
}))
return ( return (
<> <>
<Suspense fallback={<DestinationCountryPageSkeleton />}> <Suspense fallback={<DestinationCountryPageSkeleton />}>
<DestinationDataProvider <DestinationDataProvider
allHotels={allHotels} allHotels={allHotels}
allCities={allCities} allCities={allCitiesWithCount}
hotelFilters={hotelFilters} hotelFilters={hotelFilters}
seoFilters={seo_filters} seoFilters={seo_filters}
sortItems={sortItems} sortItems={sortItems}
@@ -128,6 +143,7 @@ export default async function DestinationCountryPage({
<SidebarContentWrapper <SidebarContentWrapper
preamble={preamble} preamble={preamble}
location={translatedCountry} location={translatedCountry}
pageType="country"
> >
<ExperienceList experiences={experiences} /> <ExperienceList experiences={experiences} />
{has_sidepeek && sidepeek_content ? ( {has_sidepeek && sidepeek_content ? (
@@ -137,6 +153,13 @@ export default async function DestinationCountryPage({
location={translatedCountry} location={translatedCountry}
/> />
) : null} ) : null}
{destination_settings.country && (
<StaticMap
city={destination_settings.country}
location={destination_settings.location}
/>
)}
</SidebarContentWrapper> </SidebarContentWrapper>
</aside> </aside>
</div> </div>

View File

@@ -3,7 +3,7 @@
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer" import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import ExperienceListSkeleton from "../../ExperienceList/ExperienceListSkeleton" import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
import styles from "./cityListingItem.module.css" import styles from "./cityListingItem.module.css"

View File

@@ -17,8 +17,8 @@
.content { .content {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Space-x2);
padding: var(--Spacing-x2) var(--Spacing-x3); padding: var(--Space-x2) var(--Space-x3);
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {

View File

@@ -11,7 +11,7 @@ import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery" import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
import ExperienceList from "../../ExperienceList" import ExperienceList from "../../../ExperienceList"
import styles from "./cityListingItem.module.css" import styles from "./cityListingItem.module.css"

View File

@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton" import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
import styles from "./cityListing.module.css" import styles from "../destinationListing.module.css"
export default function CityListingSkeleton() { export default function CityListingSkeleton() {
return ( return (

View File

@@ -4,6 +4,7 @@ import { useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop" import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
import { Alert } from "@scandic-hotels/design-system/Alert" import { Alert } from "@scandic-hotels/design-system/Alert"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton" import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
@@ -11,16 +12,16 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort" import { SeeOnMapFilterWrapper } from "../../SeeOnMapFilterWrapper"
import CityListingItem from "./CityListingItem" import CityListingItem from "./CityListingItem"
import CityListingSkeleton from "./CityListingSkeleton" import CityListingSkeleton from "./CityListingSkeleton"
import styles from "./cityListing.module.css" import styles from "../destinationListing.module.css"
export default function CityListing() { export default function CityListing() {
const intl = useIntl() const intl = useIntl()
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const mapUrl = useSetMapView()
const { showBackToTop, scrollToTop } = useScrollToTop({ const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300, threshold: 300,
elementRef: scrollRef, elementRef: scrollRef,
@@ -46,7 +47,7 @@ export default function CityListing() {
)} )}
</h2> </h2>
</Typography> </Typography>
<DestinationFilterAndSort listType="city" /> <SeeOnMapFilterWrapper mapUrl={mapUrl} listType="city" />
</div> </div>
{activeCities.length === 0 ? ( {activeCities.length === 0 ? (
<Alert <Alert
@@ -61,7 +62,7 @@ export default function CityListing() {
/> />
) : ( ) : (
<> <>
<ul className={styles.cityList}> <ul className={styles.list}>
{activeCities.map((city) => ( {activeCities.map((city) => (
<li key={city.system.uid}> <li key={city.system.uid}>
<CityListingItem city={city} /> <CityListingItem city={city} />

View File

@@ -1,10 +1,9 @@
"use client" "use client"
import NextLink from "next/link" import NextLink from "next/link"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting" import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink" import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
@@ -33,20 +32,12 @@ export default function HotelListingItem({
url, url,
}: HotelListingItemProps) { }: HotelListingItemProps) {
const intl = useIntl() const intl = useIntl()
const params = useParams()
const { setActiveMarker } = useDestinationPageHotelsMapStore() const { setActiveMarker } = useDestinationPageHotelsMapStore()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5) const amenities = hotel.detailedFacilities.slice(0, 5)
const [mapUrl, setMapUrl] = useState<string | null>(null) const mapUrl = useSetMapView()
const address = `${hotel.address.streetAddress}, ${hotel.address.city}` const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params, hotel.name])
return ( return (
<article className={styles.container}> <article className={styles.container}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>

View File

@@ -4,7 +4,7 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton" import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton"
import styles from "./hotelListing.module.css" import styles from "../destinationListing.module.css"
export default function HotelListingSkeleton() { export default function HotelListingSkeleton() {
return ( return (

View File

@@ -1,32 +1,27 @@
"use client" "use client"
import Link from "next/link" import { useRef } from "react"
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop" import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
import { Alert } from "@scandic-hotels/design-system/Alert" import { Alert } from "@scandic-hotels/design-system/Alert"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton" import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort" import { SeeOnMapFilterWrapper } from "../../SeeOnMapFilterWrapper"
import HotelListingItem from "./HotelListingItem" import HotelListingItem from "./HotelListingItem"
import HotelListingSkeleton from "./HotelListingSkeleton" import HotelListingSkeleton from "./HotelListingSkeleton"
import styles from "./hotelListing.module.css" import styles from "../destinationListing.module.css"
export default function HotelListing() { export default function HotelListing() {
const intl = useIntl() const intl = useIntl()
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const params = useParams() const mapUrl = useSetMapView()
const [mapUrl, setMapUrl] = useState<string | null>(null)
const { showBackToTop, scrollToTop } = useScrollToTop({ const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300, threshold: 300,
elementRef: scrollRef, elementRef: scrollRef,
@@ -36,12 +31,6 @@ export default function HotelListing() {
isLoading: state.isLoading, isLoading: state.isLoading,
})) }))
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params])
return isLoading ? ( return isLoading ? (
<HotelListingSkeleton /> <HotelListingSkeleton />
) : ( ) : (
@@ -58,26 +47,7 @@ export default function HotelListing() {
)} )}
</h2> </h2>
</Typography> </Typography>
<div className={styles.cta}> <SeeOnMapFilterWrapper mapUrl={mapUrl} listType="hotel" />
{mapUrl && (
<Button
className={styles.mapButton}
asChild
intent="secondary"
variant="icon"
size="small"
theme="base"
>
<Link href={mapUrl}>
<MaterialIcon icon="map" color="CurrentColor" />
{intl.formatMessage({
defaultMessage: "See on map",
})}
</Link>
</Button>
)}
<DestinationFilterAndSort listType="hotel" />
</div>
</div> </div>
{activeHotels.length === 0 ? ( {activeHotels.length === 0 ? (
<Alert <Alert
@@ -92,7 +62,7 @@ export default function HotelListing() {
/> />
) : ( ) : (
<> <>
<ul className={styles.hotelList}> <ul className={styles.list}>
{activeHotels.map(({ hotel, url }) => ( {activeHotels.map(({ hotel, url }) => (
<li key={hotel.id}> <li key={hotel.id}>
<HotelListingItem hotel={hotel} url={url} /> <HotelListingItem hotel={hotel} url={url} />

View File

@@ -1,10 +1,10 @@
.container { .container {
--scroll-margin-top: calc( --scroll-margin-top: calc(
var(--booking-widget-mobile-height) + var(--Spacing-x2) var(--booking-widget-mobile-height) + var(--Space-x2)
); );
position: relative;
display: grid; display: grid;
gap: var(--Spacing-x2); position: relative;
gap: var(--Space-x4);
scroll-margin-top: var(--scroll-margin-top); scroll-margin-top: var(--scroll-margin-top);
} }
@@ -13,16 +13,10 @@
gap: var(--Space-x2); gap: var(--Space-x2);
} }
.cta { .list {
display: flex;
justify-content: space-between;
gap: var(--Spacing-x2);
}
.hotelList {
list-style: none; list-style: none;
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Space-x2);
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
@@ -33,13 +27,14 @@
} }
} }
@media screen and (min-width: 1367px) { @media screen and (min-width: 950px) {
.listHeader { .listHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
} }
.mapButton { .container {
display: none !important; /* Important to override button higher specificy */ gap: var(--Space-x1);
} }
} }

View File

@@ -29,7 +29,7 @@ export default async function OverviewMapContainer() {
boundsPadding={0} boundsPadding={0}
gestureHandling="cooperative" gestureHandling="cooperative"
> >
<MapContent geojson={geoJson} /> <MapContent geojson={geoJson} pageType="overview" />
<ActiveMapCard markers={markers} /> <ActiveMapCard markers={markers} />
</DynamicMap> </DynamicMap>
</MapProvider> </MapProvider>

View File

@@ -1,20 +1,3 @@
.imagePlaceholder {
height: 100%;
width: 100%;
background-color: #fff;
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 120px 120px;
background-position:
0 0,
0 60px,
60px -60px,
-60px 0;
}
.imageContainer { .imageContainer {
position: relative; position: relative;
min-width: 177px; min-width: 177px;

View File

@@ -1,6 +1,7 @@
import Chip from "@scandic-hotels/design-system/Chip" import Chip from "@scandic-hotels/design-system/Chip"
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon" import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import Image from "@scandic-hotels/design-system/Image" import Image from "@scandic-hotels/design-system/Image"
import ImageFallback from "@scandic-hotels/design-system/ImageFallback"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./dialogImage.module.css" import styles from "./dialogImage.module.css"
@@ -22,7 +23,7 @@ export default function DialogImage({
return ( return (
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
{!image || imageError ? ( {!image || imageError ? (
<div className={styles.imagePlaceholder} /> <ImageFallback />
) : ( ) : (
<Image <Image
src={image} src={image}

View File

@@ -11,7 +11,7 @@
.name { .name {
height: 48px; height: 48px;
max-width: 180px; max-width: 180px;
margin-bottom: var(--Spacing-x-half); margin-bottom: var(--Space-x05);
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -23,16 +23,10 @@
z-index: 1; z-index: 1;
} }
.closeButton:hover .closeIcon {
background-color: var(--Component-Button-Muted-Fill-Hover-inverted);
color: var(--Component-Button-Muted-On-fill-Hover-Inverted);
border-radius: 50%;
}
.content { .content {
width: 100%; width: 100%;
min-width: 150px; min-width: 150px;
padding: var(--Spacing-x-one-and-half); padding: var(--Space-x15);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -46,18 +40,14 @@
.facilitiesItem { .facilitiesItem {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--Spacing-x-half); gap: var(--Space-x05);
}
.content .button {
margin-top: auto;
} }
.facilities { .facilities {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 var(--Spacing-x1); gap: 0 var(--Space-x1);
padding-bottom: var(--Spacing-x1); padding-bottom: var(--Space-x1);
} }
@media (min-width: 950px) { @media (min-width: 950px) {

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -6,7 +6,7 @@
z-index: 0; z-index: 0;
} }
.mapWrapperWithCloseButton:after { .mapWrapperWithSeeAsListButton:after {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
@@ -23,39 +23,35 @@
.ctaButtons { .ctaButtons {
position: absolute; position: absolute;
top: var(--Spacing-x2); top: var(--Space-x2);
right: var(--Spacing-x2); right: var(--Space-x2);
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x7); gap: var(--Space-x7);
align-items: flex-end; align-items: flex-end;
pointer-events: none; pointer-events: none;
} }
.zoomButtons { .zoomButtons {
display: grid; display: grid;
gap: var(--Spacing-x1); gap: var(--Space-x1);
margin-top: auto; margin-top: auto;
} }
.closeButton { .seeAsListButton {
display: none !important; display: none !important;
} }
.zoomButton { .zoomButton {
width: var(--Spacing-x5);
height: var(--Spacing-x5);
padding: 0;
pointer-events: initial; pointer-events: initial;
box-shadow: var(--button-box-shadow);
} }
@media screen and (min-width: 950px) { @media screen and (min-width: 950px) {
.ctaButtons { .ctaButtons {
top: var(--Spacing-x4); top: var(--Space-x4);
right: var(--Spacing-x4); right: var(--Space-x4);
bottom: var(--Spacing-x4); bottom: var(--Space-x4);
justify-content: space-between; justify-content: space-between;
} }
@@ -64,11 +60,10 @@
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.closeButton { .seeAsListButton {
display: flex !important; display: flex !important;
pointer-events: initial; pointer-events: initial;
box-shadow: var(--button-box-shadow); box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
} }
/* Overriding Google maps infoWindow styles */ /* Overriding Google maps infoWindow styles */

View File

@@ -7,9 +7,14 @@ import { cx } from "class-variance-authority"
import { type PropsWithChildren, useEffect, useRef, useState } from "react" import { type PropsWithChildren, useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useZoomControls } from "@scandic-hotels/common/hooks/map/useZoomControls"
import { Button } from "@scandic-hotels/design-system/Button"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { MAP_RESTRICTIONS } from "@scandic-hotels/design-system/Map/mapConstants" import {
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton" DESTINATION_PAGE,
MAP_RESTRICTIONS,
} from "@scandic-hotels/design-system/Map/mapConstants"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map" import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
@@ -20,7 +25,10 @@ import { usePageType } from "../PageTypeProvider"
import styles from "./dynamicMap.module.css" import styles from "./dynamicMap.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers" import type {
CityMarker,
DestinationMarker,
} from "@/types/components/maps/destinationMarkers"
const BACKUP_COORDINATES = { const BACKUP_COORDINATES = {
lat: 59.3293, lat: 59.3293,
@@ -28,7 +36,7 @@ const BACKUP_COORDINATES = {
} }
interface DynamicMapProps { interface DynamicMapProps {
markers: DestinationMarker[] markers: DestinationMarker[] | CityMarker[]
mapId: string mapId: string
defaultCenter?: google.maps.LatLngLiteral defaultCenter?: google.maps.LatLngLiteral
defaultZoom?: number defaultZoom?: number
@@ -42,7 +50,7 @@ export default function DynamicMap({
markers, markers,
mapId, mapId,
defaultCenter = BACKUP_COORDINATES, defaultCenter = BACKUP_COORDINATES,
defaultZoom = 3, defaultZoom = DESTINATION_PAGE.DEFAULT_ZOOM,
fitBounds = true, fitBounds = true,
boundsPadding = 100, boundsPadding = 100,
onClose, onClose,
@@ -55,6 +63,8 @@ export default function DynamicMap({
const { activeMarker } = useDestinationPageHotelsMapStore() const { activeMarker } = useDestinationPageHotelsMapStore()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const [hasFittedBounds, setHasFittedBounds] = useState(!!activeMarker) const [hasFittedBounds, setHasFittedBounds] = useState(!!activeMarker)
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } =
useZoomControls(DESTINATION_PAGE)
useEffect(() => { useEffect(() => {
if (ref.current && activeMarker && pageType === "overview") { if (ref.current && activeMarker && pageType === "overview") {
@@ -82,23 +92,10 @@ export default function DynamicMap({
} }
}) })
function zoomIn() {
const currentZoom = map && map.getZoom()
if (currentZoom) {
map.setZoom(currentZoom + 1)
}
}
function zoomOut() {
const currentZoom = map && map.getZoom()
if (currentZoom) {
map.setZoom(currentZoom - 1)
}
}
const mapOptions: MapProps = { const mapOptions: MapProps = {
defaultCenter, // Default center will be overridden by the bounds defaultCenter, // Default center will be overridden by the bounds
minZoom: 3, minZoom: DESTINATION_PAGE.MIN_ZOOM,
maxZoom: 18, maxZoom: DESTINATION_PAGE.MAX_ZOOM,
defaultZoom, defaultZoom,
disableDefaultUI: true, disableDefaultUI: true,
clickableIcons: false, clickableIcons: false,
@@ -111,7 +108,7 @@ export default function DynamicMap({
<div <div
className={cx( className={cx(
styles.mapWrapper, styles.mapWrapper,
onClose && styles.mapWrapperWithCloseButton onClose && styles.mapWrapperWithSeeAsListButton
)} )}
ref={ref} ref={ref}
> >
@@ -129,48 +126,46 @@ export default function DynamicMap({
<div className={styles.ctaButtons}> <div className={styles.ctaButtons}>
{onClose && ( {onClose && (
<Button <Button
theme="base" color="Inverted"
intent="inverted" variant="Primary"
variant="icon" size="Small"
size="small" className={styles.seeAsListButton}
className={styles.closeButton}
onClick={onClose} onClick={onClose}
typography="Body/Supporting text (caption)/smBold"
> >
<MaterialIcon icon="close" color="CurrentColor" /> <MaterialIcon icon="format_list_bulleted" color="CurrentColor" />
<span> <span>
{intl.formatMessage({ {intl.formatMessage({
defaultMessage: "Close the map", defaultMessage: "See as list",
})} })}
</span> </span>
</Button> </Button>
)} )}
<div className={styles.zoomButtons}> <div className={styles.zoomButtons}>
<Button <IconButton
theme="base" theme="Inverted"
intent="inverted" style="Elevated"
variant="icon"
size="small"
className={styles.zoomButton} className={styles.zoomButton}
onClick={zoomIn} onClick={zoomIn}
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
defaultMessage: "Zoom out", defaultMessage: "Zoom in",
})} })}
isDisabled={isMaxZoom}
> >
<MaterialIcon icon="add" color="CurrentColor" size={20} /> <MaterialIcon icon="add" color="CurrentColor" size={24} />
</Button> </IconButton>
<Button <IconButton
theme="base" theme="Inverted"
intent="inverted" style="Elevated"
variant="icon"
size="small"
className={styles.zoomButton} className={styles.zoomButton}
onClick={zoomOut} onClick={zoomOut}
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
defaultMessage: "Zoom in", defaultMessage: "Zoom out",
})} })}
isDisabled={isMinZoom}
> >
<MaterialIcon icon="remove" color="CurrentColor" size={20} /> <MaterialIcon icon="remove" color="CurrentColor" size={24} />
</Button> </IconButton>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -12,25 +12,25 @@ import { trackMapClick } from "@/utils/tracking/destinationPage"
import styles from "./clusterMarker.module.css" import styles from "./clusterMarker.module.css"
interface ClusterMarkerProps { interface HotelClusterMarkerProps {
position: google.maps.LatLngLiteral position: google.maps.LatLngLiteral
size: number size: number
sizeAsText: string sizeAsText: string
onMarkerClick?: (position: google.maps.LatLngLiteral) => void onMarkerClick?: (position: google.maps.LatLngLiteral) => void
hotelIds: number[] hotelIds: string[]
} }
export default function HotelClusterMarker({
export default function ClusterMarker({
position, position,
size, size,
sizeAsText, sizeAsText,
onMarkerClick, onMarkerClick,
hotelIds, hotelIds,
}: ClusterMarkerProps) { }: HotelClusterMarkerProps) {
const { hoveredMarker, activeMarker } = useDestinationPageHotelsMapStore() const { hoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
const isActive = const isActive =
hotelIds.includes(Number(hoveredMarker)) || hotelIds.includes(String(hoveredMarker)) ||
hotelIds.includes(Number(activeMarker)) hotelIds.includes(String(activeMarker))
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (onMarkerClick) { if (onMarkerClick) {

View File

@@ -20,12 +20,15 @@ import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
import type { MarkerProperties } from "@/types/components/maps/destinationMarkers" import type { MarkerProperties } from "@/types/components/maps/destinationMarkers"
interface MarkerProps { interface HotelMarkerProps {
position: google.maps.LatLngLiteral position: google.maps.LatLngLiteral
properties: MarkerProperties properties: MarkerProperties
} }
export default function Marker({ position, properties }: MarkerProps) { export default function HotelMarker({
position,
properties,
}: HotelMarkerProps) {
const [markerRef] = useAdvancedMarkerRef() const [markerRef] = useAdvancedMarkerRef()
const { setHoveredMarker, setActiveMarker, hoveredMarker, activeMarker } = const { setHoveredMarker, setActiveMarker, hoveredMarker, activeMarker } =

View File

@@ -2,24 +2,33 @@
import { useMap } from "@vis.gl/react-google-maps" import { useMap } from "@vis.gl/react-google-maps"
import { useEffect } from "react" import { useEffect } from "react"
import { useMediaQuery } from "usehooks-ts"
import { CITY_PAGE } from "@scandic-hotels/design-system/Map/mapConstants"
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map" import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import { useSupercluster } from "@/hooks/maps/use-supercluster" import { useSupercluster } from "@/hooks/maps/use-supercluster"
import ClusterMarker from "./ClusterMarker" import CityClusterMarker from "./ClusterMarker/CityClusterMarker"
import Marker from "./Marker" import HotelClusterMarker from "./ClusterMarker/HotelClusterMarker"
import CityMarker from "./CityMarker"
import HotelMarker from "./HotelMarker"
import type { ClusterProperties } from "supercluster" import type { ClusterProperties } from "supercluster"
import type { import type {
CityMarkerGeojson,
CityMarkerProperties,
MarkerGeojson, MarkerGeojson,
MarkerProperties, MarkerProperties,
} from "@/types/components/maps/destinationMarkers" } from "@/types/components/maps/destinationMarkers"
interface MapContentProps { interface MapContentProps {
geojson: MarkerGeojson geojson: MarkerGeojson | CityMarkerGeojson
disableClustering?: boolean disableClustering?: boolean
pageType: "city" | "country" | "overview"
} }
// Important this is outside the component to avoid re-creating the object on each render // Important this is outside the component to avoid re-creating the object on each render
@@ -33,12 +42,19 @@ const CLUSTER_OPTIONS = {
export default function MapContent({ export default function MapContent({
geojson, geojson,
disableClustering, disableClustering,
pageType,
}: MapContentProps) { }: MapContentProps) {
const { setActiveMarker, activeMarker } = useDestinationPageHotelsMapStore() const { setActiveMarker, activeMarker } = useDestinationPageHotelsMapStore()
const map = useMap() const { setActiveCityMarker, activeCityMarker } =
useDestinationPageCitiesMapStore()
const { clusters, containedHotels, getClusterZoom } = const { clusters, containedHotelIds, containedCityIds, getClusterZoom } =
useSupercluster<MarkerProperties>(geojson, CLUSTER_OPTIONS) useSupercluster<MarkerProperties | CityMarkerProperties>(
geojson,
CLUSTER_OPTIONS
)
const map = useMap()
const isDesktop = useMediaQuery("(min-width: 950px)")
// Based on the length of active filters, we decide if should show clusters or individual markers // Based on the length of active filters, we decide if should show clusters or individual markers
const markerList = disableClustering ? geojson.features : clusters const markerList = disableClustering ? geojson.features : clusters
@@ -51,6 +67,23 @@ export default function MapContent({
}) })
}, [activeMarker, map, setActiveMarker]) }, [activeMarker, map, setActiveMarker])
useEffect(() => {
map?.addListener("click", () => {
if (activeCityMarker) {
setActiveCityMarker(null)
}
})
}, [activeCityMarker, map, setActiveCityMarker])
useEffect(() => {
if (!isDesktop) return
const currentZoom = map && map.getZoom()
if (currentZoom && activeCityMarker?.location) {
map.panTo(activeCityMarker.location)
map.setZoom(CITY_PAGE.DEFAULT_ZOOM)
}
}, [activeCityMarker, map, isDesktop])
function handleClusterClick( function handleClusterClick(
position: google.maps.LatLngLiteral, position: google.maps.LatLngLiteral,
clusterProperties: ClusterProperties clusterProperties: ClusterProperties
@@ -67,26 +100,47 @@ export default function MapContent({
return markerList.map((feature, idx) => { return markerList.map((feature, idx) => {
const [lng, lat] = feature.geometry.coordinates const [lng, lat] = feature.geometry.coordinates
const clusterProperties = feature.properties as ClusterProperties const clusterProperties = feature.properties as ClusterProperties
const markerProperties = feature.properties as MarkerProperties const hotelMarkerProperties = feature.properties as MarkerProperties
const cityMarkerProperties = feature.properties as CityMarkerProperties
const isCluster = clusterProperties?.cluster const isCluster = clusterProperties?.cluster
return isCluster ? ( if (pageType === "country") {
<ClusterMarker return isCluster ? (
key={feature.id} <CityClusterMarker
position={{ lat, lng }} key={feature.id}
size={clusterProperties.point_count} position={{ lat, lng }}
sizeAsText={String(clusterProperties.point_count_abbreviated)} size={clusterProperties.point_count}
onMarkerClick={(position) => sizeAsText={String(clusterProperties.point_count_abbreviated)}
handleClusterClick(position, clusterProperties) onMarkerClick={(position) =>
} handleClusterClick(position, clusterProperties)
hotelIds={containedHotels[idx]} }
/> cities={containedCityIds[idx]}
) : ( />
<Marker ) : (
key={feature.id} <CityMarker
position={{ lat, lng }} key={feature.id}
properties={markerProperties} position={{ lat, lng }}
/> properties={cityMarkerProperties}
) />
)
} else
return isCluster ? (
<HotelClusterMarker
key={feature.id}
position={{ lat, lng }}
size={clusterProperties.point_count}
sizeAsText={String(clusterProperties.point_count_abbreviated)}
onMarkerClick={(position) =>
handleClusterClick(position, clusterProperties)
}
hotelIds={containedHotelIds[idx]}
/>
) : (
<HotelMarker
key={feature.id}
position={{ lat, lng }}
properties={hotelMarkerProperties}
/>
)
}) })
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { import {
type PropsWithChildren, type PropsWithChildren,
useCallback, useCallback,
@@ -14,47 +14,59 @@ import { useIntl } from "react-intl"
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop" import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
import { debounce } from "@scandic-hotels/common/utils/debounce" import { debounce } from "@scandic-hotels/common/utils/debounce"
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton" import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton" import {
CITY_PAGE,
COUNTRY_PAGE,
} from "@scandic-hotels/design-system/Map/mapConstants"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort" import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import DynamicMap from "./DynamicMap" import DynamicMap from "./DynamicMap"
import MapContent from "./MapContent" import MapContent from "./MapContent"
import MapProvider from "./MapProvider" import MapProvider from "./MapProvider"
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
import styles from "./map.module.css" import styles from "./map.module.css"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { MapLocation } from "@/types/components/mapLocation" import type { MapLocation } from "@/types/components/mapLocation"
import type {
CityMarker,
CityMarkerGeojson,
DestinationMarker,
MarkerGeojson,
} from "@/types/components/maps/destinationMarkers"
interface MapProps { interface MapProps {
hotels: HotelListingHotelData[]
mapId: string mapId: string
apiKey: string apiKey: string
pageType: "city" | "country" pageType: "country" | "city"
activeLocation?: { latitude: number; longitude: number } | null
setActiveLocation: (id: null) => void
markers: DestinationMarker[] | CityMarker[]
geoJson: MarkerGeojson | CityMarkerGeojson
defaultLocation: MapLocation defaultLocation: MapLocation
} }
export default function Map({ export default function Map({
hotels,
mapId, mapId,
apiKey, apiKey,
defaultLocation,
pageType, pageType,
activeLocation,
children, children,
setActiveLocation,
markers,
geoJson,
defaultLocation,
}: PropsWithChildren<MapProps>) { }: PropsWithChildren<MapProps>) {
const router = useRouter() const router = useRouter()
const { activeMarker: activeHotelId, setActiveMarker } =
useDestinationPageHotelsMapStore()
const activeHotel = hotels.find(({ hotel }) => hotel.id === activeHotelId)
const rootDiv = useRef<HTMLDivElement | null>(null) const rootDiv = useRef<HTMLDivElement | null>(null)
const [mapHeight, setMapHeight] = useState("100dvh") const [mapHeight, setMapHeight] = useState("100dvh")
const [fromCountryPage, setFromCountryPage] = useState(false)
const params = useParams()
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const { showBackToTop, scrollToTop } = useScrollToTop({ const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 550, threshold: 550,
@@ -68,24 +80,14 @@ export default function Map({
activeFilters: state.activeFilters, activeFilters: state.activeFilters,
})) }))
const zoomConstants = pageType === "city" ? CITY_PAGE : COUNTRY_PAGE
const hasActiveFilters = activeFilters.length > 0 const hasActiveFilters = activeFilters.length > 0
const markers = getHotelMapMarkers(hotels) useEffect(() => {
const geoJson = mapMarkerDataToGeoJson(markers) const url = new URL(window.location.href)
const defaultCenter = activeHotel setFromCountryPage(url.searchParams.has("fromCountry"))
? { }, [params])
lat: activeHotel.hotel.location.latitude,
lng: activeHotel.hotel.location.longitude,
}
: defaultLocation
? {
lat: defaultLocation.latitude,
lng: defaultLocation.longitude,
}
: undefined
const defaultZoom = activeHotel
? 15
: (defaultLocation?.default_zoom ?? (pageType === "city" ? 10 : 3))
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget) // Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
const handleMapHeight = useCallback(() => { const handleMapHeight = useCallback(() => {
@@ -94,10 +96,22 @@ export default function Map({
}, []) }, [])
function handleClose() { function handleClose() {
if (fromCountryPage) {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
router.push(url.toString())
setActiveLocation(null)
} else {
backToListView()
}
}
function backToListView() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.delete("view") url.searchParams.delete("view")
url.searchParams.delete("fromCountry")
router.push(url.toString()) router.push(url.toString())
setActiveMarker(null) setActiveLocation(null)
} }
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -131,6 +145,22 @@ export default function Map({
} }
}, [rootDiv, handleMapHeight]) }, [rootDiv, handleMapHeight])
const defaultCenter = activeLocation
? {
lat: activeLocation.latitude,
lng: activeLocation.longitude,
}
: defaultLocation
? {
lat: defaultLocation.latitude,
lng: defaultLocation.longitude,
}
: undefined
const defaultZoom = activeLocation
? zoomConstants.SELECTED_HOTEL_ZOOM
: (defaultLocation?.default_zoom ?? zoomConstants.DEFAULT_ZOOM)
return ( return (
<MapProvider apiKey={apiKey} pageType={pageType}> <MapProvider apiKey={apiKey} pageType={pageType}>
<div <div
@@ -140,15 +170,24 @@ export default function Map({
> >
<div className={styles.mobileNavigation}> <div className={styles.mobileNavigation}>
<Button <Button
intent="text" variant="Text"
theme="base" size="Small"
variant="icon" color="Primary"
typography="Body/Supporting text (caption)/smBold"
onClick={handleClose} onClick={handleClose}
> >
<MaterialIcon icon="chevron_left" size={20} color="CurrentColor" /> <MaterialIcon
{intl.formatMessage({ icon="arrow_back_ios"
defaultMessage: "Back", size={20}
})} color="CurrentColor"
/>
{fromCountryPage
? intl.formatMessage({
defaultMessage: "Back to cities",
})
: intl.formatMessage({
defaultMessage: "Back to list",
})}
</Button> </Button>
<DestinationFilterAndSort <DestinationFilterAndSort
listType={pageType === "city" ? "hotel" : "city"} listType={pageType === "city" ? "hotel" : "city"}
@@ -169,13 +208,17 @@ export default function Map({
<DynamicMap <DynamicMap
markers={markers} markers={markers}
mapId={mapId} mapId={mapId}
onClose={handleClose} onClose={backToListView}
defaultCenter={defaultCenter} defaultCenter={defaultCenter}
defaultZoom={defaultZoom} defaultZoom={defaultZoom}
fitBounds={!activeHotel} fitBounds={!activeLocation}
gestureHandling="greedy" gestureHandling="greedy"
> >
<MapContent geojson={geoJson} disableClustering={hasActiveFilters} /> <MapContent
geojson={geoJson}
disableClustering={hasActiveFilters}
pageType={pageType}
/>
</DynamicMap> </DynamicMap>
</div> </div>
</MapProvider> </MapProvider>

View File

@@ -22,7 +22,7 @@
.mobileNavigation { .mobileNavigation {
display: flex; display: flex;
padding: var(--Space-x2); padding: 0 var(--Space-x2);
justify-content: space-between; justify-content: space-between;
background-color: var(--Surface-Primary-OnSurface-Default); background-color: var(--Surface-Primary-OnSurface-Default);
} }

View File

@@ -1,6 +1,10 @@
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { import type {
CityMarker,
CityMarkerFeature,
CityMarkerGeojson,
DestinationMarker, DestinationMarker,
MarkerFeature, MarkerFeature,
MarkerGeojson, MarkerGeojson,
@@ -52,6 +56,59 @@ export function getHotelMapMarkers(hotels: HotelListingHotelData[]) {
return markers return markers
} }
export function mapCityMarkerDataToGeoJson(markers: CityMarker[]) {
const features = markers.map<CityMarkerFeature>(
({ coordinates, ...properties }) => {
return {
type: "Feature",
id: properties.id,
geometry: {
type: "Point",
coordinates: [coordinates.lng, coordinates.lat],
},
properties,
}
}
)
const geoJson: CityMarkerGeojson = {
type: "FeatureCollection",
features,
}
return geoJson
}
export function getCityMapMarkers(cities: DestinationCityListItem[]) {
const markers = cities
.map(
({
cityName,
cityIdentifier,
url,
destination_settings,
images,
hotelsCount,
}) => ({
id: cityIdentifier,
name: cityName,
coordinates: destination_settings.location
? {
lat: destination_settings.location.latitude,
lng: destination_settings.location.longitude,
}
: null,
hotelsCount,
url,
image: getCityImage({ images }),
})
)
.filter((item): item is CityMarker => !!item.coordinates)
return markers
}
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) { function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
if (hotel.galleryImages?.length) { if (hotel.galleryImages?.length) {
const image = hotel.galleryImages[0] const image = hotel.galleryImages[0]
@@ -62,3 +119,13 @@ function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
} }
return null return null
} }
function getCityImage({ images }: Pick<DestinationCityListItem, "images">) {
if (images?.length) {
const image = images[0]
return {
src: image.url,
alt: image.meta.alt || image.meta.caption,
}
}
return null
}

View File

@@ -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>
)
}

View File

@@ -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 */
}
}

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useRef } from "react" import React, { useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition" import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
@@ -9,18 +9,20 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import { getHeadingText } from "@/components/ContentType/DestinationPage/utils" import { getCityHeadingText, getCountryHeadingText } from "../utils"
import styles from "./sidebarContentWrapper.module.css" import styles from "./sidebarContentWrapper.module.css"
interface SidebarContentWrapperProps extends React.PropsWithChildren { interface SidebarContentWrapperProps extends React.PropsWithChildren {
preamble: string preamble: string
location: string location: string
pageType: "country" | "city"
} }
export default function SidebarContentWrapper({ export default function SidebarContentWrapper({
preamble, preamble,
location, location,
pageType,
children, children,
}: SidebarContentWrapperProps) { }: SidebarContentWrapperProps) {
const intl = useIntl() const intl = useIntl()
@@ -33,19 +35,23 @@ export default function SidebarContentWrapper({
ref: sidebarRef, ref: sidebarRef,
name: StickyElementNameEnum.DESTINATION_SIDEBAR, name: StickyElementNameEnum.DESTINATION_SIDEBAR,
}) })
const heading =
const heading = getHeadingText(intl, location, allFilters, filterFromUrl) pageType === "country"
? getCountryHeadingText(intl, location, allFilters, filterFromUrl)
: getCityHeadingText(intl, location, allFilters, filterFromUrl)
return ( return (
<div ref={sidebarRef} className={styles.sidebarContent}> <div ref={sidebarRef} className={styles.sidebarContent}>
<Typography variant="Title/md"> <div className={styles.text}>
<h1 className={styles.heading}>{heading}</h1> <Typography variant="Title/md">
</Typography> <h1 className={styles.heading}>{heading}</h1>
{!filterFromUrl ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.text}>{preamble}</p>
</Typography> </Typography>
) : null} {!filterFromUrl ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>{preamble}</p>
</Typography>
) : null}
</div>
{children} {children}
</div> </div>
) )

View File

@@ -5,20 +5,30 @@
padding: 0 var(--max-width-single-spacing) var(--Space-x3); padding: 0 var(--max-width-single-spacing) var(--Space-x3);
} }
.text {
display: grid;
gap: var(--Space-x2);
color: var(--Text-Default);
max-width: var(--max-width-text-block);
}
.heading { .heading {
color: var(--Text-Heading); color: var(--Text-Heading);
hyphens: auto; hyphens: auto;
text-wrap: balance; text-wrap: balance;
} }
.text { @media screen and (min-width: 950px) {
color: var(--Text-Default); .sidebarContent {
max-width: var(--max-width-text-block); grid-template-columns: 1fr auto;
padding: var(--Space-x4) var(--Space-x3);
}
} }
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.sidebarContent { .sidebarContent {
position: sticky; position: sticky;
padding: var(--Space-x4) var(--Space-x3); padding: var(--Space-x4) var(--Space-x3);
grid-template-columns: none;
} }
} }

View File

@@ -1,22 +1,15 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react" import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
import { MapWithButtonWrapper } from "@/components/Maps/MapWithButtonWrapper" import { MapWithButtonWrapper } from "@/components/Maps/MapWithButtonWrapper"
import styles from "./mapWrapper.module.css" import styles from "./mapWrapper.module.css"
export default function MapWrapper({ children }: React.PropsWithChildren) { export default function MapWrapper({ children }: React.PropsWithChildren) {
const params = useParams() const mapUrl = useSetMapView()
const [mapUrl, setMapUrl] = useState<string | null>(null)
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params])
if (!mapUrl) { if (!mapUrl) {
return null return null

View File

@@ -2,8 +2,8 @@
display: none; display: none;
} }
@media (min-width: 1367px) { @media (min-width: 950px) {
.link { .link {
display: flex; display: block;
} }
} }

View File

@@ -4,7 +4,7 @@ import type {
} from "@scandic-hotels/trpc/types/hotel" } from "@scandic-hotels/trpc/types/hotel"
import type { IntlShape } from "react-intl" import type { IntlShape } from "react-intl"
export function getHeadingText( export function getCityHeadingText(
intl: IntlShape, intl: IntlShape,
location: string, location: string,
allFilters: CategorizedHotelFilters, allFilters: CategorizedHotelFilters,
@@ -41,3 +41,41 @@ export function getHeadingText(
{ location } { location }
) )
} }
export function getCountryHeadingText(
intl: IntlShape,
location: string,
allFilters: CategorizedHotelFilters,
filterFromUrl: HotelFilter | null
) {
if (filterFromUrl) {
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.id === filterFromUrl.id
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.id === filterFromUrl.id
)
if (facilityFilter) {
return intl.formatMessage(
{
defaultMessage: "Destinations with {filter} in {location}",
},
{ location, filter: facilityFilter.name }
)
} else if (surroudingsFilter) {
return intl.formatMessage(
{
defaultMessage: "Destinations near {filter} in {location}",
},
{ location, filter: surroudingsFilter.name }
)
}
}
return intl.formatMessage(
{
defaultMessage: "Destinations in {location}",
},
{ location }
)
}

View File

@@ -1,9 +1,8 @@
"use client" "use client"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink" import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker" import { PoiMarker } from "@scandic-hotels/design-system/Map/Markers/PoiMarker"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -16,14 +15,7 @@ import type { MapCardProps } from "@/types/components/hotelPage/map/mapCard"
export default function MapCard({ hotelName, pois }: MapCardProps) { export default function MapCard({ hotelName, pois }: MapCardProps) {
const intl = useIntl() const intl = useIntl()
const params = useParams() const mapUrl = useSetMapView()
const [mapUrl, setMapUrl] = useState<string | null>(null)
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params])
return ( return (
<div className={styles.mapCard}> <div className={styles.mapCard}>

View File

@@ -1,10 +1,9 @@
"use client" "use client"
import { cx } from "class-variance-authority" import { cx } from "class-variance-authority"
import NextLink from "next/link" import NextLink from "next/link"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import useSetMapView from "@scandic-hotels/common/hooks/map/useSetMapView"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -14,14 +13,7 @@ import styles from "./mobileToggle.module.css"
export default function MobileMapToggle() { export default function MobileMapToggle() {
const intl = useIntl() const intl = useIntl()
const params = useParams() const mapUrl = useSetMapView()
const [mapUrl, setMapUrl] = useState<string | null>(null)
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params])
if (!mapUrl) { if (!mapUrl) {
return null return null

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -25,23 +25,6 @@
border-bottom: 1px solid var(--Base-Border-Subtle); border-bottom: 1px solid var(--Base-Border-Subtle);
} }
.buttonWrapper {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
.badge {
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-xl);
width: 20px;
height: 20px;
color: var(--Base-Surface-Primary-light-Normal);
display: flex;
align-items: center;
justify-content: center;
}
.content { .content {
display: grid; display: grid;
gap: var(--Spacing-x4); gap: var(--Spacing-x4);

View File

@@ -12,20 +12,20 @@ import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { Alert } from "@scandic-hotels/design-system/Alert" import { Alert } from "@scandic-hotels/design-system/Alert"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle" import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import Filter from "./Filter" import Filter from "./Filter"
import { FilterAndSortButton } from "./FilterAndSortButton"
import Sort from "./Sort" import Sort from "./Sort"
import styles from "./destinationFilterAndSort.module.css" import styles from "./destinationFilterAndSort.module.css"
interface HotelFilterAndSortProps { export interface HotelFilterAndSortProps {
listType: "city" | "hotel" listType: "city" | "hotel"
} }
@@ -127,23 +127,10 @@ export default function DestinationFilterAndSort({
resetPendingValues() resetPendingValues()
} }
} }
return ( return (
<> <>
<DialogTrigger onOpenChange={handleClose}> <DialogTrigger onOpenChange={handleClose}>
<div className={styles.buttonWrapper}> <FilterAndSortButton filterLength={activeFilters.length} />
<Button intent="text" theme="base" variant="icon">
<MaterialIcon icon="filter_alt" color="CurrentColor" />
{intl.formatMessage({
defaultMessage: "Filter and sort",
})}
</Button>
{activeFilters.length > 0 && (
<Footnote className={styles.badge} asChild>
<span>{activeFilters.length}</span>
</Footnote>
)}
</div>
<ModalOverlay isDismissable className={styles.overlay}> <ModalOverlay isDismissable className={styles.overlay}>
<Modal> <Modal>
<Dialog <Dialog
@@ -193,19 +180,19 @@ export default function DestinationFilterAndSort({
<footer className={styles.footer}> <footer className={styles.footer}>
<Button <Button
onClick={clearPendingFilters} onClick={clearPendingFilters}
intent="text" variant="Text"
size="medium" size="Small"
theme="base" color="Primary"
> >
{intl.formatMessage({ {intl.formatMessage({
defaultMessage: "Clear all filters", defaultMessage: "Clear all filters",
})} })}
</Button> </Button>
<Button <Button
intent="primary" variant="Primary"
size="large" size="Medium"
theme="base" color="Primary"
disabled={pendingCount === 0} isDisabled={pendingCount === 0}
onClick={() => submitAndClose(close)} onClick={() => submitAndClose(close)}
> >
{intl.formatMessage( {intl.formatMessage(

View File

@@ -5,6 +5,8 @@ import { useMapViewport } from "./use-map-viewport"
import type { FeatureCollection, GeoJsonProperties, Point } from "geojson" import type { FeatureCollection, GeoJsonProperties, Point } from "geojson"
import type { CityMarkerProperties } from "@/types/components/maps/destinationMarkers"
export function useSupercluster<T extends GeoJsonProperties>( export function useSupercluster<T extends GeoJsonProperties>(
geojson: FeatureCollection<Point, T>, geojson: FeatureCollection<Point, T>,
superclusterOptions: Supercluster.Options<T, ClusterProperties> superclusterOptions: Supercluster.Options<T, ClusterProperties>
@@ -45,16 +47,29 @@ export function useSupercluster<T extends GeoJsonProperties>(
} }
// retrieve the hotel ids included in the cluster // retrieve the hotel ids included in the cluster
const containedHotels = clusters.map((cluster) => { const containedHotelIds = clusters.map((cluster) => {
if (cluster.properties?.cluster && typeof cluster.id === "number") { if (cluster.properties?.cluster && typeof cluster.id === "number") {
return clusterer.getLeaves(cluster.id).map((hotel) => Number(hotel.id)) return clusterer
.getLeaves(cluster.id)
.map((location) => String(location.id))
}
return []
})
// retrieve the city ids included in the cluster
const containedCityIds = clusters.map((cluster) => {
if (cluster.properties?.cluster && typeof cluster.id === "number") {
return clusterer
.getLeaves(cluster.id)
.map((city) => city.properties as CityMarkerProperties)
} }
return [] return []
}) })
return { return {
clusters, clusters,
containedHotels, containedHotelIds,
containedCityIds,
getClusterZoom, getClusterZoom,
} }
} }

View 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 }),
}))

View File

@@ -18,3 +18,19 @@ export type MarkerProperties = Omit<DestinationMarker, "coordinates">
export type MarkerGeojson = FeatureCollection<Point, MarkerProperties> export type MarkerGeojson = FeatureCollection<Point, MarkerProperties>
export type MarkerFeature = MarkerGeojson["features"][number] export type MarkerFeature = MarkerGeojson["features"][number]
export interface CityMarker {
id: string
name: string
coordinates: google.maps.LatLngLiteral
url: string
image: GalleryImage
hotelsCount: number
}
export type CityMarkerProperties = Omit<CityMarker, "coordinates">
export type CitiesClusterMarkerProperties = CityMarkerProperties[]
export type CityMarkerGeojson = FeatureCollection<Point, CityMarkerProperties>
export type CityMarkerFeature = CityMarkerGeojson["features"][number]

View File

@@ -1,7 +1,7 @@
.buttonContainer { .buttonContainer {
display: flex; display: flex;
gap: var(--Spacing-x2); gap: var(--Space-x2);
margin-bottom: var(--Spacing-x3); margin-bottom: var(--Space-x3);
} }
.buttonContainer > * { .buttonContainer > * {

View 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
}

View 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
}

View 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,
}
}

View File

@@ -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>
),
}

View 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'
}
}

View 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;
}

View File

@@ -0,0 +1 @@
export { Badge } from './Badge'

View 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',
},
})

View File

@@ -7,14 +7,9 @@ import { useIntl } from 'react-intl'
import { IconButton } from '../../IconButton' import { IconButton } from '../../IconButton'
import { MaterialIcon } from '../../Icons/MaterialIcon' import { MaterialIcon } from '../../Icons/MaterialIcon'
import { import { HOTEL_PAGE, MAP_RESTRICTIONS } from '../mapConstants'
DEFAULT_ZOOM,
MAP_RESTRICTIONS,
MAX_ZOOM,
MIN_ZOOM,
} from '../mapConstants'
import { useZoomControls } from './useZoomControls' import { useZoomControls } from '@scandic-hotels/common/hooks/map/useZoomControls'
import { HotelListingMapContent } from './HotelListingMapContent' import { HotelListingMapContent } from './HotelListingMapContent'
import PoiMapMarkers from './PoiMapMarkers' import PoiMapMarkers from './PoiMapMarkers'
@@ -87,12 +82,12 @@ export function InteractiveMap({
const intl = useIntl() const intl = useIntl()
const map = useMap() const map = useMap()
const [hasInitializedBounds, setHasInitializedBounds] = useState(false) const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls() const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls(HOTEL_PAGE)
const mapOptions: MapProps = { const mapOptions: MapProps = {
defaultZoom: DEFAULT_ZOOM, defaultZoom: HOTEL_PAGE.DEFAULT_ZOOM,
minZoom: MIN_ZOOM, minZoom: HOTEL_PAGE.MIN_ZOOM,
maxZoom: MAX_ZOOM, maxZoom: HOTEL_PAGE.MAX_ZOOM,
defaultCenter: coordinates, defaultCenter: coordinates,
disableDefaultUI: true, disableDefaultUI: true,
clickableIcons: false, clickableIcons: false,

View File

@@ -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,
}
}

View File

@@ -4,6 +4,34 @@ export const MAP_RESTRICTIONS = {
latLngBounds: { north: 85, south: -85, west: -180, east: 180 }, latLngBounds: { north: 85, south: -85, west: -180, east: 180 },
} }
export const DEFAULT_ZOOM = 14 export const HOTEL_PAGE = {
export const MAX_ZOOM = 18 DEFAULT_ZOOM: 14,
export const MIN_ZOOM = 8 MAX_ZOOM: 18,
MIN_ZOOM: 8,
} as const
export const DESTINATION_PAGE = {
DEFAULT_ZOOM: 10,
MAX_ZOOM: 18,
MIN_ZOOM: 3,
}
export const CITY_PAGE = {
DEFAULT_ZOOM: 10,
SELECTED_HOTEL_ZOOM: 15,
MAX_ZOOM: 18,
MIN_ZOOM: 3,
} as const
export const COUNTRY_PAGE = {
DEFAULT_ZOOM: 3,
SELECTED_HOTEL_ZOOM: 15,
MAX_ZOOM: 18,
MIN_ZOOM: 3,
} as const
export type MapType =
| typeof HOTEL_PAGE
| typeof DESTINATION_PAGE
| typeof CITY_PAGE
| typeof COUNTRY_PAGE

View File

@@ -276,7 +276,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: block; font-display: block;
src: url(/_static/shared/fonts/material-symbols/rounded-f2d895e1.woff2) src: url(/_static/shared/fonts/material-symbols/rounded-1db5531f.woff2)
format('woff2'); format('woff2');
} }

View File

@@ -9,6 +9,7 @@
"./Alert": "./lib/components/Alert/index.tsx", "./Alert": "./lib/components/Alert/index.tsx",
"./Avatar": "./lib/components/Avatar/index.tsx", "./Avatar": "./lib/components/Avatar/index.tsx",
"./BackToTopButton": "./lib/components/BackToTopButton/index.tsx", "./BackToTopButton": "./lib/components/BackToTopButton/index.tsx",
"./Badge": "./lib/components/Badge/index.tsx",
"./Body": "./lib/components/Body/index.tsx", "./Body": "./lib/components/Body/index.tsx",
"./BookingCodeChip": "./lib/components/BookingCodeChip/index.tsx", "./BookingCodeChip": "./lib/components/BookingCodeChip/index.tsx",
"./Button": "./lib/components/Button/index.tsx", "./Button": "./lib/components/Button/index.tsx",

View File

@@ -23,6 +23,10 @@ query GetDestinationCityListData($locale: String!, $cityIdentifier: String!) {
city_norway city_norway
city_poland city_poland
city_sweden city_sweden
location {
longitude
latitude
}
} }
sort_order sort_order
preamble preamble

View File

@@ -138,7 +138,9 @@ export async function getCityPages(
lang, lang,
city.cityIdentifier city.cityIdentifier
) )
return data ? { ...data, cityName: city.name } : null return data
? { ...data, cityName: city.name, cityIdentifier: city.cityIdentifier }
: null
}) })
) )

View File

@@ -4,7 +4,7 @@ export const mapLocationSchema = z
.object({ .object({
longitude: z.number().nullable(), longitude: z.number().nullable(),
latitude: z.number().nullable(), latitude: z.number().nullable(),
default_zoom: z.number().nullable(), default_zoom: z.number().nullish(),
}) })
.nullish() .nullish()
.transform((val) => { .transform((val) => {

View File

@@ -39,6 +39,8 @@ export interface CityPageUrlsData extends z.output<typeof cityPageUrlsSchema> {}
export interface DestinationCityListItem extends DestinationCityListData { export interface DestinationCityListItem extends DestinationCityListData {
cityName: string cityName: string
cityIdentifier: string
hotelsCount?: number
} }
export type Block = z.output<typeof blocksSchema> export type Block = z.output<typeof blocksSchema>

View File

@@ -112,6 +112,7 @@ const icons = [
"festival", "festival",
"filter_alt", "filter_alt",
"filter", "filter",
"format_list_bulleted",
"floor_lamp", "floor_lamp",
"forest", "forest",
"garage", "garage",

View File

@@ -1,3 +1,3 @@
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update. Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
hash=f2d895e1 hash=1db5531f
created=2025-09-16T08:22:55.703Z created=2025-09-17T06:58:37.841Z

Binary file not shown.