feat(SW-1409): Synced tabnavigation headings and section headings on hotel pages

* feat(SW-2409): Added same headings to relevant sidepeeks


Approved-by: Christian Andolf
Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-06-03 09:22:28 +00:00
parent a47b2f46d3
commit be98c2faf6
19 changed files with 251 additions and 219 deletions

View File

@@ -12,7 +12,7 @@ import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import styles from "./accordion.module.css"
import type { AccordionProps } from "@/types/components/blocks/Accordion"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import { HotelHashValues } from "@/types/enums/hotelPage"
export default function AccordionSection({ accordion, title }: AccordionProps) {
const showToggleButton = accordion.length > 5

View File

@@ -13,7 +13,7 @@ import {
type FacilityCardType,
type FacilityGrid,
} from "@/types/components/hotelPage/facilities"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import { HotelHashValues } from "@/types/enums/hotelPage"
export default async function Facilities({
facilities,

View File

@@ -2,7 +2,6 @@
import { cx } from "class-variance-authority"
import { useMemo, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -14,10 +13,9 @@ import { RoomCard } from "./RoomCard"
import styles from "./rooms.module.css"
import type { RoomsProps } from "@/types/components/hotelPage/room"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import { HotelHashValues } from "@/types/enums/hotelPage"
export function Rooms({ rooms, preamble }: RoomsProps) {
const intl = useIntl()
export function Rooms({ heading, rooms, preamble }: RoomsProps) {
const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
const sortedRooms = useMemo(() => {
@@ -40,12 +38,8 @@ export function Rooms({ rooms, preamble }: RoomsProps) {
>
<div ref={scrollRef} className={styles.scrollRef}></div>
<header className={styles.sectionHeader}>
<Typography variant="Title/md" className={styles.heading}>
<h2>
{intl.formatMessage({
defaultMessage: "Rooms",
})}
</h2>
<Typography variant="Title/md">
<h2 className={styles.heading}>{heading}</h2>
</Typography>
{preamble && (
<Typography variant="Body/Paragraph/mdRegular">

View File

@@ -19,6 +19,7 @@ export default async function MeetingsAndConferencesSidePeek({
descriptions,
meetingRooms,
meetingPageUrl,
heading,
}: MeetingsAndConferencesSidePeekProps) {
const intl = await getIntl()
const { seatingText, roomText } = await getConferenceRoomTexts(meetingRooms)
@@ -26,12 +27,7 @@ export default async function MeetingsAndConferencesSidePeek({
const meetingPageHref = await appendSlugToPathname(meetingPageUrl)
return (
<SidePeek
contentKey={SidepeekSlugs.meetings}
title={intl.formatMessage({
defaultMessage: "Meetings & Conferences",
})}
>
<SidePeek contentKey={SidepeekSlugs.meetings} title={heading}>
<div className={styles.wrapper}>
<Subtitle color="burgundy" asChild>
<Title level="h3">

View File

@@ -1,5 +1,4 @@
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n"
import RestaurantBarItem from "./RestaurantBarItem"
@@ -8,18 +7,12 @@ import styles from "./restaurantBar.module.css"
import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage"
import type { RestaurantBarSidePeekProps } from "@/types/components/hotelPage/sidepeek/restaurantBar"
export default async function RestaurantBarSidePeek({
export default function RestaurantBarSidePeek({
restaurants,
heading,
}: RestaurantBarSidePeekProps) {
const intl = await getIntl()
return (
<SidePeek
contentKey={SidepeekSlugs.restaurant}
title={intl.formatMessage({
defaultMessage: "Restaurant & Bar",
})}
>
<SidePeek contentKey={SidepeekSlugs.restaurant} title={heading}>
<div className={styles.content}>
{restaurants.map((restaurant) => (
<div key={restaurant.id} className={styles.item}>

View File

@@ -14,6 +14,7 @@ export default async function WellnessAndExerciseSidePeek({
healthFacilities,
wellnessExercisePageUrl,
spaPage,
heading,
}: WellnessAndExerciseSidePeekProps) {
const intl = await getIntl()
const wellnessExercisePageHref = await appendSlugToPathname(
@@ -21,12 +22,7 @@ export default async function WellnessAndExerciseSidePeek({
)
return (
<SidePeek
contentKey={SidepeekSlugs.wellness}
title={intl.formatMessage({
defaultMessage: "Gym & Wellness",
})}
>
<SidePeek contentKey={SidepeekSlugs.wellness} title={heading}>
<div className={styles.wrapper}>
{healthFacilities.map((facility) => (
<Facility key={facility.type} data={facility} />

View File

@@ -4,7 +4,6 @@ import { cx } from "class-variance-authority"
import NextLink from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -18,24 +17,19 @@ import { trackHotelTabClick } from "@/utils/tracking"
import styles from "./tabNavigation.module.css"
import {
HotelHashValues,
type TabNavigationProps,
} from "@/types/components/hotelPage/tabNavigation"
import type { HotelPageSections } from "@/types/components/hotelPage/sections"
import { HotelHashValues } from "@/types/enums/hotelPage"
export default function TabNavigation({
hasActivities,
hasFAQ,
hasMeetingRooms,
hasRestaurants,
hasWellness,
tabValues,
}: TabNavigationProps) {
const hash = useHash()
const intl = useIntl()
interface TabNavigationProps {
pageSections: HotelPageSections
}
export default function TabNavigation({ pageSections }: TabNavigationProps) {
const activeHash = useHash()
const router = useRouter()
const tabNavigationRef = useRef<HTMLDivElement>(null)
const tabRefs = useMemo(() => new Map<string, HTMLAnchorElement>(), [])
const tabLinks = Object.values(pageSections).map(({ hash }) => hash)
useStickyPosition({
ref: tabNavigationRef,
@@ -46,88 +40,7 @@ export default function TabNavigation({
const { containerRef, showLeftShadow, showRightShadow } =
useScrollShadows<HTMLDivElement>()
const tabLinks: { hash: HotelHashValues; text: string }[] = [
{
hash: HotelHashValues.overview,
text:
tabValues?.overview ||
intl.formatMessage({
defaultMessage: "Overview",
}),
},
{
hash: HotelHashValues.rooms,
text:
tabValues?.rooms ||
intl.formatMessage({
defaultMessage: "Rooms",
}),
},
...(hasRestaurants
? [
{
hash: HotelHashValues.restaurant,
text:
tabValues?.restaurant_bar ||
intl.formatMessage({
defaultMessage: "Restaurant & Bar",
}),
},
]
: []),
...(hasMeetingRooms
? [
{
hash: HotelHashValues.meetings,
text:
tabValues?.conferences_meetings ||
intl.formatMessage({
defaultMessage: "Meetings & Conferences",
}),
},
]
: []),
...(hasWellness
? [
{
hash: HotelHashValues.wellness,
text:
tabValues?.health_wellness ||
intl.formatMessage({
defaultMessage: "Gym & Wellness",
}),
},
]
: []),
...(hasActivities
? [
{
hash: HotelHashValues.activities,
text:
tabValues?.activities ||
intl.formatMessage({
defaultMessage: "Activities",
}),
},
]
: []),
...(hasFAQ
? [
{
hash: HotelHashValues.faq,
text:
tabValues?.faq ||
intl.formatMessage({
defaultMessage: "FAQ",
}),
},
]
: []),
]
const { activeSectionId, pauseScrollSpy } = useScrollSpy(
tabLinks.map(({ hash }) => hash)
)
const { activeSectionId, pauseScrollSpy } = useScrollSpy(tabLinks)
const scrollLinkToCenter = useCallback(
(hash: string) => {
@@ -160,9 +73,9 @@ export default function TabNavigation({
)
useLayoutEffect(() => {
const activeHash = hash || HotelHashValues.overview
scrollLinkToCenter(activeHash)
}, [hash, scrollLinkToCenter])
const hash = activeHash || HotelHashValues.overview
scrollLinkToCenter(hash)
}, [activeHash, scrollLinkToCenter])
useEffect(() => {
if (activeSectionId) {
@@ -179,19 +92,19 @@ export default function TabNavigation({
})}
>
<nav className={styles.tabsContainer} ref={containerRef}>
{tabLinks.map((link) => {
{Object.values(pageSections).map(({ hash, heading }) => {
const isActive =
hash === link.hash ||
(!hash && link.hash === HotelHashValues.overview)
activeHash === hash ||
(!activeHash && hash === HotelHashValues.overview)
return (
<Typography
key={link.hash}
key={hash}
variant={
isActive ? "Body/Paragraph/mdBold" : "Body/Paragraph/mdRegular"
}
>
<NextLink
href={`#${link.hash}`}
href={`#${hash}`}
className={cx(styles.link, {
[styles.active]: isActive,
})}
@@ -199,15 +112,15 @@ export default function TabNavigation({
scroll={true}
ref={(element) => {
if (element) {
tabRefs.set(link.hash, element)
tabRefs.set(hash, element)
}
}}
onClick={() => {
pauseScrollSpy()
trackHotelTabClick(link.text)
trackHotelTabClick(heading)
}}
>
{link.text}
{heading}
</NextLink>
</Typography>
)

View File

@@ -12,6 +12,7 @@ import Breadcrumbs from "@/components/Breadcrumbs"
import Alert from "@/components/TempDesignSystem/Alert"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { setFacilityCards } from "@/utils/facilityCards"
import { generateHotelSchema } from "@/utils/jsonSchemas"
@@ -35,15 +36,20 @@ import PreviewImages from "./PreviewImages"
import { Rooms } from "./Rooms"
import SidePeeks from "./SidePeeks"
import TabNavigation from "./TabNavigation"
import { getTrackingHotelData, getTrackingPageData } from "./utils"
import {
getPageSectionsData,
getTrackingHotelData,
getTrackingPageData,
} from "./utils"
import styles from "./hotelPage.module.css"
import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import { HotelHashValues } from "@/types/enums/hotelPage"
export default async function HotelPage({ hotelId }: HotelPageProps) {
const lang = await getLang()
const intl = await getIntl()
void getHotelPage()
void getHotel({
@@ -68,7 +74,12 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
}
const jsonSchema = generateHotelSchema(hotelData)
const { faq, content, tabValues } = hotelPageData
const {
faq,
content: { spaPage, activitiesCards },
sectionHeadings,
} = hotelPageData
const { hotel, restaurants, roomCategories, additionalData } = hotelData
const {
name,
address,
@@ -84,9 +95,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
ratings,
parking,
hotelType,
} = hotelData.hotel
const restaurants = hotelData.restaurants
const roomCategories = hotelData.roomCategories
} = hotel
const {
healthAndWellness,
healthAndFitness,
@@ -98,28 +107,29 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
meetingRooms,
displayWebPage,
hotelSpecialNeeds,
} = hotelData.additionalData
} = additionalData
const images = gallery?.smallerImages
const description = hotelContent.texts.descriptions?.medium
const { spaPage, activitiesCards } = content
const hasRestaurants = restaurants.length > 0
const hasMeetingRooms = !!meetingRoomsData?.length
const hasWellness = healthFacilities.length > 0
const pageSections = getPageSectionsData(
intl,
{
hasWellness: healthFacilities.length > 0,
hasRestaurants: restaurants.length > 0,
hasMeetingRooms: !!(meetingRoomsData && meetingRoomsData.length > 0),
hasActivities: activitiesCards.length > 0,
hasFAQ: !!(faq && faq.accordions.length > 0),
},
sectionHeadings
)
const facilities = setFacilityCards(
restaurantImages ?? undefined,
conferencesAndMeetings ?? undefined,
healthAndWellness ?? undefined,
hasRestaurants,
hasMeetingRooms,
hasWellness
pageSections
)
const topThreePois = pointsOfInterest.slice(0, 3)
const coordinates = {
lat: location.latitude,
lng: location.longitude,
@@ -148,14 +158,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
<PreviewImages images={images} hotelName={name} />
) : null}
</header>
<TabNavigation
hasActivities={activitiesCards.length > 0}
hasFAQ={!!faq?.accordions.length}
hasWellness={hasWellness}
hasRestaurants={hasRestaurants}
hasMeetingRooms={hasMeetingRooms}
tabValues={tabValues}
/>
<TabNavigation pageSections={pageSections} />
<main className={styles.mainSection}>
<div id={HotelHashValues.overview} className={styles.overview}>
@@ -185,7 +188,11 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
</div>
) : null}
</div>
<Rooms rooms={roomCategories} preamble={hotelRoomElevatorPitchText} />
<Rooms
heading={pageSections.rooms.heading}
rooms={roomCategories}
preamble={hotelRoomElevatorPitchText}
/>
{facilities && (
<Facilities
facilities={facilities}
@@ -205,7 +212,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
hotelName={name}
markerInfo={{ hotelType, hotelId }}
/>
<MapCard hotelName={name} pois={topThreePois} />
<MapCard hotelName={name} pois={pointsOfInterest.slice(0, 3)} />
</MapWithCardWrapper>
</aside>
<MobileMapToggle />
@@ -237,8 +244,9 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
ecoLabels={hotelFacts.ecoLabels}
descriptions={hotelContent.texts}
/>
{hasWellness ? (
{pageSections.wellness ? (
<WellnessAndExerciseSidePeek
heading={pageSections.wellness.heading}
healthFacilities={healthFacilities}
spaPage={spaPage?.spa_page}
wellnessExercisePageUrl={
@@ -246,8 +254,11 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
}
/>
) : null}
{hasRestaurants ? (
<RestaurantBarSidePeek restaurants={restaurants} />
{pageSections.restaurant ? (
<RestaurantBarSidePeek
heading={pageSections.restaurant.heading}
restaurants={restaurants}
/>
) : null}
{activitiesCards.map((card) => (
<ActivitiesSidePeek
@@ -257,8 +268,9 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
sidepeekSlug={card.upcoming_activities_card.sidepeekSlug}
/>
))}
{hasMeetingRooms && (
{pageSections.meetings ? (
<MeetingsAndConferencesSidePeek
heading={pageSections.meetings.heading}
meetingFacilities={conferencesAndMeetings}
descriptions={hotelContent.texts.meetingDescription}
meetingRooms={meetingRoomsData ?? []}
@@ -266,7 +278,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
displayWebPage.meetingRoom ? meetingRooms.nameInUrl : undefined
}
/>
)}
) : null}
{roomCategories.map((room) => (
<RoomSidePeek key={room.name} hotelId={hotelId} room={room} />
))}

View File

@@ -1,11 +1,16 @@
import type { IntlShape } from "react-intl"
import { HealthFacilitiesEnum } from "@/types/components/hotelPage/facilities"
import type {
HotelPageSectionHeadings,
HotelPageSections,
} from "@/types/components/hotelPage/sections"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import { HotelHashValues } from "@/types/enums/hotelPage"
import type { Hotel, HotelData } from "@/types/hotel"
import type { HotelPage } from "@/types/trpc/routers/contentstack/hotelPage"
import type { Lang } from "@/constants/languages"
@@ -84,3 +89,94 @@ export function translateWellnessType(type: string, intl: IntlShape) {
})
}
}
export function getPageSectionsData(
intl: IntlShape,
dynamicSections: {
hasWellness: boolean
hasRestaurants: boolean
hasMeetingRooms: boolean
hasActivities: boolean
hasFAQ: boolean
},
sectionHeadings?: HotelPageSectionHeadings | null
) {
const {
hasWellness,
hasRestaurants,
hasMeetingRooms,
hasActivities,
hasFAQ,
} = dynamicSections
const sections: HotelPageSections = {
overview: {
hash: HotelHashValues.overview,
heading:
sectionHeadings?.overview ||
intl.formatMessage({
defaultMessage: "Overview",
}),
},
rooms: {
hash: HotelHashValues.rooms,
heading:
sectionHeadings?.rooms ||
intl.formatMessage({
defaultMessage: "Rooms",
}),
},
}
if (hasRestaurants) {
sections.restaurant = {
hash: HotelHashValues.restaurant,
heading:
sectionHeadings?.restaurant_bar ||
intl.formatMessage({
defaultMessage: "Restaurant & Bar",
}),
}
}
if (hasMeetingRooms) {
sections.meetings = {
hash: HotelHashValues.meetings,
heading:
sectionHeadings?.conferences_meetings ||
intl.formatMessage({
defaultMessage: "Meetings & Conferences",
}),
}
}
if (hasWellness) {
sections.wellness = {
hash: HotelHashValues.wellness,
heading:
sectionHeadings?.health_wellness ||
intl.formatMessage({
defaultMessage: "Gym & Wellness",
}),
}
}
if (hasActivities) {
sections.activities = {
hash: HotelHashValues.activities,
heading:
sectionHeadings?.activities ||
intl.formatMessage({
defaultMessage: "Activities",
}),
}
}
if (hasFAQ) {
sections.faq = {
hash: HotelHashValues.faq,
heading:
sectionHeadings?.faq ||
intl.formatMessage({
defaultMessage: "FAQ",
}),
}
}
return sections
}

View File

@@ -82,7 +82,7 @@ export const hotelPageSchema = z.object({
),
})
.transform(({ hotel_navigation, ...rest }) => ({
tabValues: hotel_navigation,
sectionHeadings: hotel_navigation,
...rest,
})),
})

View File

@@ -1,3 +1,5 @@
import type { HotelHashValues } from "@/types/enums/hotelPage"
export interface HotelPageProps {
hotelId: string
}
@@ -10,3 +12,6 @@ export enum SidepeekSlugs {
meetings = "meetings",
wellness = "wellness",
}
export type HotelHashValue =
(typeof HotelHashValues)[keyof typeof HotelHashValues]

View File

@@ -5,6 +5,7 @@ export interface RoomCardProps {
}
export type RoomsProps = {
heading: string
preamble?: string
rooms: Room[]
}

View File

@@ -0,0 +1,27 @@
import type { HotelHashValue } from "./hotelPage"
export interface HotelPageSectionHeadings {
overview?: string | null
rooms?: string | null
restaurant_bar?: string | null
conferences_meetings?: string | null
health_wellness?: string | null
activities?: string | null
offers?: string | null
faq?: string | null
}
interface HotelPageSection {
hash: HotelHashValue
heading: string
}
export interface HotelPageSections {
overview: HotelPageSection
rooms: HotelPageSection
restaurant?: HotelPageSection
meetings?: HotelPageSection
wellness?: HotelPageSection
activities?: HotelPageSection
faq?: HotelPageSection
}

View File

@@ -6,4 +6,5 @@ export type MeetingsAndConferencesSidePeekProps = {
descriptions: Hotel["hotelContent"]["texts"]["meetingDescription"]
meetingRooms: MeetingRooms
meetingPageUrl: string | undefined
heading: string
}

View File

@@ -2,6 +2,7 @@ import type { Restaurant } from "@/types/hotel"
export interface RestaurantBarSidePeekProps {
restaurants: Restaurant[]
heading: string
}
export interface RestaurantBarItemProps {

View File

@@ -7,4 +7,5 @@ export type WellnessAndExerciseSidePeekProps = {
buttonCTA: string
url: string
}
heading: string
}

View File

@@ -1,29 +0,0 @@
export enum HotelHashValues {
overview = "overview",
rooms = "rooms",
restaurant = "restaurants",
meetings = "meetings",
wellness = "wellness",
activities = "activities",
faq = "faq",
}
type Tabs = {
overview?: string | null
rooms?: string | null
restaurant_bar?: string | null
conferences_meetings?: string | null
health_wellness?: string | null
activities?: string | null
offers?: string | null
faq?: string | null
}
export type TabNavigationProps = {
hasActivities: boolean
hasFAQ: boolean
hasWellness: boolean
hasRestaurants: boolean
hasMeetingRooms: boolean
tabValues?: Tabs | null
}

View File

@@ -7,3 +7,13 @@ export namespace HotelPageEnum {
}
}
}
export const HotelHashValues = {
overview: "overview",
rooms: "rooms",
restaurant: "restaurants",
meetings: "meetings",
wellness: "wellness",
activities: "activities",
faq: "faq",
} as const

View File

@@ -11,9 +11,13 @@ import {
RestaurantHeadings,
WellnessHeadings,
} from "@/types/components/hotelPage/facilities"
import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import {
type HotelHashValue,
SidepeekSlugs,
} from "@/types/components/hotelPage/hotelPage";
import type { HotelPageSections } from "@/types/components/hotelPage/sections"
import { FacilityEnum } from "@/types/enums/facilities"
import { HotelHashValues } from "@/types/enums/hotelPage"
import type {
Amenities,
Facility,
@@ -26,24 +30,34 @@ export function setFacilityCards(
restaurantImages: FacilityData | undefined,
conferencesAndMeetings: FacilityData | undefined,
healthAndWellness: FacilityData | undefined,
hasRestaurants: boolean,
hasMeetingRooms: boolean,
hasWellness: boolean
pageSections: HotelPageSections
): Facility[] {
const facilities = []
if (hasRestaurants) {
if (pageSections.restaurant) {
facilities.push(
setFacilityCard(restaurantImages, FacilityCardTypeEnum.restaurant)
setFacilityCard(
restaurantImages,
FacilityCardTypeEnum.restaurant,
pageSections.restaurant.heading
)
)
}
if (hasMeetingRooms) {
if (pageSections.meetings) {
facilities.push(
setFacilityCard(conferencesAndMeetings, FacilityCardTypeEnum.conference)
setFacilityCard(
conferencesAndMeetings,
FacilityCardTypeEnum.conference,
pageSections.meetings.heading
)
)
}
if (hasWellness) {
if (pageSections.wellness) {
facilities.push(
setFacilityCard(healthAndWellness, FacilityCardTypeEnum.wellness)
setFacilityCard(
healthAndWellness,
FacilityCardTypeEnum.wellness,
pageSections.wellness.heading
)
)
}
return facilities
@@ -51,12 +65,13 @@ export function setFacilityCards(
function setFacilityCard(
facility: FacilityData | undefined,
type: FacilityCardTypeEnum
type: FacilityCardTypeEnum,
heading: string
): Facility {
return {
...facility,
id: type,
headingText: facility?.headingText ?? "",
headingText: heading,
heroImages: facility?.heroImages ?? [],
}
}
@@ -72,7 +87,7 @@ export function isFacilityImage(card: FacilityCardType): card is FacilityImage {
function setCardProps(
theme: CardProps["theme"],
buttonText: (typeof FacilityCardButtonText)[keyof typeof FacilityCardButtonText],
href: HotelHashValues,
href: HotelHashValue,
heading: string,
slug: SidepeekSlugs,
scriptedTopTitle?: string