Merge branch 'master' into feat/sw-929-release-preps

This commit is contained in:
Linus Flood
2024-11-27 19:13:21 +01:00
310 changed files with 7543 additions and 4417 deletions

View File

@@ -83,6 +83,10 @@ export const createBookingInput = z.object({
payment: paymentSchema,
})
export const priceChangeInput = z.object({
confirmationNumber: z.string(),
})
// Query
const confirmationNumberInput = z.object({
confirmationNumber: z.string(),

View File

@@ -6,7 +6,7 @@ import { router, safeProtectedServiceProcedure } from "@/server/trpc"
import { getMembership } from "@/utils/user"
import { createBookingInput } from "./input"
import { createBookingInput, priceChangeInput } from "./input"
import { createBookingSchema } from "./output"
import type { Session } from "next-auth"
@@ -20,6 +20,14 @@ const createBookingFailCounter = meter.createCounter(
"trpc.bookings.create-fail"
)
const priceChangeCounter = meter.createCounter("trpc.bookings.price-change")
const priceChangeSuccessCounter = meter.createCounter(
"trpc.bookings.price-change-success"
)
const priceChangeFailCounter = meter.createCounter(
"trpc.bookings.price-change-fail"
)
async function getMembershipNumber(
session: Session | null
): Promise<string | undefined> {
@@ -122,6 +130,71 @@ export const bookingMutationRouter = router({
query: loggingAttributes,
})
)
return verifiedData.data
}),
priceChange: safeProtectedServiceProcedure
.input(priceChangeInput)
.mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumber } = input
priceChangeCounter.add(1, { confirmationNumber })
const headers = {
Authorization: `Bearer ${accessToken}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.priceChange(confirmationNumber),
{
headers,
body: input,
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
priceChangeFailCounter.add(1, {
confirmationNumber,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
}),
})
console.error(
"api.booking.priceChange error",
JSON.stringify({
query: { confirmationNumber },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
priceChangeFailCounter.add(1, {
confirmationNumber,
error_type: "validation_error",
})
console.error(
"api.booking.priceChange validation error",
JSON.stringify({
query: { confirmationNumber },
error: verifiedData.error,
})
)
return null
}
priceChangeSuccessCounter.add(1, { confirmationNumber })
return verifiedData.data
}),
})

View File

@@ -17,13 +17,14 @@ export const createBookingSchema = z
paymentUrl: z.string().nullable(),
metadata: z
.object({
errorCode: z.number().optional(),
errorMessage: z.string().optional(),
errorCode: z.number().nullable().optional(),
errorMessage: z.string().nullable().optional(),
priceChangedMetadata: z
.object({
roomPrice: z.number().optional(),
totalPrice: z.number().optional(),
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),
})
.nullable(),
@@ -53,19 +54,20 @@ export const createBookingSchema = z
// QUERY
const extraBedTypesSchema = z.object({
quantity: z.number(),
bedType: z.nativeEnum(ChildBedTypeEnum),
quantity: z.number().int(),
})
const guestSchema = z.object({
email: z.string().email().nullable().default(""),
firstName: z.string(),
lastName: z.string(),
firstName: z.string().nullable().default(""),
lastName: z.string().nullable().default(""),
membershipNumber: z.string().nullable().default(""),
phoneNumber: phoneValidator().nullable().default(""),
})
const packageSchema = z.object({
code: z.string().default(""),
code: z.string().nullable().default(""),
currency: z.nativeEnum(CurrencyEnum),
quantity: z.number().int(),
totalPrice: z.number(),
@@ -73,35 +75,37 @@ const packageSchema = z.object({
unitPrice: z.number(),
})
const rateDefinitionSchema = z.object({
breakfastIncluded: z.boolean().default(false),
cancellationRule: z.string().nullable().default(""),
cancellationText: z.string().nullable().default(""),
generalTerms: z.array(z.string()).default([]),
isMemberRate: z.boolean().default(false),
mustBeGuaranteed: z.boolean().default(false),
rateCode: z.string().nullable().default(""),
title: z.string().nullable().default(""),
})
export const bookingConfirmationSchema = z
.object({
data: z.object({
attributes: z.object({
adults: z.number(),
adults: z.number().int(),
checkInDate: z.date({ coerce: true }),
checkOutDate: z.date({ coerce: true }),
createDateTime: z.date({ coerce: true }),
childrenAges: z.array(z.number()),
childrenAges: z.array(z.number().int()).default([]),
extraBedTypes: z.array(extraBedTypesSchema).default([]),
computedReservationStatus: z.string(),
confirmationNumber: z.string(),
computedReservationStatus: z.string().nullable().default(""),
confirmationNumber: z.string().nullable().default(""),
currencyCode: z.nativeEnum(CurrencyEnum),
guest: guestSchema,
hotelId: z.string(),
packages: z.array(packageSchema),
rateDefinition: z.object({
rateCode: z.string(),
title: z.string().nullable(),
breakfastIncluded: z.boolean(),
isMemberRate: z.boolean(),
generalTerms: z.array(z.string()).optional(),
cancellationRule: z.string().optional(),
cancellationText: z.string().optional(),
mustBeGuaranteed: z.boolean(),
}),
reservationStatus: z.string(),
roomPrice: z.number().int(),
roomTypeCode: z.string(),
packages: z.array(packageSchema).default([]),
rateDefinition: rateDefinitionSchema,
reservationStatus: z.string().nullable().default(""),
roomPrice: z.number(),
roomTypeCode: z.string().nullable().default(""),
totalPrice: z.number(),
totalPriceExVat: z.number(),
vatAmount: z.number(),

View File

@@ -11,7 +11,6 @@ import {
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { textContentSchema } from "../schemas/blocks/textContent"
import { page } from "../schemas/metadata"
import { systemSchema } from "../schemas/system"
import { AccountPageEnum } from "@/types/enums/accountPage"
@@ -81,7 +80,3 @@ export const accountPageRefsSchema = z.object({
system: systemSchema,
}),
})
export const accountPageMetadataSchema = z.object({
account_page: page,
})

View File

@@ -5,7 +5,6 @@ import {
GetAccountPage,
GetAccountPageRefs,
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
import { GetMyPagesMetaData } from "@/lib/graphql/Query/AccountPage/MetaData.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
@@ -16,13 +15,7 @@ import {
generateTagsFromSystem,
} from "@/utils/generateTag"
import { removeEmptyObjects } from "../../utils"
import { getMetaData, getResponse } from "../metadata/utils"
import {
accountPageMetadataSchema,
accountPageRefsSchema,
accountPageSchema,
} from "./output"
import { accountPageRefsSchema, accountPageSchema } from "./output"
import { getConnections } from "./utils"
import {
@@ -30,7 +23,6 @@ import {
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetAccountpageMetadata,
GetAccountPageRefsSchema,
GetAccountPageSchema,
} from "@/types/trpc/routers/contentstack/accountPage"
@@ -203,30 +195,4 @@ export const accountPageQueryRouter = router({
tracking,
}
}),
metadata: router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
locale: ctx.lang,
uid: ctx.uid,
}
const response = await getResponse<GetAccountpageMetadata>(
GetMyPagesMetaData,
variables
)
const validatedMetadata = accountPageMetadataSchema.safeParse(
response.data
)
if (!validatedMetadata.success) {
console.error(
`Failed to validate My Page MetaData Data - (uid: ${variables.uid})`
)
console.error(validatedMetadata.error)
return null
}
return getMetaData(validatedMetadata.data.account_page)
}),
}),
})

View File

@@ -422,6 +422,7 @@ export const baseQueryRouter = router({
locale: input.lang,
},
{
cache: "force-cache",
next: {
tags: [generateTag(input.lang, currentFooterUID)],
},

View File

@@ -82,6 +82,8 @@ export function getSiteConfigConnections(refs: GetSiteConfigRefData) {
const siteConfigData = refs.all_site_config.items[0]
const connections: System["system"][] = []
if (!siteConfigData) return connections
const alertConnection = siteConfigData.sitewide_alert.alertConnection
alertConnection.edges.forEach(({ node }) => {

View File

@@ -70,6 +70,7 @@ export const bookingwidgetQueryRouter = router({
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid, bookingwidgetAffix)],
},

View File

@@ -1,16 +1,11 @@
import { z } from "zod"
import { removeMultipleSlashes } from "@/utils/url"
import { systemSchema } from "../schemas/system"
import { homeBreadcrumbs } from "./utils"
export const getBreadcrumbsSchema = z.array(
z.object({
href: z.string().optional(),
title: z.string(),
uid: z.string(),
})
)
const breadcrumbsRefs = z.object({
export const breadcrumbsRefsSchema = z.object({
web: z
.object({
breadcrumbs: z
@@ -32,43 +27,7 @@ const breadcrumbsRefs = z.object({
system: systemSchema,
})
export type BreadcrumbsRefs = z.infer<typeof breadcrumbsRefs>
export const validateMyPagesBreadcrumbsRefsContentstackSchema = z.object({
account_page: breadcrumbsRefs,
})
export type GetMyPagesBreadcrumbsRefsData = z.infer<
typeof validateMyPagesBreadcrumbsRefsContentstackSchema
>
export const validateLoyaltyPageBreadcrumbsRefsContentstackSchema = z.object({
loyalty_page: breadcrumbsRefs,
})
export type GetLoyaltyPageBreadcrumbsRefsData = z.infer<
typeof validateLoyaltyPageBreadcrumbsRefsContentstackSchema
>
export const validateCollectionPageBreadcrumbsRefsContentstackSchema = z.object(
{
collection_page: breadcrumbsRefs,
}
)
export type GetCollectionPageBreadcrumbsRefsData = z.infer<
typeof validateCollectionPageBreadcrumbsRefsContentstackSchema
>
export const validateContentPageBreadcrumbsRefsContentstackSchema = z.object({
content_page: breadcrumbsRefs,
})
export type GetContentPageBreadcrumbsRefsData = z.infer<
typeof validateContentPageBreadcrumbsRefsContentstackSchema
>
const page = z.object({
export const rawBreadcrumbsDataSchema = z.object({
web: z.object({
breadcrumbs: z.object({
title: z.string(),
@@ -92,36 +51,24 @@ const page = z.object({
system: systemSchema,
})
export type Page = z.infer<typeof page>
export const breadcrumbsSchema = rawBreadcrumbsDataSchema.transform((data) => {
const { parentsConnection, title } = data.web.breadcrumbs
const parentBreadcrumbs = parentsConnection.edges.map((breadcrumb) => {
return {
href: removeMultipleSlashes(
`/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
),
title: breadcrumb.node.web.breadcrumbs.title,
uid: breadcrumb.node.system.uid,
}
})
export const validateMyPagesBreadcrumbsContentstackSchema = z.object({
account_page: page,
const pageBreadcrumb = {
title,
uid: data.system.uid,
href: undefined,
}
const homeBreadcrumb = homeBreadcrumbs[data.system.locale]
return [homeBreadcrumb, parentBreadcrumbs, pageBreadcrumb].flat()
})
export type GetMyPagesBreadcrumbsData = z.infer<
typeof validateMyPagesBreadcrumbsContentstackSchema
>
export const validateLoyaltyPageBreadcrumbsContentstackSchema = z.object({
loyalty_page: page,
})
export type GetLoyaltyPageBreadcrumbsData = z.infer<
typeof validateLoyaltyPageBreadcrumbsContentstackSchema
>
export const validateContentPageBreadcrumbsContentstackSchema = z.object({
content_page: page,
})
export type GetContentPageBreadcrumbsData = z.infer<
typeof validateContentPageBreadcrumbsContentstackSchema
>
export const validateCollectionPageBreadcrumbsContentstackSchema = z.object({
collection_page: page,
})
export type GetCollectionPageBreadcrumbsData = z.infer<
typeof validateCollectionPageBreadcrumbsContentstackSchema
>

