feat(SW-201): Added structured data for hotel pages

This commit is contained in:
Erik Tiekstra
2024-11-18 15:47:28 +01:00
parent ca2f60253f
commit bab7c15424
12 changed files with 137 additions and 83 deletions

View File

@@ -12,42 +12,43 @@ import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { getRestaurantHeading } from "@/utils/facilityCards" import { getRestaurantHeading } from "@/utils/facilityCards"
import { generateHotelSchema } from "@/utils/jsonSchemas"
import DynamicMap from "./Map/DynamicMap" import DynamicMap from "./Map/DynamicMap"
import MapCard from "./Map/MapCard" import MapCard from "./Map/MapCard"
import MapWithCardWrapper from "./Map/MapWithCard" import MapWithCardWrapper from "./Map/MapWithCard"
import MobileMapToggle from "./Map/MobileMapToggle" import MobileMapToggle from "./Map/MobileMapToggle"
import StaticMap from "./Map/StaticMap" import StaticMap from "./Map/StaticMap"
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
import AmenitiesList from "./AmenitiesList" import AmenitiesList from "./AmenitiesList"
import Facilities from "./Facilities" import Facilities from "./Facilities"
import IntroSection from "./IntroSection" import IntroSection from "./IntroSection"
import PreviewImages from "./PreviewImages" import PreviewImages from "./PreviewImages"
import { Rooms } from "./Rooms" import { Rooms } from "./Rooms"
import { AboutTheHotelSidePeek } from "./SidePeeks" import { AboutTheHotelSidePeek, WellnessAndExerciseSidePeek } from "./SidePeeks"
import TabNavigation from "./TabNavigation" import TabNavigation from "./TabNavigation"
import styles from "./hotelPage.module.css" import styles from "./hotelPage.module.css"
import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities" import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities"
import { HotelPageProps } from "@/types/components/hotelPage/hotelPage" import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import { Facility } from "@/types/hotel" import type { Facility } from "@/types/hotel"
export default async function HotelPage({ hotelId }: HotelPageProps) { export default async function HotelPage({ hotelId }: HotelPageProps) {
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const lang = getLang() const lang = getLang()
const [intl, hotelPageData, hotelData] = await Promise.all([ const [intl, hotelPageData, hotelData] = await Promise.all([
getIntl(), getIntl(),
getHotelPage(), getHotelPage(),
getHotelData({ hotelId, language: lang }), getHotelData({ hotelId, language: lang }),
]) ])
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
if (!hotelData?.data || !hotelPageData) { if (!hotelData?.data || !hotelPageData) {
return notFound() return notFound()
} }
const jsonSchema = generateHotelSchema(hotelData.data.attributes)
const { faq, content } = hotelPageData const { faq, content } = hotelPageData
const { const {
name, name,
@@ -103,6 +104,12 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
return ( return (
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
<script
type={jsonSchema.type}
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonSchema.jsonLd),
}}
/>
<div className={styles.hotelImages}> <div className={styles.hotelImages}>
{images?.length && <PreviewImages images={images} hotelName={name} />} {images?.length && <PreviewImages images={images} hotelName={name} />}
</div> </div>

View File

@@ -351,6 +351,7 @@
"Sort by": "Sorter efter", "Sort by": "Sorter efter",
"Sports": "Sport", "Sports": "Sport",
"Standard price": "Standardpris", "Standard price": "Standardpris",
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Bo på {hotelName} | Hotel i {destination}",
"Street": "Gade", "Street": "Gade",
"Successfully updated profile!": "Profilen er opdateret med succes!", "Successfully updated profile!": "Profilen er opdateret med succes!",
"Summary": "Opsummering", "Summary": "Opsummering",

View File

@@ -351,6 +351,7 @@
"Sort by": "Sortieren nach", "Sort by": "Sortieren nach",
"Sports": "Sport", "Sports": "Sport",
"Standard price": "Standardpreis", "Standard price": "Standardpreis",
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Übernachten Sie im {hotelName} | Hotel in {destination}",
"Street": "Straße", "Street": "Straße",
"Successfully updated profile!": "Profil erfolgreich aktualisiert!", "Successfully updated profile!": "Profil erfolgreich aktualisiert!",
"Summary": "Zusammenfassung", "Summary": "Zusammenfassung",

View File

@@ -380,6 +380,7 @@
"Sort by": "Sort by", "Sort by": "Sort by",
"Sports": "Sports", "Sports": "Sports",
"Standard price": "Standard price", "Standard price": "Standard price",
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Stay at {hotelName} | Hotel in {destination}",
"Street": "Street", "Street": "Street",
"Successfully updated profile!": "Successfully updated profile!", "Successfully updated profile!": "Successfully updated profile!",
"Summary": "Summary", "Summary": "Summary",
@@ -510,6 +511,5 @@
"uppercase letter": "uppercase letter", "uppercase letter": "uppercase letter",
"{amount} out of {total}": "{amount} out of {total}", "{amount} out of {total}": "{amount} out of {total}",
"{card} ending with {cardno}": "{card} ending with {cardno}", "{card} ending with {cardno}": "{card} ending with {cardno}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}", "{difference}{amount} {currency}": "{difference}{amount} {currency}"
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Stay at {hotelName} | Hotel in {destination}"
} }

View File

@@ -352,6 +352,7 @@
"Sort by": "Lajitteluperuste", "Sort by": "Lajitteluperuste",
"Sports": "Urheilu", "Sports": "Urheilu",
"Standard price": "Normaali hinta", "Standard price": "Normaali hinta",
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Majoitu kohteessa {hotelName} | Hotelli kohteessa {destination}",
"Street": "Katu", "Street": "Katu",
"Successfully updated profile!": "Profiilin päivitys onnistui!", "Successfully updated profile!": "Profiilin päivitys onnistui!",
"Summary": "Yhteenveto", "Summary": "Yhteenveto",

View File

@@ -349,6 +349,7 @@
"Sort by": "Sorter etter", "Sort by": "Sorter etter",
"Sports": "Sport", "Sports": "Sport",
"Standard price": "Standardpris", "Standard price": "Standardpris",
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Bo på {hotelName} | Hotell i {destination}",
"Street": "Gate", "Street": "Gate",
"Successfully updated profile!": "Vellykket oppdatert profil!", "Successfully updated profile!": "Vellykket oppdatert profil!",
"Summary": "Sammendrag", "Summary": "Sammendrag",

View File

@@ -349,6 +349,7 @@
"Sort by": "Sortera efter", "Sort by": "Sortera efter",
"Sports": "Sport", "Sports": "Sport",
"Standard price": "Standardpris", "Standard price": "Standardpris",
"Stay at HOTEL_NAME | Hotel in DESTINATION": "Bo på {hotelName} | Hotell i {destination}",
"Street": "Gata", "Street": "Gata",
"Successfully updated profile!": "Profilen har uppdaterats framgångsrikt!", "Successfully updated profile!": "Profilen har uppdaterats framgångsrikt!",
"Summary": "Sammanfattning", "Summary": "Sammanfattning",

View File

@@ -1,5 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { hotelAttributesSchema } from "../../hotels/output"
import { tempImageVaultAssetSchema } from "../schemas/imageVault" import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { getDescription, getImage, getTitle } from "./utils" import { getDescription, getImage, getTitle } from "./utils"
@@ -72,13 +73,8 @@ export const rawMetadataSchema = z.object({
hero_image: tempImageVaultAssetSchema.nullable(), hero_image: tempImageVaultAssetSchema.nullable(),
blocks: metaDataBlocksSchema, blocks: metaDataBlocksSchema,
hotel_page_id: z.string().optional().nullable(), hotel_page_id: z.string().optional().nullable(),
hotelData: z hotelData: hotelAttributesSchema
.object({ .pick({ name: true, address: true, hotelContent: true, gallery: true })
name: z.string(),
city: z.string(),
description: z.string(),
image: z.object({ url: z.string(), alt: z.string() }).nullable(),
})
.optional() .optional()
.nullable(), .nullable(),
}) })
@@ -88,7 +84,7 @@ export const metadataSchema = rawMetadataSchema.transform(async (data) => {
const metadata: Metadata = { const metadata: Metadata = {
title: await getTitle(data), title: await getTitle(data),
description: await getDescription(data), description: getDescription(data),
openGraph: { openGraph: {
images: getImage(data), images: getImage(data),
}, },

View File

@@ -153,26 +153,10 @@ export const metadataQueryRouter = router({
) )
: null : null
const rawHotelData = hotelPageData return getTransformedMetadata({
...hotelPageData,
if (hotelData?.data.attributes) { hotelData: hotelData?.data.attributes,
const attributes = hotelData.data.attributes })
const images = attributes.gallery?.smallerImages
rawHotelData.hotelData = {
name: attributes.name,
city: attributes.cityName,
description: attributes.hotelContent.texts.descriptions.short,
image: images?.length
? {
url: images[0].imageSizes.small,
alt: images[0].metaData.altText,
}
: null,
}
}
return getTransformedMetadata(rawHotelData)
default: default:
return null return null
} }

View File

@@ -69,7 +69,10 @@ export async function getTitle(data: RawMetadataSchema) {
if (data.hotelData) { if (data.hotelData) {
return intl.formatMessage( return intl.formatMessage(
{ id: "Stay at HOTEL_NAME | Hotel in DESTINATION" }, { id: "Stay at HOTEL_NAME | Hotel in DESTINATION" },
{ hotelName: data.hotelData.name, destination: data.hotelData.city } {
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
) )
} }
if (data.web?.breadcrumbs?.title) { if (data.web?.breadcrumbs?.title) {
@@ -84,14 +87,13 @@ export async function getTitle(data: RawMetadataSchema) {
return "" return ""
} }
export async function getDescription(data: RawMetadataSchema) { export function getDescription(data: RawMetadataSchema) {
const intl = await getIntl()
const metadata = data.web?.seo_metadata const metadata = data.web?.seo_metadata
if (metadata?.description) { if (metadata?.description) {
return metadata.description return metadata.description
} }
if (data.hotelData) { if (data.hotelData) {
return data.hotelData.description return data.hotelData.hotelContent.texts.descriptions.short
} }
if (data.preamble) { if (data.preamble) {
return truncateTextAfterLastPeriod(data.preamble) return truncateTextAfterLastPeriod(data.preamble)
@@ -118,6 +120,9 @@ export async function getDescription(data: RawMetadataSchema) {
export function getImage(data: RawMetadataSchema) { export function getImage(data: RawMetadataSchema) {
const metadataImage = data.web?.seo_metadata?.seo_image const metadataImage = data.web?.seo_metadata?.seo_image
const heroImage = data.hero_image const heroImage = data.hero_image
const hotelImage =
data.hotelData?.gallery?.heroImages?.[0] ||
data.hotelData?.gallery?.smallerImages?.[0]
// Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15) // Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15)
if (metadataImage) { if (metadataImage) {
@@ -128,8 +133,11 @@ export function getImage(data: RawMetadataSchema) {
height: metadataImage.dimensions.height, height: metadataImage.dimensions.height,
} }
} }
if (data.hotelData?.image) { if (hotelImage) {
return data.hotelData.image return {
url: hotelImage.imageSizes.small,
alt: hotelImage.metaData.altText || undefined,
}
} }
if (heroImage) { if (heroImage) {
return { return {

View File

@@ -423,6 +423,47 @@ const hotelFactsSchema = z.object({
yearBuilt: z.string(), yearBuilt: z.string(),
}) })
export const hotelAttributesSchema = z.object({
accessibilityElevatorPitchText: z.string().optional(),
address: addressSchema,
cityId: z.string(),
cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(),
contactInformation: contactInformationSchema,
detailedFacilities: z
.array(detailedFacilitySchema)
.transform((facilities) =>
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
),
gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(),
healthFacilities: z.array(healthFacilitySchema),
hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema,
hotelRoomElevatorPitchText: z.string().optional(),
hotelType: z.string().optional(),
isActive: z.boolean(),
isPublished: z.boolean(),
keywords: z.array(z.string()),
location: locationSchema,
merchantInformationData: merchantInformationSchema,
name: z.string(),
operaId: z.string(),
parking: z.array(parkingSchema),
pointsOfInterest: z
.array(pointOfInterestSchema)
.transform((pois) =>
pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))
),
ratings: ratingsSchema,
rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema,
specialNeedGroups: z.array(specialNeedGroupSchema),
})
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
export const getHotelDataSchema = z.object({ export const getHotelDataSchema = z.object({
data: z.object({ data: z.object({
@@ -435,46 +476,7 @@ export const getHotelDataSchema = z.object({
} }
return lang return lang
}), }),
attributes: z.object({ attributes: hotelAttributesSchema,
accessibilityElevatorPitchText: z.string().optional(),
address: addressSchema,
cityId: z.string(),
cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(),
contactInformation: contactInformationSchema,
detailedFacilities: z
.array(detailedFacilitySchema)
.transform((facilities) =>
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
),
gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(),
healthFacilities: z.array(healthFacilitySchema),
hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema,
hotelRoomElevatorPitchText: z.string().optional(),
hotelType: z.string().optional(),
isActive: z.boolean(),
isPublished: z.boolean(),
keywords: z.array(z.string()),
location: locationSchema,
merchantInformationData: merchantInformationSchema,
name: z.string(),
operaId: z.string(),
parking: z.array(parkingSchema),
pointsOfInterest: z
.array(pointOfInterestSchema)
.transform((pois) =>
pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))
),
ratings: ratingsSchema,
rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema,
specialNeedGroups: z.array(specialNeedGroupSchema),
}),
relationships: relationshipsSchema, relationships: relationshipsSchema,
}), }),
// NOTE: We can pass an "include" param to the hotel API to retrieve // NOTE: We can pass an "include" param to the hotel API to retrieve

View File

@@ -1,7 +1,13 @@
import { env } from "@/env/server" import { env } from "@/env/server"
import type { BreadcrumbList, ListItem, WithContext } from "schema-dts" import type {
BreadcrumbList,
Hotel as HotelSchema,
ListItem,
WithContext,
} from "schema-dts"
import type { Hotel } from "@/types/hotel"
import type { Breadcrumbs } from "@/types/trpc/routers/contentstack/breadcrumbs" import type { Breadcrumbs } from "@/types/trpc/routers/contentstack/breadcrumbs"
export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) { export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) {
@@ -25,3 +31,49 @@ export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) {
jsonLd, jsonLd,
} }
} }
export function generateHotelSchema(hotel: Hotel) {
const ratings = hotel.ratings?.tripAdvisor
const checkinData = hotel.hotelFacts.checkin
const image = hotel.gallery?.heroImages[0] || hotel.gallery?.smallerImages[0]
const facilities = hotel.detailedFacilities
const jsonLd: WithContext<HotelSchema> = {
"@context": "https://schema.org",
"@type": "Hotel",
name: hotel.name,
address: {
"@type": "PostalAddress",
streetAddress: hotel.address.streetAddress,
addressLocality: hotel.address.city,
postalCode: hotel.address.zipCode,
addressCountry: hotel.address.country,
},
checkinTime: checkinData.checkInTime,
checkoutTime: checkinData.checkOutTime,
amenityFeature: facilities.map((facility) => ({
"@type": "LocationFeatureSpecification",
name: facility.name,
})),
}
if (image) {
jsonLd.image = {
"@type": "ImageObject",
url: image.imageSizes.small,
caption: image.metaData.title,
}
}
if (ratings && ratings.rating && ratings.numberOfReviews) {
jsonLd.aggregateRating = {
"@type": "AggregateRating",
ratingValue: ratings.rating,
reviewCount: ratings.numberOfReviews,
}
}
return {
type: "application/ld+json",
jsonLd,
}
}