Merge develop

This commit is contained in:
Linus Flood
2024-09-27 14:20:53 +02:00
171 changed files with 3507 additions and 5188 deletions

View File

@@ -19,7 +19,12 @@ import {
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { textColsRefsSchema, textColsSchema } from "../schemas/blocks/textCols"
import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import {
linkAndTitleSchema,
linkConnectionRefs,
} from "../schemas/linkConnection"
import {
contentRefsSchema as sidebarContentRefsSchema,
contentSchema as sidebarContentSchema,
@@ -64,12 +69,19 @@ export const contentPageTextCols = z
})
.merge(textColsSchema)
export const contentPageUspGrid = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.UspGrid),
})
.merge(uspGridSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageCards,
contentPageContent,
contentPageDynamicContent,
contentPageShortcuts,
contentPageTextCols,
contentPageUspGrid,
])
export const contentPageSidebarContent = z
@@ -98,6 +110,22 @@ export const sidebarSchema = z.discriminatedUnion("__typename", [
contentPageJoinLoyaltyContact,
])
const navigationLinksSchema = z
.array(linkAndTitleSchema)
.nullable()
.transform((data) => {
if (!data) {
return null
}
return data
.filter((item) => !!item.link)
.map((item) => ({
url: item.link!.url,
title: item.title || item.link!.title,
}))
})
// Content Page Schema and types
export const contentPageSchema = z.object({
content_page: z.object({
@@ -108,6 +136,7 @@ export const contentPageSchema = z.object({
header: z.object({
heading: z.string(),
preamble: z.string(),
navigation_links: navigationLinksSchema,
}),
system: systemSchema.merge(
z.object({
@@ -152,12 +181,19 @@ const contentPageTextColsRefs = z
})
.merge(textColsRefsSchema)
const contentPageUspGridRefs = z
.object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.UspGrid),
})
.merge(uspGridRefsSchema)
const contentPageBlockRefsItem = z.discriminatedUnion("__typename", [
contentPageBlockContentRefs,
contentPageShortcutsRefs,
contentPageCardsRefs,
contentPageDynamicContentRefs,
contentPageTextColsRefs,
contentPageUspGridRefs,
])
const contentPageSidebarContentRef = z
@@ -179,8 +215,13 @@ const contentPageSidebarRefsItem = z.discriminatedUnion("__typename", [
contentPageSidebarJoinLoyaltyContactRef,
])
const contentPageHeaderRefs = z.object({
navigation_links: z.array(linkConnectionRefs),
})
export const contentPageRefsSchema = z.object({
content_page: z.object({
header: contentPageHeaderRefs,
blocks: discriminatedUnionArray(
contentPageBlockRefsItem.options
).nullable(),

View File

@@ -147,6 +147,12 @@ export function getConnections({ content_page }: ContentPageRefs) {
}
break
}
case ContentPageEnum.ContentStack.blocks.UspGrid: {
if (block.usp_grid.length) {
connections.push(...block.usp_grid)
}
break
}
}
})
}

View File

@@ -7,9 +7,11 @@ import { breadcrumbsRouter } from "./breadcrumbs"
import { contentPageRouter } from "./contentPage"
import { hotelPageRouter } from "./hotelPage"
import { languageSwitcherRouter } from "./languageSwitcher"
import { loyaltyLevelRouter } from "./loyaltyLevel"
import { loyaltyPageRouter } from "./loyaltyPage"
import { metaDataRouter } from "./metadata"
import { myPagesRouter } from "./myPages"
import { rewardRouter } from "./reward"
export const contentstackRouter = router({
accountPage: accountPageRouter,
@@ -22,4 +24,6 @@ export const contentstackRouter = router({
contentPage: contentPageRouter,
myPages: myPagesRouter,
metaData: metaDataRouter,
rewards: rewardRouter,
loyaltyLevels: loyaltyLevelRouter,
})

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { loyaltyLevelQueryRouter } from "./query"
export const loyaltyLevelRouter = mergeRouters(loyaltyLevelQueryRouter)

View File

@@ -0,0 +1,7 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const loyaltyLevelInput = z.object({
level: z.nativeEnum(MembershipLevelEnum),
})

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const validateLoyaltyLevelsSchema = z
.object({
all_loyalty_level: z.object({
items: z.array(
z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
name: z.string(),
user_facing_tag: z.string().optional(),
description: z.string().optional(),
required_nights: z.number().optional().nullable(),
required_points: z.number(),
})
),
}),
})
.transform((data) => data.all_loyalty_level.items)
export type LoyaltyLevelsResponse = z.input<typeof validateLoyaltyLevelsSchema>
export type LoyaltyLevel = z.output<typeof validateLoyaltyLevelsSchema>[0]

View File

@@ -0,0 +1,147 @@
import { metrics } from "@opentelemetry/api"
import {
MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
import {
GetAllLoyaltyLevels,
GetLoyaltyLevel,
} from "@/lib/graphql/Query/LoyaltyLevels.graphql"
import { request } from "@/lib/graphql/request"
import { Context } from "@/server/context"
import { notFound } from "@/server/errors/trpc"
import { contentstackBaseProcedure, router } from "@/server/trpc"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import { loyaltyLevelInput } from "./input"
import { LoyaltyLevelsResponse, validateLoyaltyLevelsSchema } from "./output"
const meter = metrics.getMeter("trpc.loyaltyLevel")
// OpenTelemetry metrics: Loyalty Level
const getAllLoyaltyLevelCounter = meter.createCounter(
"trpc.contentstack.loyaltyLevel.all"
)
const getAllLoyaltyLevelSuccessCounter = meter.createCounter(
"trpc.contentstack.loyaltyLevel.all-success"
)
const getAllLoyaltyLevelFailCounter = meter.createCounter(
"trpc.contentstack.loyaltyLevel.all-fail"
)
export async function getAllLoyaltyLevels(ctx: Context) {
getAllLoyaltyLevelCounter.add(1)
// Ideally we should fetch all available tiers from API, but since they
// are static, we can just use the enum values. We want to know which
// levels we are fetching so that we can use tags to cache them
const allLevelIds = Object.values(MembershipLevelEnum)
const tags = allLevelIds.map((levelId) =>
generateLoyaltyConfigTag(ctx.lang, "loyalty_level", levelId)
)
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetAllLoyaltyLevels,
{ lang: ctx.lang, level_ids: allLevelIds },
{ next: { tags }, cache: "force-cache" }
)
if (!loyaltyLevelsConfigResponse.data) {
getAllLoyaltyLevelFailCounter.add(1)
const notFoundError = notFound(loyaltyLevelsConfigResponse)
console.error(
"contentstack.loyaltyLevels not found error",
JSON.stringify({
query: {
lang: ctx.lang,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
loyaltyLevelsConfigResponse.data
)
if (!validatedLoyaltyLevels.success) {
getAllLoyaltyLevelFailCounter.add(1)
console.error(validatedLoyaltyLevels.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: {
lang: ctx.lang,
},
error: validatedLoyaltyLevels.error,
})
)
return []
}
getAllLoyaltyLevelSuccessCounter.add(1)
return validatedLoyaltyLevels.data
}
export async function getLoyaltyLevel(ctx: Context, level_id: MembershipLevel) {
getAllLoyaltyLevelCounter.add(1)
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetLoyaltyLevel,
{ lang: ctx.lang, level_id },
{
next: {
tags: [generateLoyaltyConfigTag(ctx.lang, "loyalty_level", level_id)],
},
cache: "force-cache",
}
)
if (
!loyaltyLevelsConfigResponse.data ||
!loyaltyLevelsConfigResponse.data.all_loyalty_level.items.length
) {
getAllLoyaltyLevelFailCounter.add(1)
const notFoundError = notFound(loyaltyLevelsConfigResponse)
console.error(
"contentstack.loyaltyLevels not found error",
JSON.stringify({
query: { lang: ctx.lang, level_id },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
loyaltyLevelsConfigResponse.data
)
if (!validatedLoyaltyLevels.success) {
getAllLoyaltyLevelFailCounter.add(1)
console.error(validatedLoyaltyLevels.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { lang: ctx.lang, level_id },
error: validatedLoyaltyLevels.error,
})
)
return null
}
getAllLoyaltyLevelSuccessCounter.add(1)
return validatedLoyaltyLevels.data[0]
}
export const loyaltyLevelQueryRouter = router({
byLevel: contentstackBaseProcedure
.input(loyaltyLevelInput)
.query(async function ({ ctx, input }) {
return getLoyaltyLevel(ctx, input.level)
}),
all: contentstackBaseProcedure.query(async function ({ ctx }) {
return getAllLoyaltyLevels(ctx)
}),
})

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { rewardQueryRouter } from "./query"
export const rewardRouter = mergeRouters(rewardQueryRouter)

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const rewardsByLevelInput = z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
unique: z.boolean().default(false),
})
export const rewardsAllInput = z
.object({ unique: z.boolean() })
.default({ unique: false })
export const rewardsCurrentInput = z.object({
limit: z.number().min(1).default(3),
cursor: z.number().optional().default(0),
lang: z.nativeEnum(Lang).optional(),
})

View File

@@ -0,0 +1,82 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const validateApiRewardSchema = z.object({
data: z.array(
z
.object({
title: z.string().optional(),
id: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
rewardId: z.string().optional(),
redeemLocation: z.string().optional(),
autoApplyReward: z.boolean().default(false),
rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(),
})
.optional()
),
})
enum TierKey {
tier1 = MembershipLevelEnum.L1,
tier2 = MembershipLevelEnum.L2,
tier3 = MembershipLevelEnum.L3,
tier4 = MembershipLevelEnum.L4,
tier5 = MembershipLevelEnum.L5,
tier6 = MembershipLevelEnum.L6,
tier7 = MembershipLevelEnum.L7,
}
type Key = keyof typeof TierKey
export const validateApiTierRewardsSchema = z.record(
z.nativeEnum(TierKey).transform((data) => {
return TierKey[data as unknown as Key]
}),
z.array(
z
.object({
title: z.string().optional(),
id: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
rewardId: z.string().optional(),
redeemLocation: z.string().optional(),
autoApplyReward: z.boolean().default(false),
rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(),
})
.optional()
)
)
export const validateCmsRewardsSchema = z
.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
taxonomies: z.array(
z.object({
term_uid: z.string().optional(),
})
),
label: z.string().optional(),
reward_id: z.string(),
grouped_label: z.string().optional(),
description: z.string().optional(),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
export type Reward = z.output<typeof validateCmsRewardsSchema>[0]

View File

@@ -0,0 +1,371 @@
import { metrics } from "@opentelemetry/api"
import { Lang } from "@/constants/languages"
import * as api from "@/lib/api"
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
import { request } from "@/lib/graphql/request"
import { Context } from "@/server/context"
import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProfileServiceProcedure,
contentStackBaseWithProtectedProcedure,
router,
} from "@/server/trpc"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
rewardsAllInput,
rewardsByLevelInput,
rewardsCurrentInput,
} from "./input"
import {
CmsRewardsResponse,
Reward,
validateApiRewardSchema,
validateApiTierRewardsSchema,
validateCmsRewardsSchema,
} from "./output"
const meter = metrics.getMeter("trpc.reward")
// OpenTelemetry metrics: Reward
const getCurrentRewardCounter = meter.createCounter(
"trpc.contentstack.reward.current"
)
const getCurrentRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.current-success"
)
const getCurrentRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.current-fail"
)
const getByLevelRewardCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel"
)
const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
const getByLevelRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-fail"
)
const getAllRewardCounter = meter.createCounter("trpc.contentstack.reward.all")
const getAllRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.all-success"
)
const getAllRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.all-fail"
)
function getUniqueRewardIds(rewardIds: string[]) {
const uniqueRewardIds = new Set(rewardIds)
return Array.from(uniqueRewardIds)
}
async function getAllApiRewards(ctx: Context & { serviceToken: string }) {
const apiResponse = await api.get(api.endpoints.v1.tierRewards, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
// One hour. Since the service token is refreshed every hour, this is the longest cache we can have.
next: { revalidate: 60 * 60 },
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.rewards.tierRewards error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
}
const data = await apiResponse.json()
const validatedApiTierRewards = validateApiTierRewardsSchema.safeParse(data)
if (!validatedApiTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiTierRewards.error),
})
console.error(validatedApiTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiTierRewards.error,
})
)
return null
}
return validatedApiTierRewards.data
}
async function getCmsRewards(locale: Lang, rewardIds: string[]) {
const tags = rewardIds.map((id) =>
generateLoyaltyConfigTag(locale, "reward", id)
)
const cmsRewardsResponse = await request<CmsRewardsResponse>(
GetRewards,
{
locale: locale,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
)
if (!cmsRewardsResponse.data) {
getAllRewardFailCounter.add(1, {
lang: locale,
error_type: "validation_error",
error: JSON.stringify(cmsRewardsResponse.data),
})
const notFoundError = notFound(cmsRewardsResponse)
console.error(
"contentstack.rewards not found error",
JSON.stringify({
query: {
locale,
rewardIds,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedCmsRewards =
validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
if (!validatedCmsRewards.success) {
getAllRewardFailCounter.add(1, {
locale,
rewardIds,
error_type: "validation_error",
error: JSON.stringify(validatedCmsRewards.error),
})
console.error(validatedCmsRewards.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { locale, rewardIds },
error: validatedCmsRewards.error,
})
)
return null
}
return validatedCmsRewards.data
}
export const rewardQueryRouter = router({
current: contentStackBaseWithProtectedProcedure
.input(rewardsCurrentInput)
.query(async function ({ input, ctx }) {
getCurrentRewardCounter.add(1)
const { limit, cursor } = input
const apiResponse = await api.get(api.endpoints.v1.rewards, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 60 * 60 },
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const data = await apiResponse.json()
const validatedApiRewards = validateApiRewardSchema.safeParse(data)
if (!validatedApiRewards.success) {
getCurrentRewardFailCounter.add(1, {
locale: ctx.lang,
error_type: "validation_error",
error: JSON.stringify(validatedApiRewards.error),
})
console.error(validatedApiRewards.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { locale: ctx.lang },
error: validatedApiRewards.error,
})
)
return null
}
const rewardIds = validatedApiRewards.data.data
.map((reward) => reward?.rewardId)
.filter(Boolean)
.sort() as string[]
const slicedData = rewardIds.slice(cursor, limit + cursor)
const cmsRewards = await getCmsRewards(ctx.lang, slicedData)
if (!cmsRewards) {
return null
}
const nextCursor =
limit + cursor < rewardIds.length ? limit + cursor : undefined
getCurrentRewardSuccessCounter.add(1)
return {
rewards: cmsRewards,
nextCursor,
}
}),
byLevel: contentStackBaseWithProfileServiceProcedure
.input(rewardsByLevelInput)
.query(async function ({ input, ctx }) {
getByLevelRewardCounter.add(1)
const { level_id } = input
const allUpcomingApiRewards = await getAllApiRewards(ctx)
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
getByLevelRewardFailCounter.add(1)
return null
}
let apiRewards = allUpcomingApiRewards[level_id]!
if (input.unique) {
apiRewards = allUpcomingApiRewards[level_id]!.filter(
(reward) => reward?.rewardTierLevel === level_id
)
}
const rewardIds = apiRewards
.map((reward) => reward?.rewardId)
.filter((id): id is string => Boolean(id))
const contentStackRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!contentStackRewards) {
return null
}
const loyaltyLevelsConfig = await getLoyaltyLevel(ctx, input.level_id)
const levelsWithRewards = apiRewards
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
return r.reward_id === reward?.rewardId
})
if (contentStackReward) {
return contentStackReward
} else {
console.error("No contentStackReward found", reward?.rewardId)
}
})
.filter((reward): reward is Reward => Boolean(reward))
getByLevelRewardSuccessCounter.add(1)
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
}),
all: contentStackBaseWithProfileServiceProcedure
.input(rewardsAllInput)
.query(async function ({ input, ctx }) {
getAllRewardCounter.add(1)
const allApiRewards = await getAllApiRewards(ctx)
if (!allApiRewards) {
return []
}
const rewardIds = Object.values(allApiRewards)
.flatMap((level) => level.map((reward) => reward?.rewardId))
.filter((id): id is string => Boolean(id))
const contentStackRewards = await getCmsRewards(
ctx.lang,
getUniqueRewardIds(rewardIds)
)
if (!contentStackRewards) {
return []
}
const loyaltyLevelsConfig = await getAllLoyaltyLevels(ctx)
const levelsWithRewards = Object.entries(allApiRewards).map(
([level, rewards]) => {
const combinedRewards = rewards
.filter((r) => (input.unique ? r?.rewardTierLevel === level : true))
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
return r.reward_id === reward?.rewardId
})
if (contentStackReward) {
return contentStackReward
} else {
console.error("No contentStackReward found", reward?.rewardId)
}
})
.filter((reward): reward is Reward => Boolean(reward))
const levelConfig = loyaltyLevelsConfig.find(
(l) => l.level_id === level
)
if (!levelConfig) {
getAllRewardFailCounter.add(1)
console.error("contentstack.loyaltyLevels level not found")
throw notFound()
}
return { ...levelConfig, rewards: combinedRewards }
}
)
getAllRewardSuccessCounter.add(1)
return levelsWithRewards
}),
})

