Merged in feat/sw-2863-move-contentstack-router-to-trpc-package (pull request #2389)

feat(SW-2863): Move contentstack router to trpc package

* Add exports to packages and lint rule to prevent relative imports

* Add env to trpc package

* Add eslint to trpc package

* Apply lint rules

* Use direct imports from trpc package

* Add lint-staged config to trpc

* Move lang enum to common

* Restructure trpc package folder structure

* WIP first step

* update internal imports in trpc

* Fix most errors in scandic-web

Just 100 left...

* Move Props type out of trpc

* Fix CategorizedFilters types

* Move more schemas in hotel router

* Fix deps

* fix getNonContentstackUrls

* Fix import error

* Fix entry error handling

* Fix generateMetadata metrics

* Fix alertType enum

* Fix duplicated types

* lint:fix

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package

* Fix broken imports

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-26 07:53:01 +00:00
parent 0263ab8c87
commit 002d093af4
921 changed files with 3112 additions and 3008 deletions
@@ -0,0 +1,43 @@
import { getIntl } from "@/i18n"
import { truncateTextAfterLastPeriod } from "../truncate"
import type { RawMetadataSchema } from "@scandic-hotels/trpc/routers/contentstack/metadata/output"
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 "@scandic-hotels/trpc/routers/contentstack/metadata/output"
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 "@scandic-hotels/trpc/routers/contentstack/metadata/output"
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,59 @@
import { PageContentTypeEnum } from "@scandic-hotels/trpc/enums/contentType"
import { RTETypeEnum } from "@scandic-hotels/trpc/types/RTEenums"
import { truncateTextAfterLastPeriod } from "../truncate"
import { getDestinationCityPageDescription } from "./destinationCityPage"
import { getDestinationCountryPageDescription } from "./destinationCountryPage"
import { getHotelPageDescription } from "./hotelPage"
import type { RawMetadataSchema } from "@scandic-hotels/trpc/routers/contentstack/metadata/output"
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,131 @@
import { type RawMetadataSchema } from "@scandic-hotels/trpc/routers/contentstack/metadata/output"
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import { getDescription } from "./description"
import { getImage } from "./image"
import { getTitle } from "./title"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Metadata } from "next"
import type { AlternateURLs } from "next/dist/lib/metadata/types/alternative-urls-types"
import type {
ContentTypeParams,
LangParams,
PageArgs,
UIDParams,
} from "@/types/params"
export async function generateMetadata({
searchParams,
params,
}: PageArgs<
LangParams & ContentTypeParams & UIDParams,
{ subpage?: string; filterFromUrl?: string }
>) {
const { lang } = await params
const { subpage, filterFromUrl, ...otherSearchParams } = await searchParams
// If there are other (real) search params, we don't want to index the page as this will
// cause duplicate content issues.
const noIndexOnSearchParams = !!Object.keys(otherSearchParams).length
const caller = await serverClient()
const { rawMetadata, alternates, robots } =
await caller.contentstack.metadata.get({
subpage,
filterFromUrl,
noIndex: noIndexOnSearchParams,
})
if (!rawMetadata) {
return {
robots: {
index: env.isLangLive(lang),
follow: env.isLangLive(lang),
},
}
}
const metadata = await getTransformedMetadata(rawMetadata, alternates, robots)
if (typeof metadata?.robots === "string") {
return metadata
}
return {
...metadata,
robots: {
...(metadata.robots ?? {}),
index: isIndexable(metadata.robots?.index, lang, alternates),
follow: isIndexable(metadata.robots?.follow, lang, alternates),
},
}
}
function isIndexable(
pageIndexableFromSettings: boolean | null | undefined,
lang: Lang,
alternates: AlternateURLs | null
) {
// This is a special case for whitelisting the scandic friends pages, this can be removed when all pages are live
const url = getUrl(alternates)
const firstNonLangSegment = (url ?? "").substring(3)
if (firstNonLangSegment.startsWith("/scandic-friends")) {
return true
}
// If we are live we want to index the page, but if the page has been marked as noindex in contentstack we don't
return (pageIndexableFromSettings ?? true) && env.isLangLive(lang)
}
function getUrl(alternates: AlternateURLs | null): string | null {
try {
if (!alternates?.canonical) {
return null
}
if (typeof alternates.canonical === "string") {
return alternates.canonical
}
if ("href" in alternates.canonical) {
return alternates.canonical.href
}
if (typeof alternates.canonical.url === "string") {
return alternates.canonical.url
}
return alternates.canonical.url.href
} catch {
return null
}
}
async function getTransformedMetadata(
data: RawMetadataSchema,
alternates: Metadata["alternates"] | null,
robots: Metadata["robots"] | null = null
) {
const noIndex = !!data.web?.seo_metadata?.noindex
const metadata: Metadata = {
metadataBase: env.PUBLIC_URL ? new URL(env.PUBLIC_URL) : undefined,
title: await getTitle(data),
description: await getDescription(data),
openGraph: {
images: getImage(data),
},
alternates,
robots,
}
if (noIndex) {
metadata.robots = {
index: false,
follow: false,
}
}
return metadata
}
+117
View File
@@ -0,0 +1,117 @@
import type { RawMetadataSchema } from "@scandic-hotels/trpc/routers/contentstack/metadata/output"
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
}
+214
View File
@@ -0,0 +1,214 @@
import { getIntl } from "@/i18n"
import type { RawMetadataSchema } from "@scandic-hotels/trpc/routers/contentstack/metadata/output"
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) {
const restaurantTitleLong = intl.formatMessage(
{
defaultMessage:
"Explore {restaurantName} at {hotelName} in {destination}",
},
{
restaurantName: restaurantSubPage.name,
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const restaurantTitleShort = intl.formatMessage(
{
defaultMessage: "Explore {restaurantName} at {hotelName}",
},
{
restaurantName: restaurantSubPage.name,
hotelName: data.hotelData.name,
}
)
if (restaurantTitleLong.length > 60) {
return restaurantTitleShort
}
return restaurantTitleLong
}
switch (data.subpageUrl) {
case "reviews":
const reviewsTitleLong = intl.formatMessage(
{
defaultMessage:
"Ratings & reviews for {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const reviewsTitleShort = intl.formatMessage(
{ defaultMessage: "Ratings & reviews for {hotelName}" },
{ hotelName: data.hotelData.name }
)
if (reviewsTitleLong.length > 60) {
return reviewsTitleShort
}
return reviewsTitleLong
case data.additionalHotelData?.hotelParking.nameInUrl:
const parkingTitleLong = intl.formatMessage(
{
defaultMessage:
"Parking information for {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const parkingTitleShort = intl.formatMessage(
{ defaultMessage: "Parking information for {hotelName}" },
{ hotelName: data.hotelData.name }
)
if (parkingTitleLong.length > 60) {
return parkingTitleShort
}
return parkingTitleLong
case data.additionalHotelData?.healthAndFitness.nameInUrl:
const wellnessTitleLong = intl.formatMessage(
{
defaultMessage:
"Gym & health facilities at {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const wellnessTitleShort = intl.formatMessage(
{
defaultMessage: "Gym & health facilities at {hotelName}",
},
{
hotelName: data.hotelData.name,
}
)
if (wellnessTitleLong.length > 60) {
return wellnessTitleShort
}
return wellnessTitleLong
case data.additionalHotelData?.hotelSpecialNeeds.nameInUrl:
const accessibilityTitleLong = intl.formatMessage(
{
defaultMessage:
"Accessibility information for {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const accessibilityTitleShort = intl.formatMessage(
{
defaultMessage: "Accessibility information for {hotelName}",
},
{
hotelName: data.hotelData.name,
}
)
if (accessibilityTitleLong.length > 60) {
return accessibilityTitleShort
}
return accessibilityTitleLong
case data.additionalHotelData?.meetingRooms.nameInUrl:
const meetingsTitleLong = intl.formatMessage(
{
defaultMessage:
"Meetings & conferences at {hotelName} in {destination}",
},
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
const meetingsTitleShort = intl.formatMessage(
{
defaultMessage: "Meetings & conferences at {hotelName}",
},
{
hotelName: data.hotelData.name,
}
)
if (meetingsTitleLong.length > 60) {
return meetingsTitleShort
}
return meetingsTitleLong
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}...`
}