fix(SW-2933): Making the hotels/city listing render correct for SEO purposes

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-06-04 09:05:05 +00:00
parent 0bd78bc7a6
commit a8f167025d
9 changed files with 75 additions and 40 deletions

View File

@@ -15,7 +15,9 @@ export default function CityListingSkeleton() {
</div> </div>
<ul className={styles.cityList}> <ul className={styles.cityList}>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<CityListingItemSkeleton key={index} /> <li key={index}>
<CityListingItemSkeleton />
</li>
))} ))}
</ul> </ul>
</section> </section>

View File

@@ -3,12 +3,13 @@
import { useRef } from "react" import { useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
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 DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton" import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useScrollToTop } from "@/hooks/useScrollToTop" import { useScrollToTop } from "@/hooks/useScrollToTop"
import CityListingItem from "./CityListingItem" import CityListingItem from "./CityListingItem"
@@ -35,15 +36,17 @@ export default function CityListing() {
) : ( ) : (
<section className={styles.container} ref={scrollRef}> <section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}> <div className={styles.listHeader}>
<Subtitle type="two"> <Typography variant="Title/Subtitle/md">
{intl.formatMessage( <h2>
{ {intl.formatMessage(
defaultMessage: {
"{count, plural, one {# destination} other {# destinations}}", defaultMessage:
}, "{count, plural, one {# destination} other {# destinations}}",
{ count: activeCities.length } },
)} { count: activeCities.length }
</Subtitle> )}
</h2>
</Typography>
<DestinationFilterAndSort listType="city" /> <DestinationFilterAndSort listType="city" />
</div> </div>
{activeCities.length === 0 ? ( {activeCities.length === 0 ? (

View File

@@ -61,14 +61,14 @@ export default async function DestinationCityPage({
/> />
) : ( ) : (
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
<header className={styles.header}> <div className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}> <Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs /> <Breadcrumbs />
</Suspense> </Suspense>
{images?.length ? ( {images?.length ? (
<TopImages images={images} destinationName={city.name} /> <TopImages images={images} destinationName={city.name} />
) : null} ) : null}
</header> </div>
<main className={styles.mainContent}> <main className={styles.mainContent}>
<HotelListing /> <HotelListing />
{blocks && <Blocks blocks={blocks} />} {blocks && <Blocks blocks={blocks} />}

View File

@@ -60,7 +60,7 @@ export default async function DestinationCountryPage({
/> />
) : ( ) : (
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
<header className={styles.header}> <div className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}> <Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs /> <Breadcrumbs />
</Suspense> </Suspense>
@@ -70,7 +70,7 @@ export default async function DestinationCountryPage({
destinationName={translatedCountry} destinationName={translatedCountry}
/> />
) : null} ) : null}
</header> </div>
<main className={styles.mainContent}> <main className={styles.mainContent}>
<CityListing /> <CityListing />
{blocks && <Blocks blocks={blocks} />} {blocks && <Blocks blocks={blocks} />}

View File

@@ -15,7 +15,9 @@ export default function HotelListingSkeleton() {
</div> </div>
<ul className={styles.hotelList}> <ul className={styles.hotelList}>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<HotelListingItemSkeleton key={index} /> <li key={index}>
<HotelListingItemSkeleton />
</li>
))} ))}
</ul> </ul>
</section> </section>

View File

