Merged in feat/SW-1751-destination-0-results (pull request #1448)

feat(SW-1751): Added alert if no results are found, also implemented default location data from Contentstack

* feat(SW-1751): Added alert if no results are found, also implemented default location data from Contentstack


Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-03-03 10:32:40 +00:00
parent c3e3fa62ec
commit 93187a9c33
31 changed files with 372 additions and 206 deletions

View File

@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -16,6 +17,8 @@ import CityListingSkeleton from "./CityListingSkeleton"
import styles from "./cityListing.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function CityListing() {
const intl = useIntl()
const scrollRef = useRef<HTMLElement>(null)
@@ -60,23 +63,35 @@ export default function CityListing() {
listType="city"
/>
</div>
<ul
className={`${styles.cityList} ${allCitiesVisible ? styles.allVisible : ""}`}
>
{activeCities.map((city) => (
<li key={city.system.uid}>
<CityListingItem city={city} />
</li>
))}
</ul>
{activeCities.length > 5 ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allCitiesVisible}
{activeCities.length === 0 ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No matching locations found" })}
text={intl.formatMessage({
id: "It looks like no location match your filters. Try adjusting your search to find the perfect stay.",
})}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
) : (
<>
<ul
className={`${styles.cityList} ${allCitiesVisible ? styles.allVisible : ""}`}
>
{activeCities.map((city) => (
<li key={city.system.uid}>
<CityListingItem city={city} />
</li>
))}
</ul>
{activeCities.length > 5 ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allCitiesVisible}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</>
)}
</section>
)

View File

