Merge branch 'master' into feat/sw-929-release-preps
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -422,6 +422,7 @@ export const baseQueryRouter = router({
|
||||
locale: input.lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(input.lang, currentFooterUID)],
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export const bookingwidgetQueryRouter = router({
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid, bookingwidgetAffix)],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -154,6 +154,7 @@ export const getVerifiedUser = cache(
|
||||
"api.user.profile validation error",
|
||||
JSON.stringify({
|
||||
errors: verifiedData.error,
|
||||
apiResponse: apiJson,
|
||||
})
|
||||
)
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user