View File

@@ -15,17 +15,10 @@ export const cardBlockSchema = z.object({
body_text: z.string().optional().default(""),
has_primary_button: z.boolean().default(false),
has_secondary_button: z.boolean().default(false),
has_sidepeek_button: z.boolean().optional().default(false),
heading: z.string().optional().default(""),
is_content_card: z.boolean().optional().default(false),
primary_button: buttonSchema,
scripted_top_title: z.string().optional(),
secondary_button: buttonSchema,
sidepeek_button: z
.object({
call_to_action_text: z.string().optional(),
})
.optional(),
system: systemSchema,
title: z.string().optional(),
})
@@ -36,23 +29,53 @@ export function transformCardBlock(card: typeof cardBlockSchema._type) {
backgroundImage: card.background_image,
body_text: card.body_text,
heading: card.heading,
isContentCard: card.is_content_card,
primaryButton: card.has_primary_button ? card.primary_button : undefined,
scripted_top_title: card.scripted_top_title,
secondaryButton: card.has_secondary_button
? card.secondary_button
: undefined,
sidePeekButton:
card.has_sidepeek_button && card.sidepeek_button?.call_to_action_text
? {
title: card.sidepeek_button.call_to_action_text,
}
: undefined,
system: card.system,
title: card.title,
}
}
export const teaserCardBlockSchema = z.object({
__typename: z.literal(CardsGridEnum.cards.TeaserCard),
heading: z.string().default(""),
body_text: z.string().default(""),
image: tempImageVaultAssetSchema,
primary_button: buttonSchema,
secondary_button: buttonSchema,
has_primary_button: z.boolean().default(false),
has_secondary_button: z.boolean().default(false),
has_sidepeek_button: z.boolean().default(false),
side_peek_button: z
.object({
title: z.string().optional().default(""),
})
.optional(),
system: systemSchema,
})
export function transformTeaserCardBlock(
card: typeof teaserCardBlockSchema._type
) {
return {
__typename: card.__typename,
body_text: card.body_text,
heading: card.heading,
primaryButton: card.has_primary_button ? card.primary_button : undefined,
secondaryButton: card.has_secondary_button
? card.secondary_button
: undefined,
sidePeekButton: card.has_sidepeek_button
? card.side_peek_button
: undefined,
image: card.image,
system: card.system,
}
}
const loyaltyCardBlockSchema = z.object({
__typename: z.literal(CardsGridEnum.cards.LoyaltyCard),
body_text: z.string().optional(),
@@ -77,6 +100,7 @@ export const cardsGridSchema = z.object({
node: z.discriminatedUnion("__typename", [
cardBlockSchema,
loyaltyCardBlockSchema,
teaserCardBlockSchema,
]),
})
),
@@ -95,6 +119,8 @@ export const cardsGridSchema = z.object({
cards: data.cardConnection.edges.map((card) => {
if (card.node.__typename === CardsGridEnum.cards.Card) {
return transformCardBlock(card.node)
} else if (card.node.__typename === CardsGridEnum.cards.TeaserCard) {
return transformTeaserCardBlock(card.node)
} else {
return {
__typename: card.node.__typename,
@@ -118,7 +144,11 @@ export const cardBlockRefsSchema = z.object({
system: systemSchema,
})
export function transformCardBlockRefs(card: typeof cardBlockRefsSchema._type) {
export function transformCardBlockRefs(
card:
| typeof cardBlockRefsSchema._type
| typeof teaserCardBlockRefsSchema._type
) {
const cards = [card.system]
if (card.primary_button) {
cards.push(card.primary_button)
@@ -135,6 +165,13 @@ const loyaltyCardBlockRefsSchema = z.object({
system: systemSchema,
})
export const teaserCardBlockRefsSchema = z.object({
__typename: z.literal(CardsGridEnum.cards.TeaserCard),
primary_button: linkConnectionRefsSchema,
secondary_button: linkConnectionRefsSchema,
system: systemSchema,
})
export const cardGridRefsSchema = z.object({
cards_grid: z
.object({
@@ -144,6 +181,7 @@ export const cardGridRefsSchema = z.object({
node: z.discriminatedUnion("__typename", [
cardBlockRefsSchema,
loyaltyCardBlockRefsSchema,
teaserCardBlockRefsSchema,
]),
})
),
@@ -152,7 +190,10 @@ export const cardGridRefsSchema = z.object({
.transform((data) => {
return data.cardConnection.edges
.map(({ node }) => {
if (node.__typename === CardsGridEnum.cards.Card) {
if (
node.__typename === CardsGridEnum.cards.Card ||
node.__typename === CardsGridEnum.cards.TeaserCard
) {
return transformCardBlockRefs(node)
} else {
const loyaltyCards = [node.system]

View File

@@ -1,47 +1,48 @@
import { z } from "zod"
import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks"
import { imageRefsSchema, imageSchema } from "./image"
import { BlocksEnums } from "@/types/enums/blocks"
import { ContentEnum } from "@/types/enums/content"
export const textColsSchema = z
.object({
typename: z
.literal(BlocksEnums.block.TextCols)
.optional()
.default(BlocksEnums.block.TextCols),
text_cols: z.object({
columns: z.array(
z.object({
title: z.string().optional().default(""),
text: z.object({
json: z.any(), // JSON
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: z.discriminatedUnion("__typename", [
export const textColsSchema = z.object({
typename: z
.literal(BlocksEnums.block.TextCols)
.optional()
.default(BlocksEnums.block.TextCols),
text_cols: z.object({
columns: z.array(
z.object({
title: z.string().optional().default(""),
text: z.object({
json: z.any(), // JSON
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: z
.discriminatedUnion("__typename", [
imageSchema,
pageLinks.contentPageSchema,
pageLinks.hotelPageSchema,
pageLinks.loyaltyPageSchema,
])
.transform((data) => {
const link = pageLinks.transform(data)
if (link) {
return link
}
return data
}),
})
),
}),
.transform((data) => {
const link = pageLinks.transform(data)
if (link) {
return link
}
return data
}),
})
),
}),
})
),
}),
})
}),
})
),
}),
})
const actualRefs = z.discriminatedUnion("__typename", [
pageLinks.contentPageRefSchema,
@@ -53,9 +54,9 @@ type Refs = {
node: z.TypeOf<typeof actualRefs>
}
export const textColsRefsSchema = z
.object({
text_cols: z.object({
export const textColsRefsSchema = z.object({
text_cols: z
.object({
columns: z.array(
z.object({
text: z.object({
@@ -65,20 +66,22 @@ export const textColsRefsSchema = z
node: z.discriminatedUnion("__typename", [
imageRefsSchema,
...actualRefs.options,
])
]),
})
),
}),
}),
})
),
}).transform(data => {
return data.columns.map(column => {
const filtered = column.text.embedded_itemsConnection.edges
.filter(
block => block.node.__typename !== ContentEnum.blocks.SysAsset
})
.transform((data) => {
return data.columns
.map((column) => {
const filtered = column.text.embedded_itemsConnection.edges.filter(
(block) => block.node.__typename !== ContentEnum.blocks.SysAsset
) as unknown as Refs[] // TS issue with filtered out types
return filtered.map(({ node }) => node.system)
}).flat()
return filtered.map(({ node }) => node.system)
})
.flat()
}),
})
})

View File

@@ -0,0 +1,102 @@
import { z } from "zod"
import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks"
import { BlocksEnums } from "@/types/enums/blocks"
import { UspGridEnum } from "@/types/enums/uspGrid"
const uspCardSchema = z.object({
icon: UspGridEnum.uspIcons,
text: z.object({
json: z.any(), // JSON
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: z
.discriminatedUnion("__typename", [
pageLinks.accountPageSchema,
pageLinks.contentPageSchema,
pageLinks.hotelPageSchema,
pageLinks.loyaltyPageSchema,
])
.transform((data) => {
const link = pageLinks.transform(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
})
export const uspGridSchema = z.object({
typename: z
.literal(BlocksEnums.block.UspGrid)
.optional()
.default(BlocksEnums.block.UspGrid),
usp_grid: z
.object({
cardsConnection: z.object({
edges: z.array(
z.object({
node: z.object({
usp_card: z.array(uspCardSchema),
}),
})
),
}),
})
.transform((data) => {
return {
usp_card: data.cardsConnection.edges.flatMap(
(edge) => edge.node.usp_card
),
}
}),
})
const actualRefs = z.discriminatedUnion("__typename", [
pageLinks.accountPageRefSchema,
pageLinks.contentPageRefSchema,
pageLinks.hotelPageRefSchema,
pageLinks.loyaltyPageRefSchema,
])
export const uspGridRefsSchema = z.object({
usp_grid: z
.object({
cardsConnection: z.object({
edges: z.array(
z.object({
node: z.object({
usp_card: z.array(
z.object({
text: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: actualRefs,
})
),
}),
}),
})
),
}),
})
),
}),
})
.transform((data) => {
return data.cardsConnection.edges.flatMap(({ node }) =>
node.usp_card.flatMap((card) =>
card.text.embedded_itemsConnection.edges.map(
({ node }) => node.system
)
)
)
}),
})

View File

@@ -0,0 +1,72 @@
import { z } from "zod"
import { discriminatedUnion } from "@/lib/discriminatedUnion"
import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks"
const linkUnionSchema = z.discriminatedUnion("__typename", [
pageLinks.contentPageSchema,
pageLinks.hotelPageSchema,
pageLinks.loyaltyPageSchema,
])
const titleSchema = z.object({
title: z.string().optional().default(""),
})
export const linkConnectionSchema = z
.object({
linkConnection: z.object({
edges: z.array(
z.object({
node: discriminatedUnion(linkUnionSchema.options),
})
),
}),
})
.transform((data) => {
if (data.linkConnection.edges.length) {
const link = pageLinks.transform(data.linkConnection.edges[0].node)
if (link) {
return {
link,
}
}
}
return {
link: null,
}
})
export const linkAndTitleSchema = z.intersection(
linkConnectionSchema,
titleSchema
)
const linkRefsUnionSchema = z.discriminatedUnion("__typename", [
pageLinks.contentPageRefSchema,
pageLinks.hotelPageRefSchema,
pageLinks.loyaltyPageRefSchema,
])
export const linkConnectionRefs = z
.object({
linkConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
})
.transform((data) => {
if (data.linkConnection.edges.length) {
const link = pageLinks.transformRef(data.linkConnection.edges[0].node)
if (link) {
return {
link,
}
}
}
return { link: null }
})

View File

@@ -2,6 +2,13 @@ import { z } from "zod"
import { toLang } from "@/server/utils"
import { getPoiGroupByCategoryName } from "./utils"
import {
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
const ratingsSchema = z
.object({
tripAdvisor: z.object({
@@ -152,12 +159,9 @@ const hotelContentSchema = z.object({
const detailedFacilitySchema = z.object({
id: z.number(),
name: z.string(),
code: z.string().optional(),
applyToAllHotels: z.boolean(),
public: z.boolean(),
icon: z.string(),
iconName: z.string().optional(),
sortOrder: z.number(),
filter: z.string().optional(),
})
const healthFacilitySchema = z.object({
@@ -216,32 +220,15 @@ const rewardNightSchema = z.object({
}),
})
const poiCategories = z.enum([
"Airport",
"Amusement park",
"Bus terminal",
"Fair",
"Hospital",
"Hotel",
"Marketing city",
"Museum",
"Nearby companies",
"Parking / Garage",
"Restaurant",
"Shopping",
"Sports",
"Theatre",
"Tourist",
"Transportations",
"Zoo",
])
const poiGroups = z.nativeEnum(PointOfInterestGroupEnum)
const poiCategoryNames = z.nativeEnum(PointOfInterestCategoryNameEnum)
export const pointOfInterestSchema = z
.object({
name: z.string(),
distance: z.number(),
category: z.object({
name: poiCategories,
name: poiCategoryNames,
group: z.string(),
}),
location: locationSchema,
@@ -250,7 +237,8 @@ export const pointOfInterestSchema = z
.transform((poi) => ({
name: poi.name,
distance: poi.distance,
category: poi.category.name,
categoryName: poi.category.name,
group: getPoiGroupByCategoryName(poi.category.name),
coordinates: {
lat: poi.location.latitude,
lng: poi.location.longitude,

View File

@@ -11,10 +11,10 @@ import {
} from "@/server/errors/trpc"
import { extractHotelImages } from "@/server/routers/utils/hotels"
import {
contentStackUidWithServiceProcedure,
contentStackUidWithHotelServiceProcedure,
hotelServiceProcedure,
publicProcedure,
router,
serviceProcedure,
} from "@/server/trpc"
import { toApiLang } from "@/server/utils"
@@ -83,7 +83,7 @@ async function getContentstackData(
}
export const hotelQueryRouter = router({
get: contentStackUidWithServiceProcedure
get: contentStackUidWithHotelServiceProcedure
.input(getHotelInputSchema)
.query(async ({ ctx, input }) => {
const { lang, uid } = ctx
@@ -178,34 +178,34 @@ export const hotelQueryRouter = router({
const roomCategories = included
? included
.filter((item) => item.type === "roomcategories")
.map((roomCategory) => {
const validatedRoom = roomSchema.safeParse(roomCategory)
if (!validatedRoom.success) {
getHotelFailCounter.add(1, {
hotelId,
lang,
include,
error_type: "validation_error",
error: JSON.stringify(
validatedRoom.error.issues.map(({ code, message }) => ({
code,
message,
}))
),
})
console.error(
"api.hotels.hotel validation error",
JSON.stringify({
query: { hotelId, params },
error: validatedRoom.error,
.filter((item) => item.type === "roomcategories")
.map((roomCategory) => {
const validatedRoom = roomSchema.safeParse(roomCategory)
if (!validatedRoom.success) {
getHotelFailCounter.add(1, {
hotelId,
lang,
include,
error_type: "validation_error",
error: JSON.stringify(
validatedRoom.error.issues.map(({ code, message }) => ({
code,
message,
}))
),
})
)
throw badRequestError()
}
console.error(
"api.hotels.hotel validation error",
JSON.stringify({
query: { hotelId, params },
error: validatedRoom.error,
})
)
throw badRequestError()
}
return validatedRoom.data
})
return validatedRoom.data
})
: []
const activities = contentstackData?.content
@@ -233,7 +233,7 @@ export const hotelQueryRouter = router({
}
}),
availability: router({
get: serviceProcedure
get: hotelServiceProcedure
.input(getAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const {
@@ -395,7 +395,7 @@ export const hotelQueryRouter = router({
}),
}),
hotelData: router({
get: serviceProcedure
get: hotelServiceProcedure
.input(getlHotelDataInputSchema)
.query(async ({ ctx, input }) => {
const { hotelId, language, include } = input
@@ -493,7 +493,7 @@ export const hotelQueryRouter = router({
}),
}),
locations: router({
get: serviceProcedure.query(async function ({ ctx }) {
get: hotelServiceProcedure.query(async function ({ ctx }) {
const searchParams = new URLSearchParams()
searchParams.set("language", toApiLang(ctx.lang))

View File

@@ -1,5 +1,3 @@
import { IconName } from "@/types/components/icon"
import deepmerge from "deepmerge"
import { unstable_cache } from "next/cache"
@@ -15,23 +13,39 @@ import {
} from "./output"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import {
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
export function getIconByPoiCategory(category: string) {
export function getPoiGroupByCategoryName(
category: PointOfInterestCategoryNameEnum
) {
switch (category) {
case "Transportations":
return IconName.Train
case "Shopping":
return IconName.Shopping
case "Museum":
return IconName.Museum
case "Tourist":
return IconName.Cultural
case "Restaurant":
return IconName.Restaurant
case PointOfInterestCategoryNameEnum.AIRPORT:
case PointOfInterestCategoryNameEnum.BUS_TERMINAL:
case PointOfInterestCategoryNameEnum.TRANSPORTATIONS:
return PointOfInterestGroupEnum.PUBLIC_TRANSPORT
case PointOfInterestCategoryNameEnum.AMUSEMENT_PARK:
case PointOfInterestCategoryNameEnum.MUSEUM:
case PointOfInterestCategoryNameEnum.SPORTS:
case PointOfInterestCategoryNameEnum.THEATRE:
case PointOfInterestCategoryNameEnum.TOURIST:
case PointOfInterestCategoryNameEnum.ZOO:
return PointOfInterestGroupEnum.ATTRACTIONS
case PointOfInterestCategoryNameEnum.NEARBY_COMPANIES:
case PointOfInterestCategoryNameEnum.FAIR:
return PointOfInterestGroupEnum.BUSINESS
case PointOfInterestCategoryNameEnum.PARKING_GARAGE:
return PointOfInterestGroupEnum.PARKING
case PointOfInterestCategoryNameEnum.SHOPPING:
case PointOfInterestCategoryNameEnum.RESTAURANT:
return PointOfInterestGroupEnum.SHOPPING_DINING
case PointOfInterestCategoryNameEnum.HOSPITAL:
default:
return null
return PointOfInterestGroupEnum.LOCATION
}
}