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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
104
apps/scandic-web/utils/metadata/description/hotelPage.ts
Normal file
104
apps/scandic-web/utils/metadata/description/hotelPage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
59
apps/scandic-web/utils/metadata/description/index.ts
Normal file
59
apps/scandic-web/utils/metadata/description/index.ts
Normal 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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
117
apps/scandic-web/utils/metadata/image.ts
Normal file
117
apps/scandic-web/utils/metadata/image.ts
Normal 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
apps/scandic-web/utils/metadata/title.ts
Normal file
214
apps/scandic-web/utils/metadata/title.ts
Normal 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 ""
|
||||
}
|
||||
60
apps/scandic-web/utils/metadata/truncate.ts
Normal file
60
apps/scandic-web/utils/metadata/truncate.ts
Normal 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}...`
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"> & {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type MembershipLevel,
|
||||
MembershipLevelEnum,
|
||||
} from "@/constants/membershipLevels"
|
||||
} from "@scandic-hotels/common/constants/membershipLevels"
|
||||
|
||||
import type {
|
||||
EurobonusMembership,
|
||||
|
||||
@@ -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) : []))
|
||||
@@ -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))
|
||||
@@ -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 : ""))
|
||||
Reference in New Issue
Block a user