View File

@@ -1,3 +1,6 @@
import { metrics } from "@opentelemetry/api"
import { cache } from "react"
import {
GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs,
@@ -14,237 +17,199 @@ import {
GetLoyaltyPageBreadcrumbs,
GetLoyaltyPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/LoyaltyPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import {
GetCollectionPageBreadcrumbsData,
GetCollectionPageBreadcrumbsRefsData,
type GetContentPageBreadcrumbsData,
type GetContentPageBreadcrumbsRefsData,
type GetLoyaltyPageBreadcrumbsData,
type GetLoyaltyPageBreadcrumbsRefsData,
type GetMyPagesBreadcrumbsData,
type GetMyPagesBreadcrumbsRefsData,
validateCollectionPageBreadcrumbsContentstackSchema,
validateCollectionPageBreadcrumbsRefsContentstackSchema,
validateContentPageBreadcrumbsContentstackSchema,
validateContentPageBreadcrumbsRefsContentstackSchema,
validateLoyaltyPageBreadcrumbsContentstackSchema,
validateLoyaltyPageBreadcrumbsRefsContentstackSchema,
validateMyPagesBreadcrumbsContentstackSchema,
validateMyPagesBreadcrumbsRefsContentstackSchema,
} from "./output"
import {
getBreadcrumbs,
getRefsResponse,
getResponse,
getTags,
Variables,
} from "./utils"
import { breadcrumbsRefsSchema, breadcrumbsSchema } from "./output"
import { getTags } from "./utils"
import { PageTypeEnum } from "@/types/requests/pageType"
import type {
BreadcrumbsRefsSchema,
RawBreadcrumbsSchema,
} from "@/types/trpc/routers/contentstack/breadcrumbs"
import type { Lang } from "@/constants/languages"
async function getLoyaltyPageBreadcrumbs(variables: Variables) {
const refsResponse = await getRefsResponse<GetLoyaltyPageBreadcrumbsRefsData>(
GetLoyaltyPageBreadcrumbsRefs,
variables
)
const meter = metrics.getMeter("trpc.breadcrumbs")
const validatedRefsData =
validateLoyaltyPageBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
// OpenTelemetry metrics
const getBreadcrumbsRefsCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.refs.get"
)
const getBreadcrumbsRefsSuccessCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.refs.get-success"
)
const getBreadcrumbsRefsFailCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.refs.get-fail"
)
const getBreadcrumbsCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.get"
)
const getBreadcrumbsSuccessCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.get-success"
)
const getBreadcrumbsFailCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.get-fail"
)
if (!validatedRefsData.success) {
console.error(
`Failed to validate Loyaltypage Breadcrumbs Refs - (uid: ${variables.uid})`
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.loyalty_page, variables)
const response = await getResponse<GetLoyaltyPageBreadcrumbsData>(
GetLoyaltyPageBreadcrumbs,
variables,
tags
)
if (!response.data.loyalty_page.web?.breadcrumbs?.title) {
return null
}
const validatedBreadcrumbsData =
validateLoyaltyPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) {
console.error(
`Failed to validate Loyaltypage Breadcrumbs Data - (uid: ${variables.uid})`
)
console.error(validatedBreadcrumbsData.error)
return null
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.loyalty_page,
variables.locale
)
interface BreadcrumbsPageData<T> {
dataKey: keyof T
refQuery: string
query: string
}
async function getCollectionPageBreadcrumbs(variables: Variables) {
const refsResponse =
await getRefsResponse<GetCollectionPageBreadcrumbsRefsData>(
GetCollectionPageBreadcrumbsRefs,
variables
)
const validatedRefsData =
validateCollectionPageBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
const getBreadcrumbs = cache(async function fetchMemoizedBreadcrumbs<T>(
{ dataKey, refQuery, query }: BreadcrumbsPageData<T>,
{ uid, lang }: { uid: string; lang: Lang }
) {
getBreadcrumbsRefsCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs refs get start",
JSON.stringify({ query: { lang, uid } })
)
const refsResponse = await request<{ [K in keyof T]: BreadcrumbsRefsSchema }>(
refQuery,
{ locale: lang, uid }
)
const validatedRefsData = breadcrumbsRefsSchema.safeParse(
refsResponse.data[dataKey]
)
if (!validatedRefsData.success) {
getBreadcrumbsRefsFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedRefsData.error),
})
console.error(
`Failed to validate CollectionPpage Breadcrumbs Refs - (uid: ${variables.uid})`
"contentstack.breadcrumbs refs validation error",
JSON.stringify({
error: validatedRefsData.error,
})
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.collection_page, variables)
const response = await getResponse<GetCollectionPageBreadcrumbsData>(
GetCollectionPageBreadcrumbs,
variables,
tags
)
if (!response.data.collection_page.web?.breadcrumbs?.title) {
return null
}
const validatedBreadcrumbsData =
validateCollectionPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) {
console.error(
`Failed to validate Collectionpage Breadcrumbs Data - (uid: ${variables.uid})`
)
console.error(validatedBreadcrumbsData.error)
return null
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.collection_page,
variables.locale
)
}
async function getContentPageBreadcrumbs(variables: Variables) {
const refsResponse = await getRefsResponse<GetContentPageBreadcrumbsRefsData>(
GetContentPageBreadcrumbsRefs,
variables
)
const validatedRefsData =
validateContentPageBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
console.error(
`Failed to validate Contentpage Breadcrumbs Refs - (uid: ${variables.uid})`
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.content_page, variables)
const response = await getResponse<GetContentPageBreadcrumbsData>(
GetContentPageBreadcrumbs,
variables,
tags
)
if (!response.data.content_page.web?.breadcrumbs?.title) {
return null
}
const validatedBreadcrumbsData =
validateContentPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) {
console.error(
`Failed to validate Contentpage Breadcrumbs Data - (uid: ${variables.uid})`
)
console.error(validatedBreadcrumbsData.error)
return null
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.content_page,
variables.locale
)
}
async function getMyPagesBreadcrumbs(variables: Variables) {
const refsResponse = await getRefsResponse<GetMyPagesBreadcrumbsRefsData>(
GetMyPagesBreadcrumbsRefs,
variables
)
const validatedRefsData =
validateMyPagesBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
console.error(
`Failed to validate My Page Breadcrumbs Refs - (uid: ${variables.uid})`
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.account_page, variables)
const response = await getResponse<GetMyPagesBreadcrumbsData>(
GetMyPagesBreadcrumbs,
variables,
tags
)
if (!response.data.account_page.web?.breadcrumbs?.title) {
return []
}
const validatedBreadcrumbsData =
validateMyPagesBreadcrumbsContentstackSchema.safeParse(response.data)
getBreadcrumbsRefsSuccessCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs refs get success",
JSON.stringify({ query: { lang, uid } })
)
if (!validatedBreadcrumbsData.success) {
const tags = getTags(validatedRefsData.data, lang)
getBreadcrumbsCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs get start",
JSON.stringify({ query: { lang, uid } })
)
const response = await request<T>(
query,
{ locale: lang, uid },
{
cache: "force-cache",
next: { tags },
}
)
if (!response.data) {
const notFoundError = notFound(response)
getBreadcrumbsFailCounter.add(1, {
lang,
uid,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
`Failed to validate My Page Breadcrumbs Data - (uid: ${variables.uid})`
"contentstack.breadcrumbs get not found error",
JSON.stringify({
query: { lang, uid },
error: { code: notFoundError.code },
})
)
console.error(validatedBreadcrumbsData.error)
return null
throw notFoundError
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.account_page,
variables.locale
const validatedBreadcrumbs = breadcrumbsSchema.safeParse(
response.data[dataKey]
)
}
if (!validatedBreadcrumbs.success) {
getBreadcrumbsFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedBreadcrumbs.error),
})
console.error(
"contentstack.breadcrumbs validation error",
JSON.stringify({
error: validatedBreadcrumbs.error,
})
)
return []
}
getBreadcrumbsSuccessCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs get success",
JSON.stringify({ query: { lang, uid } })
)
return validatedBreadcrumbs.data
})
export const breadcrumbsQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
locale: ctx.lang,
lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageTypeEnum.accountPage:
return await getMyPagesBreadcrumbs(variables)
return await getBreadcrumbs<{
account_page: RawBreadcrumbsSchema
}>(
{
dataKey: "account_page",
refQuery: GetMyPagesBreadcrumbsRefs,
query: GetMyPagesBreadcrumbs,
},
variables
)
case PageTypeEnum.collectionPage:
return await getCollectionPageBreadcrumbs(variables)
return await getBreadcrumbs<{
collection_page: RawBreadcrumbsSchema
}>(
{
dataKey: "collection_page",
refQuery: GetCollectionPageBreadcrumbsRefs,
query: GetCollectionPageBreadcrumbs,
},
variables
)
case PageTypeEnum.contentPage:
return await getContentPageBreadcrumbs(variables)
return await getBreadcrumbs<{
content_page: RawBreadcrumbsSchema
}>(
{
dataKey: "content_page",
refQuery: GetContentPageBreadcrumbsRefs,
query: GetContentPageBreadcrumbs,
},
variables
)
case PageTypeEnum.loyaltyPage:
return await getLoyaltyPageBreadcrumbs(variables)
return await getBreadcrumbs<{
loyalty_page: RawBreadcrumbsSchema
}>(
{
dataKey: "loyalty_page",
refQuery: GetLoyaltyPageBreadcrumbsRefs,
query: GetLoyaltyPageBreadcrumbs,
},
variables
)
default:
return []
}

