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

View File

@@ -1,4 +1,4 @@
import type { Room } from "@/types/hotel"
import type { Room } from "@scandic-hotels/trpc/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function getBookedHotelRoom(

View File

@@ -1,25 +0,0 @@
import { describe, expect } from "@jest/globals"
import { chunk } from "./chunk"
describe("chunk", () => {
it("should split an array into equally sized chunks of specified size", () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const size = 3
const expected = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
expect(chunk(array, size)).toEqual(expected)
})
it("should split an array into equally sized chunks of specified size", () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const size = 4
const expected = [[1, 2, 3, 4], [5, 6, 7, 8], [9]]
expect(chunk(array, size)).toEqual(expected)
})
})

View File

@@ -1,11 +0,0 @@
/**
* Splits an array into chunks of a specified size
*/
export function chunk<T>(array: T[], size: number): T[][] {
const result: T[][] = []
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size))
}
return result
}

View File

@@ -1,20 +0,0 @@
import type {
ContactConfig,
ContactFieldGroups,
} from "@/server/routers/contentstack/base/output"
export function getValueFromContactConfig(
keyString: string,
data: ContactConfig
): string | undefined {
const [groupName, key] = keyString.split(".") as [
ContactFieldGroups,
keyof ContactConfig[ContactFieldGroups],
]
if (data[groupName]) {
const fieldGroup = data[groupName]
return fieldGroup[key]
}
return undefined
}

View File

@@ -1,4 +1,4 @@
import { dt } from "@/lib/dt"
import { dt } from "@scandic-hotels/common/dt"
import type { Lang } from "@scandic-hotels/common/constants/language"

View File

@@ -1,61 +0,0 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { batchRequest } from "@/lib/graphql/batchRequest"
import {
EntryByUrlBatch1,
EntryByUrlBatch2,
} from "@/lib/graphql/Query/ResolveEntry.graphql"
import { internalServerError } from "@/server/errors/next"
import { validateEntryResolveSchema } from "@/types/requests/entry"
export function resolveEntryCacheKey(lang: Lang, url: string) {
return `${lang}:${url}:resolveentry`
}
export async function resolve(url: string, lang = Lang.en) {
const variables = { locale: lang, url: url || "/" }
const cacheKey = resolveEntryCacheKey(variables.locale, variables.url)
// The maximum amount of content types you can query is 6, therefor more
// than that is being batched
const response = await batchRequest([
{
document: EntryByUrlBatch1,
variables,
cacheOptions: {
ttl: "max",
key: cacheKey,
},
},
{
document: EntryByUrlBatch2,
variables,
cacheOptions: {
ttl: "max",
key: cacheKey,
},
},
])
const validatedData = validateEntryResolveSchema.safeParse(response.data)
if (!validatedData.success) {
throw internalServerError(validatedData.error)
}
for (const value of Object.values(validatedData.data)) {
if (value.total) {
const { content_type_uid, uid } = value.items[0].system
return {
contentType: content_type_uid,
uid,
}
}
}
return {
contentType: null,
uid: null,
}
}

View File

