feat(BOOK-53): Added component for SEO filters and support filter switching

Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Erik Tiekstra
2025-09-19 08:26:41 +00:00
parent 0e30a2d218
commit 7c92a8fc9a
30 changed files with 339 additions and 111 deletions

View File

@@ -32,8 +32,6 @@ export default function AccordionSection({ accordion, title }: AccordionProps) {
<SectionHeader textTransform="uppercase" title={title} />
<Accordion
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
theme="light"
variant="card"
>
{accordion.map((acc) =>
acc ? (

View File

@@ -14,6 +14,7 @@ import {
} from "@/lib/trpc/memoizedRequests"
import Breadcrumbs from "@/components/Breadcrumbs"
import { SeoFilters } from "@/components/ContentType/DestinationPage/SeoFilters"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
@@ -59,6 +60,7 @@ export default async function DestinationCityPage({
sidepeek_button_text,
sidepeek_content,
destination_settings,
seo_filters,
} = destinationCityPage
const allHotels = await getHotelsByCityIdentifier(cityIdentifier)
@@ -114,6 +116,7 @@ export default async function DestinationCityPage({
<main className={styles.mainContent}>
<HotelListing />
{blocks && <Blocks blocks={blocks} />}
<SeoFilters seoFilters={seo_filters} location={city.name} />
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper preamble={preamble} location={city.name}>

View File

@@ -15,6 +15,7 @@ import {
} from "@/lib/trpc/memoizedRequests"
import Breadcrumbs from "@/components/Breadcrumbs"
import { SeoFilters } from "@/components/ContentType/DestinationPage/SeoFilters"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
@@ -59,6 +60,7 @@ export default async function DestinationCountryPage({
sidepeek_button_text,
sidepeek_content,
destination_settings,
seo_filters,
} = destinationCountryPage
const [allHotels, allCities] = await Promise.all([
@@ -116,6 +118,10 @@ export default async function DestinationCountryPage({
<main className={styles.mainContent}>
<CityListing />
{blocks && <Blocks blocks={blocks} />}
<SeoFilters
seoFilters={seo_filters}
location={translatedCountry}
/>
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper

View File

@@ -0,0 +1,91 @@
"use client"
import { useIntl } from "react-intl"
import Accordion from "@scandic-hotels/design-system/Accordion"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import Link from "@scandic-hotels/design-system/Link"
import { useDestinationDataStore } from "@/stores/destination-data"
import styles from "./seoFilters.module.css"
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
interface SeoFiltersProps {
seoFilters: {
facilityFilters: HotelFilter[]
surroundingsFilters: HotelFilter[]
} | null
location: string
}
export function SeoFilters({ seoFilters, location }: SeoFiltersProps) {
const intl = useIntl()
const { basePath } = useDestinationDataStore((state) => ({
basePath: state.basePathnameWithoutFilters,
}))
if (!seoFilters) {
return null
}
const { facilityFilters, surroundingsFilters } = seoFilters
if (!facilityFilters.length && !surroundingsFilters.length) {
return null
}
return (
<nav className={styles.seoFilters}>
<Accordion className={styles.accordion}>
{facilityFilters.length > 0 ? (
<AccordionItem
title={intl.formatMessage(
{ defaultMessage: "{location} Hotel facilities" },
{ location }
)}
showAsSubtitle
>
<ul className={styles.filterList}>
{facilityFilters.map((filter) => (
<li key={filter.id}>
<Link
href={`${basePath}/${filter.slug}`}
color="Text/Interactive/Secondary"
textDecoration="underline"
>
{filter.name}
</Link>
</li>
))}
</ul>
</AccordionItem>
) : null}
{surroundingsFilters.length > 0 ? (
<AccordionItem
title={intl.formatMessage(
{ defaultMessage: "{location} Hotel surroundings" },
{ location }
)}
showAsSubtitle
>
<ul className={styles.filterList}>
{surroundingsFilters.map((filter) => (
<li key={filter.id}>
<Link
href={`${basePath}/${filter.slug}`}
color="Text/Interactive/Secondary"
textDecoration="underline"
>
{filter.name}
</Link>
</li>
))}
</ul>
</AccordionItem>
) : null}
</Accordion>
</nav>
)
}

View File

@@ -0,0 +1,11 @@
.filterList {
column-count: 2;
list-style-type: none;
gap: var(--Space-x15);
}
@media screen and (min-width: 1367px) {
.filterList {
column-count: 3;
}
}

View File

@@ -37,7 +37,7 @@ export default async function AmenitiesSidePeek({
defaultMessage: "Close",
})}
>
<Accordion>
<Accordion type="sidepeek">
<ParkingAccordionItem
parking={parking.parking}
elevatorPitch={parking.parkingElevatorPitch}

View File

@@ -30,7 +30,7 @@ export default function AccessibilityAccordionItem({
})}
iconName={IconName.Accessibility}
className={styles.accordionItem}
variant="sidepeek"
type="sidepeek"
onOpen={() => trackAccordionClick("amenities:accessibility")}
>
<div className={styles.accessibilityContent}>

View File

@@ -439,12 +439,12 @@ export default function BookedRoomSidePeek({
/>
</div>
{hotelRoom ? (
<Accordion>
<Accordion type="sidepeek">
<AccordionItem
title={intl.formatMessage({
defaultMessage: "Room details",
})}
variant="sidepeek"
type="sidepeek"
>
<RoomDetails
roomDescription={hotelRoom.descriptions.medium}

View File

@@ -37,7 +37,7 @@ export default function DestinationDataProviderContent({
filters.push(...filterParam.split("&"))
}
updateActiveFiltersAndSort(filters, sort)
updateActiveFiltersAndSort(filters, sort, filterFromUrl)
}, [params, updateActiveFiltersAndSort, basePath])
return <>{children}</>

View File

@@ -69,7 +69,7 @@ export function createDestinationDataStore({
return create<DestinationDataState>((set) => ({
actions: {
updateActiveFiltersAndSort(filterSlugs, sort) {
updateActiveFiltersAndSort(filterSlugs, sort, filterSlugFromUrl) {
return set(
produce((state: DestinationDataState) => {
const newSort =
@@ -79,6 +79,10 @@ export function createDestinationDataStore({
const filters = flattenedFilters.filter((filter) =>
filterSlugs.includes(filter.slug)
)
const filterFromUrl =
flattenedFilters.find(
(filter) => filter.slug === filterSlugFromUrl
) ?? null
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, newSort)
const filteredCities = state.allHotels.length
@@ -121,6 +125,7 @@ export function createDestinationDataStore({
state.activeHotels = sortedHotels
state.activeCities = sortedCities
state.filterFromUrl = filterFromUrl
state.pendingFilters = filters
state.pendingSort = newSort
state.pendingHotelCount = filteredHotels.length

View File

@@ -11,7 +11,8 @@ import type { ReadonlyURLSearchParams } from "next/navigation"
interface Actions {
updateActiveFiltersAndSort: (
filterSlugs: string[],
sort: string | null
sort: string | null,
filterSlugFromUrl: string | null
) => void
setPendingSort: (sort: HotelSortOption) => void
togglePendingFilter: (filter: HotelFilter) => void

View File

@@ -43,7 +43,7 @@ export function HotelSidePeekContent({
</Typography>
<Contact hotel={hotel} />
<Accordion>
<Accordion type="sidepeek">
<ParkingAccordionItem
parking={hotel.parking}
elevatorPitch={additionalHotelData?.hotelParking.elevatorPitch}
@@ -95,7 +95,7 @@ function AccessibilityAccordionItem({
})}
iconName={IconName.Accessibility}
className={styles.accordionItem}
variant="sidepeek"
type="sidepeek"
onOpen={() => tracking.trackAccordionItemOpen("amenities:accessibility")}
>
<div className={styles.accessibilityContent}>

View File

@@ -44,7 +44,7 @@ export default function BreakfastAccordionItem({
<AccordionItem
title={intl.formatMessage({ defaultMessage: "Breakfast" })}
iconName={IconName.CoffeeAlt}
variant="sidepeek"
type="sidepeek"
className={styles.accordionItem}
onOpen={() => tracking.trackAccordionItemOpen("amenities:breakfast")}
>

View File

@@ -29,7 +29,7 @@ export default function CheckInCheckOutAccordionItem({
<AccordionItem
title={intl.formatMessage({ defaultMessage: "Check-in/Check-out" })}
iconName={IconName.Business}
variant="sidepeek"
type="sidepeek"
className={styles.accordionItem}
onOpen={() => tracking.trackAccordionItemOpen("amenities:check-in")}
>

View File

@@ -38,7 +38,7 @@ export default function ParkingAccordionItem({
defaultMessage: "Parking",
})}
iconName={IconName.Parking}
variant="sidepeek"
type="sidepeek"
className={styles.accordionItem}
onOpen={() => tracking.trackAccordionItemOpen("amenities:parking")}
>

View File

@@ -263,4 +263,6 @@ export enum FacilityEnum {
WideEntrance = 2085,
WideRestaurantEntrance = 2087,
WiFiWirelessInternetAccessAllScandic = 5774,
UNKNOWN = -1,
}

View File

@@ -1,45 +1,42 @@
.accordionItem {
border-bottom: 1px solid var(--Base-Border-Subtle);
border-bottom: 1px solid var(--Border-Default);
&.card {
padding: var(--Space-x1);
.summary {
padding: var(--Space-x15) var(--Space-x2);
}
}
.accordionItem.card {
padding: var(--Spacing-x1);
&.sidepeek {
padding: var(--Space-x1) 0;
.summary {
padding: var(--Space-x15) var(--Space-x1);
align-items: center;
}
}
.accordionItem.sidepeek {
padding: var(--Spacing-x1) 0;
.summary:hover {
background-color: var(--Surface-Primary-Hover);
}
}
.summary {
position: relative;
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half) var(--Spacing-x1);
gap: var(--Space-x15) var(--Space-x1);
cursor: pointer;
color: var(--Base-Text-High-contrast);
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
color: var(--Text-Interactive-Default);
transition: background-color 0.3s;
}
.summary.card:hover {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.accordionItem.light .summary:hover {
background-color: var(--Base-Surface-Primary-light-Hover);
}
.accordionItem.subtle .summary:hover {
background-color: var(--Base-Surface-Primary-light-Normal);
}
.accordionItem.card .summary {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
border-radius: var(--Corner-radius-md);
}
list-style: none;
.accordionItem.sidepeek .summary {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x1);
align-items: center;
&::-webkit-details-marker {
display: none;
}
}
.title {
@@ -47,7 +44,7 @@
}
.content {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x1);
padding: var(--Space-x15) var(--Space-x2) var(--Space-x1);
overflow: hidden;
max-height: 0;
transition: max-height 0.3s;
@@ -57,6 +54,7 @@
transition: transform 0.3s;
flex-shrink: 0;
}
details[open] .chevron {
transform: rotate(180deg);
}

View File

@@ -2,17 +2,16 @@
import { type ReactNode, useRef } from 'react'
import Body from '../../Body'
import { IconByIconName } from '../../Icons/IconByIconName'
import { MaterialIcon } from '../../Icons/MaterialIcon'
import Subtitle from '../../Subtitle'
import { accordionItemVariants } from './variants'
import styles from './accordionItem.module.css'
import type { IconName } from '../../Icons/iconName'
import type { VariantProps } from 'class-variance-authority'
import type { IconName } from '../../Icons/iconName'
import { Typography } from '../../Typography'
export interface AccordionItemProps
extends React.HtmlHTMLAttributes<HTMLDetailsElement>,
@@ -21,6 +20,7 @@ export interface AccordionItemProps
iconName?: IconName
icon?: ReactNode
subtitle?: string
showAsSubtitle?: boolean
onOpen?: () => void
}
@@ -29,10 +29,10 @@ export default function AccordionItem({
icon,
iconName,
title,
theme,
variant,
type,
className,
subtitle,
showAsSubtitle = false,
onOpen,
}: AccordionItemProps) {
const contentRef = useRef<HTMLDivElement>(null)
@@ -71,36 +71,36 @@ export default function AccordionItem({
}
return (
<li className={accordionItemVariants({ className, variant, theme })}>
<li className={accordionItemVariants({ className, type })}>
<details ref={detailsRef} onToggle={toggleAccordion}>
<summary className={styles.summary}>
{IconComp}
{variant === 'sidepeek' ? (
<Subtitle
className={styles.title}
type="two"
color="baseTextHighContrast"
>
{title}
</Subtitle>
{type === 'sidepeek' ? (
<Typography variant="Title/Subtitle/md">
<p className={styles.title}>{title}</p>
</Typography>
) : (
<div className={styles.title}>
{subtitle ? (
<Subtitle type="two" color="baseTextHighContrast">
{title}
</Subtitle>
{subtitle || showAsSubtitle ? (
<Typography variant="Title/Subtitle/md">
<p className={styles.title}>{title}</p>
</Typography>
) : (
<Body textTransform="bold" color="baseTextHighContrast">
{title}
</Body>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.title}>{title}</p>
</Typography>
)}
{subtitle && (
<Typography variant="Body/Paragraph/mdBold">
<p>{subtitle}</p>
</Typography>
)}
{subtitle && <Body color="baseTextHighContrast">{subtitle}</Body>}
</div>
)}
<MaterialIcon
icon="keyboard_arrow_down"
className={styles.chevron}
color="Icon/Interactive/Default"
color="CurrentColor"
size={20}
/>
</summary>

View File

@@ -4,18 +4,12 @@ import styles from './accordionItem.module.css'
export const accordionItemVariants = cva(styles.accordionItem, {
variants: {
variant: {
type: {
card: styles.card,
sidepeek: styles.sidepeek,
},
theme: {
default: styles.default,
light: styles.light,
subtle: styles.subtle,
},
},
defaultVariants: {
variant: 'card',
theme: 'default',
type: 'card',
},
})

View File

@@ -1,25 +1,11 @@
.accordion {
list-style: none;
}
.accordion.card {
&.card {
border-radius: var(--Corner-radius-md);
}
.accordion.light {
background-color: var(--Base-Surface-Primary-light-Normal);
li:last-child {
border-width: 0;
}
.accordion.subtle {
background-color: var(--Background-Primary);
}
.accordion li:last-child {
border: none;
}
.accordion details > summary {
list-style: none;
}
.accordion details > summary::-webkit-details-marker {
display: none;
}

View File

@@ -1,10 +1,9 @@
import { Children, cloneElement, isValidElement } from 'react'
import { accordionVariants } from './variants'
import type { VariantProps } from 'class-variance-authority'
import type { AccordionItemProps } from './AccordionItem'
import { accordionVariants } from './variants'
interface AccordionProps
extends React.HtmlHTMLAttributes<HTMLUListElement>,
@@ -13,14 +12,13 @@ interface AccordionProps
export default function Accordion({
children,
className,
theme,
variant,
type,
}: AccordionProps) {
return (
<ul className={accordionVariants({ className, variant, theme })}>
<ul className={accordionVariants({ className, type })}>
{Children.map(children, (child) => {
if (isValidElement<AccordionItemProps>(child)) {
return cloneElement(child, { variant, theme })
return cloneElement(child, { type })
} else {
return child
}

View File

@@ -4,18 +4,12 @@ import styles from './accordion.module.css'
export const accordionVariants = cva(styles.accordion, {
variants: {
variant: {
type: {
card: styles.card,
sidepeek: styles.sidepeek,
},
theme: {
default: styles.default,
light: styles.light,
subtle: styles.subtle,
},
},
defaultVariants: {
variant: 'card',
theme: 'default',
type: 'card',
},
})

View File

@@ -1,10 +1,10 @@
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
import type { JSX } from 'react'
import { IconName } from '../Icons/iconName'
import { MaterialIconSetIconProps } from '../Icons/MaterialIcon'
import { IconProps, NucleoIconProps } from '../Icons'
import { IconByIconName } from '../Icons/IconByIconName'
import { IconName } from '../Icons/iconName'
import { MaterialIconSetIconProps } from '../Icons/MaterialIcon'
interface mapFacilityToIconProps {
id: FacilityEnum
@@ -299,4 +299,6 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
[FacilityEnum.WideEntrance]: IconName.StarFilled,
[FacilityEnum.WideRestaurantEntrance]: IconName.StarFilled,
[FacilityEnum.WiFiWirelessInternetAccessAllScandic]: IconName.StarFilled,
[FacilityEnum.UNKNOWN]: IconName.StarFilled,
}

View File

@@ -0,0 +1,14 @@
#import "./System.graphql"
fragment HotelFilter on HotelFilter {
title
facility_id
category
slug
}
fragment HotelFilterRef on HotelFilter {
system {
...System
}
}

View File

@@ -1,5 +1,7 @@
#import "../../Fragments/System.graphql"
#import "../../Fragments/HotelFilter.graphql"
#import "../../Fragments/Blocks/Accordion.graphql"
#import "../../Fragments/Blocks/Content.graphql"
@@ -82,6 +84,15 @@ query GetDestinationCityPage($locale: String!, $uid: String!) {
...Accordion_DestinationCityPage
...Content_DestinationCityPage
}
seo_filters {
filterConnection {
edges {
node {
...HotelFilter
}
}
}
}
system {
...System
created_at
@@ -135,6 +146,15 @@ query GetDestinationCityPageRefs($locale: String!, $uid: String!) {
...Accordion_DestinationCityPageRefs
...Content_DestinationCityPageRefs
}
seo_filters {
filterConnection {
edges {
node {
...HotelFilterRef
}
}
}
}
system {
...System
}

View File

@@ -1,5 +1,7 @@
#import "../../Fragments/System.graphql"
#import "../../Fragments/HotelFilter.graphql"
#import "../../Fragments/Blocks/Accordion.graphql"
#import "../../Fragments/Blocks/Content.graphql"
@@ -77,6 +79,15 @@ query GetDestinationCountryPage($locale: String!, $uid: String!) {
...Accordion_DestinationCountryPage
...Content_DestinationCountryPage
}
seo_filters {
filterConnection {
edges {
node {
...HotelFilter
}
}
}
}
system {
...System
created_at
@@ -117,6 +128,15 @@ query GetDestinationCountryPageRefs($locale: String!, $uid: String!) {
...Accordion_DestinationCountryPageRefs
...Content_DestinationCountryPageRefs
}
seo_filters {
filterConnection {
edges {
node {
...HotelFilterRef
}
}
}
}
system {
...System
}

View File

@@ -11,6 +11,10 @@ import {
accordionSchema,
} from "../schemas/blocks/accordion"
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
import {
destinationFiltersRefsSchema,
destinationFiltersSchema,
} from "../schemas/destinationFilters"
import { mapLocationSchema } from "../schemas/mapLocation"
import {
linkRefsUnionSchema,
@@ -159,6 +163,7 @@ export const destinationCityPageSchema = z.object({
})
.nullish(),
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
seo_filters: destinationFiltersSchema,
system: systemSchema.merge(
z.object({
created_at: z.string(),
@@ -258,6 +263,7 @@ export const destinationCityPageRefsSchema = z.object({
})
.nullish(),
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
seo_filters: destinationFiltersRefsSchema,
system: systemSchema,
}),
})

View File

@@ -11,6 +11,10 @@ import {
accordionSchema,
} from "../schemas/blocks/accordion"
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
import {
destinationFiltersRefsSchema,
destinationFiltersSchema,
} from "../schemas/destinationFilters"
import { mapLocationSchema } from "../schemas/mapLocation"
import {
linkRefsUnionSchema,
@@ -88,6 +92,7 @@ export const destinationCountryPageSchema = z.object({
})
.nullish(),
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
seo_filters: destinationFiltersSchema,
system: systemSchema.merge(
z.object({
created_at: z.string(),
@@ -160,6 +165,7 @@ export const destinationCountryPageRefsSchema = z.object({
}),
}),
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
seo_filters: destinationFiltersRefsSchema,
system: systemSchema,
}),
})

View File

@@ -0,0 +1,55 @@
import { z } from "zod"
import { isDefined } from "@scandic-hotels/common/utils/isDefined"
import { hotelFilterSchema } from "./hotelFilter"
import { systemSchema } from "./system"
export const destinationFiltersSchema = z
.array(
z.object({
filterConnection: z.object({
edges: z.array(
z.object({
node: hotelFilterSchema,
})
),
}),
})
)
.nullish()
.transform((data) => {
const filters = data
?.map(({ filterConnection }) => filterConnection.edges[0]?.node)
.filter(isDefined)
if (!data || !filters?.length) {
return null
}
const facilityFilters = filters.filter((f) => f.filterType === "facility")
const surroundingsFilters = filters.filter(
(f) => f.filterType === "surroundings"
)
return {
facilityFilters,
surroundingsFilters,
}
})
export const destinationFiltersRefsSchema = z
.array(
z.object({
filterConnection: z.object({
edges: z.array(
z.object({
node: z.object({
system: systemSchema,
}),
})
),
}),
})
)
.nullish()

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
export const hotelFilterSchema = z
.object({
title: z.string(),
facility_id: z.nativeEnum(FacilityEnum).catch(FacilityEnum.UNKNOWN),
category: z.string(),
slug: z.string(),
})
.transform((data) => ({
id: data.facility_id,
name: data.title,
filterType: data.category,
slug: data.slug,
sortOrder: 0,
}))