Feat/SW-2152 seo descriptions
* feat(SW-2152): Added improved meta descriptions for hotel pages * feat(SW-2152): Added improved meta descriptions for destination pages * feat(SW-2152): Refactoring metadata description functionality * feat(SW-2152): Improved truncate function and added cities check to country page description Approved-by: Michael Zetterberg Approved-by: Matilda Landström
This commit is contained in:
@@ -19,6 +19,9 @@ query GetDestinationCityPageMetadata($locale: String!, $uid: String!) {
|
|||||||
city_poland
|
city_poland
|
||||||
city_sweden
|
city_sweden
|
||||||
}
|
}
|
||||||
|
images {
|
||||||
|
image
|
||||||
|
}
|
||||||
system {
|
system {
|
||||||
...System
|
...System
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ query GetDestinationCountryPageMetadata($locale: String!, $uid: String!) {
|
|||||||
destination_settings {
|
destination_settings {
|
||||||
country
|
country
|
||||||
}
|
}
|
||||||
|
images {
|
||||||
|
image
|
||||||
|
}
|
||||||
system {
|
system {
|
||||||
...System
|
...System
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import { additionalDataAttributesSchema } from "../../hotels/schemas/hotel/inclu
|
|||||||
import { imageSchema } from "../../hotels/schemas/image"
|
import { imageSchema } from "../../hotels/schemas/image"
|
||||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||||
import { systemSchema } from "../schemas/system"
|
import { systemSchema } from "../schemas/system"
|
||||||
import { getDescription, getImage, getTitle } from "./utils"
|
import { getDescription } from "./utils/description"
|
||||||
|
import { getImage } from "./utils/image"
|
||||||
|
import { getTitle } from "./utils/title"
|
||||||
|
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
|
||||||
|
import type { ImageVaultAsset } from "@/types/components/imageVault"
|
||||||
import { Country } from "@/types/enums/country"
|
import { Country } from "@/types/enums/country"
|
||||||
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
|
import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher"
|
||||||
import { RTETypeEnum } from "@/types/rte/enums"
|
import { RTETypeEnum } from "@/types/rte/enums"
|
||||||
@@ -57,56 +60,59 @@ export const rawMetadataSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
seo_metadata: z
|
seo_metadata: z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().optional().nullable(),
|
title: z.string().nullish(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().nullish(),
|
||||||
noindex: z.boolean().optional().nullable(),
|
noindex: z.boolean().nullish(),
|
||||||
seo_image: tempImageVaultAssetSchema.nullable(),
|
seo_image: tempImageVaultAssetSchema.nullable(),
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
breadcrumbs: z
|
breadcrumbs: z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().optional().nullable(),
|
title: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
destination_settings: z
|
destination_settings: z
|
||||||
.object({
|
.object({
|
||||||
city_denmark: z.string().optional().nullable(),
|
city_denmark: z.string().nullish(),
|
||||||
city_finland: z.string().optional().nullable(),
|
city_finland: z.string().nullish(),
|
||||||
city_germany: z.string().optional().nullable(),
|
city_germany: z.string().nullish(),
|
||||||
city_poland: z.string().optional().nullable(),
|
city_poland: z.string().nullish(),
|
||||||
city_norway: z.string().optional().nullable(),
|
city_norway: z.string().nullish(),
|
||||||
city_sweden: z.string().optional().nullable(),
|
city_sweden: z.string().nullish(),
|
||||||
country: z.nativeEnum(Country).optional().nullable(),
|
country: z.nativeEnum(Country).nullish(),
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
heading: z.string().nullish(),
|
||||||
heading: z.string().optional().nullable(),
|
preamble: z.string().nullish(),
|
||||||
preamble: z.string().optional().nullable(),
|
|
||||||
header: z
|
header: z
|
||||||
.object({
|
.object({
|
||||||
heading: z.string().optional().nullable(),
|
heading: z.string().nullish(),
|
||||||
preamble: z.string().optional().nullable(),
|
preamble: z.string().nullish(),
|
||||||
hero_image: tempImageVaultAssetSchema.nullable(),
|
hero_image: tempImageVaultAssetSchema.nullable(),
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
hero_image: tempImageVaultAssetSchema.nullable(),
|
hero_image: tempImageVaultAssetSchema.nullable(),
|
||||||
|
images: z
|
||||||
|
.array(z.object({ image: tempImageVaultAssetSchema }).nullish())
|
||||||
|
.transform((images) =>
|
||||||
|
images
|
||||||
|
.map((image) => image?.image)
|
||||||
|
.filter((image): image is ImageVaultAsset => !!image)
|
||||||
|
)
|
||||||
|
.nullish(),
|
||||||
blocks: metaDataBlocksSchema,
|
blocks: metaDataBlocksSchema,
|
||||||
hotel_page_id: z.string().optional().nullable(),
|
hotel_page_id: z.string().nullish(),
|
||||||
hotelData: hotelAttributesSchema
|
hotelData: hotelAttributesSchema
|
||||||
.pick({
|
.pick({
|
||||||
name: true,
|
name: true,
|
||||||
address: true,
|
address: true,
|
||||||
|
detailedFacilities: true,
|
||||||
hotelContent: true,
|
hotelContent: true,
|
||||||
healthFacilities: true,
|
healthFacilities: true,
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
additionalHotelData: additionalDataAttributesSchema
|
additionalHotelData: additionalDataAttributesSchema
|
||||||
.pick({
|
.pick({
|
||||||
gallery: true,
|
gallery: true,
|
||||||
@@ -118,28 +124,31 @@ export const rawMetadataSchema = z.object({
|
|||||||
accessibility: true,
|
accessibility: true,
|
||||||
conferencesAndMeetings: true,
|
conferencesAndMeetings: true,
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
hotelRestaurants: z
|
hotelRestaurants: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
nameInUrl: z.string().optional().nullable(),
|
nameInUrl: z.string().nullish(),
|
||||||
elevatorPitch: z.string().optional().nullable(),
|
elevatorPitch: z.string().nullish(),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().nullish(),
|
||||||
content: z
|
content: z
|
||||||
.object({
|
.object({
|
||||||
images: z.array(imageSchema).optional().nullable(),
|
images: z.array(imageSchema).nullish(),
|
||||||
})
|
})
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional()
|
.nullish(),
|
||||||
.nullable(),
|
subpageUrl: z.string().nullish(),
|
||||||
subpageUrl: z.string().optional().nullable(),
|
destinationData: z
|
||||||
location: z.string().optional().nullable(),
|
.object({
|
||||||
filter: z.string().optional().nullable(),
|
location: z.string().nullish(),
|
||||||
filterType: z.enum(["facility", "surroundings"]).optional().nullable(),
|
filter: z.string().nullish(),
|
||||||
|
filterType: z.enum(["facility", "surroundings"]).nullish(),
|
||||||
|
hotelCount: z.number().nullish(),
|
||||||
|
cities: z.array(z.string()).nullish(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
system: systemSchema,
|
system: systemSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -149,7 +158,7 @@ export const metadataSchema = rawMetadataSchema.transform(async (data) => {
|
|||||||
const metadata: Metadata = {
|
const metadata: Metadata = {
|
||||||
metadataBase: env.PUBLIC_URL ? new URL(env.PUBLIC_URL) : undefined,
|
metadataBase: env.PUBLIC_URL ? new URL(env.PUBLIC_URL) : undefined,
|
||||||
title: await getTitle(data),
|
title: await getTitle(data),
|
||||||
description: getDescription(data),
|
description: await getDescription(data),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: getImage(data),
|
images: getImage(data),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const metadataQueryRouter = router({
|
|||||||
)
|
)
|
||||||
metadata = await getTransformedMetadata({
|
metadata = await getTransformedMetadata({
|
||||||
...destinationCountryPageResponse.destination_country_page,
|
...destinationCountryPageResponse.destination_country_page,
|
||||||
...countryData,
|
destinationData: countryData,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case PageContentTypeEnum.destinationCityPage:
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
@@ -178,7 +178,7 @@ export const metadataQueryRouter = router({
|
|||||||
)
|
)
|
||||||
metadata = await getTransformedMetadata({
|
metadata = await getTransformedMetadata({
|
||||||
...destinationCityPageResponse.destination_city_page,
|
...destinationCityPageResponse.destination_city_page,
|
||||||
...cityData,
|
destinationData: cityData,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case PageContentTypeEnum.loyaltyPage:
|
case PageContentTypeEnum.loyaltyPage:
|
||||||
|
|||||||
@@ -1,485 +0,0 @@
|
|||||||
import { type Lang } from "@/constants/languages"
|
|
||||||
import { getFiltersFromHotels } from "@/stores/destination-data/helper"
|
|
||||||
|
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
|
|
||||||
import {
|
|
||||||
getCityByCityIdentifier,
|
|
||||||
getHotelIdsByCityIdentifier,
|
|
||||||
getHotelIdsByCountry,
|
|
||||||
getHotelsByHotelIds,
|
|
||||||
} from "../../hotels/utils"
|
|
||||||
|
|
||||||
import { ApiCountry } from "@/types/enums/country"
|
|
||||||
import { RTETypeEnum } from "@/types/rte/enums"
|
|
||||||
import type {
|
|
||||||
MetadataInputSchema,
|
|
||||||
RawMetadataSchema,
|
|
||||||
} from "@/types/trpc/routers/contentstack/metadata"
|
|
||||||
|
|
||||||
export const affix = "metadata"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncates the given text "intelligently" based on the last period found near the max length.
|
|
||||||
*
|
|
||||||
* - If a period exists within the extended range (`maxLength` to `maxLength + maxExtension`),
|
|
||||||
* the function truncates after the closest period to `maxLength`.
|
|
||||||
* - If no period is found in the range, it truncates the text after the last period found in the max length of the text.
|
|
||||||
* - If no periods exist at all, it truncates at `maxLength` and appends ellipsis (`...`).
|
|
||||||
*
|
|
||||||
* @param {string} text - The input text to be truncated.
|
|
||||||
* @param {number} [maxLength=150] - The desired maximum length of the truncated text.
|
|
||||||
* @param {number} [minLength=120] - The minimum allowable length for the truncated text.
|
|
||||||
* @param {number} [maxExtension=10] - The maximum number of characters to extend beyond `maxLength` to find a period.
|
|
||||||
* @returns {string} - The truncated text.
|
|
||||||
*/
|
|
||||||
function truncateTextAfterLastPeriod(
|
|
||||||
text: string,
|
|
||||||
maxLength: number = 150,
|
|
||||||
minLength: number = 120,
|
|
||||||
maxExtension: number = 10
|
|
||||||
): string {
|
|
||||||
if (text.length <= maxLength) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the extended range
|
|
||||||
const extendedEnd = Math.min(text.length, maxLength + maxExtension)
|
|
||||||
const extendedText = text.slice(0, extendedEnd)
|
|
||||||
|
|
||||||
// Find all periods within the extended range and filter after minLength to get valid periods
|
|
||||||
const periodsInRange = [...extendedText.matchAll(/\./g)].map(
|
|
||||||
({ index }) => index
|
|
||||||
)
|
|
||||||
const validPeriods = periodsInRange.filter((index) => index + 1 >= minLength)
|
|
||||||
|
|
||||||
if (validPeriods.length > 0) {
|
|
||||||
// Find the period closest to maxLength
|
|
||||||
const closestPeriod = validPeriods.reduce((closest, index) =>
|
|
||||||
Math.abs(index + 1 - maxLength) < Math.abs(closest + 1 - maxLength)
|
|
||||||
? index
|
|
||||||
: closest
|
|
||||||
)
|
|
||||||
return extendedText.slice(0, closestPeriod + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: If no period is found within the valid range, look for the last period in the truncated text
|
|
||||||
const maxLengthText = text.slice(0, maxLength)
|
|
||||||
const lastPeriodIndex = maxLengthText.lastIndexOf(".")
|
|
||||||
if (lastPeriodIndex !== -1) {
|
|
||||||
return text.slice(0, lastPeriodIndex + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final fallback: Return maxLength text including ellipsis
|
|
||||||
return `${maxLengthText}...`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTitle(data: RawMetadataSchema) {
|
|
||||||
const intl = await getIntl()
|
|
||||||
const metadata = data.web?.seo_metadata
|
|
||||||
if (metadata?.title) {
|
|
||||||
return metadata.title
|
|
||||||
}
|
|
||||||
if (data.hotelData) {
|
|
||||||
if (data.subpageUrl) {
|
|
||||||
const restaurantSubPage = data.hotelRestaurants?.find(
|
|
||||||
(restaurant) => restaurant.nameInUrl === data.subpageUrl
|
|
||||||
)
|
|
||||||
if (restaurantSubPage) {
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage:
|
|
||||||
"Explore {restaurantName} at {hotelName} in {destination}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
restaurantName: restaurantSubPage.name,
|
|
||||||
hotelName: data.hotelData.name,
|
|
||||||
destination: data.hotelData.address.city,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (data.subpageUrl) {
|
|
||||||
case data.additionalHotelData?.hotelParking.nameInUrl:
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage:
|
|
||||||
"Parking information for {hotelName} in {destination}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hotelName: data.hotelData.name,
|
|
||||||
destination: data.hotelData.address.city,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case data.additionalHotelData?.healthAndFitness.nameInUrl:
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage:
|
|
||||||
"Gym & Health Facilities at {hotelName} in {destination}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hotelName: data.hotelData.name,
|
|
||||||
destination: data.hotelData.address.city,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage:
|
|
||||||
"Accessibility information for {hotelName} in {destination}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hotelName: data.hotelData.name,
|
|
||||||
destination: data.hotelData.address.city,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case data.additionalHotelData?.meetingRooms.nameInUrl:
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage:
|
|
||||||
"Meetings, Conferences & Events at {hotelName} in {destination}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hotelName: data.hotelData.name,
|
|
||||||
destination: data.hotelData.address.city,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "Stay at {hotelName} | Hotel in {destination}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hotelName: data.hotelData.name,
|
|
||||||
destination: data.hotelData.address.city,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.system.content_type_uid === "destination_city_page" ||
|
|
||||||
data.system.content_type_uid === "destination_country_page"
|
|
||||||
) {
|
|
||||||
const { location, filter, filterType } = data
|
|
||||||
if (location) {
|
|
||||||
if (filter) {
|
|
||||||
if (filterType === "facility") {
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "Hotels with {filter} in {location}",
|
|
||||||
},
|
|
||||||
{ location, filter }
|
|
||||||
)
|
|
||||||
} else if (filterType === "surroundings") {
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "Hotels near {filter} in {location}",
|
|
||||||
},
|
|
||||||
{ location, filter }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "Hotels in {location}",
|
|
||||||
},
|
|
||||||
{ location }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.web?.breadcrumbs?.title) {
|
|
||||||
return data.web.breadcrumbs.title
|
|
||||||
}
|
|
||||||
if (data.heading) {
|
|
||||||
return data.heading
|
|
||||||
}
|
|
||||||
if (data.header?.heading) {
|
|
||||||
return data.header.heading
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDescription(data: RawMetadataSchema) {
|
|
||||||
const metadata = data.web?.seo_metadata
|
|
||||||
if (metadata?.description) {
|
|
||||||
return metadata.description
|
|
||||||
}
|
|
||||||
if (data.hotelData) {
|
|
||||||
if (data.subpageUrl) {
|
|
||||||
let subpageDescription: string | undefined
|
|
||||||
const restaurantSubPage = data.hotelRestaurants?.find(
|
|
||||||
(restaurant) => restaurant.nameInUrl === data.subpageUrl
|
|
||||||
)
|
|
||||||
if (restaurantSubPage?.elevatorPitch) {
|
|
||||||
subpageDescription = restaurantSubPage.elevatorPitch
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (data.subpageUrl) {
|
|
||||||
case data.additionalHotelData?.hotelParking.nameInUrl:
|
|
||||||
subpageDescription =
|
|
||||||
data.additionalHotelData?.hotelParking.elevatorPitch
|
|
||||||
break
|
|
||||||
case data.additionalHotelData?.healthAndFitness.nameInUrl:
|
|
||||||
subpageDescription =
|
|
||||||
data.additionalHotelData?.healthAndFitness.elevatorPitch
|
|
||||||
break
|
|
||||||
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
|
|
||||||
subpageDescription =
|
|
||||||
data.additionalHotelData?.hotelSpecialNeeds.elevatorPitch
|
|
||||||
break
|
|
||||||
case data.additionalHotelData?.meetingRooms.nameInUrl:
|
|
||||||
subpageDescription =
|
|
||||||
data.additionalHotelData?.meetingRooms.elevatorPitch
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subpageDescription) {
|
|
||||||
return truncateTextAfterLastPeriod(subpageDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data.hotelData.hotelContent.texts.descriptions?.short
|
|
||||||
}
|
|
||||||
if (data.preamble) {
|
|
||||||
return truncateTextAfterLastPeriod(data.preamble)
|
|
||||||
}
|
|
||||||
if (data.header?.preamble) {
|
|
||||||
return truncateTextAfterLastPeriod(data.header.preamble)
|
|
||||||
}
|
|
||||||
if (data.blocks?.length) {
|
|
||||||
const jsonData = data.blocks[0].content?.content?.json
|
|
||||||
// Finding the first paragraph with text
|
|
||||||
const firstParagraph = jsonData?.children?.find(
|
|
||||||
(child) => child.type === RTETypeEnum.p && child.children[0].text
|
|
||||||
)
|
|
||||||
|
|
||||||
if (firstParagraph?.children?.length) {
|
|
||||||
return firstParagraph.children[0].text
|
|
||||||
? truncateTextAfterLastPeriod(firstParagraph.children[0].text)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImage(data: RawMetadataSchema) {
|
|
||||||
const metadataImage = data.web?.seo_metadata?.seo_image
|
|
||||||
const heroImage = data.hero_image || data.header?.hero_image
|
|
||||||
|
|
||||||
// Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15)
|
|
||||||
if (metadataImage) {
|
|
||||||
return {
|
|
||||||
url: metadataImage.url,
|
|
||||||
alt: metadataImage.meta.alt || undefined,
|
|
||||||
width: metadataImage.dimensions.width,
|
|
||||||
height: metadataImage.dimensions.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.hotelData) {
|
|
||||||
if (data.subpageUrl) {
|
|
||||||
let subpageImage: { url: string; alt: string } | undefined
|
|
||||||
const restaurantSubPage = data.hotelRestaurants?.find(
|
|
||||||
(restaurant) => restaurant.nameInUrl === data.subpageUrl
|
|
||||||
)
|
|
||||||
const restaurantImage = restaurantSubPage?.content?.images?.[0]
|
|
||||||
if (restaurantImage) {
|
|
||||||
subpageImage = {
|
|
||||||
url: restaurantImage.imageSizes.small,
|
|
||||||
alt:
|
|
||||||
restaurantImage.metaData.altText ||
|
|
||||||
restaurantImage.metaData.altText_En ||
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (data.subpageUrl) {
|
|
||||||
case data.additionalHotelData?.hotelParking.nameInUrl:
|
|
||||||
const parkingImage =
|
|
||||||
data.additionalHotelData?.parkingImages?.heroImages[0]
|
|
||||||
if (parkingImage) {
|
|
||||||
subpageImage = {
|
|
||||||
url: parkingImage.imageSizes.small,
|
|
||||||
alt:
|
|
||||||
parkingImage.metaData.altText ||
|
|
||||||
parkingImage.metaData.altText_En ||
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case data.additionalHotelData?.healthAndFitness.nameInUrl:
|
|
||||||
const wellnessImage = data.hotelData.healthFacilities.find(
|
|
||||||
(fac) => fac.content.images.length
|
|
||||||
)?.content.images[0]
|
|
||||||
if (wellnessImage) {
|
|
||||||
subpageImage = {
|
|
||||||
url: wellnessImage.imageSizes.small,
|
|
||||||
alt:
|
|
||||||
wellnessImage.metaData.altText ||
|
|
||||||
wellnessImage.metaData.altText_En ||
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
|
|
||||||
const accessibilityImage =
|
|
||||||
data.additionalHotelData?.accessibility?.heroImages[0]
|
|
||||||
if (accessibilityImage) {
|
|
||||||
subpageImage = {
|
|
||||||
url: accessibilityImage.imageSizes.small,
|
|
||||||
alt:
|
|
||||||
accessibilityImage.metaData.altText ||
|
|
||||||
accessibilityImage.metaData.altText_En ||
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case data.additionalHotelData?.meetingRooms.nameInUrl:
|
|
||||||
const meetingImage =
|
|
||||||
data.additionalHotelData?.conferencesAndMeetings?.heroImages[0]
|
|
||||||
if (meetingImage) {
|
|
||||||
subpageImage = {
|
|
||||||
url: meetingImage.imageSizes.small,
|
|
||||||
alt:
|
|
||||||
meetingImage.metaData.altText ||
|
|
||||||
meetingImage.metaData.altText_En ||
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subpageImage) {
|
|
||||||
return subpageImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hotelImage =
|
|
||||||
data.additionalHotelData?.gallery?.heroImages?.[0] ||
|
|
||||||
data.additionalHotelData?.gallery?.smallerImages?.[0]
|
|
||||||
if (hotelImage) {
|
|
||||||
return {
|
|
||||||
url: hotelImage.imageSizes.small,
|
|
||||||
alt: hotelImage.metaData.altText || undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (heroImage) {
|
|
||||||
return {
|
|
||||||
url: heroImage.url,
|
|
||||||
alt: heroImage.meta.alt || undefined,
|
|
||||||
width: heroImage.dimensions.width,
|
|
||||||
height: heroImage.dimensions.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCityData(
|
|
||||||
data: RawMetadataSchema,
|
|
||||||
input: MetadataInputSchema,
|
|
||||||
serviceToken: string,
|
|
||||||
lang: Lang
|
|
||||||
) {
|
|
||||||
const destinationSettings = data.destination_settings
|
|
||||||
const filter = input.filterFromUrl
|
|
||||||
|
|
||||||
if (destinationSettings) {
|
|
||||||
const {
|
|
||||||
city_sweden,
|
|
||||||
city_norway,
|
|
||||||
city_denmark,
|
|
||||||
city_finland,
|
|
||||||
city_germany,
|
|
||||||
city_poland,
|
|
||||||
} = destinationSettings
|
|
||||||
const cities = [
|
|
||||||
city_denmark,
|
|
||||||
city_finland,
|
|
||||||
city_germany,
|
|
||||||
city_poland,
|
|
||||||
city_norway,
|
|
||||||
city_sweden,
|
|
||||||
].filter((city): city is string => Boolean(city))
|
|
||||||
|
|
||||||
const cityIdentifier = cities[0]
|
|
||||||
|
|
||||||
if (cityIdentifier) {
|
|
||||||
const cityData = await getCityByCityIdentifier({
|
|
||||||
cityIdentifier,
|
|
||||||
serviceToken,
|
|
||||||
lang,
|
|
||||||
})
|
|
||||||
const hotelIds = await getHotelIdsByCityIdentifier(
|
|
||||||
cityIdentifier,
|
|
||||||
serviceToken
|
|
||||||
)
|
|
||||||
|
|
||||||
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
|
||||||
|
|
||||||
let filterType
|
|
||||||
if (filter) {
|
|
||||||
const allFilters = getFiltersFromHotels(hotels)
|
|
||||||
const facilityFilter = allFilters.facilityFilters.find(
|
|
||||||
(f) => f.slug === filter
|
|
||||||
)
|
|
||||||
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
|
||||||
(f) => f.slug === filter
|
|
||||||
)
|
|
||||||
|
|
||||||
if (facilityFilter) {
|
|
||||||
filterType = "facility"
|
|
||||||
} else if (surroudingsFilter) {
|
|
||||||
filterType = "surroundings"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { location: cityData?.name, filter, filterType }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCountryData(
|
|
||||||
data: RawMetadataSchema,
|
|
||||||
input: MetadataInputSchema,
|
|
||||||
serviceToken: string,
|
|
||||||
lang: Lang
|
|
||||||
) {
|
|
||||||
const country = data.destination_settings?.country
|
|
||||||
const filter = input.filterFromUrl
|
|
||||||
|
|
||||||
if (country) {
|
|
||||||
const translatedCountry = ApiCountry[lang][country]
|
|
||||||
let filterType
|
|
||||||
|
|
||||||
const hotelIds = await getHotelIdsByCountry({
|
|
||||||
country,
|
|
||||||
serviceToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
const allFilters = getFiltersFromHotels(hotels)
|
|
||||||
const facilityFilter = allFilters.facilityFilters.find(
|
|
||||||
(f) => f.slug === filter
|
|
||||||
)
|
|
||||||
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
|
||||||
(f) => f.slug === filter
|
|
||||||
)
|
|
||||||
|
|
||||||
if (facilityFilter) {
|
|
||||||
filterType = "facility"
|
|
||||||
} else if (surroudingsFilter) {
|
|
||||||
filterType = "surroundings"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { location: translatedCountry, filter, filterType }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import { truncateTextAfterLastPeriod } from "../truncate"
|
||||||
|
|
||||||
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
export async function getDestinationCityPageDescription(
|
||||||
|
data: RawMetadataSchema
|
||||||
|
) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
|
||||||
|
if (!data.destinationData || !data.destinationData.hotelCount) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hotelCount, location } = data.destinationData
|
||||||
|
|
||||||
|
if (hotelCount === 1) {
|
||||||
|
const destinationCitySingleHotelDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Discover our Scandic hotel in {location}. Start your day with a delicious breakfast before exploring {location}. Book your stay at a Scandic hotel now!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
location: location,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return truncateTextAfterLastPeriod(destinationCitySingleHotelDescription)
|
||||||
|
}
|
||||||
|
const destinationCityMultipleHotelDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Discover all our {hotelCount} Scandic hotels in {location}. Start your day with a delicious breakfast before exploring {location}. Book your stay at a Scandic hotel now!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelCount: hotelCount,
|
||||||
|
location: location,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return truncateTextAfterLastPeriod(destinationCityMultipleHotelDescription)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import { truncateTextAfterLastPeriod } from "../truncate"
|
||||||
|
|
||||||
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
export async function getDestinationCountryPageDescription(
|
||||||
|
data: RawMetadataSchema
|
||||||
|
) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
|
||||||
|
if (!data.destinationData?.location) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hotelCount, location, cities } = data.destinationData
|
||||||
|
|
||||||
|
let destinationCountryDescription: string | null = null
|
||||||
|
|
||||||
|
if (!hotelCount) {
|
||||||
|
destinationCountryDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Discover {location}. Enjoy your stay at a Scandic hotel. Book now!",
|
||||||
|
},
|
||||||
|
{ location }
|
||||||
|
)
|
||||||
|
} else if (!cities || cities.length < 2) {
|
||||||
|
destinationCountryDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Discover all our {hotelCount} Scandic hotels in {location}. Enjoy your stay at a Scandic hotel. Book now!",
|
||||||
|
},
|
||||||
|
{ hotelCount, location }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
destinationCountryDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Discover all our {hotelCount} Scandic hotels in {location}. Explore {city1}, {city2}, and more! All while enjoying your stay at a Scandic hotel. Book now!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelCount: hotelCount,
|
||||||
|
location: location,
|
||||||
|
city1: cities[0],
|
||||||
|
city2: cities[1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncateTextAfterLastPeriod(destinationCountryDescription)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import { truncateTextAfterLastPeriod } from "../truncate"
|
||||||
|
|
||||||
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
function getSubpageDescription(
|
||||||
|
subpageUrl: string,
|
||||||
|
additionalHotelData: RawMetadataSchema["additionalHotelData"],
|
||||||
|
hotelRestaurants: RawMetadataSchema["hotelRestaurants"]
|
||||||
|
) {
|
||||||
|
const restaurantSubPage = hotelRestaurants?.find(
|
||||||
|
(restaurant) => restaurant.nameInUrl === subpageUrl
|
||||||
|
)
|
||||||
|
if (restaurantSubPage?.elevatorPitch) {
|
||||||
|
return restaurantSubPage.elevatorPitch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!additionalHotelData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (subpageUrl) {
|
||||||
|
case additionalHotelData.hotelParking.nameInUrl:
|
||||||
|
return additionalHotelData.hotelParking.elevatorPitch
|
||||||
|
case additionalHotelData.healthAndFitness.nameInUrl:
|
||||||
|
return additionalHotelData.healthAndFitness.elevatorPitch
|
||||||
|
case additionalHotelData.hotelSpecialNeeds.nameInUrl:
|
||||||
|
return additionalHotelData.hotelSpecialNeeds.elevatorPitch
|
||||||
|
case additionalHotelData.meetingRooms.nameInUrl:
|
||||||
|
return additionalHotelData.meetingRooms.elevatorPitch
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHotelPageDescription(data: RawMetadataSchema) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const { subpageUrl, hotelData, additionalHotelData, hotelRestaurants } = data
|
||||||
|
if (!hotelData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subpageUrl) {
|
||||||
|
const subpageDescription = getSubpageDescription(
|
||||||
|
subpageUrl,
|
||||||
|
additionalHotelData,
|
||||||
|
hotelRestaurants
|
||||||
|
)
|
||||||
|
|
||||||
|
if (subpageDescription) {
|
||||||
|
return truncateTextAfterLastPeriod(subpageDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelName = hotelData.name
|
||||||
|
const location = hotelData.address.city
|
||||||
|
const amenities = hotelData.detailedFacilities
|
||||||
|
|
||||||
|
if (amenities.length < 4) {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{ defaultMessage: "{hotelName} in {location}. Book your stay now!" },
|
||||||
|
{ hotelName, location }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{hotelName} in {location} offers {amenity1} and {amenity2}. Guests can also enjoy {amenity3} and {amenity4}. Book your stay at {hotelName} today!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName,
|
||||||
|
location,
|
||||||
|
amenity1: amenities[0].name,
|
||||||
|
amenity2: amenities[1].name,
|
||||||
|
amenity3: amenities[2].name,
|
||||||
|
amenity4: amenities[3].name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const shortHotelDescription = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{hotelName} in {location} offers {amenity1} and {amenity2}. Guests can also enjoy {amenity3} and {amenity4}.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName,
|
||||||
|
location,
|
||||||
|
amenity1: amenities[0].name,
|
||||||
|
amenity2: amenities[1].name,
|
||||||
|
amenity3: amenities[2].name,
|
||||||
|
amenity4: amenities[3].name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hotelDescription.length > 160) {
|
||||||
|
if (shortHotelDescription.length > 160) {
|
||||||
|
return truncateTextAfterLastPeriod(shortHotelDescription)
|
||||||
|
}
|
||||||
|
return shortHotelDescription
|
||||||
|
} else {
|
||||||
|
return hotelDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { truncateTextAfterLastPeriod } from "../truncate"
|
||||||
|
import { getDestinationCityPageDescription } from "./destinationCityPage"
|
||||||
|
import { getDestinationCountryPageDescription } from "./destinationCountryPage"
|
||||||
|
import { getHotelPageDescription } from "./hotelPage"
|
||||||
|
|
||||||
|
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||||
|
import { RTETypeEnum } from "@/types/rte/enums"
|
||||||
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
export async function getDescription(data: RawMetadataSchema) {
|
||||||
|
const metadata = data.web?.seo_metadata
|
||||||
|
|
||||||
|
if (metadata?.description) {
|
||||||
|
return metadata.description
|
||||||
|
}
|
||||||
|
|
||||||
|
let description: string | null = null
|
||||||
|
switch (data.system.content_type_uid) {
|
||||||
|
case PageContentTypeEnum.hotelPage:
|
||||||
|
description = await getHotelPageDescription(data)
|
||||||
|
break
|
||||||
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
|
description = await getDestinationCityPageDescription(data)
|
||||||
|
break
|
||||||
|
case PageContentTypeEnum.destinationCountryPage:
|
||||||
|
description = await getDestinationCountryPageDescription(data)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback descriptions from contentstack content
|
||||||
|
if (data.preamble) {
|
||||||
|
return truncateTextAfterLastPeriod(data.preamble)
|
||||||
|
}
|
||||||
|
if (data.header?.preamble) {
|
||||||
|
return truncateTextAfterLastPeriod(data.header.preamble)
|
||||||
|
}
|
||||||
|
if (data.blocks?.length) {
|
||||||
|
const jsonData = data.blocks[0].content?.content?.json
|
||||||
|
// Finding the first paragraph with text
|
||||||
|
const firstParagraph = jsonData?.children?.find(
|
||||||
|
(child) => child.type === RTETypeEnum.p && child.children[0].text
|
||||||
|
)
|
||||||
|
|
||||||
|
if (firstParagraph?.children?.length) {
|
||||||
|
return firstParagraph.children[0].text
|
||||||
|
? truncateTextAfterLastPeriod(firstParagraph.children[0].text)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
export function getImage(data: RawMetadataSchema) {
|
||||||
|
const metadataImage = data.web?.seo_metadata?.seo_image
|
||||||
|
const heroImage =
|
||||||
|
data.hero_image || data.header?.hero_image || data.images?.[0]
|
||||||
|
|
||||||
|
// Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15)
|
||||||
|
if (metadataImage) {
|
||||||
|
return {
|
||||||
|
url: metadataImage.url,
|
||||||
|
alt: metadataImage.meta.alt || undefined,
|
||||||
|
width: metadataImage.dimensions.width,
|
||||||
|
height: metadataImage.dimensions.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.system.content_type_uid === "hotel_page" && data.hotelData) {
|
||||||
|
if (data.subpageUrl) {
|
||||||
|
let subpageImage: { url: string; alt: string } | undefined
|
||||||
|
const restaurantSubPage = data.hotelRestaurants?.find(
|
||||||
|
(restaurant) => restaurant.nameInUrl === data.subpageUrl
|
||||||
|
)
|
||||||
|
const restaurantImage = restaurantSubPage?.content?.images?.[0]
|
||||||
|
if (restaurantImage) {
|
||||||
|
subpageImage = {
|
||||||
|
url: restaurantImage.imageSizes.small,
|
||||||
|
alt:
|
||||||
|
restaurantImage.metaData.altText ||
|
||||||
|
restaurantImage.metaData.altText_En ||
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.subpageUrl) {
|
||||||
|
case data.additionalHotelData?.hotelParking.nameInUrl:
|
||||||
|
const parkingImage =
|
||||||
|
data.additionalHotelData?.parkingImages?.heroImages[0]
|
||||||
|
if (parkingImage) {
|
||||||
|
subpageImage = {
|
||||||
|
url: parkingImage.imageSizes.small,
|
||||||
|
alt:
|
||||||
|
parkingImage.metaData.altText ||
|
||||||
|
parkingImage.metaData.altText_En ||
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case data.additionalHotelData?.healthAndFitness.nameInUrl:
|
||||||
|
const wellnessImage = data.hotelData.healthFacilities.find(
|
||||||
|
(fac) => fac.content.images.length
|
||||||
|
)?.content.images[0]
|
||||||
|
if (wellnessImage) {
|
||||||
|
subpageImage = {
|
||||||
|
url: wellnessImage.imageSizes.small,
|
||||||
|
alt:
|
||||||
|
wellnessImage.metaData.altText ||
|
||||||
|
wellnessImage.metaData.altText_En ||
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
|
||||||
|
const accessibilityImage =
|
||||||
|
data.additionalHotelData?.accessibility?.heroImages[0]
|
||||||
|
if (accessibilityImage) {
|
||||||
|
subpageImage = {
|
||||||
|
url: accessibilityImage.imageSizes.small,
|
||||||
|
alt:
|
||||||
|
accessibilityImage.metaData.altText ||
|
||||||
|
accessibilityImage.metaData.altText_En ||
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case data.additionalHotelData?.meetingRooms.nameInUrl:
|
||||||
|
const meetingImage =
|
||||||
|
data.additionalHotelData?.conferencesAndMeetings?.heroImages[0]
|
||||||
|
if (meetingImage) {
|
||||||
|
subpageImage = {
|
||||||
|
url: meetingImage.imageSizes.small,
|
||||||
|
alt:
|
||||||
|
meetingImage.metaData.altText ||
|
||||||
|
meetingImage.metaData.altText_En ||
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subpageImage) {
|
||||||
|
return subpageImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelImage =
|
||||||
|
data.additionalHotelData?.gallery?.heroImages?.[0] ||
|
||||||
|
data.additionalHotelData?.gallery?.smallerImages?.[0]
|
||||||
|
if (hotelImage) {
|
||||||
|
return {
|
||||||
|
url: hotelImage.imageSizes.small,
|
||||||
|
alt: hotelImage.metaData.altText || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (heroImage) {
|
||||||
|
return {
|
||||||
|
url: heroImage.url,
|
||||||
|
alt: heroImage.meta.alt || undefined,
|
||||||
|
width: heroImage.dimensions.width,
|
||||||
|
height: heroImage.dimensions.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { type Lang } from "@/constants/languages"
|
||||||
|
import {
|
||||||
|
getFiltersFromHotels,
|
||||||
|
getSortedCities,
|
||||||
|
} from "@/stores/destination-data/helper"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCityByCityIdentifier,
|
||||||
|
getHotelIdsByCityIdentifier,
|
||||||
|
getHotelIdsByCountry,
|
||||||
|
getHotelsByHotelIds,
|
||||||
|
} from "../../../hotels/utils"
|
||||||
|
import { getCityPages } from "../../destinationCountryPage/utils"
|
||||||
|
|
||||||
|
import { ApiCountry } from "@/types/enums/country"
|
||||||
|
import { SortOption } from "@/types/enums/destinationFilterAndSort"
|
||||||
|
import type {
|
||||||
|
MetadataInputSchema,
|
||||||
|
RawMetadataSchema,
|
||||||
|
} from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
export const affix = "metadata"
|
||||||
|
|
||||||
|
export async function getCityData(
|
||||||
|
data: RawMetadataSchema,
|
||||||
|
input: MetadataInputSchema,
|
||||||
|
serviceToken: string,
|
||||||
|
lang: Lang
|
||||||
|
) {
|
||||||
|
const destinationSettings = data.destination_settings
|
||||||
|
const filter = input.filterFromUrl
|
||||||
|
|
||||||
|
if (destinationSettings) {
|
||||||
|
const {
|
||||||
|
city_sweden,
|
||||||
|
city_norway,
|
||||||
|
city_denmark,
|
||||||
|
city_finland,
|
||||||
|
city_germany,
|
||||||
|
city_poland,
|
||||||
|
} = destinationSettings
|
||||||
|
const cities = [
|
||||||
|
city_denmark,
|
||||||
|
city_finland,
|
||||||
|
city_germany,
|
||||||
|
city_poland,
|
||||||
|
city_norway,
|
||||||
|
city_sweden,
|
||||||
|
].filter((city): city is string => Boolean(city))
|
||||||
|
|
||||||
|
const cityIdentifier = cities[0]
|
||||||
|
|
||||||
|
if (cityIdentifier) {
|
||||||
|
const cityData = await getCityByCityIdentifier({
|
||||||
|
cityIdentifier,
|
||||||
|
serviceToken,
|
||||||
|
lang,
|
||||||
|
})
|
||||||
|
const hotelIds = await getHotelIdsByCityIdentifier(
|
||||||
|
cityIdentifier,
|
||||||
|
serviceToken
|
||||||
|
)
|
||||||
|
|
||||||
|
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
||||||
|
|
||||||
|
let filterType
|
||||||
|
if (filter) {
|
||||||
|
const allFilters = getFiltersFromHotels(hotels)
|
||||||
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
|
||||||
|
if (facilityFilter) {
|
||||||
|
filterType = "facility"
|
||||||
|
} else if (surroudingsFilter) {
|
||||||
|
filterType = "surroundings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: cityData?.name,
|
||||||
|
filter,
|
||||||
|
filterType,
|
||||||
|
hotelCount: hotelIds.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountryData(
|
||||||
|
data: RawMetadataSchema,
|
||||||
|
input: MetadataInputSchema,
|
||||||
|
serviceToken: string,
|
||||||
|
lang: Lang
|
||||||
|
) {
|
||||||
|
const country = data.destination_settings?.country
|
||||||
|
const filter = input.filterFromUrl
|
||||||
|
|
||||||
|
if (country) {
|
||||||
|
const translatedCountry = ApiCountry[lang][country]
|
||||||
|
let filterType
|
||||||
|
|
||||||
|
const cities = await getCityPages(lang, serviceToken, country)
|
||||||
|
const sortedCities = getSortedCities(cities, SortOption.Recommended)
|
||||||
|
const hotelIds = await getHotelIdsByCountry({
|
||||||
|
country,
|
||||||
|
serviceToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
const allFilters = getFiltersFromHotels(hotels)
|
||||||
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
|
||||||
|
if (facilityFilter) {
|
||||||
|
filterType = "facility"
|
||||||
|
} else if (surroudingsFilter) {
|
||||||
|
filterType = "surroundings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
location: translatedCountry,
|
||||||
|
filter,
|
||||||
|
filterType,
|
||||||
|
cities: sortedCities.slice(0, 2).map(({ cityName }) => cityName),
|
||||||
|
hotelCount: hotelIds.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
|
|
||||||
|
export async function getTitle(data: RawMetadataSchema) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const metadata = data.web?.seo_metadata
|
||||||
|
if (metadata?.title) {
|
||||||
|
return metadata.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.system.content_type_uid === "hotel_page" && data.hotelData) {
|
||||||
|
if (data.subpageUrl) {
|
||||||
|
const restaurantSubPage = data.hotelRestaurants?.find(
|
||||||
|
(restaurant) => restaurant.nameInUrl === data.subpageUrl
|
||||||
|
)
|
||||||
|
if (restaurantSubPage) {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Explore {restaurantName} at {hotelName} in {destination}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
restaurantName: restaurantSubPage.name,
|
||||||
|
hotelName: data.hotelData.name,
|
||||||
|
destination: data.hotelData.address.city,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.subpageUrl) {
|
||||||
|
case data.additionalHotelData?.hotelParking.nameInUrl:
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Parking information for {hotelName} in {destination}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName: data.hotelData.name,
|
||||||
|
destination: data.hotelData.address.city,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case data.additionalHotelData?.healthAndFitness.nameInUrl:
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Gym & Health Facilities at {hotelName} in {destination}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName: data.hotelData.name,
|
||||||
|
destination: data.hotelData.address.city,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Accessibility information for {hotelName} in {destination}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName: data.hotelData.name,
|
||||||
|
destination: data.hotelData.address.city,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case data.additionalHotelData?.meetingRooms.nameInUrl:
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Meetings, Conferences & Events at {hotelName} in {destination}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName: data.hotelData.name,
|
||||||
|
destination: data.hotelData.address.city,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Stay at {hotelName} | Hotel in {destination}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotelName: data.hotelData.name,
|
||||||
|
destination: data.hotelData.address.city,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.system.content_type_uid === "destination_city_page" ||
|
||||||
|
data.system.content_type_uid === "destination_country_page"
|
||||||
|
) {
|
||||||
|
if (data.destinationData) {
|
||||||
|
const { location, filter, filterType } = data.destinationData
|
||||||
|
if (location) {
|
||||||
|
if (filter) {
|
||||||
|
if (filterType === "facility") {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Hotels with {filter} in {location}",
|
||||||
|
},
|
||||||
|
{ location, filter }
|
||||||
|
)
|
||||||
|
} else if (filterType === "surroundings") {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Hotels near {filter} in {location}",
|
||||||
|
},
|
||||||
|
{ location, filter }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Hotels in {location}",
|
||||||
|
},
|
||||||
|
{ location }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.web?.breadcrumbs?.title) {
|
||||||
|
return data.web.breadcrumbs.title
|
||||||
|
}
|
||||||
|
if (data.heading) {
|
||||||
|
return data.heading
|
||||||
|
}
|
||||||
|
if (data.header?.heading) {
|
||||||
|
return data.header.heading
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Truncates the given text "intelligently" based on the last period found near the max length.
|
||||||
|
*
|
||||||
|
* - If a period exists within the extended range (`maxLength` to `maxLength + maxExtension`),
|
||||||
|
* the function truncates after the closest period to `maxLength`.
|
||||||
|
* - If no period is found in the range, it truncates the text after the last period found in the max length of the text.
|
||||||
|
* - If no periods exist at all, it truncates at `maxLength` and appends ellipsis (`...`).
|
||||||
|
*
|
||||||
|
* @param {string} text - The input text to be truncated.
|
||||||
|
* @param {number} [maxLength=150] - The desired maximum length of the truncated text.
|
||||||
|
* @param {number} [minLength=120] - The minimum allowable length for the truncated text.
|
||||||
|
* @param {number} [maxExtension=10] - The maximum number of characters to extend beyond `maxLength` to find a period.
|
||||||
|
* @returns {string} - The truncated text.
|
||||||
|
*/
|
||||||
|
export function truncateTextAfterLastPeriod(
|
||||||
|
text: string,
|
||||||
|
maxLength: number = 160,
|
||||||
|
minLength: number = 120,
|
||||||
|
maxExtension: number = 10
|
||||||
|
): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the extended range
|
||||||
|
const extendedEnd = Math.min(text.length, maxLength + maxExtension)
|
||||||
|
const extendedText = text.slice(0, extendedEnd)
|
||||||
|
|
||||||
|
// Find all periods within the extended range and filter after minLength to get valid periods
|
||||||
|
const periodsInRange = [...extendedText.matchAll(/\./g)].map(
|
||||||
|
({ index }) => index
|
||||||
|
)
|
||||||
|
const validPeriods = periodsInRange.filter((index) => index + 1 >= minLength)
|
||||||
|
|
||||||
|
if (validPeriods.length > 0) {
|
||||||
|
// Find the period closest to maxLength
|
||||||
|
const closestPeriod = validPeriods.reduce((closest, currentIndex) => {
|
||||||
|
const distanceFromCurrentToMaxLength = Math.abs(
|
||||||
|
currentIndex + 1 - maxLength
|
||||||
|
)
|
||||||
|
const distanceFromClosestToMaxLength = Math.abs(closest + 1 - maxLength)
|
||||||
|
|
||||||
|
return distanceFromCurrentToMaxLength < distanceFromClosestToMaxLength
|
||||||
|
? currentIndex
|
||||||
|
: closest
|
||||||
|
}, validPeriods[0])
|
||||||
|
|
||||||
|
return extendedText.slice(0, closestPeriod + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: If no period is found within the valid range, look for the last period in the truncated text
|
||||||
|
const maxLengthText = text.slice(0, maxLength)
|
||||||
|
const lastPeriodIndex = maxLengthText.lastIndexOf(".")
|
||||||
|
if (lastPeriodIndex !== -1) {
|
||||||
|
return text.slice(0, lastPeriodIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: Return maxLength text including ellipsis
|
||||||
|
return `${maxLengthText}...`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user