View File

@@ -1,33 +1,17 @@
import { Lang } from "@/constants/languages"
import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc"
import {
generateRefsResponseTag,
generateTag,
generateTags,
} from "@/utils/generateTag"
import { removeMultipleSlashes } from "@/utils/url"
import { type BreadcrumbsRefs, getBreadcrumbsSchema, Page } from "./output"
import { generateTag, generateTags } from "@/utils/generateTag"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
export function getConnections(refs: BreadcrumbsRefs) {
const connections: Edges<NodeRefs>[] = []
if (refs.web?.breadcrumbs) {
connections.push(refs.web.breadcrumbs.parentsConnection)
}
return connections
}
import type { BreadcrumbsRefsSchema } from "@/types/trpc/routers/contentstack/breadcrumbs"
export const affix = "breadcrumbs"
// TODO: Make these editable in CMS?
export const homeBreadcrumbs = {
export const homeBreadcrumbs: {
[key in keyof typeof Lang]: { href: string; title: string; uid: string }
} = {
[Lang.da]: {
href: "/da",
title: "Hjem",
@@ -60,76 +44,19 @@ export const homeBreadcrumbs = {
},
}
export type Variables = {
locale: Lang
uid: string
}
export function getConnections(data: BreadcrumbsRefsSchema) {
const connections: Edges<NodeRefs>[] = []
export async function getRefsResponse<T>(query: string, variables: Variables) {
const refsResponse = await request<T>(query, variables, {
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(variables.locale, variables.uid, affix)],
},
})
if (!refsResponse.data) {
throw notFound(refsResponse)
if (data.web?.breadcrumbs) {
connections.push(data.web.breadcrumbs.parentsConnection)
}
return refsResponse
return connections
}
export function getTags(page: BreadcrumbsRefs, variables: Variables) {
const connections = getConnections(page)
const tags = generateTags(variables.locale, connections)
tags.push(generateTag(variables.locale, page.system.uid, affix))
export function getTags(data: BreadcrumbsRefsSchema, lang: Lang) {
const connections = getConnections(data)
const tags = generateTags(lang, connections)
tags.push(generateTag(lang, data.system.uid, affix))
return tags
}
export async function getResponse<T>(
query: string,
variables: Variables,
tags: string[]
) {
const response = await request<T>(query, variables, {
cache: "force-cache",
next: { tags },
})
if (!response.data) {
throw notFound(response)
}
return response
}
export function getBreadcrumbs(page: Page, lang: Lang) {
const parentBreadcrumbs = page.web.breadcrumbs.parentsConnection.edges.map(
(breadcrumb) => {
return {
href: removeMultipleSlashes(
`/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
),
title: breadcrumb.node.web.breadcrumbs.title,
uid: breadcrumb.node.system.uid,
}
}
)
const pageBreadcrumb = {
title: page.web.breadcrumbs.title,
uid: page.system.uid,
}
const breadcrumbs = [
homeBreadcrumbs[lang],
parentBreadcrumbs,
pageBreadcrumb,
].flat()
const validatedBreadcrumbs = getBreadcrumbsSchema.safeParse(breadcrumbs)
if (!validatedBreadcrumbs.success) {
throw internalServerError(validatedBreadcrumbs.error)
}
return validatedBreadcrumbs.data
}

View File

@@ -10,7 +10,7 @@ import { hotelPageRouter } from "./hotelPage"
import { languageSwitcherRouter } from "./languageSwitcher"
import { loyaltyLevelRouter } from "./loyaltyLevel"
import { loyaltyPageRouter } from "./loyaltyPage"
import { metaDataRouter } from "./metadata"
import { metadataRouter } from "./metadata"
import { myPagesRouter } from "./myPages"
import { rewardRouter } from "./reward"
@@ -25,7 +25,7 @@ export const contentstackRouter = router({
collectionPage: collectionPageRouter,
contentPage: contentPageRouter,
myPages: myPagesRouter,
metaData: metaDataRouter,
metadata: metadataRouter,
rewards: rewardRouter,
loyaltyLevels: loyaltyLevelRouter,
})

View File

@@ -1,5 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { metaDataQueryRouter } from "./query"
import { metadataQueryRouter } from "./query"
export const metaDataRouter = mergeRouters(metaDataQueryRouter)
export const metadataRouter = mergeRouters(metadataQueryRouter)

View File

@@ -1,11 +1,100 @@
import { z } from "zod"
import { page } from "../schemas/metadata"
import { hotelAttributesSchema } from "../../hotels/output"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { getDescription, getImage, getTitle } from "./utils"
export const getLoyaltyPageMetadataSchema = z.object({
loyalty_page: page,
import type { Metadata } from "next"
import { RTETypeEnum } from "@/types/rte/enums"
const metaDataJsonSchema = z.object({
children: z.array(
z.object({
type: z.nativeEnum(RTETypeEnum),
children: z.array(
z.object({
text: z.string().optional(),
})
),
})
),
})
export type GetLoyaltyPageMetaDataData = z.infer<
typeof getLoyaltyPageMetadataSchema
>
const metaDataBlocksSchema = z
.array(
z.object({
content: z
.object({
content: z
.object({
json: metaDataJsonSchema,
})
.optional()
.nullable(),
})
.optional()
.nullable(),
})
)
.optional()
.nullable()
export const rawMetadataSchema = z.object({
web: z
.object({
seo_metadata: z
.object({
title: z.string().optional().nullable(),
description: z.string().optional().nullable(),
noindex: z.boolean().optional().nullable(),
seo_image: tempImageVaultAssetSchema.nullable(),
})
.optional()
.nullable(),
breadcrumbs: z
.object({
title: z.string().optional().nullable(),
})
.optional()
.nullable(),
})
.optional()
.nullable(),
heading: z.string().optional().nullable(),
preamble: z.string().optional().nullable(),
header: z
.object({
heading: z.string().optional().nullable(),
preamble: z.string().optional().nullable(),
})
.optional()
.nullable(),
hero_image: tempImageVaultAssetSchema.nullable(),
blocks: metaDataBlocksSchema,
hotel_page_id: z.string().optional().nullable(),
hotelData: hotelAttributesSchema
.pick({ name: true, address: true, hotelContent: true, gallery: true })
.optional()
.nullable(),
})
export const metadataSchema = rawMetadataSchema.transform(async (data) => {
const noIndex = !!data.web?.seo_metadata?.noindex
const metadata: Metadata = {
title: await getTitle(data),
description: getDescription(data),
openGraph: {
images: getImage(data),
},
}
if (noIndex) {
metadata.robots = {
index: false,
follow: true,
}
}
return metadata
})

View File

@@ -1,45 +1,162 @@
import { GetLoyaltyPageMetaData } from "@/lib/graphql/Query/LoyaltyPage/MetaData.graphql"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { metrics } from "@opentelemetry/api"
import { cache } from "react"
import {
type GetLoyaltyPageMetaDataData,
getLoyaltyPageMetadataSchema,
} from "./output"
import { getMetaData, getResponse, type Variables } from "./utils"
import { GetAccountPageMetadata } from "@/lib/graphql/Query/AccountPage/Metadata.graphql"
import { GetCollectionPageMetadata } from "@/lib/graphql/Query/CollectionPage/Metadata.graphql"
import { GetContentPageMetadata } from "@/lib/graphql/Query/ContentPage/Metadata.graphql"
import { GetHotelPageMetadata } from "@/lib/graphql/Query/HotelPage/Metadata.graphql"
import { GetLoyaltyPageMetadata } from "@/lib/graphql/Query/LoyaltyPage/Metadata.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag"
import { getHotelData } from "../../hotels/query"
import { metadataSchema } from "./output"
import { affix } from "./utils"
import { PageTypeEnum } from "@/types/requests/pageType"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
import type { Lang } from "@/constants/languages"
async function getLoyaltyPageMetaData(variables: Variables) {
const response = await getResponse<GetLoyaltyPageMetaDataData>(
GetLoyaltyPageMetaData,
variables
const meter = metrics.getMeter("trpc.metadata")
// OpenTelemetry metrics
const fetchMetadataCounter = meter.createCounter(
"trpc.contentstack.metadata.get"
)
const fetchMetadataSuccessCounter = meter.createCounter(
"trpc.contentstack.metadata.get-success"
)
const fetchMetadataFailCounter = meter.createCounter(
"trpc.contentstack.metadata.get-fail"
)
const transformMetadataCounter = meter.createCounter(
"trpc.contentstack.metadata.transform"
)
const transformMetadataSuccessCounter = meter.createCounter(
"trpc.contentstack.metadata.transform-success"
)
const transformMetadataFailCounter = meter.createCounter(
"trpc.contentstack.metadata.transform-fail"
)
const fetchMetadata = cache(async function fetchMemoizedMetadata<T>(
query: string,
{ uid, lang }: { uid: string; lang: Lang }
) {
fetchMetadataCounter.add(1, { lang, uid })
console.info(
"contentstack.metadata fetch start",
JSON.stringify({ query: { lang, uid } })
)
const response = await request<T>(
query,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid, affix)],
},
}
)
if (!response.data) {
const notFoundError = notFound(response)
fetchMetadataFailCounter.add(1, {
lang,
uid,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.metadata fetch not found error",
JSON.stringify({
query: { lang, uid },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
fetchMetadataSuccessCounter.add(1, { lang, uid })
console.info(
"contentstack.metadata fetch success",
JSON.stringify({ query: { lang, uid } })
)
const validatedMetadata = getLoyaltyPageMetadataSchema.safeParse(
response.data
)
return response.data
})
async function getTransformedMetadata(data: unknown) {
transformMetadataCounter.add(1)
console.info("contentstack.metadata transform start")
const validatedMetadata = await metadataSchema.safeParseAsync(data)
if (!validatedMetadata.success) {
transformMetadataFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedMetadata.error),
})
console.error(
`Failed to validate Loyaltypage MetaData Data - (uid: ${variables.uid})`
"contentstack.metadata validation error",
JSON.stringify({
error: validatedMetadata.error,
})
)
console.error(validatedMetadata.error)
return null
}
return getMetaData(validatedMetadata.data.loyalty_page)
transformMetadataSuccessCounter.add(1)
console.info("contentstack.metadata transform success")
return validatedMetadata.data
}
export const metaDataQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
export const metadataQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const variables = {
locale: ctx.lang,
lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageTypeEnum.accountPage:
const accountPageResponse = await fetchMetadata<{
account_page: RawMetadataSchema
}>(GetAccountPageMetadata, variables)
return getTransformedMetadata(accountPageResponse.account_page)
case PageTypeEnum.collectionPage:
const collectionPageResponse = await fetchMetadata<{
collection_page: RawMetadataSchema
}>(GetCollectionPageMetadata, variables)
return getTransformedMetadata(collectionPageResponse.collection_page)
case PageTypeEnum.contentPage:
const contentPageResponse = await fetchMetadata<{
content_page: RawMetadataSchema
}>(GetContentPageMetadata, variables)
return getTransformedMetadata(contentPageResponse.content_page)
case PageTypeEnum.loyaltyPage:
return await getLoyaltyPageMetaData(variables)
const loyaltyPageResponse = await fetchMetadata<{
loyalty_page: RawMetadataSchema
}>(GetLoyaltyPageMetadata, variables)
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
case PageTypeEnum.hotelPage:
const hotelPageResponse = await fetchMetadata<{
hotel_page: RawMetadataSchema
}>(GetHotelPageMetadata, variables)
const hotelPageData = hotelPageResponse.hotel_page
const hotelData = hotelPageData.hotel_page_id
? await getHotelData(
{ hotelId: hotelPageData.hotel_page_id, language: ctx.lang },
ctx.serviceToken
)
: null
return getTransformedMetadata({
...hotelPageData,
hotelData: hotelData?.data.attributes,
})
default:
return null
}

View File

@@ -1,44 +1,151 @@
import { Lang } from "@/constants/languages"
import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc"
import { getIntl } from "@/i18n"
import { generateTag } from "@/utils/generateTag"
import { getMetaDataSchema, Page } from "../schemas/metadata"
export type Variables = {
locale: Lang
uid: string
}
import { RTETypeEnum } from "@/types/rte/enums"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export const affix = "metadata"
export async function getResponse<T>(query: string, variables: Variables) {
const response = await request<T>(query, variables, {
cache: "force-cache",
next: {
tags: [generateTag(variables.locale, variables.uid, affix)],
},
})
if (!response.data) {
throw notFound(response)
/**
* Truncates the given text "intelligently" based on the last period found near the max length.
*
* - If a period exists within the extended range (`maxLength` to `maxLength + maxExtension`),
* the function truncates after the closest period to `maxLength`.
* - If no period is found in the range, it truncates the text after the last period found in the max length of the text.
* - If no periods exist at all, it truncates at `maxLength` and appends ellipsis (`...`).
*
* @param {string} text - The input text to be truncated.
* @param {number} [maxLength=150] - The desired maximum length of the truncated text.
* @param {number} [minLength=120] - The minimum allowable length for the truncated text.
* @param {number} [maxExtension=10] - The maximum number of characters to extend beyond `maxLength` to find a period.
* @returns {string} - The truncated text.
*/
function truncateTextAfterLastPeriod(
text: string,
maxLength: number = 150,
minLength: number = 120,
maxExtension: number = 10
): string {
if (text.length <= maxLength) {
return text
}
return response
// Define the extended range
const extendedEnd = Math.min(text.length, maxLength + maxExtension)
const extendedText = text.slice(0, extendedEnd)
// Find all periods within the extended range and filter after minLength to get valid periods
const periodsInRange = [...extendedText.matchAll(/\./g)].map(
({ index }) => index
)
const validPeriods = periodsInRange.filter((index) => index + 1 >= minLength)
if (validPeriods.length > 0) {
// Find the period closest to maxLength
const closestPeriod = validPeriods.reduce((closest, index) =>
Math.abs(index + 1 - maxLength) < Math.abs(closest + 1 - maxLength)
? index
: closest
)
return extendedText.slice(0, closestPeriod + 1)
}
// Fallback: If no period is found within the valid range, look for the last period in the truncated text
const maxLengthText = text.slice(0, maxLength)
const lastPeriodIndex = maxLengthText.lastIndexOf(".")
if (lastPeriodIndex !== -1) {
return text.slice(0, lastPeriodIndex + 1)
}
// Final fallback: Return maxLength text including ellipsis
return `${maxLengthText}...`
}
export function getMetaData(page: Page) {
const pageMetaData = {
breadcrumbsTitle: page.web.breadcrumbs.title,
title: page.web.seo_metadata.title,
description: page.web.seo_metadata.description,
imageConnection: page.web.seo_metadata.imageConnection,
uid: page.system.uid,
export async function getTitle(data: RawMetadataSchema) {
const intl = await getIntl()
const metadata = data.web?.seo_metadata
if (metadata?.title) {
return metadata.title
}
const validatedMetaData = getMetaDataSchema.safeParse(pageMetaData)
if (!validatedMetaData.success) {
throw internalServerError(validatedMetaData.error)
if (data.hotelData) {
return intl.formatMessage(
{ id: "Stay at HOTEL_NAME | Hotel in DESTINATION" },
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
}
return validatedMetaData.data
if (data.web?.breadcrumbs?.title) {
return data.web.breadcrumbs.title
}
if (data.heading) {
return data.heading
}
if (data.header?.heading) {
return data.header.heading
}
return ""
}
export function getDescription(data: RawMetadataSchema) {
const metadata = data.web?.seo_metadata
if (metadata?.description) {
return metadata.description
}
if (data.hotelData) {
return data.hotelData.hotelContent.texts.descriptions.short
}
if (data.preamble) {
return truncateTextAfterLastPeriod(data.preamble)
}
if (data.header?.preamble) {
return truncateTextAfterLastPeriod(data.header.preamble)
}
if (data.blocks?.length) {
const jsonData = data.blocks[0].content?.content?.json
// Finding the first paragraph with text
const firstParagraph = jsonData?.children?.find(
(child) => child.type === RTETypeEnum.p && child.children[0].text
)
if (firstParagraph?.children?.length) {
return firstParagraph.children[0].text
? truncateTextAfterLastPeriod(firstParagraph.children[0].text)
: ""
}
}
return ""
}
export function getImage(data: RawMetadataSchema) {
const metadataImage = data.web?.seo_metadata?.seo_image
const heroImage = data.hero_image
const hotelImage =
data.hotelData?.gallery?.heroImages?.[0] ||
data.hotelData?.gallery?.smallerImages?.[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 (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

@@ -18,6 +18,9 @@ export const rewardsCurrentInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
export const rewardsUpdateInput = z.object({
id: z.string(),
})
export const rewardsUpdateInput = z.array(
z.object({
rewardId: z.string(),
couponCode: z.string(),
})
)

View File

@@ -4,6 +4,7 @@ import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure,
protectedProcedure,
router,
} from "@/server/trpc"
@@ -16,7 +17,6 @@ import {
} from "./input"
import {
Reward,
SurpriseReward,
validateApiRewardSchema,
validateCategorizedRewardsSchema,
} from "./output"
@@ -34,10 +34,11 @@ import {
getCurrentRewardFailCounter,
getCurrentRewardSuccessCounter,
getUniqueRewardIds,
getUnwrapSurpriseCounter,
getUnwrapSurpriseFailCounter,
getUnwrapSurpriseSuccessCounter,
} from "./utils"
import { Surprise } from "@/types/components/blocks/surprises"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
@@ -130,13 +131,15 @@ export const rewardQueryRouter = router({
.map((reward) => reward?.rewardId)
.filter((id): id is string => Boolean(id))
const contentStackRewards = await getCmsRewards(ctx.lang, rewardIds)
const [contentStackRewards, loyaltyLevelsConfig] = await Promise.all([
getCmsRewards(ctx.lang, rewardIds),
getLoyaltyLevel(ctx, input.level_id),
])
if (!contentStackRewards) {
return null
}
const loyaltyLevelsConfig = await getLoyaltyLevel(ctx, input.level_id)
const levelsWithRewards = apiRewards
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
@@ -327,44 +330,100 @@ export const rewardQueryRouter = router({
getCurrentRewardSuccessCounter.add(1)
const surprises =
validatedApiRewards.data
.filter(
(reward): reward is SurpriseReward =>
reward?.type === "coupon" && reward?.rewardType === "Surprise"
const surprises = validatedApiRewards.data
// TODO: Add predicates once legacy endpoints are removed
.filter((reward) => {
if (reward?.rewardType !== "Surprise") {
return false
}
if (!("coupon" in reward)) {
return false
}
const unwrappedCoupons =
reward.coupon?.filter((coupon) => !coupon.unwrapped) || []
if (unwrappedCoupons.length === 0) {
return false
}
return true
})
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
if (!reward) {
return null
}
if (!reward) {
return null
}
return {
...reward,
id: surprise.id,
endsAt: surprise.endsAt,
}
})
.filter((surprise): surprise is Surprise => !!surprise) ?? []
return {
...reward,
id: surprise.id,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))
return surprises
}),
update: contentStackBaseWithProtectedProcedure
unwrap: protectedProcedure
.input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => {
const response = await Promise.resolve({ ok: true })
// const response = await api.post(api.endpoints.v1.rewards, {
// body: {
// ids: [input.id],
// },
// })
if (!response.ok) {
return false
getUnwrapSurpriseCounter.add(1)
const promises = input.map(({ rewardId, couponCode }) => {
return api.post(api.endpoints.v1.Profile.Reward.unwrap, {
body: {
rewardId,
couponCode,
},
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
})
const responses = await Promise.all(promises)
const errors = await Promise.all(
responses.map(async (apiResponse) => {
if (!apiResponse.ok) {
const text = await apiResponse.text()
getUnwrapSurpriseFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"contentstack.unwrap API error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
query: {},
})
)
return false
}
return true
})
)
if (errors.filter((ok) => !ok).length > 0) {
return null
}
getUnwrapSurpriseSuccessCounter.add(1)
return true
}),
})

View File

@@ -44,6 +44,15 @@ export const getByLevelRewardFailCounter = meter.createCounter(
export const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
export const getUnwrapSurpriseCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap"
)
export const getUnwrapSurpriseFailCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-fail"
)
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-success"
)
const ONE_HOUR = 60 * 60

View File

@@ -25,7 +25,7 @@ export const tableSchema = z.object({
data: z.array(z.object({}).catchall(z.string())),
skipReset: z.boolean(),
tableActionEnabled: z.boolean(),
headerRowAdded: z.boolean(),
headerRowAdded: z.boolean().optional().default(false),
}),
}),
})

View File

@@ -1,46 +0,0 @@
import { z } from "zod"
import { systemSchema } from "./system"
export const getMetaDataSchema = z.object({
breadcrumbsTitle: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
imageConnection: z
.object({
edges: z.array(
z.object({
node: z.object({
url: z.string(),
}),
})
),
})
.optional(),
})
export const page = z.object({
web: z.object({
seo_metadata: z.object({
title: z.string().optional(),
description: z.string().optional(),
imageConnection: z
.object({
edges: z.array(
z.object({
node: z.object({
url: z.string(),
}),
})
),
})
.optional(),
}),
breadcrumbs: z.object({
title: z.string(),
}),
}),
system: systemSchema,
})
export type Page = z.infer<typeof page>

View File

@@ -74,3 +74,9 @@ export const getRoomPackagesInputSchema = z.object({
children: z.number().optional().default(0),
packageCodes: z.array(z.string()).optional().default([]),
})
export const getCityCoordinatesInputSchema = z.object({
city: z.string(),
hotel: z.object({
address: z.string(),
}),
})

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { ChildBedTypeEnum } from "@/constants/booking"
import { ChildBedTypeEnum, PaymentMethodEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { toLang } from "@/server/utils"
@@ -112,10 +112,10 @@ const hotelContentSchema = z.object({
}),
}),
restaurantsOverviewPage: z.object({
restaurantsOverviewPageLinkText: z.string(),
restaurantsOverviewPageLink: z.string(),
restaurantsContentDescriptionShort: z.string(),
restaurantsContentDescriptionMedium: z.string(),
restaurantsOverviewPageLinkText: z.string().optional(),
restaurantsOverviewPageLink: z.string().optional(),
restaurantsContentDescriptionShort: z.string().optional(),
restaurantsContentDescriptionMedium: z.string().optional(),
}),
})
@@ -125,6 +125,7 @@ const detailedFacilitySchema = z.object({
public: z.boolean(),
sortOrder: z.number(),
filter: z.string().optional(),
icon: z.string().optional(),
})
export const facilitySchema = z.object({
@@ -200,14 +201,14 @@ const rewardNightSchema = z.object({
export const pointOfInterestSchema = z
.object({
name: z.string(),
distance: z.number(),
name: z.string().optional(),
distance: z.number().optional(),
category: z.object({
name: z.string(),
group: z.string(),
name: z.string().optional(),
group: z.string().optional(),
}),
location: locationSchema,
isHighlighted: z.boolean(),
location: locationSchema.optional(),
isHighlighted: z.boolean().optional(),
})
.transform((poi) => ({
name: poi.name,
@@ -215,8 +216,8 @@ export const pointOfInterestSchema = z
categoryName: poi.category.name,
group: getPoiGroupByCategoryName(poi.category.name),
coordinates: {
lat: poi.location.latitude,
lng: poi.location.longitude,
lat: poi.location?.latitude ?? 0,
lng: poi.location?.longitude ?? 0,
},
}))
@@ -375,6 +376,7 @@ const merchantInformationSchema = z.object({
return Object.entries(val)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
.filter((key): key is PaymentMethodEnum => !!key)
}),
})
@@ -421,6 +423,47 @@ const hotelFactsSchema = z.object({
yearBuilt: z.string(),
})
export const hotelAttributesSchema = z.object({
accessibilityElevatorPitchText: z.string().optional(),
address: addressSchema,
cityId: z.string(),
cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(),
contactInformation: contactInformationSchema,
detailedFacilities: z
.array(detailedFacilitySchema)
.transform((facilities) =>
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
),
gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(),
healthFacilities: z.array(healthFacilitySchema),
hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema,
hotelRoomElevatorPitchText: z.string().optional(),
hotelType: z.string().optional(),
isActive: z.boolean(),
isPublished: z.boolean(),
keywords: z.array(z.string()),
location: locationSchema,
merchantInformationData: merchantInformationSchema,
name: z.string(),
operaId: z.string(),
parking: z.array(parkingSchema),
pointsOfInterest: z
.array(pointOfInterestSchema)
.transform((pois) =>
pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))
),
ratings: ratingsSchema,
rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema,
specialNeedGroups: z.array(specialNeedGroupSchema),
})
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
export const getHotelDataSchema = z.object({
data: z.object({
@@ -433,44 +476,7 @@ export const getHotelDataSchema = z.object({
}
return lang
}),
attributes: z.object({
accessibilityElevatorPitchText: z.string().optional(),
address: addressSchema,
cityId: z.string(),
cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(),
contactInformation: contactInformationSchema,
detailedFacilities: z
.array(detailedFacilitySchema)
.transform((facilities) =>
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
),
gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(),
healthFacilities: z.array(healthFacilitySchema),
hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema,
hotelRoomElevatorPitchText: z.string().optional(),
hotelType: z.string().optional(),
isActive: z.boolean(),
isPublished: z.boolean(),
keywords: z.array(z.string()),
location: locationSchema,
merchantInformationData: merchantInformationSchema,
name: z.string(),
operaId: z.string(),
parking: z.array(parkingSchema),
pointsOfInterest: z
.array(pointOfInterestSchema)
.transform((pois) => pois.sort((a, b) => a.distance - b.distance)),
ratings: ratingsSchema,
rewardNight: rewardNightSchema,
restaurantImages: facilitySchema.optional(),
socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema,
specialNeedGroups: z.array(specialNeedGroupSchema),
}),
attributes: hotelAttributesSchema,
relationships: relationshipsSchema,
}),
// NOTE: We can pass an "include" param to the hotel API to retrieve
@@ -864,22 +870,24 @@ export const packagesSchema = z.object({
export const getRoomPackagesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
packages: z.array(packagesSchema).optional().default([]),
}),
relationships: z
.object({
links: z.array(
z.object({
url: z.string(),
type: z.string(),
})
),
})
.optional(),
type: z.string(),
}),
data: z
.object({
attributes: z.object({
hotelId: z.number(),
packages: z.array(packagesSchema).optional().default([]),
}),
relationships: z
.object({
links: z.array(
z.object({
url: z.string(),
type: z.string(),
})
),
})
.optional(),
type: z.string(),
})
.optional(),
})
.transform((data) => data.data.attributes.packages)
.transform((data) => data.data?.attributes?.packages ?? [])

View File

@@ -1,18 +1,9 @@
import { metrics } from "@opentelemetry/api"
import { cache } from "react"
import { Lang } from "@/constants/languages"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
import { request } from "@/lib/graphql/request"
import { badRequestError } from "@/server/errors/trpc"
import {
badRequestError,
notFound,
serverErrorByStatus,
} from "@/server/errors/trpc"
import {
contentStackUidWithServiceProcedure,
publicProcedure,
router,
safeProtectedServiceProcedure,
@@ -20,16 +11,12 @@ import {
} from "@/server/trpc"
import { toApiLang } from "@/server/utils"
import { hotelPageSchema } from "../contentstack/hotelPage/output"
import {
fetchHotelPageRefs,
generatePageTags,
getHotelPageCounter,
validateHotelPageRefs,
} from "../contentstack/hotelPage/utils"
import { cache } from "@/utils/cache"
import { getVerifiedUser, parsedUser } from "../user/query"
import {
getBreakfastPackageInputSchema,
getCityCoordinatesInputSchema,
getHotelDataInputSchema,
getHotelsAvailabilityInputSchema,
getRatesInputSchema,
@@ -54,14 +41,10 @@ import {
TWENTYFOUR_HOURS,
} from "./utils"
import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { Facility } from "@/types/hotel"
import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
const meter = metrics.getMeter("trpc.hotels")
const getHotelCounter = meter.createCounter("trpc.hotel.get")
@@ -114,55 +97,6 @@ const breakfastPackagesFailCounter = meter.createCounter(
"trpc.package.breakfast-fail"
)
async function getContentstackData(lang: Lang, uid?: string | null) {
if (!uid) {
return null
}
const contentPageRefsData = await fetchHotelPageRefs(lang, uid)
const contentPageRefs = validateHotelPageRefs(contentPageRefsData, lang, uid)
if (!contentPageRefs) {
return null
}
const tags = generatePageTags(contentPageRefs, lang)
getHotelPageCounter.add(1, { lang, uid })
console.info(
"contentstack.hotelPage start",
JSON.stringify({
query: { lang, uid },
})
)
const response = await request<GetHotelPageData>(
GetHotelPage,
{
locale: lang,
uid,
},
{
cache: "force-cache",
next: {
tags,
},
}
)
if (!response.data) {
throw notFound(response)
}
const hotelPageData = hotelPageSchema.safeParse(response.data)
if (!hotelPageData.success) {
console.error(
`Failed to validate Hotel Page - (uid: ${uid}, lang: ${lang})`
)
console.error(hotelPageData.error)
return null
}
return hotelPageData.data.hotel_page
}
export const getHotelData = cache(
async (input: HotelDataInput, serviceToken: string) => {
const { hotelId, language, isCardOnlyPayment } = input
@@ -276,87 +210,6 @@ export const getHotelData = cache(
)
export const hotelQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid } = ctx
const contentstackData = await getContentstackData(lang, uid)
const hotelId = contentstackData?.hotel_page_id
if (!hotelId) {
throw notFound(`Hotel not found for uid: ${uid}`)
}
const hotelData = await getHotelData(
{
hotelId,
language: ctx.lang,
},
ctx.serviceToken
)
if (!hotelData) {
throw notFound()
}
const included = hotelData.included || []
const hotelAttributes = hotelData.data.attributes
const images = hotelAttributes.gallery?.smallerImages
const hotelAlerts = hotelAttributes.specialAlerts
const roomCategories = included
? included.filter((item) => item.type === "roomcategories")
: []
const activities = contentstackData?.content
? contentstackData?.content[0]
: null
const facilities: Facility[] = [
{
...hotelData.data.attributes.restaurantImages,
id: FacilityCardTypeEnum.restaurant,
headingText:
hotelData?.data.attributes.restaurantImages?.headingText ?? "",
heroImages:
hotelData?.data.attributes.restaurantImages?.heroImages ?? [],
},
{
...hotelData.data.attributes.conferencesAndMeetings,
id: FacilityCardTypeEnum.conference,
headingText:
hotelData?.data.attributes.conferencesAndMeetings?.headingText ?? "",
heroImages:
hotelData?.data.attributes.conferencesAndMeetings?.heroImages ?? [],
},
{
...hotelData.data.attributes.healthAndWellness,
id: FacilityCardTypeEnum.wellness,
headingText:
hotelData?.data.attributes.healthAndWellness?.headingText ?? "",
heroImages:
hotelData?.data.attributes.healthAndWellness?.heroImages ?? [],
},
]
return {
hotelId,
hotelName: hotelAttributes.name,
hotelDescription: hotelAttributes.hotelContent.texts.descriptions.short,
hotelLocation: hotelAttributes.location,
hotelAddress: hotelAttributes.address,
hotelRatings: hotelAttributes.ratings,
hotelDetailedFacilities: hotelAttributes.detailedFacilities,
hotelImages: images,
pointsOfInterest: hotelAttributes.pointsOfInterest,
roomCategories,
activitiesCard: activities?.upcoming_activities_card,
facilities,
alerts: hotelAlerts,
faq: contentstackData?.faq,
healthFacilities: hotelAttributes.healthFacilities,
}
}),
availability: router({
hotels: serviceProcedure
.input(getHotelsAvailabilityInputSchema)
@@ -939,12 +792,10 @@ export const hotelQueryRouter = router({
"api.hotels.packages error",
JSON.stringify({ query: { hotelId, params } })
)
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson)
if (!validatedPackagesData.success) {
getHotelFailCounter.add(1, {
hotelId,
@@ -1080,4 +931,42 @@ export const hotelQueryRouter = router({
)
}),
}),
map: router({
city: serviceProcedure
.input(getCityCoordinatesInputSchema)
.query(async function ({ input }) {
const apiKey = process.env.GOOGLE_STATIC_MAP_KEY
const { city, hotel } = input
async function fetchCoordinates(address: string) {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`
const response = await fetch(url)
const data = await response.json()
if (data.status !== "OK") {
console.error(`Geocode error: ${data.status}`)
return null
}
const location = data.results[0]?.geometry?.location
if (!location) {
console.error("No location found in geocode response")
return null
}
return location
}
let location = await fetchCoordinates(city)
if (!location) {
location = await fetchCoordinates(`${city}, ${hotel.address}`)
}
if (!location) {
throw new Error("Unable to fetch coordinates")
}
return location
}),
}),
})

View File

@@ -56,6 +56,7 @@ const roomFacilitiesSchema = z.object({
name: z.string(),
isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(),
icon: z.string().optional(),
})
export const roomSchema = z

View File

@@ -12,13 +12,14 @@ import {
type Countries,
} from "./output"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { PointOfInterestGroupEnum } from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
export function getPoiGroupByCategoryName(category: string) {
export function getPoiGroupByCategoryName(category: string | undefined) {
if (!category) return PointOfInterestGroupEnum.LOCATION
switch (category) {
case "Airport":
case "Bus terminal":

View File

@@ -1,8 +1,6 @@
import { z } from "zod"
import { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
import { getMembership } from "@/utils/user"
export const membershipSchema = z.object({

View File

@@ -154,6 +154,7 @@ export const getVerifiedUser = cache(
"api.user.profile validation error",
JSON.stringify({
errors: verifiedData.error,
apiResponse: apiJson,
})
)
return null