@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
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 { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
@@ -13,7 +14,6 @@ import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton" import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useScrollToTop } from "@/hooks/useScrollToTop" import { useScrollToTop } from "@/hooks/useScrollToTop"
import HotelListingItem from "./HotelListingItem" import HotelListingItem from "./HotelListingItem"
@@ -48,14 +48,17 @@ export default function HotelListing() {
) : ( ) : (
<section className={styles.container} ref={scrollRef}> <section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}> <div className={styles.listHeader}>
<Subtitle type="two"> <Typography variant="Title/Subtitle/md">
{intl.formatMessage( <h2>
{ {intl.formatMessage(
defaultMessage: "{count, plural, one {# hotel} other {# hotels}}", {
}, defaultMessage:
{ count: activeHotels.length } "{count, plural, one {# hotel} other {# hotels}}",
)} },
</Subtitle> { count: activeHotels.length }
)}
</h2>
</Typography>
<div className={styles.cta}> <div className={styles.cta}>
{mapUrl && ( {mapUrl && (
<Button <Button

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import { usePathname } from "next/navigation" import { usePathname, useSearchParams } from "next/navigation"
import { useRef } from "react" import { useRef } from "react"
import { createDestinationDataStore } from "@/stores/destination-data" import { createDestinationDataStore } from "@/stores/destination-data"
@@ -19,6 +19,7 @@ export default function DestinationDataProvider({
}: DestinationDataProviderProps) { }: DestinationDataProviderProps) {
const storeRef = useRef<DestinationDataStore>(undefined) const storeRef = useRef<DestinationDataStore>(undefined)
const pathname = usePathname() const pathname = usePathname()
const searchParams = useSearchParams()
if (!storeRef.current) { if (!storeRef.current) {
storeRef.current = createDestinationDataStore({ storeRef.current = createDestinationDataStore({
@@ -26,6 +27,7 @@ export default function DestinationDataProvider({
allHotels, allHotels,
pathname, pathname,
sortItems, sortItems,
searchParams,
}) })
} }

View File

@@ -29,15 +29,38 @@ export function createDestinationDataStore({
allHotels, allHotels,
pathname, pathname,
sortItems, sortItems,
searchParams,
}: InitialState) { }: InitialState) {
const defaultSort = const defaultSort =
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
const allFilters = getFiltersFromHotels(allHotels) const allFilters = getFiltersFromHotels(allHotels)
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) => const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
filter.map((f) => f.slug) filter.map((f) => f.slug)
) )
const activeFilters: string[] = []
const basePathnameWithoutFilters = getBasePathNameWithoutFilters(
pathname,
allFilterSlugs
)
if (basePathnameWithoutFilters !== pathname) {
const pathParts = pathname.split("/")
const lastPathPart = pathParts[pathParts.length - 1]
activeFilters.push(lastPathPart)
}
let activeSort = defaultSort
if (searchParams) {
const sortParam = searchParams.get("sort")
if (sortParam && isValidSortOption(sortParam, sortItems)) {
activeSort = sortParam
}
}
const filteredHotels = getFilteredHotels(allHotels, activeFilters)
const activeHotels = getSortedHotels(filteredHotels, activeSort)
const filteredCities = getFilteredCities(filteredHotels, allCities)
const activeCities = getSortedCities(filteredCities, activeSort)
return create<DestinationDataState>((set) => ({ return create<DestinationDataState>((set) => ({
actions: { actions: {
updateActiveFiltersAndSort(filters, sort) { updateActiveFiltersAndSort(filters, sort) {
@@ -144,24 +167,21 @@ export function createDestinationDataStore({
}, },
}, },
allHotels, allHotels,
activeHotels: allHotels, activeHotels: activeHotels,
pendingHotelCount: allHotels.length, pendingHotelCount: activeHotels.length,
allCities, allCities,
activeCities: allCities, activeCities: activeCities,
pendingCityCount: allCities.length, pendingCityCount: activeCities.length,
activeSort: defaultSort, activeSort,
pendingSort: defaultSort, pendingSort: activeSort,
defaultSort, defaultSort,
activeFilters: [], activeFilters,
pendingFilters: [], pendingFilters: activeFilters,
allFilters, allFilters,
allFilterSlugs, allFilterSlugs,
basePathnameWithoutFilters: getBasePathNameWithoutFilters( basePathnameWithoutFilters,
pathname,
allFilterSlugs
),
sortItems, sortItems,
isLoading: true, isLoading: false,
})) }))
} }

View File

@@ -1,3 +1,5 @@
import type { ReadonlyURLSearchParams } from "next/navigation"
import type { import type {
CategorizedFilters, CategorizedFilters,
SortItem, SortItem,
@@ -44,4 +46,5 @@ export interface DestinationDataState {
export interface InitialState export interface InitialState
extends Pick<DestinationDataState, "allHotels" | "allCities" | "sortItems"> { extends Pick<DestinationDataState, "allHotels" | "allCities" | "sortItems"> {
pathname: string pathname: string
searchParams: ReadonlyURLSearchParams
} }