@@ -1,3 +1,12 @@
import { FacilityEnum } from "@scandic-hotels/trpc/enums/facilities"
import type {
Amenities,
Facility,
FacilityData,
HealthFacilities,
} from "@scandic-hotels/trpc/types/hotel"
import {
type Facilities,
type FacilityCard,
@@ -14,16 +23,9 @@ import {
import {
type HotelHashValue,
SidepeekSlugs,
} from "@/types/components/hotelPage/hotelPage";
} from "@/types/components/hotelPage/hotelPage"
import type { HotelPageSections } from "@/types/components/hotelPage/sections"
import { FacilityEnum } from "@/types/enums/facilities"
import { HotelHashValues } from "@/types/enums/hotelPage"
import type {
Amenities,
Facility,
FacilityData,
HealthFacilities,
} from "@/types/hotel"
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
export function setFacilityCards(

View File

@@ -1,113 +0,0 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { System } from "@/types/requests/system"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
/**
* Function to generate tag for initial refs request
*
* @param lang Lang
* @param identifier Should be uid for all pages and content_type_uid for
* everything else
* @param affix possible extra value to add to string, e.g lang:identifier:breadcrumbs:refs
* as it is the same entity as the actual page tag otherwise
* @returns string
*/
export function generateRefsResponseTag(
lang: Lang,
identifier: string,
affix?: string
) {
if (affix) {
return `${lang}:${identifier}:${affix}:refs`
}
return `${lang}:${identifier}:refs`
}
/**
* Function to generate all tags to references on entity
*
* @param lang Lang
* @param contentTypeUid content_type_uid of reference
* @param uid system.uid of reference
* @returns string
*/
export function generateRefTag(
lang: Lang,
contentTypeUid: string,
uid: string
) {
return `${lang}:ref:${contentTypeUid}:${uid}`
}
/**
* Function to generate tag for entity being requested
*
* @param lang Lang
* @param uid system.uid of entity
* @param affix possible extra value to add to string, e.g lang:uid:breadcrumbs
* as it is the same entity as the actual page tag otherwise
* @returns string
*/
export function generateTag(lang: Lang, uid: string, affix?: string | null) {
if (affix) {
return `${lang}:${uid}:${affix}`
}
return `${lang}:${uid}`
}
export function generateTags(lang: Lang, connections: Edges<NodeRefs>[]) {
return connections
.map((connection) => {
return connection.edges.map(({ node }) => {
return generateRefTag(
lang,
node.system.content_type_uid,
node.system.uid
)
})
})
.flat()
}
export function generateTagsFromSystem(
lang: Lang,
connections: System["system"][]
) {
return connections.map((system) => {
return generateRefTag(
system.locale ?? lang,
system.content_type_uid,
system.uid
)
})
}
/**
* Function to generate tags for loyalty configuration models
*
* @param lang Lang
* @param contentTypeUid content_type_uid of reference
* @param id system shared identifier, e.g reward_id, level_id
* @returns string
*/
export function generateLoyaltyConfigTag(
lang: Lang,
contentTypeUid: string,
id: string
) {
return `${lang}:loyalty_config:${contentTypeUid}:${id}`
}
/**
* Function to generate tags for hotel page urls
*
* @param lang Lang
* @param hotelId hotelId of reference
* @returns string
*/
export function generateHotelUrlTag(lang: Lang, hotelId: string) {
return `${lang}:hotel_page_url:${hotelId}`
}

View File

@@ -1,6 +1,11 @@
import { notFound } from "next/navigation"
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
import {
type HotelLocation,
isHotelLocation,
type Location,
} from "@scandic-hotels/trpc/types/locations"
import { REDEMPTION } from "@/constants/booking"
import { getLocations } from "@/lib/trpc/memoizedRequests"
@@ -9,11 +14,6 @@ import { generateChildrenString } from "@/components/HotelReservation/utils"
import type { BookingSearchType } from "@/types/components/hotelReservation/booking"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
type HotelLocation,
isHotelLocation,
type Location,
} from "@/types/trpc/routers/hotel/locations"
export type ChildrenInRoom = (Child[] | null)[] | null
export type ChildrenInRoomString = (string | null)[] | null

View File

@@ -1,6 +1,6 @@
import type { GalleryImage } from "@/types/components/imageGallery"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import type { ApiImage } from "@/types/hotel"
import type { ImageVaultAsset } from "@scandic-hotels/trpc/types/imageVault"
import type { ApiImage } from "@scandic-hotels/trpc/types/hotel"
function mapApiImageToGalleryImage(apiImage: ApiImage): GalleryImage {
return {

View File

@@ -1,44 +0,0 @@
import type {
ImageVaultAsset,
ImageVaultAssetResponse,
} from "@/types/components/imageVault"
export function insertResponseToImageVaultAsset(
response: ImageVaultAssetResponse
): ImageVaultAsset {
const alt = response.Metadata?.find((meta) =>
meta.Name.includes("AltText_")
)?.Value
const caption = response.Metadata?.find((meta) =>
meta.Name.includes("Title_")
)?.Value
const mediaConversion = response.MediaConversions[0]
const aspectRatio =
mediaConversion.FormatAspectRatio ||
mediaConversion.AspectRatio ||
mediaConversion.Width / mediaConversion.Height
return {
url: mediaConversion.Url,
id: response.Id,
meta: {
alt,
caption,
},
title: response.Name,
dimensions: {
width: mediaConversion.Width,
height: mediaConversion.Height,
aspectRatio,
},
focalPoint: response.FocalPoint || { x: 50, y: 50 },
}
}
export function makeImageVaultImage(image: any) {
return image && !!Object.keys(image).length
? insertResponseToImageVaultAsset(image as ImageVaultAssetResponse)
: undefined
}

View File

@@ -7,8 +7,8 @@ import type {
WithContext,
} from "schema-dts"
import type { HotelData } from "@/types/hotel"
import type { Breadcrumbs } from "@/types/trpc/routers/contentstack/breadcrumbs"
import type { HotelData } from "@scandic-hotels/trpc/types/hotel"
import type { Breadcrumbs } from "@scandic-hotels/trpc/types/breadcrumbs"
export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) {
const itemListElement: ListItem[] = breadcrumbs.map((item, index) => ({

View File

@@ -1,21 +0,0 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
export const languageSchema = z.preprocess(
(arg) => (typeof arg === "string" ? arg.toLowerCase() : arg),
z.nativeEnum(Lang)
)
export function isValidLang(lang?: string): lang is Lang {
const result = languageSchema.safeParse(lang)
return result.success
}
export function findLang(pathname: string): Lang | undefined {
const langFromPath = Object.values(Lang).find(
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
)
return isValidLang(langFromPath) ? langFromPath : undefined
}

View File

@@ -1,5 +1,6 @@
import type { CMSReward } from "@scandic-hotels/trpc/types/reward"
import type { ComparisonLevel } from "@/types/components/overviewTable"
import type { CMSReward } from "@/types/trpc/routers/contentstack/reward"
export function getGroupedRewards(levels: ComparisonLevel[]) {
const allRewards = levels

View File

@@ -1,4 +1,4 @@
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels"
export function isMembershipLevel(value: string): value is MembershipLevelEnum {
return Object.values(MembershipLevelEnum).some((level) => level === value)

View File

@@ -1,19 +0,0 @@
import merge from "deepmerge"
export function arrayMerge(
target: any[],
source: any[],
options: merge.ArrayMergeOptions
) {
const destination = target.slice()
source.forEach((item, index) => {
if (typeof destination[index] === "undefined") {
destination[index] = options.cloneUnlessOtherwiseSpecified(item, options)
} else if (options?.isMergeableObject(item)) {
destination[index] = merge(target[index], item, options)
} else if (target.indexOf(item) === -1) {
destination.push(item)
}
})
return destination
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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 ""
}

View File

@@ -1,7 +1,14 @@
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 {
@@ -24,13 +31,14 @@ export async function generateMetadata({
// cause duplicate content issues.
const noIndexOnSearchParams = !!Object.keys(otherSearchParams).length
const caller = await serverClient()
const { metadata, alternates } = await caller.contentstack.metadata.get({
subpage,
filterFromUrl,
noIndex: noIndexOnSearchParams,
})
const { rawMetadata, alternates, robots } =
await caller.contentstack.metadata.get({
subpage,
filterFromUrl,
noIndex: noIndexOnSearchParams,
})
if (!metadata) {
if (!rawMetadata) {
return {
robots: {
index: env.isLangLive(lang),
@@ -39,6 +47,8 @@ export async function generateMetadata({
}
}
const metadata = await getTransformedMetadata(rawMetadata, alternates, robots)
if (typeof metadata?.robots === "string") {
return metadata
}
@@ -92,3 +102,30 @@ function getUrl(alternates: AlternateURLs | null): string | null {
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
}

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
}

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 ""
}

View File

@@ -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}...`
}

View File

@@ -1,29 +1,26 @@
import { RESTAURANT_REWARD_IDS, REWARD_IDS } from "@/constants/rewards"
import { dt } from "@/lib/dt"
import { dt } from "@scandic-hotels/common/dt"
import { getReedemableCoupons } from "@scandic-hotels/trpc/routers/contentstack/reward/helpers"
import {
RESTAURANT_REWARD_IDS,
type RestaurantRewardId,
REWARD_IDS,
type RewardId,
} from "@scandic-hotels/trpc/types/rewards"
import type { Dayjs } from "dayjs"
import type {
RestaurantRewardId,
RewardId,
} from "@/types/components/myPages/rewards"
import type {
ApiReward,
RedeemableCoupon,
RedeemLocation,
SurpriseReward,
} from "@/types/trpc/routers/contentstack/reward"
} from "@scandic-hotels/trpc/types/reward"
import type { Dayjs } from "dayjs"
export {
getEarliestExpirationDate,
getFirstRedeemableCoupon,
getRedeemableRewards,
getReedemableCoupons,
getUnwrappedSurpriseRewards,
isOnSiteTierReward,
isRestaurantOnSiteTierReward,
isRestaurantReward,
isSurpriseReward,
isTierType,
isValidRewardId,
redeemLocationIsOnSite,
@@ -58,15 +55,6 @@ function isRestaurantOnSiteTierReward(reward: ApiReward): boolean {
return isOnSiteTierReward(reward) && isRestaurantReward(reward.rewardId)
}
function getReedemableCoupons(reward: ApiReward): RedeemableCoupon[] {
if ("coupon" in reward) {
return reward.coupon.filter(
(coupon): coupon is RedeemableCoupon => coupon.state !== "redeemed"
)
}
return []
}
function getFirstRedeemableCoupon(reward: ApiReward): RedeemableCoupon {
const sortedCoupons = getReedemableCoupons(reward).sort((a, b) => {
// null values used instead of undefined, otherwise it will return current time
@@ -88,37 +76,3 @@ function getEarliestExpirationDate(reward: ApiReward) {
return earliestDate.isBefore(expiresAtDate) ? earliestDate : expiresAtDate
}, null)
}
function isSurpriseReward(reward: ApiReward): reward is SurpriseReward {
return reward.rewardType === "Surprise"
}
function getUnwrappedSurpriseRewards(rewards: ApiReward[]) {
return rewards
.filter(isSurpriseReward)
.filter((reward) => getReedemableCoupons(reward).length)
.filter((reward) => {
const unwrappedCoupons =
reward.coupon.filter((coupon) => !coupon.unwrapped) || []
return unwrappedCoupons.length
})
}
function getRedeemableRewards(rewards: ApiReward[]) {
return rewards
.filter((reward) => {
if ("coupon" in reward && reward.coupon.length > 0) {
if (reward.coupon.every((coupon) => coupon.state === "redeemed")) {
return false
}
}
return true
})
.filter((reward) => {
if (isSurpriseReward(reward)) {
return !reward.coupon.some(({ unwrapped }) => !unwrapped)
}
return true
})
}

View File

@@ -1,14 +0,0 @@
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
// Used to ensure `Available` rooms
// are shown before all `NotAvailable`
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
export function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
// @ts-expect-error - array indexing
return statusLookup[a.status] - statusLookup[b.status]
}

View File

@@ -1,4 +1,4 @@
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { Packages } from "@/types/requests/packages"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"

View File

@@ -1,8 +1,9 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { trackEvent } from "./base"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { CurrencyEnum } from "@/types/enums/currency"
import type { Room } from "@/types/stores/my-stay"
import type { PackageSchema } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user"

View File

@@ -1,6 +1,8 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { bookingSearchTypes } from "@/constants/booking"
@@ -9,26 +11,12 @@ import { parseSearchParams, serializeSearchParams } from "./searchParams"
import type { BookingWidgetSearchData } from "@/types/components/bookingWidget"
import type { DetailsBooking } from "@/types/components/hotelReservation/enterDetails/details"
import type { SelectHotelBooking } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type {
Room,
SelectRateBooking,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { NextSearchParams } from "@/types/params"
export function removeMultipleSlashes(pathname: string) {
return pathname.replaceAll(/\/\/+/g, "/")
}
export function removeTrailingSlash(pathname: string) {
if (pathname.endsWith("/")) {
// Remove the trailing slash
return pathname.slice(0, -1)
}
return pathname
}
type PartialRoom = { rooms?: Partial<Room>[] }
export type SelectHotelParams<T> = Omit<T, "hotel"> & {

View File

@@ -1,7 +1,7 @@
import {
type MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
} from "@scandic-hotels/common/constants/membershipLevels"
import type {
EurobonusMembership,

View File

@@ -1,17 +0,0 @@
import { nullableStringValidator } from "./stringValidator"
import type { ZodObject, ZodRawShape } from "zod"
export function nullableArrayObjectValidator<T extends ZodRawShape>(
schema: ZodObject<T>
) {
return schema
.array()
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
}
export const nullableArrayStringValidator = nullableStringValidator
.array()
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))

View File

@@ -1,12 +0,0 @@
import { z } from "zod"
export const nullableNumberValidator = z
.number()
.nullish()
.transform((num) => (typeof num === "number" ? num : 0))
export const nullableIntValidator = z
.number()
.int()
.nullish()
.transform((num) => (typeof num === "number" ? num : 0))

View File

@@ -1,18 +0,0 @@
import { z } from "zod"
export const nullableStringValidator = z
.string()
.nullish()
.transform((str) => (str ? str : ""))
export const nullableStringEmailValidator = z
.string()
.email()
.nullish()
.transform((str) => (str ? str : ""))
export const nullableStringUrlValidator = z
.string()
.url()
.nullish()
.transform((str) => (str ? str : ""))