feat(SW-435): Added hotel subpage for reviews, refactored subpages

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-05-23 10:21:53 +00:00
parent 231ea05348
commit 1095a65ef7
36 changed files with 1143 additions and 590 deletions

View File

@@ -0,0 +1,58 @@
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n"
import { appendSlugToPathname } from "@/utils/appendSlugToPathname"
import styles from "./tripAdvisorLink.module.css"
import type { HotelTripAdvisor } from "@/types/hotel"
interface TripAdvisorLinkProps {
tripAdvisor: NonNullable<HotelTripAdvisor>
}
export default async function TripAdvisorLink({
tripAdvisor,
}: TripAdvisorLinkProps) {
const intl = await getIntl()
const { rating, numberOfReviews, reviews } = tripAdvisor
const hasTripAdvisorData = !!(rating && numberOfReviews)
if (!hasTripAdvisorData) {
return null
}
const formattedTripAdvisorText = intl.formatMessage(
{
defaultMessage: "{rating} ({count} reviews on Tripadvisor)",
},
{ rating, count: numberOfReviews }
)
const hasTripAdvisorIframeSrc = !!reviews.widgetScriptEmbedUrlIframe
const tripAdvisorHref = hasTripAdvisorIframeSrc
? appendSlugToPathname("reviews")
: null
if (!tripAdvisorHref) {
return (
<span className={styles.tripAdvisorText}>
<TripadvisorIcon color="CurrentColor" />
{formattedTripAdvisorText}
</span>
)
}
return (
<Link
variant="icon"
textDecoration="underline"
color="peach80"
href={tripAdvisorHref}
>
<TripadvisorIcon color="CurrentColor" />
{formattedTripAdvisorText}
</Link>
)
}

View File

@@ -0,0 +1,6 @@
.tripAdvisorText {
display: flex;
gap: var(--Space-x05);
align-items: center;
color: var(--Text-Secondary);
}

View File

@@ -1,11 +1,12 @@
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { getSingleDecimal } from "@/utils/numberFormatting" import { getSingleDecimal } from "@/utils/numberFormatting"
import TripAdvisorLink from "./TripAdvisorLink"
import styles from "./introSection.module.css" import styles from "./introSection.module.css"
import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage" import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage"
@@ -29,17 +30,6 @@ export default async function IntroSection({
) )
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})` const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
const hasTripAdvisorData = !!(
tripAdvisor?.rating && tripAdvisor?.numberOfReviews
)
const formattedTripAdvisorText = hasTripAdvisorData
? intl.formatMessage(
{
defaultMessage: "{rating} ({count} reviews on Tripadvisor)",
},
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
)
: ""
return ( return (
<section className={styles.introSection}> <section className={styles.introSection}>
@@ -59,12 +49,8 @@ export default async function IntroSection({
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.bodyText}>{formattedLocationText}</p> <p className={styles.bodyText}>{formattedLocationText}</p>
</Typography> </Typography>
{formattedTripAdvisorText && (
<span className={styles.tripAdvisorText}> {tripAdvisor ? <TripAdvisorLink tripAdvisor={tripAdvisor} /> : null}
<TripadvisorIcon color="Icon/Interactive/Secondary" />
{formattedTripAdvisorText}
</span>
)}
</div> </div>
<div className={styles.subtitleContent}> <div className={styles.subtitleContent}>
<Typography variant="Body/Lead text"> <Typography variant="Body/Lead text">

View File

@@ -8,6 +8,7 @@
.mainContent { .mainContent {
display: grid; display: grid;
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
justify-items: start;
} }
.subtitleContent { .subtitleContent {
@@ -15,18 +16,6 @@
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
} }
.tripAdvisorText {
display: flex;
gap: var(--Space-x05);
align-items: center;
color: var(--Text-Secondary);
}
.tripAdvisorText svg,
.tripAdvisorText svg * {
fill: var(--Icon-Default);
}
.title { .title {
color: var(--Text-Heading); color: var(--Text-Heading);
} }

View File

@@ -0,0 +1,61 @@
.accessibilitySubpage {
padding-bottom: var(--Space-x8);
color: var(--Text-Default);
display: grid;
gap: var(--Space-x4);
}
.contentContainer {
display: grid;
gap: var(--Space-x3);
width: var(--max-width-content);
margin: 0 auto;
color: var(--Text-Default);
}
.mainContent {
display: grid;
width: 100%;
gap: var(--Space-x4);
max-width: var(--max-width-text-block);
align-content: start;
}
.heading {
color: var(--Text-Heading);
}
.intro {
display: grid;
gap: var(--Space-x2);
}
.accessibilityInformation {
display: grid;
gap: var(--Space-x2);
}
.accessibilityGroup {
display: grid;
gap: var(--Space-x1);
}
.list {
display: grid;
gap: var(--Space-x1);
list-style-type: none;
}
.listItem::before {
content: url("/_static/icons/heart.svg");
position: relative;
height: 8px;
top: 3px;
margin-right: var(--Space-x1);
}
@media screen and (min-width: 1367px) {
.divider {
display: none;
}
}

View File

@@ -0,0 +1,96 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import HeroHeader from "../HeroHeader"
import HtmlContent from "../HtmlContent"
import styles from "./accessibilitySubpage.module.css"
import type { AdditionalData } from "@/types/hotel"
interface AccessibilitySubpageProps {
hotelName: string
additionalData: AdditionalData
}
export default async function AccessibilitySubpage({
hotelName,
additionalData,
}: AccessibilitySubpageProps) {
const intl = await getIntl()
const { hotelSpecialNeeds, specialNeedGroups, accessibility } = additionalData
const { mainBody, elevatorPitch } = hotelSpecialNeeds
const heroImage = accessibility?.heroImages[0]
return (
<>
<section className={styles.accessibilitySubpage}>
<HeroHeader
breadcrumbsTitle={intl.formatMessage({
defaultMessage: "Accessibility",
})}
heroImage={heroImage}
/>
<div className={styles.contentContainer}>
<Typography variant="Title/md">
<h1 className={styles.heading}>
{intl.formatMessage(
{
defaultMessage: "Accessibility at {hotel}",
},
{
hotel: hotelName,
}
)}
</h1>
</Typography>
<Divider color="baseSurfaceSubtleHover" className={styles.divider} />
<main className={styles.mainContent}>
{elevatorPitch ? (
<div className={styles.intro}>
<Typography variant="Body/Lead text">
<p>{elevatorPitch}</p>
</Typography>
</div>
) : null}
{mainBody ? <HtmlContent html={mainBody} /> : null}
<div className={styles.accessibilityInformation}>
{specialNeedGroups.map((accessibilityGroup) => (
<div
key={accessibilityGroup.name}
className={styles.accessibilityGroup}
>
<Typography variant="Title/Subtitle/md">
<h2>{accessibilityGroup.name}</h2>
</Typography>
<ul className={styles.list}>
{accessibilityGroup.specialNeeds.map((groupItem) => (
<Typography
key={groupItem.name}
variant="Body/Paragraph/mdRegular"
>
<li className={styles.listItem}>
{groupItem.details
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${groupItem.name}: ${groupItem.details}`
: groupItem.name}
</li>
</Typography>
))}
</ul>
</div>
))}
</div>
</main>
</div>
</section>
</>
)
}