@@ -7,6 +7,7 @@ import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Alert from "@/components/TempDesignSystem/Alert"
import Body from "@/components/TempDesignSystem/Text/Body"
import { debounce } from "@/utils/debounce"
@@ -16,6 +17,7 @@ import { getVisibleHotels } from "./utils"
import styles from "./hotelList.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { HotelDataWithUrl } from "@/types/hotel"
export default function HotelList() {
@@ -71,13 +73,23 @@ export default function HotelList() {
listType="hotel"
/>
</div>
<ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => (
<li key={hotel.operaId}>
<HotelListItem hotel={hotel} url={url} />
</li>
))}
</ul>
{activeHotels.length === 0 ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No matching hotels found" })}
text={intl.formatMessage({
id: "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
})}
/>
) : (
<ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => (
<li key={hotel.operaId}>
<HotelListItem hotel={hotel} url={url} />
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -11,15 +11,22 @@ import HotelList from "./HotelList"
import styles from "./cityMap.module.css"
import type { MapLocation } from "@/types/components/mapLocation"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
interface CityMapProps {
mapId: string
apiKey: string
city: CityLocation
defaultLocation: MapLocation
}
export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
export default function CityMap({
mapId,
apiKey,
city,
defaultLocation,
}: CityMapProps) {
const intl = useIntl()
const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
(state) => ({
@@ -30,7 +37,13 @@ export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
)
return (
<Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="city">
<Map
hotels={activeHotels}
mapId={mapId}
apiKey={apiKey}
pageType="city"
defaultLocation={defaultLocation}
>
<Title
level="h2"
as="h3"

View File

@@ -69,7 +69,10 @@ export default async function DestinationCityPage() {
)}
{destination_settings.city && (
<StaticMap city={destination_settings.city} />
<StaticMap
city={destination_settings.city}
location={destination_settings.location}
/>
)}
</SidebarContentWrapper>
</aside>
@@ -78,6 +81,7 @@ export default async function DestinationCityPage() {
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
city={city}
defaultLocation={destination_settings.location}
/>
</HotelDataContainer>
</Suspense>

View File

@@ -5,6 +5,7 @@ import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Alert from "@/components/TempDesignSystem/Alert"
import Body from "@/components/TempDesignSystem/Text/Body"
import CityListItem from "../CityListItem"
@@ -12,6 +13,8 @@ import CityListSkeleton from "./CityListSkeleton"
import styles from "./cityList.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function CityList() {
const intl = useIntl()
const { filters, sortItems, activeCities, isLoading } =
@@ -39,13 +42,23 @@ export default function CityList() {
listType="city"
/>
</div>
<ul className={styles.cityList}>
{activeCities.map((city) => (
<li key={city.system.uid}>
<CityListItem city={city} />
</li>
))}
</ul>
{activeCities.length === 0 ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No matching locations found" })}
text={intl.formatMessage({
id: "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>
)
}

View File

@@ -12,16 +12,20 @@ import CityList from "./CityList"
import styles from "./countryMap.module.css"
import type { MapLocation } from "@/types/components/mapLocation"
interface CountryMapProps {
mapId: string
apiKey: string
country: string
defaultLocation: MapLocation
}
export default function CountryMap({
mapId,
apiKey,
country,
defaultLocation,
}: CountryMapProps) {
const intl = useIntl()
const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
@@ -33,7 +37,13 @@ export default function CountryMap({
)
return (
<Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="country">
<Map
hotels={activeHotels}
mapId={mapId}
apiKey={apiKey}
pageType="country"
defaultLocation={defaultLocation}
>
<Title
level="h2"
as="h3"

View File

@@ -71,19 +71,22 @@ export default async function DestinationCountryPage() {
sidePeekContent={sidepeek_content}
/>
)}
<StaticMap country={destination_settings.country} />
<StaticMap
country={destination_settings.country}
location={destination_settings.location}
/>
</SidebarContentWrapper>
</aside>
</div>
<CountryMap
country={translatedCountry}
defaultLocation={destination_settings.location}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
/>
</CityDataContainer>
</Suspense>
<TrackingSDK pageData={tracking} />
<TrackingSDK pageData={tracking} />
</>
)
}

View File

@@ -7,7 +7,15 @@ import MapProvider from "../../Map/MapProvider"
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils"
import InputForm from "./InputForm"
export default async function OverviewMapContainer() {
import type { MapLocation } from "@/types/components/mapLocation"
interface OverviewMapContainerProps {
defaultLocation: MapLocation
}
export default async function OverviewMapContainer({
defaultLocation,
}: OverviewMapContainerProps) {
const hotelData = await getAllHotels()
if (!hotelData) {
@@ -19,11 +27,23 @@ export default async function OverviewMapContainer() {
const markers = getHotelMapMarkers(hotelData)
const geoJson = mapMarkerDataToGeoJson(markers)
const defaultCoordinates = defaultLocation
? {
lat: defaultLocation.latitude,
lng: defaultLocation.longitude,
}
: null
const defaultZoom = defaultLocation?.default_zoom ?? 3
return (
<MapProvider apiKey={googleMapsApiKey} pageType="overview">
<InputForm />
<DynamicMap mapId={googleMapId} markers={markers}>
<DynamicMap
mapId={googleMapId}
markers={markers}
defaultCoordinates={defaultCoordinates}
defaultZoom={defaultZoom}
>
<MapContent geojson={geoJson} />
</DynamicMap>
</MapProvider>

View File

@@ -26,7 +26,9 @@ export default async function DestinationOverviewPage() {
return (
<>
<div className={styles.mapContainer}>
<OverviewMapContainer />
<OverviewMapContainer
defaultLocation={destinationOverviewPage.location}
/>
</div>
<main className={styles.main}>
<div className={styles.blocks}>

View File

@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -16,6 +17,8 @@ import HotelListingSkeleton from "./HotelListingSkeleton"
import styles from "./hotelListing.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelListing() {
const intl = useIntl()
const scrollRef = useRef<HTMLElement>(null)
@@ -60,23 +63,35 @@ export default function HotelListing() {
listType="hotel"
/>
</div>
<ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
>
{activeHotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{activeHotels.length > 5 ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allHotelsVisible}
{activeHotels.length === 0 ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No matching hotels found" })}
text={intl.formatMessage({
id: "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
})}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
) : (
<>
<ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
>
{activeHotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{activeHotels.length > 5 ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allHotelsVisible}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</>
)}
</section>
)

View File

@@ -14,17 +14,24 @@ import styles from "./dynamicMap.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
const BACKUP_COORDINATES = {
lat: 59.3293,
lng: 18.0686,
}
interface DynamicMapProps {
markers: DestinationMarker[]
mapId: string
onTilesLoaded?: () => void
defaultCoordinates: google.maps.LatLngLiteral | null
defaultZoom: number
onClose?: () => void
}
export default function DynamicMap({
markers,
mapId,
onTilesLoaded,
defaultCoordinates,
defaultZoom,
onClose,
children,
}: PropsWithChildren<DynamicMapProps>) {
@@ -33,13 +40,17 @@ export default function DynamicMap({
useEffect(() => {
if (map) {
const bounds = new google.maps.LatLngBounds()
markers.forEach((marker) => {
bounds.extend(marker.coordinates)
})
map.fitBounds(bounds)
if (markers.length) {
const bounds = new google.maps.LatLngBounds()
markers.forEach((marker) => {
bounds.extend(marker.coordinates)
})
map.fitBounds(bounds, 100)
} else if (defaultCoordinates) {
map.setCenter(defaultCoordinates)
}
}
}, [map, markers])
}, [map, markers, defaultCoordinates])
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && onClose) {
@@ -61,13 +72,10 @@ export default function DynamicMap({
}
const mapOptions: MapProps = {
defaultCenter: markers[0]?.coordinates || {
lat: 59.3293,
lng: 18.0686,
}, // Default center will be overridden by the bounds
defaultCenter: defaultCoordinates || BACKUP_COORDINATES, // Default center will be overridden by the bounds
minZoom: 3,
maxZoom: 18,
defaultZoom: 5,
defaultZoom,
disableDefaultUI: true,
clickableIcons: false,
gestureHandling: "greedy",
@@ -76,9 +84,7 @@ export default function DynamicMap({
return (
<div className={styles.mapWrapper}>
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
{children}
</Map>
<Map {...mapOptions}>{children}</Map>
<div className={styles.ctaButtons}>
{onClose && (
<Button

View File

@@ -20,6 +20,7 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
import styles from "./map.module.css"
import type { MapLocation } from "@/types/components/mapLocation"
import type { HotelDataWithUrl } from "@/types/hotel"
interface MapProps {
@@ -27,14 +28,16 @@ interface MapProps {
mapId: string
apiKey: string
pageType: "city" | "country"
defaultLocation: MapLocation
}
export default function Map({
hotels,
mapId,
apiKey,
children,
defaultLocation,
pageType,
children,
}: PropsWithChildren<MapProps>) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -48,6 +51,14 @@ export default function Map({
const markers = getHotelMapMarkers(hotels)
const geoJson = mapMarkerDataToGeoJson(markers)
const defaultCoordinates = defaultLocation
? {
lat: defaultLocation.latitude,
lng: defaultLocation.longitude,
}
: null
const defaultZoom =
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)
const handleMapHeight = useCallback(() => {
@@ -112,7 +123,13 @@ export default function Map({
aria-label={"Mapview"}
>
<aside className={styles.sidebar}>{children}</aside>
<DynamicMap markers={markers} mapId={mapId} onClose={handleClose}>
<DynamicMap
markers={markers}
mapId={mapId}
onClose={handleClose}
defaultCoordinates={defaultCoordinates}
defaultZoom={defaultZoom}
>
<MapContent geojson={geoJson} />
</DynamicMap>
</Dialog>

View File

@@ -5,29 +5,48 @@ import MapButton from "./MapButton"
import styles from "./staticMap.module.css"
import type { MapLocation } from "@/types/components/mapLocation"
interface StaticMapProps {
country?: string
city?: string
location: MapLocation
}
// Zoom levels on the static map are always more zoomed in than on the dynamic maps.
// One of the reasons is because of the smaller size of the map. This function
// adjusts the zoom level for the static map based on the default zoom level
// of the location and whether the location is a country or a city.
function getZoomLevel(defaultZoom: number | undefined, isCountry: boolean) {
if (defaultZoom) {
return isCountry ? defaultZoom - 3 : defaultZoom - 1
}
return isCountry ? 4 : 10
}
export default async function DestinationStaticMap({
country,
city,
location,
}: StaticMapProps) {
const intl = await getIntl()
const altText = city
? intl.formatMessage({ id: "Map of the city" })
: intl.formatMessage({ id: "Map of the country" })
const zoomLevel = city ? 10 : 3
const coordinates = location
? { lat: location.latitude, lng: location.longitude }
: undefined
return (
<div className={styles.mapWrapper}>
<StaticMap
country={country}
city={city}
coordinates={coordinates}
width={320}
height={200}
zoomLevel={zoomLevel}
zoomLevel={getZoomLevel(location?.default_zoom, !!country)}
altText={altText}
/>
<MapButton className={styles.button} />

View File

@@ -50,6 +50,15 @@
height: min(calc(80dvh - 180px), 500px);
}
.alertWrapper:not(:empty) {
padding: var(--Spacing-x2) var(--Spacing-x4) 0;
border-top: 1px solid var(--Base-Border-Subtle);
}
.alertWrapper:not(:empty) + .footer {
border-top: none;
}
.footer {
display: flex;
justify-content: space-between;

View File

@@ -17,6 +17,7 @@ import Divider from "@/components/TempDesignSystem/Divider"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Alert from "../TempDesignSystem/Alert"
import Filter from "./Filter"
import Sort from "./Sort"
@@ -26,6 +27,7 @@ import type {
CategorizedFilters,
SortItem,
} from "@/types/components/destinationFilterAndSort"
import { AlertTypeEnum } from "@/types/enums/alert"
interface HotelFilterAndSortProps {
filters: CategorizedFilters
@@ -62,12 +64,24 @@ export default function DestinationFilterAndSort({
resetPendingValues: state.actions.resetPendingValues,
setIsLoading: state.actions.setIsLoading,
}))
const alertHeading =
listType === "city"
? intl.formatMessage({ id: "No matching locations found" })
: intl.formatMessage({ id: "No matching hotels found" })
const alertText =
listType === "city"
? intl.formatMessage({
id: "It looks like no location match your filters. Try adjusting your search to find the perfect stay.",
})
: intl.formatMessage({
id: "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
})
function submitAndClose(close: () => void) {
setIsLoading(true)
const sort = pendingSort
const filters = pendingFilters
const parsedUrl = new URL(window.location.href)
const searchParams = parsedUrl.searchParams
if (sort === defaultSort && searchParams.has("sort")) {
@@ -75,7 +89,6 @@ export default function DestinationFilterAndSort({
} else if (sort !== defaultSort) {
searchParams.set("sort", sort)
}
const [firstFilter, ...remainingFilters] = filters
parsedUrl.pathname = basePath
@@ -138,6 +151,16 @@ export default function DestinationFilterAndSort({
<Divider color="subtle" />
<Filter filters={filters} />
</div>
{pendingCount === 0 && (
<div className={styles.alertWrapper}>
<Alert
type={AlertTypeEnum.Warning}
heading={alertHeading}
text={alertText}
ariaRole="status"
/>
</div>
)}
<footer className={styles.footer}>
<Button
onClick={clearPendingFilters}
@@ -151,6 +174,7 @@ export default function DestinationFilterAndSort({
intent="tertiary"
size="large"
theme="base"
disabled={pendingCount === 0}
onClick={() => submitAndClose(close)}
>
{intl.formatMessage(

View File

@@ -1,4 +1,5 @@
import type { VariantProps } from "class-variance-authority"
import type { AriaRole } from "react"
import type { AlertTypeEnum } from "@/types/enums/alert"
import type { SidepeekContent } from "@/types/trpc/routers/contentstack/siteConfig"
@@ -21,4 +22,6 @@ export interface AlertProps extends VariantProps<typeof alertVariants> {
title: string
keepSearchParams?: boolean
} | null
ariaRole?: AriaRole
ariaLive?: "off" | "assertive" | "polite"
}

View File

@@ -1,3 +1,5 @@
"use client"
import Body from "@/components/TempDesignSystem/Text/Body"
import Link from "../Link"
@@ -19,6 +21,8 @@ export default function Alert({
phoneContact,
sidepeekCtaText,
sidepeekContent,
ariaLive,
ariaRole,
}: AlertProps) {
const classNames = alertVariants({
className,
@@ -31,7 +35,7 @@ export default function Alert({
return null
}
return (
<section className={classNames}>
<section className={classNames} role={ariaRole} aria-live={ariaLive}>
<div className={styles.content}>
<span className={styles.iconWrapper}>
<Icon className={styles.icon} width={24} height={24} />