From 4ae5da8a04e02f5c93703bad1c9dcedbeb5d6af5 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Tue, 29 Apr 2025 06:52:04 +0000 Subject: [PATCH] Feat/SW-2152 seo descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../DestinationCityPage/Metadata.graphql | 3 + .../DestinationCountryPage/Metadata.graphql | 3 + .../routers/contentstack/metadata/output.ts | 97 ++-- .../routers/contentstack/metadata/query.ts | 4 +- .../routers/contentstack/metadata/utils.ts | 485 ------------------ .../utils/description/destinationCityPage.ts | 43 ++ .../description/destinationCountryPage.ts | 52 ++ .../metadata/utils/description/hotelPage.ts | 104 ++++ .../metadata/utils/description/index.ts | 58 +++ .../contentstack/metadata/utils/image.ts | 117 +++++ .../contentstack/metadata/utils/index.ts | 140 +++++ .../contentstack/metadata/utils/title.ts | 133 +++++ .../contentstack/metadata/utils/truncate.ts | 60 +++ 13 files changed, 768 insertions(+), 531 deletions(-) delete mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCityPage.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCountryPage.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/description/hotelPage.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/description/index.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/image.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/index.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/title.ts create mode 100644 apps/scandic-web/server/routers/contentstack/metadata/utils/truncate.ts diff --git a/apps/scandic-web/lib/graphql/Query/DestinationCityPage/Metadata.graphql b/apps/scandic-web/lib/graphql/Query/DestinationCityPage/Metadata.graphql index 166ee52f9..6ebf7dd6d 100644 --- a/apps/scandic-web/lib/graphql/Query/DestinationCityPage/Metadata.graphql +++ b/apps/scandic-web/lib/graphql/Query/DestinationCityPage/Metadata.graphql @@ -19,6 +19,9 @@ query GetDestinationCityPageMetadata($locale: String!, $uid: String!) { city_poland city_sweden } + images { + image + } system { ...System } diff --git a/apps/scandic-web/lib/graphql/Query/DestinationCountryPage/Metadata.graphql b/apps/scandic-web/lib/graphql/Query/DestinationCountryPage/Metadata.graphql index 4c1152d15..e1837d3b3 100644 --- a/apps/scandic-web/lib/graphql/Query/DestinationCountryPage/Metadata.graphql +++ b/apps/scandic-web/lib/graphql/Query/DestinationCountryPage/Metadata.graphql @@ -14,6 +14,9 @@ query GetDestinationCountryPageMetadata($locale: String!, $uid: String!) { destination_settings { country } + images { + image + } system { ...System } diff --git a/apps/scandic-web/server/routers/contentstack/metadata/output.ts b/apps/scandic-web/server/routers/contentstack/metadata/output.ts index 9f3ccd392..d29749afc 100644 --- a/apps/scandic-web/server/routers/contentstack/metadata/output.ts +++ b/apps/scandic-web/server/routers/contentstack/metadata/output.ts @@ -11,10 +11,13 @@ import { additionalDataAttributesSchema } from "../../hotels/schemas/hotel/inclu import { imageSchema } from "../../hotels/schemas/image" import { tempImageVaultAssetSchema } from "../schemas/imageVault" 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 { ImageVaultAsset } from "@/types/components/imageVault" import { Country } from "@/types/enums/country" import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher" import { RTETypeEnum } from "@/types/rte/enums" @@ -57,56 +60,59 @@ export const rawMetadataSchema = z.object({ .object({ seo_metadata: z .object({ - title: z.string().optional().nullable(), - description: z.string().optional().nullable(), - noindex: z.boolean().optional().nullable(), + title: z.string().nullish(), + description: z.string().nullish(), + noindex: z.boolean().nullish(), seo_image: tempImageVaultAssetSchema.nullable(), }) - .optional() - .nullable(), + .nullish(), breadcrumbs: z .object({ - title: z.string().optional().nullable(), + title: z.string().nullish(), }) - .optional() - .nullable(), + .nullish(), }) - .optional() - .nullable(), + .nullish(), destination_settings: z .object({ - city_denmark: z.string().optional().nullable(), - city_finland: z.string().optional().nullable(), - city_germany: z.string().optional().nullable(), - city_poland: z.string().optional().nullable(), - city_norway: z.string().optional().nullable(), - city_sweden: z.string().optional().nullable(), - country: z.nativeEnum(Country).optional().nullable(), + city_denmark: z.string().nullish(), + city_finland: z.string().nullish(), + city_germany: z.string().nullish(), + city_poland: z.string().nullish(), + city_norway: z.string().nullish(), + city_sweden: z.string().nullish(), + country: z.nativeEnum(Country).nullish(), }) - .optional() - .nullable(), - heading: z.string().optional().nullable(), - preamble: z.string().optional().nullable(), + .nullish(), + heading: z.string().nullish(), + preamble: z.string().nullish(), header: z .object({ - heading: z.string().optional().nullable(), - preamble: z.string().optional().nullable(), + heading: z.string().nullish(), + preamble: z.string().nullish(), hero_image: tempImageVaultAssetSchema.nullable(), }) - .optional() - .nullable(), + .nullish(), 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, - hotel_page_id: z.string().optional().nullable(), + hotel_page_id: z.string().nullish(), hotelData: hotelAttributesSchema .pick({ name: true, address: true, + detailedFacilities: true, hotelContent: true, healthFacilities: true, }) - .optional() - .nullable(), + .nullish(), additionalHotelData: additionalDataAttributesSchema .pick({ gallery: true, @@ -118,28 +124,31 @@ export const rawMetadataSchema = z.object({ accessibility: true, conferencesAndMeetings: true, }) - .optional() - .nullable(), + .nullish(), hotelRestaurants: z .array( z.object({ - nameInUrl: z.string().optional().nullable(), - elevatorPitch: z.string().optional().nullable(), - name: z.string().optional().nullable(), + nameInUrl: z.string().nullish(), + elevatorPitch: z.string().nullish(), + name: z.string().nullish(), content: z .object({ - images: z.array(imageSchema).optional().nullable(), + images: z.array(imageSchema).nullish(), }) - .optional() - .nullable(), + .nullish(), }) ) - .optional() - .nullable(), - subpageUrl: z.string().optional().nullable(), - location: z.string().optional().nullable(), - filter: z.string().optional().nullable(), - filterType: z.enum(["facility", "surroundings"]).optional().nullable(), + .nullish(), + subpageUrl: z.string().nullish(), + destinationData: z + .object({ + location: z.string().nullish(), + filter: z.string().nullish(), + filterType: z.enum(["facility", "surroundings"]).nullish(), + hotelCount: z.number().nullish(), + cities: z.array(z.string()).nullish(), + }) + .nullish(), system: systemSchema, }) @@ -149,7 +158,7 @@ export const metadataSchema = rawMetadataSchema.transform(async (data) => { const metadata: Metadata = { metadataBase: env.PUBLIC_URL ? new URL(env.PUBLIC_URL) : undefined, title: await getTitle(data), - description: getDescription(data), + description: await getDescription(data), openGraph: { images: getImage(data), }, diff --git a/apps/scandic-web/server/routers/contentstack/metadata/query.ts b/apps/scandic-web/server/routers/contentstack/metadata/query.ts index a22e0faa6..def6138a8 100644 --- a/apps/scandic-web/server/routers/contentstack/metadata/query.ts +++ b/apps/scandic-web/server/routers/contentstack/metadata/query.ts @@ -163,7 +163,7 @@ export const metadataQueryRouter = router({ ) metadata = await getTransformedMetadata({ ...destinationCountryPageResponse.destination_country_page, - ...countryData, + destinationData: countryData, }) break case PageContentTypeEnum.destinationCityPage: @@ -178,7 +178,7 @@ export const metadataQueryRouter = router({ ) metadata = await getTransformedMetadata({ ...destinationCityPageResponse.destination_city_page, - ...cityData, + destinationData: cityData, }) break case PageContentTypeEnum.loyaltyPage: diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils.ts deleted file mode 100644 index 8a2ff5a0b..000000000 --- a/apps/scandic-web/server/routers/contentstack/metadata/utils.ts +++ /dev/null @@ -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 -} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCityPage.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCityPage.ts new file mode 100644 index 000000000..ba02a8a9a --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCityPage.ts @@ -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) +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCountryPage.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCountryPage.ts new file mode 100644 index 000000000..79a6889fc --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/destinationCountryPage.ts @@ -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) +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/description/hotelPage.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/hotelPage.ts new file mode 100644 index 000000000..e5d09c869 --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/hotelPage.ts @@ -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 + } +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/description/index.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/index.ts new file mode 100644 index 000000000..c8dace28e --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/description/index.ts @@ -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 "" +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/image.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/image.ts new file mode 100644 index 000000000..92ded8ff5 --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/image.ts @@ -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 +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/index.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/index.ts new file mode 100644 index 000000000..8003b56f5 --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/index.ts @@ -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 +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/title.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/title.ts new file mode 100644 index 000000000..d34f75de3 --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/title.ts @@ -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 "" +} diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils/truncate.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils/truncate.ts new file mode 100644 index 000000000..40b95c0f2 --- /dev/null +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils/truncate.ts @@ -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}...` +}