View File

@@ -1,23 +0,0 @@
.container {
display: grid;
gap: var(--Spacing-x2);
}
.accessibilityGroup {
display: grid;
gap: var(--Spacing-x1);
}
.list {
display: grid;
gap: var(--Spacing-x1);
list-style-type: none;
}
.list > li::before {
content: url("/_static/icons/heart.svg");
position: relative;
height: 8px;
top: 3px;
margin-right: var(--Spacing-x1);
}

View File

@@ -1,41 +0,0 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./accessibilityAdditionalContent.module.css"
import type { AdditionalData } from "@/types/hotel"
interface AccessibilityAdditionalContentProps {
additionalData: AdditionalData
}
export default function AccessibilityAdditionalContent({
additionalData,
}: AccessibilityAdditionalContentProps) {
return (
<div className={styles.container}>
{additionalData.specialNeedGroups.map((accessibilityGroup) => (
<div
key={accessibilityGroup.name}
className={styles.accessibilityGroup}
>
<Subtitle type="two" color="uiTextHighContrast" asChild>
<h2>{accessibilityGroup.name}</h2>
</Subtitle>
<ul className={styles.list}>
{accessibilityGroup.specialNeeds.map((groupItem) => (
<Body key={groupItem.name} asChild>
<li>
{groupItem.details
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${groupItem.name}: ${groupItem.details}`
: groupItem.name}
</li>
</Body>
))}
</ul>
</div>
))}
</div>
)
}

View File

@@ -1,27 +0,0 @@
import ParkingInformation from "@/components/ParkingInformation"
import styles from "./parkingAdditionalContent.module.css"
import type { Hotel } from "@/types/hotel"
interface ParkingAdditionalContentProps {
hotel: Hotel
}
export default async function ParkingAdditionalContent({
hotel,
}: ParkingAdditionalContentProps) {
const parking = hotel.parking
return (
<div className={styles.additionalContent}>
{parking.map((data) => (
<ParkingInformation
key={data.type}
parking={data}
showExternalParkingButton={false}
/>
))}
</div>
)
}

View File

@@ -1,4 +0,0 @@
.additionalContent {
display: grid;
gap: var(--Spacing-x4);
}

View File

@@ -1,27 +0,0 @@
import AccessibilityAdditionalContent from "./Accessibility"
import ParkingAdditionalContent from "./Parking"
import type { AdditionalData, Hotel } from "@/types/hotel"
interface HotelSubpageAdditionalContentProps {
subpage: string
hotel: Hotel
additionalData: AdditionalData
}
export default function HotelSubpageAdditionalContent({
subpage,
hotel,
additionalData,
}: HotelSubpageAdditionalContentProps) {
switch (subpage) {
case additionalData.hotelParking.nameInUrl:
return <ParkingAdditionalContent hotel={hotel} />
case additionalData.healthAndFitness.nameInUrl:
return null
case additionalData.hotelSpecialNeeds.nameInUrl:
return <AccessibilityAdditionalContent additionalData={additionalData} />
default:
return null
}
}

View File

@@ -0,0 +1,10 @@
.header {
display: grid;
background-color: var(--Base-Surface-Subtle-Normal);
padding-bottom: var(--Space-x4);
}
.heroWrapper {
width: var(--max-width-content);
margin: 0 auto;
}

View File

@@ -0,0 +1,36 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import Hero from "@/components/Hero"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import styles from "./heroHeader.module.css"
import type { ApiImage } from "@/types/hotel"
interface HeroHeaderProps {
breadcrumbsTitle: string
heroImage?: ApiImage
}
export default async function HeroHeader({
breadcrumbsTitle,
heroImage,
}: HeroHeaderProps) {
return (
<div className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton size="contentWidth" />}>
<Breadcrumbs subpageTitle={breadcrumbsTitle} size="contentWidth" />
</Suspense>
{heroImage ? (
<div className={styles.heroWrapper}>
<Hero
src={heroImage.imageSizes.medium}
alt={heroImage.metaData.altText || ""}
/>
</div>
) : null}
</div>
)
}

View File

@@ -1,9 +1,9 @@
.ul, .ul,
.ol { .ol {
padding: var(--Spacing-x2) var(--Spacing-x0); padding: var(--Space-x2) var(--Space-x0);
display: grid; display: grid;
gap: var(--Spacing-x1); gap: var(--Space-x1);
margin-left: var(--Spacing-x2); margin-left: var(--Space-x2);
} }
.ol > li::marker { .ol > li::marker {
@@ -11,7 +11,7 @@
} }
.li { .li {
margin-left: var(--Spacing-x3); margin-left: var(--Space-x3);
} }
.li > p { .li > p {

View File

@@ -5,17 +5,15 @@ import Grids from "@/components/TempDesignSystem/Grids"
import MeetingRoomCard from "@/components/TempDesignSystem/MeetingRoomCard" import MeetingRoomCard from "@/components/TempDesignSystem/MeetingRoomCard"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton" import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import styles from "./meetingsAdditionalContent.module.css" import styles from "./meetingRooms.module.css"
import type { MeetingRooms } from "@/types/components/hotelPage/meetingRooms" import type { MeetingRooms } from "@/types/components/hotelPage/meetingRooms"
interface MeetingsAdditionalContentProps { interface MeetingRoomsProps {
rooms: MeetingRooms rooms: MeetingRooms
} }
export default function MeetingsAdditionalContent({ export default function MeetingRooms({ rooms }: MeetingRoomsProps) {
rooms,
}: MeetingsAdditionalContentProps) {
const showToggleButton = rooms.length > 3 const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton) const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)

View File

@@ -8,6 +8,6 @@
.section { .section {
display: grid; display: grid;
gap: var(--Spacing-x4); gap: var(--Space-x4);
z-index: 0; z-index: 0;
} }

View File

@@ -0,0 +1,86 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getMeetingRooms } from "@/lib/trpc/memoizedRequests"
import { meetingPackageDestinationByHotelId } from "@/components/MeetingPackageWidget/utils"
import StickyMeetingPackageWidget from "@/components/StickyMeetingPackageWidget"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { safeTry } from "@/utils/safeTry"
import HeroHeader from "../HeroHeader"
import HtmlContent from "../HtmlContent"
import MeetingsSidebar from "../Sidebar/MeetingsSidebar"
import MeetingsAdditionalContent from "./MeetingRooms"
import styles from "./meetingsSubpage.module.css"
import type { AdditionalData, Hotel } from "@/types/hotel"
interface MeetingsSubpageProps {
hotelId: string
hotel: Hotel
additionalData: AdditionalData
}
export default async function MeetingsSubpage({
hotelId,
hotel,
additionalData,
}: MeetingsSubpageProps) {
const intl = await getIntl()
const lang = getLang()
const heading = intl.formatMessage({
defaultMessage: "Meetings, Conferences & Events",
})
const mainBody = additionalData.meetingRooms.mainBody
const elevatorPitch = hotel.hotelContent.texts.meetingDescription?.medium
const heroImage = additionalData.conferencesAndMeetings?.heroImages[0]
const [meetingRooms] = await safeTry(
getMeetingRooms({ hotelId, language: lang })
)
const meetingPackageDestination = meetingPackageDestinationByHotelId[hotelId]
return (
<>
<StickyMeetingPackageWidget destination={meetingPackageDestination} />
<section className={styles.meetingsSubpage}>
<HeroHeader breadcrumbsTitle={heading} heroImage={heroImage} />
<div className={styles.contentContainer}>
<Typography variant="Title/md">
<h1 className={styles.heading}>{heading}</h1>
</Typography>
<aside className={styles.sidebar}>
{meetingRooms ? (
<MeetingsSidebar
country={hotel.address.country}
email={meetingRooms[0].attributes.email}
phoneNumber={meetingRooms[0].attributes.phoneNumber}
/>
) : null}
</aside>
<Divider color="baseSurfaceSubtleHover" className={styles.divider} />
<main className={styles.mainContent}>
{elevatorPitch ? (
<div className={styles.intro}>
<Typography variant="Body/Lead text">
<p>{elevatorPitch}</p>
</Typography>
</div>
) : null}
{mainBody ? <HtmlContent html={mainBody} /> : null}
</main>
{meetingRooms ? (
<div className={styles.meetingsInformation}>
<MeetingsAdditionalContent rooms={meetingRooms} />
</div>
) : null}
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,64 @@
.meetingsSubpage {
padding-bottom: var(--Space-x8);
color: var(--Text-Default);
display: grid;
gap: var(--Space-x4);
}
.contentContainer {
display: grid;
gap: var(--Space-x3);
width: var(--max-width-content);
margin: 0 auto;
color: var(--Text-Default);
}
.mainContent {
display: grid;
width: 100%;
gap: var(--Space-x4);
align-content: start;
}
.heading {
color: var(--Text-Heading);
}
.intro {
display: grid;
gap: var(--Space-x2);
}
.sidebar {
display: grid;
gap: var(--Space-x3);
align-content: start;
}
.parkingInformation {
display: grid;
gap: var(--Space-x4);
}
@media screen and (min-width: 1367px) {
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
grid-template-rows: auto 1fr;
row-gap: var(--Space-x2);
column-gap: var(--Space-x8);
}
.divider {
display: none;
}
.sidebar {
grid-column: 2;
grid-row: 1 / span 2;
align-items: start;
}
.meetingsInformation {
grid-column: 1 / span 2;
}
}

View File

@@ -0,0 +1,73 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import ParkingInformation from "@/components/ParkingInformation"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import HeroHeader from "../HeroHeader"
import HtmlContent from "../HtmlContent"
import ParkingSidebar from "../Sidebar/ParkingSidebar"
import styles from "./parkingSubpage.module.css"
import type { AdditionalData, Hotel } from "@/types/hotel"
interface ParkingSubpageProps {
hotel: Hotel
additionalData: AdditionalData
}
export default async function ParkingSubpage({
hotel,
additionalData,
}: ParkingSubpageProps) {
const intl = await getIntl()
const heading = intl.formatMessage({
defaultMessage: "Parking",
})
const { mainBody, elevatorPitch } = additionalData.hotelParking
const heroImage = additionalData.parkingImages?.heroImages[0]
return (
<>
<section className={styles.parkingSubpage}>
<HeroHeader breadcrumbsTitle={heading} heroImage={heroImage} />
<div className={styles.contentContainer}>
<Typography variant="Title/md">
<h1 className={styles.heading}>{heading}</h1>
</Typography>
<aside className={styles.sidebar}>
<ParkingSidebar
address={hotel.address}
contactInformation={hotel.contactInformation}
/>
</aside>
<Divider color="baseSurfaceSubtleHover" className={styles.divider} />
<main className={styles.mainContent}>
{elevatorPitch ? (
<div className={styles.intro}>
<Typography variant="Body/Lead text">
<p>{elevatorPitch}</p>
</Typography>
</div>
) : null}
{mainBody ? <HtmlContent html={mainBody} /> : null}
<div className={styles.parkingInformation}>
{hotel.parking.map((data) => (
<ParkingInformation
key={data.type}
parking={data}
showExternalParkingButton={false}
/>
))}
</div>
</main>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,60 @@
.parkingSubpage {
padding-bottom: var(--Space-x8);
color: var(--Text-Default);
display: grid;
gap: var(--Space-x4);
}
.contentContainer {
display: grid;
gap: var(--Space-x3);
width: var(--max-width-content);
margin: 0 auto;
color: var(--Text-Default);
}
.mainContent {
display: grid;
width: 100%;
gap: var(--Space-x4);
align-content: start;
}
.heading {
color: var(--Text-Heading);
}
.intro {
display: grid;
gap: var(--Space-x2);
}
.sidebar {
display: grid;
gap: var(--Space-x3);
align-content: start;
}
.parkingInformation {
display: grid;
gap: var(--Space-x4);
}
@media screen and (min-width: 1367px) {
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
grid-template-rows: auto 1fr;
row-gap: var(--Space-x2);
column-gap: var(--Space-x8);
}
.divider {
display: none;
}
.sidebar {
grid-column: 2;
grid-row: 1 / span 2;
align-items: start;
}
}

View File

@@ -0,0 +1,73 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import ButtonLink from "@/components/ButtonLink"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import HeroHeader from "../HeroHeader"
import HtmlContent from "../HtmlContent"
import RestaurantSidebar from "../Sidebar/RestaurantSidebar"
import styles from "./restaurantSubpage.module.css"
import type { Hotel, Restaurant } from "@/types/hotel"
interface RestaurantSubpageProps {
restaurant: Restaurant
hotelAddress: Hotel["address"]
}
export default async function RestaurantSubpage({
restaurant,
hotelAddress,
}: RestaurantSubpageProps) {
const intl = await getIntl()
const { mainBody, elevatorPitch, name, bookTableUrl } = restaurant
const heroImage = restaurant.content.images[0]
return (
<>
<section className={styles.restaurantSubpage}>
<HeroHeader breadcrumbsTitle={name} heroImage={heroImage} />
<div className={styles.contentContainer}>
<Typography variant="Title/md">
<h1 className={styles.heading}>{name}</h1>
</Typography>
<aside className={styles.sidebar}>
<RestaurantSidebar
restaurant={restaurant}
hotelAddress={hotelAddress}
/>
</aside>
<Divider color="baseSurfaceSubtleHover" className={styles.divider} />
<main className={styles.mainContent}>
{elevatorPitch ? (
<div className={styles.intro}>
<Typography variant="Body/Lead text">
<p>{elevatorPitch}</p>
</Typography>
</div>
) : null}
{mainBody ? <HtmlContent html={mainBody} /> : null}
</main>
</div>
{bookTableUrl ? (
<div className={styles.buttonContainer}>
<ButtonLink
href={bookTableUrl}
variant="Primary"
typography="Body/Paragraph/mdBold"
>
{intl.formatMessage({
defaultMessage: "Book a table",
})}
</ButtonLink>
</div>
) : null}
</section>
</>
)
}

View File

@@ -0,0 +1,67 @@
.restaurantSubpage {
padding-bottom: var(--Space-x8);
color: var(--Text-Default);
display: grid;
gap: var(--Space-x4);
}
.contentContainer {
display: grid;
gap: var(--Space-x3);
width: var(--max-width-content);
margin: 0 auto;
color: var(--Text-Default);
}
.mainContent {
display: grid;
width: 100%;
gap: var(--Space-x4);
align-content: start;
}
.heading {
color: var(--Text-Heading);
}
.intro {
display: grid;
gap: var(--Space-x2);
}
.sidebar {
display: grid;
gap: var(--Space-x3);
align-content: start;
}
.buttonContainer {
position: sticky;
padding: var(--Space-x3) var(--Space-x2);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-top: 1px solid var(--Base-Border-Subtle);
bottom: 0;
}
@media screen and (min-width: 1367px) {
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
grid-template-rows: auto 1fr;
row-gap: var(--Space-x2);
column-gap: var(--Space-x8);
}
.divider {
display: none;
}
.sidebar {
grid-column: 2;
grid-row: 1 / span 2;
align-items: start;
}
.buttonContainer {
display: none;
}
}

View File

@@ -0,0 +1,121 @@
import NextImage from "next/image"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./reviewsSubpage.module.css"
import type { Hotel } from "@/types/hotel"
interface ReviewsSubpageProps {
hotel: Hotel
}
export default async function ReviewsSubpage({ hotel }: ReviewsSubpageProps) {
const intl = await getIntl()
const lang = getLang()
const tripAdvisorData = hotel.ratings?.tripAdvisor
if (!tripAdvisorData?.reviews.widgetScriptEmbedUrlIframe) {
notFound()
}
const awardsLogos = tripAdvisorData.awards
.map((award) => ({
imageUrl: award.images.small,
altText: award.displayName,
}))
.filter((award) => !!award.imageUrl)
const showNordicEcoLabel = !!hotel.hotelFacts?.ecoLabels.nordicEcoLabel
return (
<>
<section className={styles.reviewsSubpage}>
<Suspense
fallback={
<BreadcrumbsSkeleton
size="contentWidth"
color="Surface/Secondary/Default"
/>
}
>
<Breadcrumbs
subpageTitle={intl.formatMessage({
defaultMessage: "Reviews",
})}
size="contentWidth"
color="Surface/Secondary/Default"
/>
</Suspense>
<header className={styles.header}>
<Typography variant="Title/md">
<h1 className={styles.heading}>{hotel.name}</h1>
</Typography>
<Typography variant="Title/Subtitle/md">
<p className={styles.subheading}>
{intl.formatMessage({
defaultMessage: "Ratings and reviews",
})}
</p>
</Typography>
</header>
<div className={styles.contentContainer}>
<aside className={styles.sidebar}>
<Typography variant="Title/xs">
<h2 className={styles.sidebarHeading}>
{intl.formatMessage({
defaultMessage: "Awards and certifications",
})}
</h2>
</Typography>
<div className={styles.sidebarContent}>
{showNordicEcoLabel ? (
<NextImage
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
alt={intl.formatMessage({
defaultMessage: "Nordic Swan Ecolabel",
})}
className={styles.logo}
height={100}
width={100}
/>
) : null}
{awardsLogos?.map((award) => (
<NextImage
src={award.imageUrl}
alt={award.altText}
className={styles.logo}
width={100}
height={100}
key={award.imageUrl}
/>
))}
</div>
</aside>
<main className={styles.mainContent}>
<iframe
className={styles.iframe}
src={tripAdvisorData.reviews.widgetScriptEmbedUrlIframe}
loading="lazy"
title={intl.formatMessage(
{
defaultMessage: "Ratings and reviews for {hotelName}",
},
{
hotelName: hotel.name,
}
)}
/>
</main>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,77 @@
.reviewsSubpage {
padding-bottom: var(--Space-x8);
color: var(--Text-Default);
}
.header {
width: var(--max-width-content);
margin: 0 auto;
display: grid;
gap: var(--Space-x3);
padding: var(--Space-x4) 0;
}
.heading,
.sidebarHeading {
color: var(--Text-Heading);
}
.contentContainer {
display: grid;
gap: var(--Space-x3);
width: var(--max-width-content);
margin: 0 auto;
}
.sidebar {
display: grid;
gap: var(--Space-x3);
align-content: start;
}
.sidebarContent {
display: flex;
flex-wrap: wrap;
gap: var(--Space-x7);
padding: var(--Space-x5) var(--Space-x15);
background-color: var(--Background-Secondary);
border-radius: var(--Corner-radius-lg);
justify-content: center;
align-items: center;
}
.iframe {
width: 100%;
height: 1100px; /* Maximum(ish) content height on desktop without the need of scrolling */
border-width: 0;
border-radius: var(--Corner-radius-lg);
}
.logo {
width: 100%;
max-width: 120px;
height: 100px;
object-fit: contain;
}
@media screen and (max-width: 1366px) {
.subheading,
.sidebarHeading {
display: none;
}
}
@media screen and (min-width: 1367px) {
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
grid-template-areas: "main sidebar";
gap: var(--Space-x8);
}
.mainContent {
grid-area: main;
}
.sidebar {
grid-area: sidebar;
}
}

View File

@@ -21,7 +21,7 @@ export default async function MeetingsSidebar({
const intl = await getIntl() const intl = await getIntl()
return ( return (
<aside className={styles.sidebar}> <>
<div className={styles.content}> <div className={styles.content}>
<Typography variant="Title/xs" className={styles.heading}> <Typography variant="Title/xs" className={styles.heading}>
<h3> <h3>
@@ -51,6 +51,6 @@ export default async function MeetingsSidebar({
)} )}
</div> </div>
</div> </div>
</aside> </>
) )
} }

View File

@@ -9,14 +9,18 @@ import { Country } from "@/types/enums/country"
import type { Hotel } from "@/types/hotel" import type { Hotel } from "@/types/hotel"
interface HotelSidebarProps { interface HotelSidebarProps {
hotel: Hotel address: Hotel["address"]
contactInformation: Hotel["contactInformation"]
} }
export default async function ParkingSidebar({ hotel }: HotelSidebarProps) { export default async function ParkingSidebar({
address,
contactInformation,
}: HotelSidebarProps) {
const intl = await getIntl() const intl = await getIntl()
return ( return (
<aside className={styles.sidebar}> <>
<div className={styles.content}> <div className={styles.content}>
<Typography variant="Title/xs" className={styles.heading}> <Typography variant="Title/xs" className={styles.heading}>
<h3> <h3>
@@ -28,11 +32,11 @@ export default async function ParkingSidebar({ hotel }: HotelSidebarProps) {
<Typography variant="Body/Paragraph/mdRegular" className={styles.text}> <Typography variant="Body/Paragraph/mdRegular" className={styles.text}>
<div> <div>
<p>{hotel.address.streetAddress}</p> <p>{address.streetAddress}</p>
<p> <p>
{hotel.address.zipCode} {hotel.address.city} {address.zipCode} {address.city}
</p> </p>
<p> {hotel.address.country}</p> <p> {address.country}</p>
</div> </div>
</Typography> </Typography>
</div> </div>
@@ -46,10 +50,10 @@ export default async function ParkingSidebar({ hotel }: HotelSidebarProps) {
</h3> </h3>
</Typography> </Typography>
<div className={styles.contactDetails}> <div className={styles.contactDetails}>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}> <Link href={`tel:${contactInformation.phoneNumber}`}>
{hotel.contactInformation.phoneNumber} {contactInformation.phoneNumber}
</Link> </Link>
{hotel.address.country === Country.Finland ? ( {address.country === Country.Finland ? (
<Typography <Typography
variant="Body/Paragraph/mdRegular" variant="Body/Paragraph/mdRegular"
className={styles.text} className={styles.text}
@@ -63,12 +67,12 @@ export default async function ParkingSidebar({ hotel }: HotelSidebarProps) {
) : null} ) : null}
<Link <Link
textDecoration="underline" textDecoration="underline"
href={`mailto:${hotel.contactInformation.email}`} href={`mailto:${contactInformation.email}`}
> >
{hotel.contactInformation.email} {contactInformation.email}
</Link> </Link>
</div> </div>
</div> </div>
</aside> </>
) )
} }

View File

@@ -12,21 +12,20 @@ import { Country } from "@/types/enums/country"
import type { Hotel, Restaurant } from "@/types/hotel" import type { Hotel, Restaurant } from "@/types/hotel"
interface RestaurantSidebarProps { interface RestaurantSidebarProps {
hotel: Hotel hotelAddress: Hotel["address"]
restaurant: Restaurant restaurant: Restaurant
} }
export default async function RestaurantSidebar({ export default async function RestaurantSidebar({
hotel, hotelAddress,
restaurant, restaurant,
}: RestaurantSidebarProps) { }: RestaurantSidebarProps) {
const intl = await getIntl() const intl = await getIntl()
const { address } = hotel
const { openingDetails, phoneNumber, email, bookTableUrl } = restaurant const { openingDetails, phoneNumber, email, bookTableUrl } = restaurant
return ( return (
<aside className={styles.sidebar}> <>
{openingDetails.length ? ( {openingDetails.length ? (
<div className={styles.content}> <div className={styles.content}>
<Typography variant="Title/xs" className={styles.heading}> <Typography variant="Title/xs" className={styles.heading}>
@@ -97,9 +96,9 @@ export default async function RestaurantSidebar({
</Typography> </Typography>
<Typography variant="Body/Paragraph/mdRegular" className={styles.text}> <Typography variant="Body/Paragraph/mdRegular" className={styles.text}>
<div> <div>
<p>{address.streetAddress}</p> <p>{hotelAddress.streetAddress}</p>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`${address.zipCode} ${address.city}`}</p> <p>{`${hotelAddress.zipCode} ${hotelAddress.city}`}</p>
</div> </div>
</Typography> </Typography>
</div> </div>
@@ -116,7 +115,7 @@ export default async function RestaurantSidebar({
{phoneNumber && ( {phoneNumber && (
<> <>
<Link href={`tel:${phoneNumber}`}>{phoneNumber}</Link> <Link href={`tel:${phoneNumber}`}>{phoneNumber}</Link>
{address.country === Country.Finland ? ( {hotelAddress.country === Country.Finland ? (
<Typography <Typography
variant="Body/Paragraph/mdRegular" variant="Body/Paragraph/mdRegular"
className={styles.text} className={styles.text}
@@ -138,6 +137,6 @@ export default async function RestaurantSidebar({
</div> </div>
</div> </div>
)} )}
</aside> </>
) )
} }

View File

@@ -8,17 +8,23 @@ import { translateWellnessType } from "../../HotelPage/utils"
import styles from "./sidebar.module.css" import styles from "./sidebar.module.css"
import { Country } from "@/types/enums/country" import { Country } from "@/types/enums/country"
import type { Hotel } from "@/types/hotel" import type { HealthFacility, Hotel } from "@/types/hotel"
interface WellnessSidebarProps { interface WellnessSidebarProps {
hotel: Hotel healthFacilities: HealthFacility[]
address: Hotel["address"]
contactInformation: Hotel["contactInformation"]
} }
export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) { export default async function WellnessSidebar({
healthFacilities,
address,
contactInformation,
}: WellnessSidebarProps) {
const intl = await getIntl() const intl = await getIntl()
return ( return (
<aside className={styles.sidebar}> <>
<div className={styles.content}> <div className={styles.content}>
<Typography variant="Title/xs" className={styles.heading}> <Typography variant="Title/xs" className={styles.heading}>
<h3> <h3>
@@ -27,7 +33,7 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
})} })}
</h3> </h3>
</Typography> </Typography>
{hotel.healthFacilities.map((facility) => ( {healthFacilities.map((facility) => (
<div key={facility.type}> <div key={facility.type}>
<Typography variant="Title/Subtitle/md" className={styles.text}> <Typography variant="Title/Subtitle/md" className={styles.text}>
<h4>{translateWellnessType(facility.type, intl)}</h4> <h4>{translateWellnessType(facility.type, intl)}</h4>
@@ -97,11 +103,11 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
className={styles.text} className={styles.text}
> >
<div> <div>
<p>{hotel.address.streetAddress}</p> <p>{address.streetAddress}</p>
<p> <p>
{hotel.address.zipCode} {hotel.address.city} {address.zipCode} {address.city}
</p> </p>
<p> {hotel.address.country}</p> <p> {address.country}</p>
</div> </div>
</Typography> </Typography>
</div> </div>
@@ -115,10 +121,11 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
})} })}
</h3> </h3>
</Typography> </Typography>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}> <div className={styles.contactDetails}>
{hotel.contactInformation.phoneNumber} <Link href={`tel:${contactInformation.phoneNumber}`}>
{contactInformation.phoneNumber}
</Link> </Link>
{hotel.address.country === Country.Finland ? ( {address.country === Country.Finland ? (
<Typography <Typography
variant="Body/Paragraph/mdRegular" variant="Body/Paragraph/mdRegular"
className={styles.text} className={styles.text}
@@ -131,6 +138,7 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
</Typography> </Typography>
) : null} ) : null}
</div> </div>
</aside> </div>
</>
) )
} }

View File

@@ -1,53 +0,0 @@
import MeetingsSidebar from "./MeetingsSidebar"
import ParkingSidebar from "./ParkingSidebar"
import RestaurantSidebar from "./RestaurantSidebar"
import WellnessSidebar from "./WellnessSidebar"
import type { MeetingRooms } from "@/types/components/hotelPage/meetingRooms"
import type { AdditionalData, Hotel, Restaurant } from "@/types/hotel"
interface HotelSubpageSidebarProps {
subpage: string
hotel: Hotel
additionalData: AdditionalData
restaurants: Restaurant[]
meetingRooms: MeetingRooms | undefined
}
export default function HotelSubpageSidebar({
subpage,
hotel,
additionalData,
restaurants,
meetingRooms,
}: HotelSubpageSidebarProps) {
const restaurantSubPage = restaurants.find(
(restaurant) => restaurant.nameInUrl === subpage
)
if (restaurantSubPage) {
return <RestaurantSidebar hotel={hotel} restaurant={restaurantSubPage} />
}
switch (subpage) {
case additionalData.hotelParking.nameInUrl:
return <ParkingSidebar hotel={hotel} />
case additionalData.healthAndFitness.nameInUrl:
return <WellnessSidebar hotel={hotel} />
case additionalData.hotelSpecialNeeds.nameInUrl:
return null
case additionalData.meetingRooms.nameInUrl:
if (!meetingRooms) {
return null
}
return (
<MeetingsSidebar
phoneNumber={meetingRooms[0].attributes.phoneNumber}
email={meetingRooms[0].attributes.email}
country={hotel.address.country}
/>
)
default:
return null
}
}

View File

@@ -1,26 +1,17 @@
.sidebar {
display: grid;
gap: var(--Spacing-x3);
grid-column: 1;
}
.content { .content {
display: grid; display: grid;
gap: var(--Spacing-x-one-and-half); gap: var(--Space-x15);
} }
.menuList { .menuList {
display: grid; display: grid;
gap: var(--Spacing-x-half); gap: var(--Space-x05);
list-style-type: none; list-style-type: none;
} }
.buttonContainer {
display: none;
}
.contactDetails { .contactDetails {
display: grid; display: grid;
justify-items: start;
} }
.heading { .heading {
@@ -31,14 +22,8 @@
color: var(--Text-Default); color: var(--Text-Default);
} }
@media (min-width: 1367px) { @media screen and (max-width: 1366px) {
.sidebar {
grid-column: 2;
grid-row: 1 / span 2;
align-items: start;
}
.buttonContainer { .buttonContainer {
display: block; display: none;
} }
} }

View File

@@ -0,0 +1,65 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import HeroHeader from "../HeroHeader"
import HtmlContent from "../HtmlContent"
import WellnessSidebar from "../Sidebar/WellnessSidebar"
import styles from "./wellnessSubpage.module.css"
import type { AdditionalData, Hotel } from "@/types/hotel"
interface WellnessSubpageProps {
hotel: Hotel
healthAndFitness: AdditionalData["healthAndFitness"]
}
export default async function WellnessSubpage({
hotel,
healthAndFitness,
}: WellnessSubpageProps) {
const intl = await getIntl()
const heading = intl.formatMessage({
defaultMessage: "Gym & Wellness",
})
const { mainBody, elevatorPitch } = healthAndFitness
const heroImage = hotel.healthFacilities.find(
(fac) => fac.content.images.length
)?.content.images[0]
return (
<>
<section className={styles.wellnessSubpage}>
<HeroHeader breadcrumbsTitle={heading} heroImage={heroImage} />
<div className={styles.contentContainer}>
<Typography variant="Title/md">
<h1 className={styles.heading}>{heading}</h1>
</Typography>
<aside className={styles.sidebar}>
<WellnessSidebar
address={hotel.address}
contactInformation={hotel.contactInformation}
healthFacilities={hotel.healthFacilities}
/>
</aside>
<Divider color="baseSurfaceSubtleHover" className={styles.divider} />
<main className={styles.mainContent}>
{elevatorPitch ? (
<div className={styles.intro}>
<Typography variant="Body/Lead text">
<p>{elevatorPitch}</p>
</Typography>
</div>
) : null}
{mainBody ? <HtmlContent html={mainBody} /> : null}
</main>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,55 @@
.wellnessSubpage {
padding-bottom: var(--Space-x8);
color: var(--Text-Default);
display: grid;
gap: var(--Space-x4);
}
.contentContainer {
display: grid;
gap: var(--Space-x3);
width: var(--max-width-content);
margin: 0 auto;
color: var(--Text-Default);
}
.mainContent {
display: grid;
width: 100%;
gap: var(--Space-x4);
align-content: start;
}
.heading {
color: var(--Text-Heading);
}
.intro {
display: grid;
gap: var(--Space-x2);
}
.sidebar {
display: grid;
gap: var(--Space-x3);
align-content: start;
}
@media screen and (min-width: 1367px) {
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
grid-template-rows: auto 1fr;
row-gap: var(--Space-x2);
column-gap: var(--Space-x8);
}
.divider {
display: none;
}
.sidebar {
grid-column: 2;
grid-row: 1 / span 2;
align-items: start;
}
}

View File

@@ -1,92 +0,0 @@
.hotelSubpage:not(.hasStickyButton) {
padding-bottom: var(--Spacing-x9);
}
.header {
display: grid;
background-color: var(--Base-Surface-Subtle-Normal);
padding-bottom: var(--Spacing-x4);
}
.heroWrapper {
width: 100%;
max-width: var(--max-width-content);
margin: 0 auto;
display: grid;
gap: var(--Spacing-x4);
}
.contentContainer {
display: grid;
gap: var(--Spacing-x3);
align-items: start;
width: 100%;
max-width: var(--max-width-content);
margin: 0 auto;
padding: var(--Spacing-x4) 0;
}
.mainContent {
display: grid;
width: 100%;
gap: var(--Spacing-x4);
grid-column: 1;
}
.intro {
display: grid;
gap: var(--Spacing-x2);
}
.meetingsContent {
grid-column: 1;
}
.buttonContainer {
position: sticky;
padding: var(--Spacing-x3) var(--Spacing-x2);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-top: 1px solid var(--Base-Border-Subtle);
bottom: 0;
}
.heading {
color: var(--Text-Heading);
}
.text {
color: var(--Text-Default);
}
@media (min-width: 1367px) {
.hotelSubpage {
padding-bottom: var(--Spacing-x9);
}
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
grid-template-rows: auto 1fr;
row-gap: var(--Spacing-x2);
column-gap: var(--Spacing-x9);
padding: var(--Spacing-x4) 0 0;
}
.divider {
display: none;
}
.mainContent {
padding: 0;
margin: 0;
gap: var(--Spacing-x3);
max-width: none;
}
.meetingsContent {
grid-column: 1 / span 2;
}
.buttonContainer {
display: none;
}
}

View File

@@ -1,32 +1,16 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography" import { getHotel, getHotelPage } from "@/lib/trpc/memoizedRequests"
import {
getHotel,
getHotelPage,
getMeetingRooms,
} from "@/lib/trpc/memoizedRequests"
import Breadcrumbs from "@/components/Breadcrumbs"
import Hero from "@/components/Hero"
import { meetingPackageDestinationByHotelId } from "@/components/MeetingPackageWidget/utils"
import StickyMeetingPackageWidget from "@/components/StickyMeetingPackageWidget"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { safeTry } from "@/utils/safeTry"
import MeetingsAdditionalContent from "./AdditionalContent/Meetings" import AccessibilitySubpage from "./AccessibilitySubpage"
import HotelSubpageAdditionalContent from "./AdditionalContent" import MeetingsSubpage from "./MeetingsSubpage"
import HtmlContent from "./HtmlContent" import ParkingSubpage from "./ParkingSubpage"
import HotelSubpageSidebar from "./Sidebar" import RestaurantSubpage from "./RestaurantSubpage"
import { getSubpageData, verifySubpageShouldExist } from "./utils" import ReviewsSubpage from "./ReviewsSubpage"
import { verifySubpageShouldExist } from "./utils"
import styles from "./hotelSubpage.module.css" import WellnessSubpage from "./WellnessSubpage"
import type { HotelSubpageProps } from "@/types/components/hotelPage/subpage" import type { HotelSubpageProps } from "@/types/components/hotelPage/subpage"
@@ -35,8 +19,7 @@ export default async function HotelSubpage({
subpage, subpage,
}: HotelSubpageProps) { }: HotelSubpageProps) {
const lang = getLang() const lang = getLang()
const [intl, hotelPageData, hotelData] = await Promise.all([ const [hotelPageData, hotelData] = await Promise.all([
getIntl(),
getHotelPage(), getHotelPage(),
getHotel({ hotelId, language: lang, isCardOnlyPayment: false }), getHotel({ hotelId, language: lang, isCardOnlyPayment: false }),
]) ])
@@ -49,99 +32,48 @@ export default async function HotelSubpage({
notFound() notFound()
} }
const pageData = getSubpageData(intl, subpage, hotelData) const { hotel, additionalData, restaurants } = hotelData
if (!pageData) {
notFound()
}
const { hotel, restaurants, additionalData } = hotelData const currentRestaurant = restaurants.find(
const [meetingRooms] =
hotelData.additionalData.meetingRooms.nameInUrl === subpage
? await safeTry(getMeetingRooms({ hotelId, language: lang }))
: []
const restaurantButton = restaurants.find(
(restaurant) => restaurant.nameInUrl === subpage (restaurant) => restaurant.nameInUrl === subpage
) )
const meetingPackageDestination = hotelId if (currentRestaurant) {
? meetingPackageDestinationByHotelId[hotelId]
: undefined
return ( return (
<> <RestaurantSubpage
<section restaurant={currentRestaurant}
className={`${styles.hotelSubpage} ${restaurantButton?.bookTableUrl ? styles.hasStickyButton : ""} `} hotelAddress={hotel.address}
>
{meetingRooms && (
<StickyMeetingPackageWidget destination={meetingPackageDestination} />
)}
<div className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton size="contentWidth" />}>
<Breadcrumbs subpageTitle={pageData.heading} size="contentWidth" />
</Suspense>
{pageData.heroImage ? (
<div className={styles.heroWrapper}>
{pageData.heroImage && (
<Hero
src={pageData.heroImage.src}
alt={pageData.heroImage.alt}
/> />
)}
</div>
) : null}
</div>
<div className={styles.contentContainer}>
<Typography variant="Title/md" className={styles.heading}>
<h1>{pageData.heading}</h1>
</Typography>
<HotelSubpageSidebar
subpage={subpage}
hotel={hotel}
additionalData={additionalData}
restaurants={restaurants}
meetingRooms={meetingRooms}
/>
<Divider color="baseSurfaceSubtleHover" className={styles.divider} />
<main className={styles.mainContent}>
{pageData.elevatorPitch && (
<div className={styles.intro}>
<Typography variant="Body/Lead text" className={styles.text}>
<p>{pageData.elevatorPitch}</p>
</Typography>
</div>
)}
{pageData.mainBody && <HtmlContent html={pageData.mainBody} />}
<HotelSubpageAdditionalContent
subpage={subpage}
hotel={hotel}
additionalData={additionalData}
/>
</main>
{meetingRooms && (
<div className={styles.meetingsContent}>
<MeetingsAdditionalContent rooms={meetingRooms} />
</div>
)}
</div>
{restaurantButton?.bookTableUrl && (
<div className={styles.buttonContainer}>
<Button intent="primary" theme="base" asChild>
<a href={restaurantButton.bookTableUrl}>
{intl.formatMessage({
defaultMessage: "Book a table",
})}
</a>
</Button>
</div>
)}
</section>
{/* Tracking */}
</>
) )
}
switch (subpage) {
case additionalData.meetingRooms.nameInUrl:
return (
<MeetingsSubpage
hotelId={hotelId}
hotel={hotel}
additionalData={additionalData}
/>
)
case additionalData.healthAndFitness.nameInUrl:
return (
<WellnessSubpage
hotel={hotel}
healthAndFitness={additionalData.healthAndFitness}
/>
)
case additionalData.hotelParking.nameInUrl:
return <ParkingSubpage hotel={hotel} additionalData={additionalData} />
case additionalData.hotelSpecialNeeds.nameInUrl:
return (
<AccessibilitySubpage
hotelName={hotel.name}
additionalData={additionalData}
/>
)
case "reviews":
return <ReviewsSubpage hotel={hotel} />
default:
notFound()
}
} }

View File

@@ -1,105 +1,5 @@
import type { IntlShape } from "react-intl"
import type { HotelData } from "@/types/hotel" import type { HotelData } from "@/types/hotel"
export function getSubpageData(
intl: IntlShape,
subpage: string,
hotelData: HotelData
) {
const additionalData = hotelData.additionalData
const hotel = hotelData.hotel
const restaurants = hotelData.restaurants
const restaurantSubPage = restaurants.find(
(restaurant) => restaurant.nameInUrl === subpage
)
if (restaurantSubPage) {
const restaurantImage = restaurantSubPage.content.images[0]
return {
mainBody: restaurantSubPage.mainBody,
elevatorPitch: restaurantSubPage.elevatorPitch,
heading: restaurantSubPage.name,
heroImage: restaurantImage
? {
src: restaurantSubPage.content.images[0].imageSizes.medium,
alt: restaurantSubPage.content.images[0].metaData.altText || "",
}
: null,
}
}
switch (subpage) {
case additionalData.hotelParking.nameInUrl:
const parkingImage = additionalData.parkingImages?.heroImages[0]
return {
...additionalData.hotelParking,
heading: intl.formatMessage({
defaultMessage: "Parking",
}),
heroImage: parkingImage
? {
src: parkingImage.imageSizes.medium,
alt: parkingImage.metaData.altText || "",
}
: null,
}
case additionalData.healthAndFitness.nameInUrl:
const wellnessImage = hotel.healthFacilities.find(
(fac) => fac.content.images.length
)?.content.images[0]
return {
...additionalData.healthAndFitness,
heading: intl.formatMessage({
defaultMessage: "Gym & Wellness",
}),
heroImage: wellnessImage
? {
src: wellnessImage.imageSizes.medium,
alt: wellnessImage.metaData.altText || "",
}
: null,
}
case additionalData.hotelSpecialNeeds.nameInUrl:
const accessibilityImage = additionalData.accessibility?.heroImages[0]
return {
...additionalData.hotelSpecialNeeds,
heading: intl.formatMessage(
{
defaultMessage: "Accessibility at {hotel}",
},
{
hotel: hotel.name,
}
),
heroImage: accessibilityImage
? {
src: accessibilityImage.imageSizes.medium,
alt: accessibilityImage.metaData.altText || "",
}
: null,
}
case additionalData.meetingRooms.nameInUrl:
const meetingImage = additionalData.conferencesAndMeetings?.heroImages[0]
return {
elevatorPitch: hotel.hotelContent.texts.meetingDescription?.medium,
mainBody: additionalData.meetingRooms.mainBody,
heading: intl.formatMessage({
defaultMessage: "Meetings, Conferences & Events",
}),
heroImage: meetingImage
? {
src: meetingImage.imageSizes.medium,
alt: meetingImage.metaData.altText || "",
}
: null,
}
default:
return null
}
}
export function verifySubpageShouldExist( export function verifySubpageShouldExist(
hotelData: HotelData, hotelData: HotelData,
subpage: string subpage: string
@@ -129,6 +29,12 @@ export function verifySubpageShouldExist(
if (additionalData.displayWebPage.meetingRoom) { if (additionalData.displayWebPage.meetingRoom) {
return true return true
} }
case "reviews":
if (
hotelData.hotel.ratings?.tripAdvisor.reviews.widgetScriptEmbedUrlIframe
) {
return true
}
default: default:
return false return false
} }

View File

@@ -60,6 +60,11 @@ const nextConfig = {
protocol: "https", protocol: "https",
hostname: "*.scandichotels.com", hostname: "*.scandichotels.com",
}, },
// Tripadvisor CDN for award images
{
protocol: "https",
hostname: "static.tacdn.com",
},
], ],
}, },