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 TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n"
import { getSingleDecimal } from "@/utils/numberFormatting"
import TripAdvisorLink from "./TripAdvisorLink"
import styles from "./introSection.module.css"
import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage"
@@ -29,17 +30,6 @@ export default async function IntroSection({
)
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 (
<section className={styles.introSection}>
@@ -59,12 +49,8 @@ export default async function IntroSection({
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.bodyText}>{formattedLocationText}</p>
</Typography>
{formattedTripAdvisorText && (
<span className={styles.tripAdvisorText}>
<TripadvisorIcon color="Icon/Interactive/Secondary" />
{formattedTripAdvisorText}
</span>
)}
{tripAdvisor ? <TripAdvisorLink tripAdvisor={tripAdvisor} /> : null}
</div>
<div className={styles.subtitleContent}>
<Typography variant="Body/Lead text">

View File

@@ -8,6 +8,7 @@
.mainContent {
display: grid;
gap: var(--Spacing-x1);
justify-items: start;
}
.subtitleContent {
@@ -15,18 +16,6 @@
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 {
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,
.ol {
padding: var(--Spacing-x2) var(--Spacing-x0);
padding: var(--Space-x2) var(--Space-x0);
display: grid;
gap: var(--Spacing-x1);
margin-left: var(--Spacing-x2);
gap: var(--Space-x1);
margin-left: var(--Space-x2);
}
.ol > li::marker {
@@ -11,7 +11,7 @@
}
.li {
margin-left: var(--Spacing-x3);
margin-left: var(--Space-x3);
}
.li > p {

View File

@@ -5,17 +5,15 @@ import Grids from "@/components/TempDesignSystem/Grids"
import MeetingRoomCard from "@/components/TempDesignSystem/MeetingRoomCard"
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"
interface MeetingsAdditionalContentProps {
interface MeetingRoomsProps {
rooms: MeetingRooms
}
export default function MeetingsAdditionalContent({
rooms,
}: MeetingsAdditionalContentProps) {
export default function MeetingRooms({ rooms }: MeetingRoomsProps) {
const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)

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()
return (
<aside className={styles.sidebar}>
<>
<div className={styles.content}>
<Typography variant="Title/xs" className={styles.heading}>
<h3>
@@ -51,6 +51,6 @@ export default async function MeetingsSidebar({
)}
</div>
</div>
</aside>
</>
)
}

View File

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

View File

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

View File

@@ -8,17 +8,23 @@ import { translateWellnessType } from "../../HotelPage/utils"
import styles from "./sidebar.module.css"
import { Country } from "@/types/enums/country"
import type { Hotel } from "@/types/hotel"
import type { HealthFacility, Hotel } from "@/types/hotel"
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()
return (
<aside className={styles.sidebar}>
<>
<div className={styles.content}>
<Typography variant="Title/xs" className={styles.heading}>
<h3>
@@ -27,7 +33,7 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
})}
</h3>
</Typography>
{hotel.healthFacilities.map((facility) => (
{healthFacilities.map((facility) => (
<div key={facility.type}>
<Typography variant="Title/Subtitle/md" className={styles.text}>
<h4>{translateWellnessType(facility.type, intl)}</h4>
@@ -97,11 +103,11 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
className={styles.text}
>
<div>
<p>{hotel.address.streetAddress}</p>
<p>{address.streetAddress}</p>
<p>
{hotel.address.zipCode} {hotel.address.city}
{address.zipCode} {address.city}
</p>
<p> {hotel.address.country}</p>
<p> {address.country}</p>
</div>
</Typography>
</div>
@@ -115,22 +121,24 @@ export default async function WellnessSidebar({ hotel }: WellnessSidebarProps) {
})}
</h3>
</Typography>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
{hotel.contactInformation.phoneNumber}
</Link>
{hotel.address.country === Country.Finland ? (
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.text}
>
<p>
{intl.formatMessage({
defaultMessage: "Price 0,16 €/min + local call charges",
})}
</p>
</Typography>
) : null}
<div className={styles.contactDetails}>
<Link href={`tel:${contactInformation.phoneNumber}`}>
{contactInformation.phoneNumber}
</Link>
{address.country === Country.Finland ? (
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.text}
>
<p>
{intl.formatMessage({
defaultMessage: "Price 0,16 €/min + local call charges",
})}
</p>
</Typography>
) : null}
</div>
</div>
</aside>
</>
)
}

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 {
display: grid;
gap: var(--Spacing-x-one-and-half);
gap: var(--Space-x15);
}
.menuList {
display: grid;
gap: var(--Spacing-x-half);
gap: var(--Space-x05);
list-style-type: none;
}
.buttonContainer {
display: none;
}
.contactDetails {
display: grid;
justify-items: start;
}
.heading {
@@ -31,14 +22,8 @@
color: var(--Text-Default);
}
@media (min-width: 1367px) {
.sidebar {
grid-column: 2;
grid-row: 1 / span 2;
align-items: start;
}
@media screen and (max-width: 1366px) {
.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 { 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 { safeTry } from "@/utils/safeTry"
import MeetingsAdditionalContent from "./AdditionalContent/Meetings"
import HotelSubpageAdditionalContent from "./AdditionalContent"
import HtmlContent from "./HtmlContent"
import HotelSubpageSidebar from "./Sidebar"
import { getSubpageData, verifySubpageShouldExist } from "./utils"
import styles from "./hotelSubpage.module.css"
import AccessibilitySubpage from "./AccessibilitySubpage"
import MeetingsSubpage from "./MeetingsSubpage"
import ParkingSubpage from "./ParkingSubpage"
import RestaurantSubpage from "./RestaurantSubpage"
import ReviewsSubpage from "./ReviewsSubpage"
import { verifySubpageShouldExist } from "./utils"
import WellnessSubpage from "./WellnessSubpage"
import type { HotelSubpageProps } from "@/types/components/hotelPage/subpage"
@@ -35,8 +19,7 @@ export default async function HotelSubpage({
subpage,
}: HotelSubpageProps) {
const lang = getLang()
const [intl, hotelPageData, hotelData] = await Promise.all([
getIntl(),
const [hotelPageData, hotelData] = await Promise.all([
getHotelPage(),
getHotel({ hotelId, language: lang, isCardOnlyPayment: false }),
])
@@ -49,99 +32,48 @@ export default async function HotelSubpage({
notFound()
}
const pageData = getSubpageData(intl, subpage, hotelData)
if (!pageData) {
notFound()
}
const { hotel, additionalData, restaurants } = hotelData
const { hotel, restaurants, additionalData } = hotelData
const [meetingRooms] =
hotelData.additionalData.meetingRooms.nameInUrl === subpage
? await safeTry(getMeetingRooms({ hotelId, language: lang }))
: []
const restaurantButton = restaurants.find(
const currentRestaurant = restaurants.find(
(restaurant) => restaurant.nameInUrl === subpage
)
const meetingPackageDestination = hotelId
? meetingPackageDestinationByHotelId[hotelId]
: undefined
if (currentRestaurant) {
return (
<RestaurantSubpage
restaurant={currentRestaurant}
hotelAddress={hotel.address}
/>
)
}
return (
<>
<section
className={`${styles.hotelSubpage} ${restaurantButton?.bookTableUrl ? styles.hasStickyButton : ""} `}
>
{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"
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(
hotelData: HotelData,
subpage: string
@@ -129,6 +29,12 @@ export function verifySubpageShouldExist(
if (additionalData.displayWebPage.meetingRoom) {
return true
}
case "reviews":
if (
hotelData.hotel.ratings?.tripAdvisor.reviews.widgetScriptEmbedUrlIframe
) {
return true
}
default:
return false
}

View File

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