Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
72
apps/scandic-web/server/context.ts
Normal file
72
apps/scandic-web/server/context.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { cookies, headers } from "next/headers"
|
||||
import { type Session } from "next-auth"
|
||||
import { cache } from "react"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
typeof auth
|
||||
|
||||
type CreateContextOptions = {
|
||||
auth: () => Promise<Session | null>
|
||||
lang: Lang
|
||||
pathname: string
|
||||
uid?: string | null
|
||||
url: string
|
||||
webToken?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
/** Use this helper for:
|
||||
* - testing, where we dont have to Mock Next.js' req/res
|
||||
* - trpc's `createSSGHelpers` where we don't have req/res
|
||||
**/
|
||||
export function createContextInner(opts: CreateContextOptions) {
|
||||
return {
|
||||
auth: opts.auth,
|
||||
lang: opts.lang,
|
||||
pathname: opts.pathname,
|
||||
uid: opts.uid,
|
||||
url: opts.url,
|
||||
webToken: opts.webToken,
|
||||
contentType: opts.contentType,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the actual context you'll use in your router
|
||||
* @link https://trpc.io/docs/context
|
||||
**/
|
||||
export const createContext = cache(function () {
|
||||
const h = headers()
|
||||
|
||||
const cookie = cookies()
|
||||
const webviewTokenCookie = cookie.get("webviewToken")
|
||||
const loginType = h.get("loginType")
|
||||
|
||||
return createContextInner({
|
||||
auth: async () => {
|
||||
const session = await auth()
|
||||
const webToken = webviewTokenCookie?.value
|
||||
if (!session?.token && !webToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
session ||
|
||||
({
|
||||
token: { access_token: webToken, loginType },
|
||||
} as Session)
|
||||
)
|
||||
},
|
||||
lang: h.get("x-lang") as Lang,
|
||||
pathname: h.get("x-pathname")!,
|
||||
uid: h.get("x-uid"),
|
||||
url: h.get("x-url")!,
|
||||
webToken: webviewTokenCookie?.value,
|
||||
contentType: h.get("x-contenttype")!,
|
||||
})
|
||||
})
|
||||
|
||||
export type Context = ReturnType<typeof createContext>
|
||||
43
apps/scandic-web/server/errors/next.ts
Normal file
43
apps/scandic-web/server/errors/next.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export function badRequest(cause?: unknown) {
|
||||
const resInit = {
|
||||
status: 400,
|
||||
statusText: "Bad request",
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
cause,
|
||||
},
|
||||
resInit
|
||||
)
|
||||
}
|
||||
|
||||
export function notFound(cause?: unknown) {
|
||||
const resInit = {
|
||||
status: 404,
|
||||
statusText: "Not found",
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
cause,
|
||||
},
|
||||
resInit
|
||||
)
|
||||
}
|
||||
|
||||
export function internalServerError(cause?: unknown) {
|
||||
const resInit = {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
cause,
|
||||
},
|
||||
resInit
|
||||
)
|
||||
}
|
||||
75
apps/scandic-web/server/errors/trpc.ts
Normal file
75
apps/scandic-web/server/errors/trpc.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { TRPCError } from "@trpc/server"
|
||||
|
||||
export function unauthorizedError(cause?: unknown) {
|
||||
return new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: `Unauthorized`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
export function forbiddenError(cause?: unknown) {
|
||||
return new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Forbidden`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
export function badRequestError(cause?: unknown) {
|
||||
return new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Bad request`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
export function notFound(cause?: unknown) {
|
||||
return new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Not found`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
export function internalServerError(cause?: unknown) {
|
||||
return new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Internal Server Error`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
export const SESSION_EXPIRED = "SESSION_EXPIRED"
|
||||
export class SessionExpiredError extends Error { }
|
||||
export function sessionExpiredError() {
|
||||
return new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: SESSION_EXPIRED,
|
||||
cause: new SessionExpiredError(SESSION_EXPIRED),
|
||||
})
|
||||
}
|
||||
|
||||
export const PUBLIC_UNAUTHORIZED = "PUBLIC_UNAUTHORIZED"
|
||||
export class PublicUnauthorizedError extends Error { }
|
||||
export function publicUnauthorizedError() {
|
||||
return new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: PUBLIC_UNAUTHORIZED,
|
||||
cause: new PublicUnauthorizedError(PUBLIC_UNAUTHORIZED),
|
||||
})
|
||||
}
|
||||
|
||||
export function serverErrorByStatus(status: number, cause?: unknown) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return unauthorizedError(cause)
|
||||
case 403:
|
||||
return forbiddenError(cause)
|
||||
case 404:
|
||||
return notFound(cause)
|
||||
case 500:
|
||||
default:
|
||||
return internalServerError(cause)
|
||||
}
|
||||
}
|
||||
19
apps/scandic-web/server/index.ts
Normal file
19
apps/scandic-web/server/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Routers */
|
||||
import { bookingRouter } from "./routers/booking"
|
||||
import { contentstackRouter } from "./routers/contentstack"
|
||||
import { hotelsRouter } from "./routers/hotels"
|
||||
import { navitaionRouter } from "./routers/navigation"
|
||||
import { partnerRouter } from "./routers/partners"
|
||||
import { userRouter } from "./routers/user"
|
||||
import { router } from "./trpc"
|
||||
|
||||
export const appRouter = router({
|
||||
booking: bookingRouter,
|
||||
contentstack: contentstackRouter,
|
||||
hotel: hotelsRouter,
|
||||
user: userRouter,
|
||||
partner: partnerRouter,
|
||||
navigation: navitaionRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
9
apps/scandic-web/server/routers/booking/index.ts
Normal file
9
apps/scandic-web/server/routers/booking/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { bookingMutationRouter } from "./mutation"
|
||||
import { bookingQueryRouter } from "./query"
|
||||
|
||||
export const bookingRouter = mergeRouters(
|
||||
bookingMutationRouter,
|
||||
bookingQueryRouter
|
||||
)
|
||||
118
apps/scandic-web/server/routers/booking/input.ts
Normal file
118
apps/scandic-web/server/routers/booking/input.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ChildBedTypeEnum } from "@/constants/booking"
|
||||
import { Lang, langToApiLang } from "@/constants/languages"
|
||||
|
||||
const signupSchema = z.discriminatedUnion("becomeMember", [
|
||||
z.object({
|
||||
dateOfBirth: z.string(),
|
||||
postalCode: z.string(),
|
||||
becomeMember: z.literal<boolean>(true),
|
||||
}),
|
||||
z.object({ becomeMember: z.literal<boolean>(false) }),
|
||||
])
|
||||
|
||||
const roomsSchema = z.array(
|
||||
z.object({
|
||||
adults: z.number().int().nonnegative(),
|
||||
childrenAges: z
|
||||
.array(
|
||||
z.object({
|
||||
age: z.number().int().nonnegative(),
|
||||
bedType: z.nativeEnum(ChildBedTypeEnum),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
rateCode: z.string(),
|
||||
roomTypeCode: z.coerce.string(),
|
||||
guest: z.intersection(
|
||||
z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
email: z.string().email(),
|
||||
phoneNumber: z.string(),
|
||||
countryCode: z.string(),
|
||||
membershipNumber: z.string().optional(),
|
||||
}),
|
||||
signupSchema
|
||||
),
|
||||
smsConfirmationRequested: z.boolean(),
|
||||
packages: z.object({
|
||||
breakfast: z.boolean(),
|
||||
allergyFriendly: z.boolean(),
|
||||
petFriendly: z.boolean(),
|
||||
accessibility: z.boolean(),
|
||||
}),
|
||||
roomPrice: z.object({
|
||||
publicPrice: z.number().or(z.string().transform((val) => Number(val))),
|
||||
memberPrice: z
|
||||
.number()
|
||||
.or(z.string().transform((val) => Number(val)))
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const paymentSchema = z.object({
|
||||
paymentMethod: z.string(),
|
||||
card: z
|
||||
.object({
|
||||
alias: z.string(),
|
||||
expiryDate: z.string(),
|
||||
cardType: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
cardHolder: z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
phoneCountryCode: z.string(),
|
||||
phoneSubscriber: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
success: z.string(),
|
||||
error: z.string(),
|
||||
cancel: z.string(),
|
||||
})
|
||||
|
||||
// Mutation
|
||||
export const createBookingInput = z.object({
|
||||
hotelId: z.string(),
|
||||
checkInDate: z.string(),
|
||||
checkOutDate: z.string(),
|
||||
rooms: roomsSchema,
|
||||
payment: paymentSchema,
|
||||
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
|
||||
})
|
||||
|
||||
export const addPackageInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
ancillaryComment: z.string(),
|
||||
ancillaryDeliveryTime: z.string().optional(),
|
||||
packages: z.array(
|
||||
z.object({
|
||||
code: z.string(),
|
||||
quantity: z.number(),
|
||||
comment: z.string().optional(),
|
||||
})
|
||||
),
|
||||
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
|
||||
})
|
||||
|
||||
export const priceChangeInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
})
|
||||
|
||||
export const cancelBookingInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
|
||||
})
|
||||
|
||||
// Query
|
||||
const confirmationNumberInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
})
|
||||
|
||||
export const bookingConfirmationInput = confirmationNumberInput
|
||||
|
||||
export const getBookingStatusInput = confirmationNumberInput
|
||||
382
apps/scandic-web/server/routers/booking/mutation.ts
Normal file
382
apps/scandic-web/server/routers/booking/mutation.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { getVerifiedUser } from "@/server/routers/user/query"
|
||||
import { router, safeProtectedServiceProcedure } from "@/server/trpc"
|
||||
|
||||
import { getMembership } from "@/utils/user"
|
||||
|
||||
import {
|
||||
cancelBookingInput,
|
||||
addPackageInput,
|
||||
createBookingInput,
|
||||
priceChangeInput,
|
||||
} from "./input"
|
||||
import { createBookingSchema } from "./output"
|
||||
|
||||
import type { Session } from "next-auth"
|
||||
|
||||
const meter = metrics.getMeter("trpc.bookings")
|
||||
const createBookingCounter = meter.createCounter("trpc.bookings.create")
|
||||
const createBookingSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.create-success"
|
||||
)
|
||||
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"
|
||||
)
|
||||
const cancelBookingCounter = meter.createCounter("trpc.bookings.cancel")
|
||||
const cancelBookingSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.cancel-success"
|
||||
)
|
||||
const cancelBookingFailCounter = meter.createCounter(
|
||||
"trpc.bookings.cancel-fail"
|
||||
)
|
||||
|
||||
const addPackageCounter = meter.createCounter("trpc.bookings.add-package")
|
||||
const addPackageSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.add-package-success"
|
||||
)
|
||||
const addPackageFailCounter = meter.createCounter(
|
||||
"trpc.bookings.add-package-fail"
|
||||
)
|
||||
|
||||
async function getMembershipNumber(
|
||||
session: Session | null
|
||||
): Promise<string | undefined> {
|
||||
if (!session) return undefined
|
||||
|
||||
const verifiedUser = await getVerifiedUser({ session })
|
||||
if (!verifiedUser || "error" in verifiedUser) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const membership = getMembership(verifiedUser.data.memberships)
|
||||
return membership?.membershipNumber
|
||||
}
|
||||
|
||||
export const bookingMutationRouter = router({
|
||||
create: safeProtectedServiceProcedure
|
||||
.input(createBookingInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { language, ...inputWithoutLang } = input
|
||||
const { hotelId, checkInDate, checkOutDate } = inputWithoutLang
|
||||
|
||||
const loggingAttributes = {
|
||||
membershipNumber: await getMembershipNumber(ctx.session),
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
hotelId,
|
||||
language,
|
||||
}
|
||||
|
||||
createBookingCounter.add(1, loggingAttributes)
|
||||
|
||||
console.info(
|
||||
"api.booking.create start",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const apiResponse = await api.post(
|
||||
api.endpoints.v1.Booking.bookings,
|
||||
{
|
||||
headers,
|
||||
body: inputWithoutLang,
|
||||
},
|
||||
{ language }
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
createBookingFailCounter.add(1, {
|
||||
hotelId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.create error",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
error: text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
createBookingFailCounter.add(1, {
|
||||
hotelId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
|
||||
console.error(
|
||||
"api.booking.create validation error",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
createBookingSuccessCounter.add(1, {
|
||||
hotelId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
})
|
||||
|
||||
console.info(
|
||||
"api.booking.create success",
|
||||
JSON.stringify({
|
||||
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
|
||||
}),
|
||||
cancel: safeProtectedServiceProcedure
|
||||
.input(cancelBookingInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { confirmationNumber, language } = input
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const cancellationReason = {
|
||||
reasonCode: "WEB-CANCEL",
|
||||
reason: "WEB-CANCEL",
|
||||
}
|
||||
|
||||
const loggingAttributes = {
|
||||
confirmationNumber,
|
||||
language,
|
||||
}
|
||||
|
||||
cancelBookingCounter.add(1, loggingAttributes)
|
||||
|
||||
console.info(
|
||||
"api.booking.cancel start",
|
||||
JSON.stringify({
|
||||
request: loggingAttributes,
|
||||
headers,
|
||||
})
|
||||
)
|
||||
|
||||
const apiResponse = await api.remove(
|
||||
api.endpoints.v1.Booking.cancel(confirmationNumber),
|
||||
{
|
||||
headers,
|
||||
body: JSON.stringify(cancellationReason),
|
||||
} as RequestInit,
|
||||
{ language }
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
cancelBookingFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.cancel error",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
|
||||
if (!verifiedData.success) {
|
||||
cancelBookingFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
|
||||
console.error(
|
||||
"api.booking.cancel validation error",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
cancelBookingSuccessCounter.add(1, loggingAttributes)
|
||||
|
||||
console.info(
|
||||
"api.booking.cancel success",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
packages: safeProtectedServiceProcedure
|
||||
.input(addPackageInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { confirmationNumber, ...body } = input
|
||||
|
||||
addPackageCounter.add(1, { confirmationNumber })
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const apiResponse = await api.post(
|
||||
api.endpoints.v1.Booking.packages(confirmationNumber),
|
||||
{
|
||||
headers,
|
||||
body: body,
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
addPackageFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.addPackage 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) {
|
||||
addPackageFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
console.error(
|
||||
"api.booking.addPackage validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
addPackageSuccessCounter.add(1, { confirmationNumber })
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
})
|
||||
223
apps/scandic-web/server/routers/booking/output.ts
Normal file
223
apps/scandic-web/server/routers/booking/output.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ChildBedTypeEnum } from "@/constants/booking"
|
||||
|
||||
import { phoneValidator } from "@/utils/zod/phoneValidator"
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
|
||||
// MUTATION
|
||||
export const createBookingSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
reservationStatus: z.string(),
|
||||
paymentUrl: z.string().nullable().optional(),
|
||||
rooms: z
|
||||
.array(
|
||||
z.object({
|
||||
confirmationNumber: z.string(),
|
||||
cancellationNumber: z.string().nullable(),
|
||||
priceChangedMetadata: z
|
||||
.object({
|
||||
roomPrice: z.number(),
|
||||
totalPrice: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
confirmationNumber: z.string().nullable().optional(),
|
||||
errorCode: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
meta: z
|
||||
.record(z.string(), z.union([z.string(), z.number()]))
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
links: z.object({
|
||||
self: z.object({
|
||||
href: z.string().url(),
|
||||
meta: z.object({
|
||||
method: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform((d) => ({
|
||||
id: d.data.id,
|
||||
links: d.data.links,
|
||||
type: d.data.type,
|
||||
reservationStatus: d.data.attributes.reservationStatus,
|
||||
paymentUrl: d.data.attributes.paymentUrl,
|
||||
rooms: d.data.attributes.rooms,
|
||||
errors: d.data.attributes.errors,
|
||||
}))
|
||||
|
||||
// QUERY
|
||||
const childBedPreferencesSchema = z.object({
|
||||
bedType: z.nativeEnum(ChildBedTypeEnum),
|
||||
quantity: z.number().int(),
|
||||
code: z.string().nullable().default(""),
|
||||
})
|
||||
|
||||
const guestSchema = z.object({
|
||||
email: z.string().email().nullable().default(""),
|
||||
firstName: z.string().nullable().default(""),
|
||||
lastName: z.string().nullable().default(""),
|
||||
membershipNumber: z.string().nullable().default(""),
|
||||
phoneNumber: phoneValidator().nullable().default(""),
|
||||
})
|
||||
|
||||
const packageSchema = z
|
||||
.object({
|
||||
description: z.string().nullable().default(""),
|
||||
type: z.string().nullable().default(""),
|
||||
code: z.string().nullable().default(""),
|
||||
price: z.object({
|
||||
unit: z.number().int().nullable(),
|
||||
unitPrice: z.number(),
|
||||
totalPrice: z.number().nullable(),
|
||||
totalUnit: z.number().int().nullable(),
|
||||
currency: z.string().default(""),
|
||||
points: z.number().int().nullable(),
|
||||
}),
|
||||
})
|
||||
.transform((packageData) => ({
|
||||
description: packageData.description,
|
||||
code: packageData.code,
|
||||
type: packageData.type,
|
||||
currency: packageData.price.currency,
|
||||
points: packageData.price.points,
|
||||
totalPrice: packageData.price.totalPrice ?? 0,
|
||||
totalUnit: packageData.price.totalUnit ?? 0,
|
||||
unit: packageData.price.unit ?? 0,
|
||||
unitPrice: packageData.price.unitPrice,
|
||||
}))
|
||||
|
||||
const ancillarySchema = z
|
||||
.object({
|
||||
comment: z.string().default(""),
|
||||
deliveryTime: z.string().default(""),
|
||||
})
|
||||
.nullable()
|
||||
.default({
|
||||
comment: "",
|
||||
deliveryTime: "",
|
||||
})
|
||||
|
||||
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 linkedReservationsSchema = z.object({
|
||||
confirmationNumber: z.string().default(""),
|
||||
hotelId: z.string().default(""),
|
||||
checkinDate: z.string(),
|
||||
checkoutDate: z.string(),
|
||||
cancellationNumber: nullableStringValidator,
|
||||
roomTypeCode: z.string().default(""),
|
||||
adults: z.number().int(),
|
||||
children: z.number().int(),
|
||||
profileId: z.string().default(""),
|
||||
})
|
||||
|
||||
const linksSchema = z.object({
|
||||
addAncillary: z
|
||||
.object({
|
||||
href: z.string(),
|
||||
meta: z.object({
|
||||
method: z.string(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
cancel: z
|
||||
.object({
|
||||
href: z.string(),
|
||||
meta: z.object({
|
||||
method: z.string(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
guarantee: z
|
||||
.object({
|
||||
href: z.string(),
|
||||
meta: z.object({
|
||||
method: z.string(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
modify: z
|
||||
.object({
|
||||
href: z.string(),
|
||||
meta: z.object({
|
||||
method: z.string(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
self: z
|
||||
.object({
|
||||
href: z.string(),
|
||||
meta: z.object({
|
||||
method: z.string(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export const bookingConfirmationSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
adults: z.number().int(),
|
||||
ancillary: ancillarySchema,
|
||||
checkInDate: z.date({ coerce: true }),
|
||||
checkOutDate: z.date({ coerce: true }),
|
||||
childBedPreferences: z.array(childBedPreferencesSchema).default([]),
|
||||
childrenAges: z.array(z.number().int()).default([]),
|
||||
canChangeDate: z.boolean(),
|
||||
computedReservationStatus: z.string().nullable().default(""),
|
||||
confirmationNumber: z.string().nullable().default(""),
|
||||
createDateTime: z.date({ coerce: true }),
|
||||
currencyCode: z.string(),
|
||||
guest: guestSchema,
|
||||
isGuaranteedForLateArrival: z.boolean().optional(),
|
||||
linkedReservations: z.array(linkedReservationsSchema).default([]),
|
||||
hotelId: 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(),
|
||||
vatPercentage: z.number(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.literal("booking"),
|
||||
links: linksSchema,
|
||||
}),
|
||||
})
|
||||
.transform(({ data }) => ({
|
||||
...data.attributes,
|
||||
showAncillaries: !!data.links.addAncillary,
|
||||
isCancelable: !!data.links.cancel,
|
||||
isModifiable: !!data.links.modify,
|
||||
}))
|
||||
231
apps/scandic-web/server/routers/booking/query.ts
Normal file
231
apps/scandic-web/server/routers/booking/query.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
|
||||
import {
|
||||
router,
|
||||
safeProtectedServiceProcedure,
|
||||
serviceProcedure,
|
||||
} from "@/server/trpc"
|
||||
|
||||
import { getHotel } from "../hotels/query"
|
||||
import { bookingConfirmationInput, getBookingStatusInput } from "./input"
|
||||
import { bookingConfirmationSchema, createBookingSchema } from "./output"
|
||||
import { getBookedHotelRoom } from "./utils"
|
||||
|
||||
const meter = metrics.getMeter("trpc.booking")
|
||||
const getBookingConfirmationCounter = meter.createCounter(
|
||||
"trpc.booking.confirmation"
|
||||
)
|
||||
const getBookingConfirmationSuccessCounter = meter.createCounter(
|
||||
"trpc.booking.confirmation-success"
|
||||
)
|
||||
const getBookingConfirmationFailCounter = meter.createCounter(
|
||||
"trpc.booking.confirmation-fail"
|
||||
)
|
||||
|
||||
const getBookingStatusCounter = meter.createCounter("trpc.booking.status")
|
||||
const getBookingStatusSuccessCounter = meter.createCounter(
|
||||
"trpc.booking.status-success"
|
||||
)
|
||||
const getBookingStatusFailCounter = meter.createCounter(
|
||||
"trpc.booking.status-fail"
|
||||
)
|
||||
|
||||
export const bookingQueryRouter = router({
|
||||
confirmation: safeProtectedServiceProcedure
|
||||
.input(bookingConfirmationInput)
|
||||
.query(async function ({ ctx, input: { confirmationNumber } }) {
|
||||
getBookingConfirmationCounter.add(1, { confirmationNumber })
|
||||
|
||||
const token = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Booking.booking(confirmationNumber),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const responseMessage = await apiResponse.text()
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: responseMessage,
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: responseMessage,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// If the booking is not found, return null.
|
||||
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
|
||||
if (apiResponse.status === 400) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw serverErrorByStatus(apiResponse.status, apiResponse)
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const booking = bookingConfirmationSchema.safeParse(apiJson)
|
||||
if (!booking.success) {
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(booking.error),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: booking.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const hotelData = await getHotel(
|
||||
{
|
||||
hotelId: booking.data.hotelId,
|
||||
isCardOnlyPayment: false,
|
||||
language: ctx.lang,
|
||||
},
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
if (!hotelData) {
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
hotelId: booking.data.hotelId,
|
||||
error_type: "http_error",
|
||||
error: "Couldn`t get hotel",
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber, hotelId: booking.data.hotelId },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: "Couldn`t get hotel",
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw serverErrorByStatus(404)
|
||||
}
|
||||
|
||||
getBookingConfirmationSuccessCounter.add(1, { confirmationNumber })
|
||||
console.info(
|
||||
"api.booking.confirmation success",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Add hotels check in and out times to booking check in and out date
|
||||
* as that is date only (YYYY-MM-DD)
|
||||
*/
|
||||
const checkInTime = hotelData.hotel.hotelFacts.checkin.checkInTime
|
||||
const [checkInHour, checkInMinute] = checkInTime.split(":")
|
||||
const checkIn = dt(booking.data.checkInDate)
|
||||
.set("hour", Number(checkInHour))
|
||||
.set("minute", Number(checkInMinute))
|
||||
const checkOutTime = hotelData.hotel.hotelFacts.checkin.checkOutTime
|
||||
const [checkOutHour, checkOutMinute] = checkOutTime.split(":")
|
||||
const checkOut = dt(booking.data.checkOutDate)
|
||||
.set("hour", Number(checkOutHour))
|
||||
.set("minute", Number(checkOutMinute))
|
||||
|
||||
booking.data.checkInDate = checkIn.toDate()
|
||||
booking.data.checkOutDate = checkOut.toDate()
|
||||
|
||||
return {
|
||||
...hotelData,
|
||||
booking: booking.data,
|
||||
room: getBookedHotelRoom(
|
||||
hotelData.roomCategories,
|
||||
booking.data.roomTypeCode
|
||||
),
|
||||
}
|
||||
}),
|
||||
status: serviceProcedure.input(getBookingStatusInput).query(async function ({
|
||||
ctx,
|
||||
input,
|
||||
}) {
|
||||
const { confirmationNumber } = input
|
||||
getBookingStatusCounter.add(1, { confirmationNumber })
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Booking.status(confirmationNumber),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const responseMessage = await apiResponse.text()
|
||||
getBookingStatusFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: responseMessage,
|
||||
})
|
||||
console.error(
|
||||
"api.booking.status error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: responseMessage,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw serverErrorByStatus(apiResponse.status, apiResponse)
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
getBookingStatusFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(verifiedData.error),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.status validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
getBookingStatusSuccessCounter.add(1, { confirmationNumber })
|
||||
console.info(
|
||||
"api.booking.status success",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
})
|
||||
)
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
})
|
||||
27
apps/scandic-web/server/routers/booking/utils.ts
Normal file
27
apps/scandic-web/server/routers/booking/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
export function getBookedHotelRoom(
|
||||
rooms: Room[] | undefined,
|
||||
roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"]
|
||||
) {
|
||||
if (!rooms?.length || !roomTypeCode) {
|
||||
return null
|
||||
}
|
||||
const room = rooms?.find((r) => {
|
||||
return r.roomTypes.find((roomType) => roomType.code === roomTypeCode)
|
||||
})
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
const bedType = room.roomTypes.find(
|
||||
(roomType) => roomType.code === roomTypeCode
|
||||
)
|
||||
if (!bedType) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...room,
|
||||
bedType,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { accountPageQueryRouter } from "./query"
|
||||
|
||||
export const accountPageRouter = mergeRouters(accountPageQueryRouter)
|
||||
@@ -0,0 +1,86 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import {
|
||||
dynamicContentRefsSchema,
|
||||
dynamicContentSchema,
|
||||
} from "../schemas/blocks/dynamicContent"
|
||||
import {
|
||||
shortcutsRefsSchema,
|
||||
shortcutsSchema,
|
||||
} from "../schemas/blocks/shortcuts"
|
||||
import { textContentSchema } from "../schemas/blocks/textContent"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { AccountPageEnum } from "@/types/enums/accountPage"
|
||||
|
||||
const accountPageDynamicContent = z
|
||||
.object({
|
||||
__typename: z.literal(AccountPageEnum.ContentStack.blocks.DynamicContent),
|
||||
})
|
||||
.merge(dynamicContentSchema)
|
||||
|
||||
const accountPageShortcuts = z
|
||||
.object({
|
||||
__typename: z.literal(AccountPageEnum.ContentStack.blocks.ShortCuts),
|
||||
})
|
||||
.merge(shortcutsSchema)
|
||||
|
||||
const accountPageTextContent = z
|
||||
.object({
|
||||
__typename: z.literal(AccountPageEnum.ContentStack.blocks.TextContent),
|
||||
})
|
||||
.merge(textContentSchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
accountPageDynamicContent,
|
||||
accountPageShortcuts,
|
||||
accountPageTextContent,
|
||||
])
|
||||
|
||||
export const accountPageSchema = z.object({
|
||||
account_page: z.object({
|
||||
content: discriminatedUnionArray(blocksSchema.options),
|
||||
heading: z.string().nullable(),
|
||||
preamble: z.string().nullable(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
const accountPageDynamicContentRefs = z
|
||||
.object({
|
||||
__typename: z.literal(AccountPageEnum.ContentStack.blocks.DynamicContent),
|
||||
})
|
||||
.merge(dynamicContentRefsSchema)
|
||||
|
||||
const accountPageShortcutsRefs = z
|
||||
.object({
|
||||
__typename: z.literal(AccountPageEnum.ContentStack.blocks.ShortCuts),
|
||||
})
|
||||
.merge(shortcutsRefsSchema)
|
||||
|
||||
const accountPageContentItemRefs = z.discriminatedUnion("__typename", [
|
||||
z.object({
|
||||
__typename: z.literal(AccountPageEnum.ContentStack.blocks.TextContent),
|
||||
}),
|
||||
accountPageDynamicContentRefs,
|
||||
accountPageShortcutsRefs,
|
||||
])
|
||||
|
||||
export const accountPageRefsSchema = z.object({
|
||||
account_page: z.object({
|
||||
content: discriminatedUnionArray(accountPageContentItemRefs.options),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import {
|
||||
GetAccountPage,
|
||||
GetAccountPageRefs,
|
||||
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
|
||||
|
||||
import {
|
||||
generateRefsResponseTag,
|
||||
generateTag,
|
||||
generateTagsFromSystem,
|
||||
} from "@/utils/generateTag"
|
||||
|
||||
import { accountPageRefsSchema, accountPageSchema } from "./output"
|
||||
import { getConnections } from "./utils"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type {
|
||||
GetAccountPageRefsSchema,
|
||||
GetAccountPageSchema,
|
||||
} from "@/types/trpc/routers/contentstack/accountPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const meter = metrics.getMeter("trpc.accountPage")
|
||||
|
||||
// OpenTelemetry metrics
|
||||
const getAccountPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.accountPage.get"
|
||||
)
|
||||
const getAccountPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.accountPage.get-success"
|
||||
)
|
||||
const getAccountPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.accountPage.get-fail"
|
||||
)
|
||||
const getAccountPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.accountPage.get"
|
||||
)
|
||||
const getAccountPageSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.accountPage.get-success"
|
||||
)
|
||||
const getAccountPageFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.accountPage.get-fail"
|
||||
)
|
||||
|
||||
export const accountPageQueryRouter = router({
|
||||
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
|
||||
const { lang, uid } = ctx
|
||||
getAccountPageRefsCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.accountPage.refs start",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
const refsResponse = await request<GetAccountPageRefsSchema>(
|
||||
GetAccountPageRefs,
|
||||
{
|
||||
locale: lang,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getAccountPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.accountPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedAccountPageRefs = accountPageRefsSchema.safeParse(
|
||||
refsResponse.data
|
||||
)
|
||||
if (!validatedAccountPageRefs.success) {
|
||||
getAccountPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedAccountPageRefs.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.accountPage.refs validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedAccountPageRefs.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const connections = getConnections(validatedAccountPageRefs.data)
|
||||
|
||||
const tags = [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedAccountPageRefs.data.account_page.system.uid),
|
||||
].flat()
|
||||
getAccountPageRefsSuccessCounter.add(1, { lang, uid, tags })
|
||||
getAccountPageCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.accountPage start",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
const response = await request<GetAccountPageSchema>(
|
||||
GetAccountPage,
|
||||
{
|
||||
locale: lang,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getAccountPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.accountPage not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedAccountPage = accountPageSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedAccountPage.success) {
|
||||
getAccountPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedAccountPage.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.accountPage validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedAccountPage.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getAccountPageSuccessCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.accountPage success",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
|
||||
const parsedtitle = response.data.account_page.title
|
||||
.replaceAll(" ", "")
|
||||
.toLowerCase()
|
||||
|
||||
const tracking: TrackingSDKPageData = {
|
||||
pageId: validatedAccountPage.data.account_page.system.uid,
|
||||
domainLanguage: lang,
|
||||
publishDate: validatedAccountPage.data.account_page.system.updated_at,
|
||||
createDate: validatedAccountPage.data.account_page.system.created_at,
|
||||
channel: TrackingChannelEnum["scandic-friends"],
|
||||
pageType: `member${parsedtitle}page`,
|
||||
pageName: validatedAccountPage.data.trackingProps.url,
|
||||
siteSections: validatedAccountPage.data.trackingProps.url,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
return {
|
||||
accountPage: validatedAccountPage.data.account_page,
|
||||
tracking,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AccountPageEnum } from "@/types/enums/accountPage"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type { AccountPageRefs } from "@/types/trpc/routers/contentstack/accountPage"
|
||||
|
||||
export function getConnections({ account_page }: AccountPageRefs) {
|
||||
const connections: System["system"][] = [account_page.system]
|
||||
|
||||
if (account_page.content) {
|
||||
account_page.content.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case AccountPageEnum.ContentStack.blocks.ShortCuts: {
|
||||
if (block.shortcuts.shortcuts.length) {
|
||||
connections.push(...block.shortcuts.shortcuts)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AccountPageEnum.ContentStack.blocks.DynamicContent: {
|
||||
if (block.dynamic_content.link) {
|
||||
connections.push(block.dynamic_content.link)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { baseQueryRouter } from "./query"
|
||||
|
||||
export const baseRouter = mergeRouters(baseQueryRouter)
|
||||
848
apps/scandic-web/server/routers/contentstack/base/output.ts
Normal file
848
apps/scandic-web/server/routers/contentstack/base/output.ts
Normal file
@@ -0,0 +1,848 @@
|
||||
import { z, ZodError, ZodIssueCode } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { discriminatedUnion } from "@/lib/discriminatedUnion"
|
||||
import {
|
||||
cardBlockRefsSchema,
|
||||
cardBlockSchema,
|
||||
transformCardBlock,
|
||||
transformCardBlockRefs,
|
||||
} from "@/server/routers/contentstack/schemas/blocks/cardsGrid"
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
linkUnionSchema,
|
||||
transformPageLink,
|
||||
transformPageLinkRef,
|
||||
} from "@/server/routers/contentstack/schemas/pageLinks"
|
||||
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { IconName } from "@/types/components/icon"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import type { Image } from "@/types/image"
|
||||
|
||||
// Help me write this zod schema based on the type ContactConfig
|
||||
export const validateContactConfigSchema = z.object({
|
||||
all_contact_config: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
email: z.object({
|
||||
name: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
}),
|
||||
email_loyalty: z.object({
|
||||
name: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
}),
|
||||
mailing_address: z.object({
|
||||
zip: z.string().nullable(),
|
||||
street: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
country: z.string().nullable(),
|
||||
}),
|
||||
phone: z.object({
|
||||
number: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
footnote: z.string().nullable(),
|
||||
}),
|
||||
phone_loyalty: z.object({
|
||||
number: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
footnote: z.string().nullable(),
|
||||
}),
|
||||
visiting_address: z.object({
|
||||
zip: z.string().nullable(),
|
||||
country: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
street: z.string().nullable(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export enum ContactFieldGroupsEnum {
|
||||
email = "email",
|
||||
email_loyalty = "email_loyalty",
|
||||
mailing_address = "mailing_address",
|
||||
phone = "phone",
|
||||
phone_loyalty = "phone_loyalty",
|
||||
visiting_address = "visiting_address",
|
||||
}
|
||||
|
||||
export type ContactFieldGroups = keyof typeof ContactFieldGroupsEnum
|
||||
|
||||
export type ContactConfigData = z.infer<typeof validateContactConfigSchema>
|
||||
|
||||
export type ContactConfig = ContactConfigData["all_contact_config"]["items"][0]
|
||||
|
||||
export type ContactFields = {
|
||||
display_text: string | null
|
||||
contact_field: string
|
||||
footnote?: string | null
|
||||
}
|
||||
|
||||
export const validateCurrentHeaderConfigSchema = z
|
||||
.object({
|
||||
all_current_header: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
frontpage_link_text: z.string(),
|
||||
logoConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
dimension: z.object({
|
||||
height: z.number(),
|
||||
width: z.number(),
|
||||
}),
|
||||
metadata: z.any().nullable(),
|
||||
system: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
title: z.string().optional().default(""),
|
||||
url: z.string().optional().default(""),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
menu: z.object({
|
||||
links: z.array(
|
||||
z.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
top_menu: z.object({
|
||||
links: z.array(
|
||||
z.object({
|
||||
link: z.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
}),
|
||||
show_on_mobile: z.boolean(),
|
||||
sort_order_mobile: z.number(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (!data.all_current_header.items.length) {
|
||||
return {
|
||||
header: null,
|
||||
}
|
||||
}
|
||||
const header = data.all_current_header.items[0]
|
||||
return {
|
||||
header: {
|
||||
frontpageLinkText: header.frontpage_link_text,
|
||||
logo: header.logoConnection.edges[0].node,
|
||||
menu: header.menu,
|
||||
topMenu: header.top_menu,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export interface GetCurrentHeaderData
|
||||
extends z.input<typeof validateCurrentHeaderConfigSchema> {}
|
||||
export type HeaderData = z.output<typeof validateCurrentHeaderConfigSchema>
|
||||
|
||||
const validateCurrentHeaderRefConfigSchema = z.object({
|
||||
all_current_header: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
system: systemSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export type CurrentHeaderRefDataRaw = z.infer<
|
||||
typeof validateCurrentHeaderRefConfigSchema
|
||||
>
|
||||
|
||||
const validateAppDownload = z.object({
|
||||
href: z.string(),
|
||||
imageConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
dimension: z.object({
|
||||
height: z.number(),
|
||||
width: z.number(),
|
||||
}),
|
||||
metadata: z.any().nullable(),
|
||||
system: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const validateNavigationItem = z.object({
|
||||
links: z.array(z.object({ href: z.string(), title: z.string() })),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
export type NavigationItem = z.infer<typeof validateNavigationItem>
|
||||
|
||||
export const validateCurrentFooterConfigSchema = z.object({
|
||||
all_current_footer: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
about: z.object({
|
||||
title: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
app_downloads: z.object({
|
||||
title: z.string(),
|
||||
app_store: validateAppDownload,
|
||||
google_play: validateAppDownload,
|
||||
}),
|
||||
logoConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
dimension: z.object({
|
||||
height: z.number(),
|
||||
width: z.number(),
|
||||
}),
|
||||
metadata: z.any().nullable(),
|
||||
system: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
navigation: z.array(validateNavigationItem),
|
||||
social_media: z.object({
|
||||
title: z.string(),
|
||||
facebook: z.object({ href: z.string(), title: z.string() }),
|
||||
instagram: z.object({ href: z.string(), title: z.string() }),
|
||||
twitter: z.object({ href: z.string(), title: z.string() }),
|
||||
}),
|
||||
trip_advisor: z.object({
|
||||
title: z.string(),
|
||||
logoConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
dimension: z.object({
|
||||
height: z.number(),
|
||||
width: z.number(),
|
||||
}),
|
||||
metadata: z.any().nullable(),
|
||||
system: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export type CurrentFooterDataRaw = z.infer<
|
||||
typeof validateCurrentFooterConfigSchema
|
||||
>
|
||||
|
||||
export type CurrentFooterData = Omit<
|
||||
CurrentFooterDataRaw["all_current_footer"]["items"][0],
|
||||
"logoConnection"
|
||||
> & {
|
||||
logo: Image
|
||||
}
|
||||
|
||||
const validateCurrentFooterRefConfigSchema = z.object({
|
||||
all_current_footer: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
system: systemSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export type CurrentFooterRefDataRaw = z.infer<
|
||||
typeof validateCurrentFooterRefConfigSchema
|
||||
>
|
||||
|
||||
const validateExternalLink = z
|
||||
.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
const validateInternalLink = z
|
||||
.object({
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
system: z.object({
|
||||
uid: z.string(),
|
||||
locale: z.nativeEnum(Lang),
|
||||
}),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
web: z
|
||||
.object({
|
||||
original_url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.max(1),
|
||||
})
|
||||
.transform((data) => {
|
||||
const node = data.edges[0]?.node
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
const url = node.url
|
||||
const originalUrl = node.web?.original_url
|
||||
const lang = node.system.locale
|
||||
|
||||
return {
|
||||
url: originalUrl || removeMultipleSlashes(`/${lang}/${url}`),
|
||||
title: node.title,
|
||||
}
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const validateLinkItem = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
open_in_new_tab: z.boolean(),
|
||||
link: validateExternalLink,
|
||||
pageConnection: validateInternalLink,
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
url: data.pageConnection?.url ?? data.link?.href ?? "",
|
||||
title: data?.title ?? data.link?.title,
|
||||
openInNewTab: data.open_in_new_tab,
|
||||
isExternal: !data.pageConnection?.url,
|
||||
}
|
||||
})
|
||||
|
||||
const validateLinks = z
|
||||
.array(validateLinkItem)
|
||||
.transform((data) => data.filter((item) => item.url))
|
||||
|
||||
export const validateSecondaryLinks = z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
links: validateLinks,
|
||||
})
|
||||
)
|
||||
|
||||
export const validateLinksWithType = z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
href: validateExternalLink,
|
||||
})
|
||||
)
|
||||
|
||||
export const validateFooterConfigSchema = z
|
||||
.object({
|
||||
all_footer: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
main_links: validateLinks.default([]),
|
||||
app_downloads: z.object({
|
||||
title: z.string(),
|
||||
links: validateLinksWithType.default([]),
|
||||
}),
|
||||
secondary_links: validateSecondaryLinks.default([]),
|
||||
social_media: z.object({
|
||||
links: validateLinksWithType.default([]),
|
||||
}),
|
||||
tertiary_links: validateLinks.default([]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
const {
|
||||
main_links,
|
||||
app_downloads,
|
||||
secondary_links,
|
||||
social_media,
|
||||
tertiary_links,
|
||||
} = data.all_footer.items[0]
|
||||
|
||||
return {
|
||||
mainLinks: main_links,
|
||||
appDownloads: app_downloads,
|
||||
secondaryLinks: secondary_links,
|
||||
socialMedia: social_media,
|
||||
tertiaryLinks: tertiary_links,
|
||||
}
|
||||
})
|
||||
|
||||
const pageConnectionRefs = z.object({
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.max(1),
|
||||
})
|
||||
|
||||
export const validateFooterRefConfigSchema = z.object({
|
||||
all_footer: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
main_links: z
|
||||
.array(
|
||||
z.object({
|
||||
pageConnection: pageConnectionRefs,
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
secondary_links: z
|
||||
.array(
|
||||
z.object({
|
||||
links: z.array(
|
||||
z.object({
|
||||
pageConnection: pageConnectionRefs,
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
tertiary_links: z
|
||||
.array(
|
||||
z.object({
|
||||
pageConnection: pageConnectionRefs,
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
system: systemSchema,
|
||||
})
|
||||
)
|
||||
.length(1),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* New Header Validation
|
||||
*/
|
||||
|
||||
const linkRefsSchema = z
|
||||
.object({
|
||||
linkConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.linkConnection.edges.length) {
|
||||
const link = transformPageLinkRef(data.linkConnection.edges[0].node)
|
||||
if (link) {
|
||||
return {
|
||||
link,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { link: null }
|
||||
})
|
||||
|
||||
const menuItemsRefsSchema = z.intersection(
|
||||
linkRefsSchema,
|
||||
z
|
||||
.object({
|
||||
cardConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: cardBlockRefsSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
see_all_link: linkRefsSchema,
|
||||
submenu: z.array(
|
||||
z.object({
|
||||
links: z.array(linkRefsSchema),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => {
|
||||
let card = null
|
||||
if (data.cardConnection.edges.length) {
|
||||
card = transformCardBlockRefs(data.cardConnection.edges[0].node)
|
||||
}
|
||||
|
||||
return {
|
||||
card,
|
||||
see_all_link: data.see_all_link,
|
||||
submenu: data.submenu,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const topLinkRefsSchema = z.object({
|
||||
logged_in: linkRefsSchema.nullable(),
|
||||
logged_out: linkRefsSchema.nullable(),
|
||||
})
|
||||
|
||||
export const headerRefsSchema = z
|
||||
.object({
|
||||
all_header: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
menu_items: z.array(menuItemsRefsSchema),
|
||||
system: systemSchema,
|
||||
top_link: topLinkRefsSchema,
|
||||
})
|
||||
)
|
||||
.max(1),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (!data.all_header.items.length) {
|
||||
console.info(`Zod Error - No header returned in refs request`)
|
||||
throw new ZodError([
|
||||
{
|
||||
code: ZodIssueCode.custom,
|
||||
fatal: true,
|
||||
message: "No header returned (Refs)",
|
||||
path: ["all_header.items"],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
header: data.all_header.items[0],
|
||||
}
|
||||
})
|
||||
|
||||
const linkSchema = z
|
||||
.object({
|
||||
linkConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: discriminatedUnion(linkUnionSchema.options),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.linkConnection.edges.length) {
|
||||
const linkNode = data.linkConnection.edges[0].node
|
||||
if (linkNode) {
|
||||
const link = transformPageLink(linkNode)
|
||||
if (link) {
|
||||
return {
|
||||
link,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
link: null,
|
||||
}
|
||||
})
|
||||
|
||||
const titleSchema = z.object({
|
||||
title: z.string().optional().default(""),
|
||||
})
|
||||
|
||||
/**
|
||||
* Intersection has to be used since you are not
|
||||
* allowed to merge two schemas where one uses
|
||||
* transform
|
||||
*/
|
||||
const linkAndTitleSchema = z.intersection(linkSchema, titleSchema)
|
||||
|
||||
/**
|
||||
* Same as above 👆
|
||||
*/
|
||||
export const menuItemSchema = z
|
||||
.intersection(
|
||||
linkAndTitleSchema,
|
||||
z
|
||||
.object({
|
||||
cardConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: cardBlockSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
see_all_link: linkAndTitleSchema,
|
||||
submenu: z.array(
|
||||
z.object({
|
||||
links: z.array(linkAndTitleSchema),
|
||||
title: z.string().optional().default(""),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => {
|
||||
let card = null
|
||||
if (data.cardConnection.edges.length) {
|
||||
card = transformCardBlock(data.cardConnection.edges[0].node)
|
||||
}
|
||||
|
||||
return {
|
||||
card,
|
||||
seeAllLink: data.see_all_link,
|
||||
submenu: data.submenu,
|
||||
}
|
||||
})
|
||||
)
|
||||
.transform((data) => {
|
||||
return {
|
||||
...data,
|
||||
link: data.submenu.length ? null : data.link,
|
||||
seeAllLink: data.submenu.length ? data.seeAllLink : null,
|
||||
}
|
||||
})
|
||||
|
||||
const topLinkItemSchema = z.intersection(
|
||||
linkAndTitleSchema,
|
||||
z.object({
|
||||
icon: z
|
||||
.enum(["loyalty", "info", "offer"])
|
||||
.nullable()
|
||||
.transform((icon) => {
|
||||
switch (icon) {
|
||||
case "loyalty":
|
||||
return IconName.Gift
|
||||
case "info":
|
||||
return IconName.InfoCircle
|
||||
case "offer":
|
||||
return IconName.PriceTag
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export const topLinkSchema = z.object({
|
||||
logged_in: topLinkItemSchema.nullable(),
|
||||
logged_out: topLinkItemSchema.nullable(),
|
||||
})
|
||||
|
||||
export const headerSchema = z
|
||||
.object({
|
||||
all_header: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
menu_items: z.array(menuItemSchema),
|
||||
top_link: topLinkSchema,
|
||||
})
|
||||
)
|
||||
.max(1),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (!data.all_header.items.length) {
|
||||
console.info(`Zod Error - No header returned in request`)
|
||||
throw new ZodError([
|
||||
{
|
||||
code: ZodIssueCode.custom,
|
||||
fatal: true,
|
||||
message: "No header returned",
|
||||
path: ["all_header.items"],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const header = data.all_header.items[0]
|
||||
return {
|
||||
header: {
|
||||
menuItems: header.menu_items,
|
||||
topLink: header.top_link,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const alertSchema = z
|
||||
.object({
|
||||
type: z.nativeEnum(AlertTypeEnum),
|
||||
text: z.string(),
|
||||
heading: z.string(),
|
||||
phone_contact: z.object({
|
||||
display_text: z.string(),
|
||||
phone_number: z.string().nullable(),
|
||||
footnote: z.string().nullable(),
|
||||
}),
|
||||
has_link: z.boolean(),
|
||||
link: linkAndTitleSchema,
|
||||
has_sidepeek_button: z.boolean(),
|
||||
sidepeek_button: z.object({
|
||||
cta_text: z.string(),
|
||||
}),
|
||||
sidepeek_content: z.object({
|
||||
heading: z.string(),
|
||||
content: z.object({
|
||||
json: z.any(),
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkUnionSchema.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform(
|
||||
({
|
||||
type,
|
||||
heading,
|
||||
text,
|
||||
phone_contact,
|
||||
has_link,
|
||||
link,
|
||||
has_sidepeek_button,
|
||||
sidepeek_button,
|
||||
sidepeek_content,
|
||||
}) => {
|
||||
const hasLink = has_link && link.link
|
||||
return {
|
||||
type,
|
||||
text,
|
||||
heading,
|
||||
phoneContact:
|
||||
phone_contact.display_text && phone_contact.phone_number
|
||||
? {
|
||||
displayText: phone_contact.display_text,
|
||||
phoneNumber: phone_contact.phone_number,
|
||||
footnote: phone_contact.footnote,
|
||||
}
|
||||
: null,
|
||||
hasSidepeekButton: !!has_sidepeek_button,
|
||||
link: hasLink
|
||||
? {
|
||||
url: link.link.url,
|
||||
title: link.title,
|
||||
}
|
||||
: null,
|
||||
sidepeekButton:
|
||||
!hasLink && has_sidepeek_button ? sidepeek_button : null,
|
||||
sidepeekContent:
|
||||
!hasLink && has_sidepeek_button ? sidepeek_content : null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const siteConfigSchema = z
|
||||
.object({
|
||||
all_site_config: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
sitewide_alert: z.object({
|
||||
booking_widget_disabled: z.boolean(),
|
||||
alertConnection: z.object({
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
node: alertSchema,
|
||||
})
|
||||
)
|
||||
.max(1),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.max(1),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (!data.all_site_config.items.length) {
|
||||
return {
|
||||
sitewideAlert: null,
|
||||
bookingWidgetDisabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
const { sitewide_alert } = data.all_site_config.items[0]
|
||||
|
||||
return {
|
||||
sitewideAlert: sitewide_alert.alertConnection.edges[0]?.node || null,
|
||||
bookingWidgetDisabled: sitewide_alert.booking_widget_disabled,
|
||||
}
|
||||
})
|
||||
|
||||
const sidepeekContentRefSchema = z.object({
|
||||
content: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const alertConnectionRefSchema = z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
link: linkRefsSchema,
|
||||
sidepeek_content: sidepeekContentRefSchema,
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const siteConfigRefSchema = z.object({
|
||||
all_site_config: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
sitewide_alert: z.object({
|
||||
alertConnection: alertConnectionRefSchema,
|
||||
}),
|
||||
system: systemSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
776
apps/scandic-web/server/routers/contentstack/base/query.ts
Normal file
776
apps/scandic-web/server/routers/contentstack/base/query.ts
Normal file
@@ -0,0 +1,776 @@
|
||||
import { cache } from "react"
|
||||
|
||||
import { GetContactConfig } from "@/lib/graphql/Query/ContactConfig.graphql"
|
||||
import {
|
||||
GetCurrentFooter,
|
||||
GetCurrentFooterRef,
|
||||
} from "@/lib/graphql/Query/Current/Footer.graphql"
|
||||
import {
|
||||
GetCurrentHeader,
|
||||
GetCurrentHeaderRef,
|
||||
} from "@/lib/graphql/Query/Current/Header.graphql"
|
||||
import { GetFooter, GetFooterRef } from "@/lib/graphql/Query/Footer.graphql"
|
||||
import { GetHeader, GetHeaderRef } from "@/lib/graphql/Query/Header.graphql"
|
||||
import {
|
||||
GetSiteConfig,
|
||||
GetSiteConfigRef,
|
||||
} from "@/lib/graphql/Query/SiteConfig.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import { contentstackBaseProcedure, router } from "@/server/trpc"
|
||||
import { langInput } from "@/server/utils"
|
||||
|
||||
import {
|
||||
generateRefsResponseTag,
|
||||
generateTag,
|
||||
generateTags,
|
||||
generateTagsFromSystem,
|
||||
} from "@/utils/generateTag"
|
||||
|
||||
import {
|
||||
type ContactConfigData,
|
||||
type CurrentFooterDataRaw,
|
||||
type CurrentFooterRefDataRaw,
|
||||
type CurrentHeaderRefDataRaw,
|
||||
type GetCurrentHeaderData,
|
||||
headerRefsSchema,
|
||||
headerSchema,
|
||||
siteConfigRefSchema,
|
||||
siteConfigSchema,
|
||||
validateContactConfigSchema,
|
||||
validateCurrentFooterConfigSchema,
|
||||
validateCurrentHeaderConfigSchema,
|
||||
validateFooterConfigSchema,
|
||||
validateFooterRefConfigSchema,
|
||||
} from "./output"
|
||||
import {
|
||||
getContactConfigCounter,
|
||||
getContactConfigFailCounter,
|
||||
getContactConfigSuccessCounter,
|
||||
getCurrentFooterCounter,
|
||||
getCurrentFooterFailCounter,
|
||||
getCurrentFooterRefCounter,
|
||||
getCurrentFooterSuccessCounter,
|
||||
getCurrentHeaderCounter,
|
||||
getCurrentHeaderFailCounter,
|
||||
getCurrentHeaderRefCounter,
|
||||
getCurrentHeaderSuccessCounter,
|
||||
getFooterCounter,
|
||||
getFooterFailCounter,
|
||||
getFooterRefCounter,
|
||||
getFooterRefFailCounter,
|
||||
getFooterRefSuccessCounter,
|
||||
getFooterSuccessCounter,
|
||||
getHeaderCounter,
|
||||
getHeaderFailCounter,
|
||||
getHeaderRefsCounter,
|
||||
getHeaderRefsFailCounter,
|
||||
getHeaderRefsSuccessCounter,
|
||||
getHeaderSuccessCounter,
|
||||
getSiteConfigCounter,
|
||||
getSiteConfigFailCounter,
|
||||
getSiteConfigRefCounter,
|
||||
getSiteConfigRefFailCounter,
|
||||
getSiteConfigRefSuccessCounter,
|
||||
getSiteConfigSuccessCounter,
|
||||
} from "./telemetry"
|
||||
import {
|
||||
getAlertPhoneContactData,
|
||||
getConnections,
|
||||
getFooterConnections,
|
||||
getSiteConfigConnections,
|
||||
} from "./utils"
|
||||
|
||||
import type {
|
||||
FooterDataRaw,
|
||||
FooterRefDataRaw,
|
||||
} from "@/types/components/footer/footer"
|
||||
import type {
|
||||
GetHeader as GetHeaderData,
|
||||
GetHeaderRefs,
|
||||
} from "@/types/trpc/routers/contentstack/header"
|
||||
import type {
|
||||
GetSiteConfigData,
|
||||
GetSiteConfigRefData,
|
||||
} from "@/types/trpc/routers/contentstack/siteConfig"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const getContactConfig = cache(async (lang: Lang) => {
|
||||
getContactConfigCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.contactConfig start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
const response = await request<ContactConfigData>(
|
||||
GetContactConfig,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [`${lang}:contact`],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
|
||||
getContactConfigFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
|
||||
console.error(
|
||||
"contentstack.config not found error",
|
||||
JSON.stringify({ query: { lang }, error: { code: notFoundError.code } })
|
||||
)
|
||||
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedContactConfigConfig = validateContactConfigSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!validatedContactConfigConfig.success) {
|
||||
getContactConfigFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedContactConfigConfig.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.contactConfig validation error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: validatedContactConfigConfig.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getContactConfigSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.contactConfig success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
return validatedContactConfigConfig.data.all_contact_config.items[0]
|
||||
})
|
||||
|
||||
export const baseQueryRouter = router({
|
||||
contact: contentstackBaseProcedure.query(async ({ ctx }) => {
|
||||
return await getContactConfig(ctx.lang)
|
||||
}),
|
||||
header: contentstackBaseProcedure.query(async ({ ctx }) => {
|
||||
const { lang } = ctx
|
||||
getHeaderRefsCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.header.refs start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
const responseRef = await request<GetHeaderRefs>(
|
||||
GetHeaderRef,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(lang, "header")],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!responseRef.data) {
|
||||
const notFoundError = notFound(responseRef)
|
||||
getHeaderRefsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.header.refs not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedHeaderRefs = headerRefsSchema.safeParse(responseRef.data)
|
||||
|
||||
if (!validatedHeaderRefs.success) {
|
||||
getHeaderRefsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedHeaderRefs.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.header.refs validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: validatedHeaderRefs.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getHeaderRefsSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.header.refs success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
const connections = getConnections(validatedHeaderRefs.data)
|
||||
|
||||
getHeaderCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.header start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
const tags = [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedHeaderRefs.data.header.system.uid),
|
||||
].flat()
|
||||
|
||||
const response = await request<GetHeaderData>(
|
||||
GetHeader,
|
||||
{ locale: lang },
|
||||
{ cache: "force-cache", next: { tags } }
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getHeaderFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.header not found error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedHeaderConfig = headerSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedHeaderConfig.success) {
|
||||
getHeaderFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedHeaderConfig.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.header validation error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: validatedHeaderConfig.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getHeaderSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.header success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
return {
|
||||
data: validatedHeaderConfig.data.header,
|
||||
}
|
||||
}),
|
||||
currentHeader: contentstackBaseProcedure
|
||||
.input(langInput)
|
||||
.query(async ({ input }) => {
|
||||
getCurrentHeaderRefCounter.add(1, { lang: input.lang })
|
||||
console.info(
|
||||
"contentstack.currentHeader.ref start",
|
||||
JSON.stringify({ query: { lang: input.lang } })
|
||||
)
|
||||
const responseRef = await request<CurrentHeaderRefDataRaw>(
|
||||
GetCurrentHeaderRef,
|
||||
{
|
||||
locale: input.lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(input.lang, "current_header")],
|
||||
},
|
||||
}
|
||||
)
|
||||
getCurrentHeaderCounter.add(1, { lang: input.lang })
|
||||
console.info(
|
||||
"contentstack.currentHeader start",
|
||||
JSON.stringify({
|
||||
query: { lang: input.lang },
|
||||
})
|
||||
)
|
||||
|
||||
const currentHeaderUID =
|
||||
responseRef.data.all_current_header.items[0].system.uid
|
||||
// There's currently no error handling/validation for the responseRef, should it be added?
|
||||
const response = await request<GetCurrentHeaderData>(
|
||||
GetCurrentHeader,
|
||||
{ locale: input.lang },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(input.lang, currentHeaderUID)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getCurrentHeaderFailCounter.add(1, {
|
||||
lang: input.lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.currentHeader not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: input.lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedHeaderConfig = validateCurrentHeaderConfigSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!validatedHeaderConfig.success) {
|
||||
getCurrentHeaderFailCounter.add(1, {
|
||||
lang: input.lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedHeaderConfig.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.currentHeader validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: input.lang,
|
||||
},
|
||||
error: validatedHeaderConfig.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getCurrentHeaderSuccessCounter.add(1, { lang: input.lang })
|
||||
console.info(
|
||||
"contentstack.currentHeader success",
|
||||
JSON.stringify({
|
||||
query: { lang: input.lang },
|
||||
})
|
||||
)
|
||||
|
||||
return validatedHeaderConfig.data
|
||||
}),
|
||||
currentFooter: contentstackBaseProcedure
|
||||
.input(langInput)
|
||||
.query(async ({ input }) => {
|
||||
getCurrentFooterRefCounter.add(1, { lang: input.lang })
|
||||
console.info(
|
||||
"contentstack.currentFooter.ref start",
|
||||
JSON.stringify({ query: { lang: input.lang } })
|
||||
)
|
||||
const responseRef = await request<CurrentFooterRefDataRaw>(
|
||||
GetCurrentFooterRef,
|
||||
{
|
||||
locale: input.lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(input.lang, "current_footer")],
|
||||
},
|
||||
}
|
||||
)
|
||||
// There's currently no error handling/validation for the responseRef, should it be added?
|
||||
getCurrentFooterCounter.add(1, { lang: input.lang })
|
||||
console.info(
|
||||
"contentstack.currentFooter start",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: input.lang,
|
||||
},
|
||||
})
|
||||
)
|
||||
const currentFooterUID =
|
||||
responseRef.data.all_current_footer.items[0].system.uid
|
||||
|
||||
const response = await request<CurrentFooterDataRaw>(
|
||||
GetCurrentFooter,
|
||||
{
|
||||
locale: input.lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(input.lang, currentFooterUID)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getCurrentFooterFailCounter.add(1, {
|
||||
lang: input.lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.currentFooter not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: input.lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedCurrentFooterConfig =
|
||||
validateCurrentFooterConfigSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedCurrentFooterConfig.success) {
|
||||
getFooterFailCounter.add(1, {
|
||||
lang: input.lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedCurrentFooterConfig.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.currentFooter validation error",
|
||||
JSON.stringify({
|
||||
query: { lang: input.lang },
|
||||
error: validatedCurrentFooterConfig.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getCurrentFooterSuccessCounter.add(1, { lang: input.lang })
|
||||
console.info(
|
||||
"contentstack.currentFooter success",
|
||||
JSON.stringify({ query: { lang: input.lang } })
|
||||
)
|
||||
return validatedCurrentFooterConfig.data.all_current_footer.items[0]
|
||||
}),
|
||||
footer: contentstackBaseProcedure.query(async ({ ctx }) => {
|
||||
const { lang } = ctx
|
||||
getFooterRefCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.footer.ref start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
const responseRef = await request<FooterRefDataRaw>(
|
||||
GetFooterRef,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(lang, "footer")],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!responseRef.data) {
|
||||
const notFoundError = notFound(responseRef)
|
||||
getFooterRefFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.footer.refs not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedFooterRefs = validateFooterRefConfigSchema.safeParse(
|
||||
responseRef.data
|
||||
)
|
||||
|
||||
if (!validatedFooterRefs.success) {
|
||||
getFooterRefFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedFooterRefs.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.footer.refs validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: validatedFooterRefs.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getFooterRefSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.footer.refs success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
const connections = getFooterConnections(validatedFooterRefs.data)
|
||||
const footerUID = responseRef.data.all_footer.items[0].system.uid
|
||||
|
||||
getFooterCounter.add(1, { lang: lang })
|
||||
console.info(
|
||||
"contentstack.footer start",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
})
|
||||
)
|
||||
const tags = [
|
||||
generateTags(lang, connections),
|
||||
generateTag(lang, footerUID),
|
||||
].flat()
|
||||
|
||||
const response = await request<FooterDataRaw>(
|
||||
GetFooter,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getFooterFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.footer not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedFooterConfig = validateFooterConfigSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!validatedFooterConfig.success) {
|
||||
getFooterFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedFooterConfig.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.footer validation error",
|
||||
JSON.stringify({
|
||||
query: { lang: lang },
|
||||
error: validatedFooterConfig.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getFooterSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.footer success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
return validatedFooterConfig.data
|
||||
}),
|
||||
siteConfig: contentstackBaseProcedure
|
||||
.input(langInput)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const lang = input.lang ?? ctx.lang
|
||||
|
||||
getSiteConfigRefCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.siteConfig.ref start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
const responseRef = await request<GetSiteConfigRefData>(
|
||||
GetSiteConfigRef,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(lang, "site_config")],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!responseRef.data) {
|
||||
const notFoundError = notFound(responseRef)
|
||||
getSiteConfigRefFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.siteConfig.refs not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedSiteConfigRef = siteConfigRefSchema.safeParse(
|
||||
responseRef.data
|
||||
)
|
||||
|
||||
if (!validatedSiteConfigRef.success) {
|
||||
getSiteConfigRefFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedSiteConfigRef.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.siteConfig.refs validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
},
|
||||
error: validatedSiteConfigRef.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const connections = getSiteConfigConnections(validatedSiteConfigRef.data)
|
||||
const siteConfigUid = responseRef.data.all_site_config.items[0].system.uid
|
||||
|
||||
const tags = [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, siteConfigUid),
|
||||
].flat()
|
||||
|
||||
getSiteConfigRefSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.siteConfig.refs success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
getSiteConfigCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.siteConfig start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
const [siteConfigResponse, contactConfig] = await Promise.all([
|
||||
request<GetSiteConfigData>(
|
||||
GetSiteConfig,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: { tags },
|
||||
}
|
||||
),
|
||||
getContactConfig(lang),
|
||||
])
|
||||
|
||||
if (!siteConfigResponse.data) {
|
||||
const notFoundError = notFound(siteConfigResponse)
|
||||
|
||||
getSiteConfigFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
|
||||
console.error(
|
||||
"contentstack.siteConfig not found error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedSiteConfig = siteConfigSchema.safeParse(
|
||||
siteConfigResponse.data
|
||||
)
|
||||
|
||||
if (!validatedSiteConfig.success) {
|
||||
getSiteConfigFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedSiteConfig.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.siteConfig validation error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: validatedSiteConfig.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getSiteConfigSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.siteConfig success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
const { sitewideAlert } = validatedSiteConfig.data
|
||||
|
||||
return {
|
||||
...validatedSiteConfig.data,
|
||||
sitewideAlert: sitewideAlert
|
||||
? {
|
||||
...sitewideAlert,
|
||||
phoneContact: contactConfig
|
||||
? getAlertPhoneContactData(sitewideAlert, contactConfig)
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
})
|
||||
112
apps/scandic-web/server/routers/contentstack/base/telemetry.ts
Normal file
112
apps/scandic-web/server/routers/contentstack/base/telemetry.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentstack.base")
|
||||
// OpenTelemetry metrics: ContactConfig
|
||||
export const getContactConfigCounter = meter.createCounter(
|
||||
"trpc.contentstack.contactConfig.get"
|
||||
)
|
||||
export const getContactConfigSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.contactConfig.get-success"
|
||||
)
|
||||
export const getContactConfigFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.contactConfig.get-fail"
|
||||
)
|
||||
// OpenTelemetry metrics: CurrentHeader
|
||||
export const getCurrentHeaderRefCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentHeader.ref.get"
|
||||
)
|
||||
export const getCurrentHeaderRefSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentHeader.ref.get-success"
|
||||
)
|
||||
export const getCurrentHeaderRefFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentHeader.ref.get-fail"
|
||||
)
|
||||
export const getCurrentHeaderCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentHeader.get"
|
||||
)
|
||||
export const getCurrentHeaderSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentHeader.get-success"
|
||||
)
|
||||
export const getCurrentHeaderFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentHeader.get-fail"
|
||||
)
|
||||
|
||||
// OpenTelemetry metrics: Header
|
||||
export const getHeaderRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.header.ref.get"
|
||||
)
|
||||
export const getHeaderRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.header.ref.get-success"
|
||||
)
|
||||
export const getHeaderRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.header.ref.get-fail"
|
||||
)
|
||||
export const getHeaderCounter = meter.createCounter(
|
||||
"trpc.contentstack.header.get"
|
||||
)
|
||||
export const getHeaderSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.header.get-success"
|
||||
)
|
||||
export const getHeaderFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.header.get-fail"
|
||||
)
|
||||
|
||||
// OpenTelemetry metrics: CurrentFooter
|
||||
export const getCurrentFooterRefCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentFooter.ref.get"
|
||||
)
|
||||
export const getCurrentFooterRefSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentFooter.ref.get-success"
|
||||
)
|
||||
export const getCurrentFooterRefFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentFooter.ref.get-fail"
|
||||
)
|
||||
export const getCurrentFooterCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentFooter.get"
|
||||
)
|
||||
export const getCurrentFooterSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentFooter.get-success"
|
||||
)
|
||||
export const getCurrentFooterFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.currentFooter.get-fail"
|
||||
)
|
||||
|
||||
// OpenTelemetry metrics: Footer
|
||||
export const getFooterRefCounter = meter.createCounter(
|
||||
"trpc.contentstack.footer.ref.get"
|
||||
)
|
||||
export const getFooterRefSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.footer.ref.get-success"
|
||||
)
|
||||
export const getFooterRefFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.footer.ref.get-fail"
|
||||
)
|
||||
export const getFooterCounter = meter.createCounter(
|
||||
"trpc.contentstack.footer.get"
|
||||
)
|
||||
export const getFooterSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.footer.get-success"
|
||||
)
|
||||
export const getFooterFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.footer.get-fail"
|
||||
)
|
||||
|
||||
// OpenTelemetry metrics: SiteConfig
|
||||
export const getSiteConfigRefCounter = meter.createCounter(
|
||||
"trpc.contentstack.SiteConfig.ref.get"
|
||||
)
|
||||
export const getSiteConfigRefSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.SiteConfig.ref.get-success"
|
||||
)
|
||||
export const getSiteConfigRefFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.SiteConfig.ref.get-fail"
|
||||
)
|
||||
export const getSiteConfigCounter = meter.createCounter(
|
||||
"trpc.contentstack.SiteConfig.get"
|
||||
)
|
||||
export const getSiteConfigSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.SiteConfig.get-success"
|
||||
)
|
||||
export const getSiteConfigFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.SiteConfig.get-fail"
|
||||
)
|
||||
122
apps/scandic-web/server/routers/contentstack/base/utils.ts
Normal file
122
apps/scandic-web/server/routers/contentstack/base/utils.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getValueFromContactConfig } from "@/utils/contactConfig"
|
||||
|
||||
import type { FooterRefDataRaw } from "@/types/components/footer/footer"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type { Edges } from "@/types/requests/utils/edges"
|
||||
import type { NodeRefs } from "@/types/requests/utils/refs"
|
||||
import type { HeaderRefs } from "@/types/trpc/routers/contentstack/header"
|
||||
import type {
|
||||
AlertOutput,
|
||||
GetSiteConfigRefData,
|
||||
} from "@/types/trpc/routers/contentstack/siteConfig"
|
||||
import type { ContactConfig } from "./output"
|
||||
|
||||
export function getConnections({ header }: HeaderRefs) {
|
||||
const connections: System["system"][] = [header.system]
|
||||
|
||||
if (header.top_link) {
|
||||
if (header.top_link.logged_in?.link) {
|
||||
connections.push(header.top_link.logged_in.link)
|
||||
}
|
||||
if (header.top_link.logged_out?.link) {
|
||||
connections.push(header.top_link.logged_out.link)
|
||||
}
|
||||
}
|
||||
|
||||
if (header.menu_items.length) {
|
||||
header.menu_items.forEach((menuItem) => {
|
||||
if (menuItem.card) {
|
||||
connections.push(...menuItem.card)
|
||||
}
|
||||
if (menuItem.link) {
|
||||
connections.push(menuItem.link)
|
||||
}
|
||||
if (menuItem.see_all_link?.link) {
|
||||
connections.push(menuItem.see_all_link.link)
|
||||
}
|
||||
if (menuItem.submenu.length) {
|
||||
menuItem.submenu.forEach((subMenuItem) => {
|
||||
if (subMenuItem.links.length) {
|
||||
subMenuItem.links.forEach((link) => {
|
||||
if (link?.link) {
|
||||
connections.push(link.link)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
export function getFooterConnections(refs: FooterRefDataRaw) {
|
||||
const connections: Edges<NodeRefs>[] = []
|
||||
const footerData = refs.all_footer.items[0]
|
||||
const mainLinks = footerData.main_links
|
||||
const secondaryLinks = footerData.secondary_links
|
||||
const tertiaryLinks = footerData.tertiary_links
|
||||
if (mainLinks) {
|
||||
mainLinks.forEach(({ pageConnection }) => {
|
||||
connections.push(pageConnection)
|
||||
})
|
||||
}
|
||||
secondaryLinks?.forEach(({ links }) => {
|
||||
if (links) {
|
||||
links.forEach(({ pageConnection }) => {
|
||||
connections.push(pageConnection)
|
||||
})
|
||||
}
|
||||
})
|
||||
if (tertiaryLinks) {
|
||||
tertiaryLinks.forEach(({ pageConnection }) => {
|
||||
connections.push(pageConnection)
|
||||
})
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
connections.push(node.system)
|
||||
|
||||
const link = node.link.link
|
||||
if (link) {
|
||||
connections.push(link)
|
||||
}
|
||||
node.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
|
||||
({ node }) => {
|
||||
connections.push(node.system)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
export function getAlertPhoneContactData(
|
||||
alert: AlertOutput,
|
||||
contactConfig: ContactConfig
|
||||
) {
|
||||
if (alert.phoneContact) {
|
||||
const { displayText, phoneNumber, footnote } = alert.phoneContact
|
||||
|
||||
return {
|
||||
displayText,
|
||||
phoneNumber: getValueFromContactConfig(phoneNumber, contactConfig),
|
||||
footnote: footnote
|
||||
? getValueFromContactConfig(footnote, contactConfig)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { breadcrumbsQueryRouter } from "./query"
|
||||
|
||||
export const breadcrumbsRouter = mergeRouters(breadcrumbsQueryRouter)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import { systemSchema } from "../schemas/system"
|
||||
import { homeBreadcrumbs } from "./utils"
|
||||
|
||||
export const breadcrumbsRefsSchema = z.object({
|
||||
web: z
|
||||
.object({
|
||||
breadcrumbs: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
parentsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
export const rawBreadcrumbsDataSchema = z.object({
|
||||
url: z.string(),
|
||||
web: z.object({
|
||||
breadcrumbs: z.object({
|
||||
title: z.string(),
|
||||
parentsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
web: z.object({
|
||||
breadcrumbs: z.object({
|
||||
title: z.string(),
|
||||
}),
|
||||
}),
|
||||
system: systemSchema,
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
const pageBreadcrumb = {
|
||||
title,
|
||||
uid: data.system.uid,
|
||||
href: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
|
||||
}
|
||||
const homeBreadcrumb = homeBreadcrumbs[data.system.locale]
|
||||
|
||||
return [homeBreadcrumb, parentBreadcrumbs, pageBreadcrumb].flat()
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
import { cache } from "react"
|
||||
|
||||
import {
|
||||
GetMyPagesBreadcrumbs,
|
||||
GetMyPagesBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/AccountPage.graphql"
|
||||
import {
|
||||
GetCollectionPageBreadcrumbs,
|
||||
GetCollectionPageBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/CollectionPage.graphql"
|
||||
import {
|
||||
GetContentPageBreadcrumbs,
|
||||
GetContentPageBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/ContentPage.graphql"
|
||||
import {
|
||||
GetDestinationCityPageBreadcrumbs,
|
||||
GetDestinationCityPageBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/DestinationCityPage.graphql"
|
||||
import {
|
||||
GetDestinationCountryPageBreadcrumbs,
|
||||
GetDestinationCountryPageBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/DestinationCountryPage.graphql"
|
||||
import {
|
||||
GetDestinationOverviewPageBreadcrumbs,
|
||||
GetDestinationOverviewPageBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/DestinationOverviewPage.graphql"
|
||||
import {
|
||||
GetHotelPageBreadcrumbs,
|
||||
GetHotelPageBreadcrumbsRefs,
|
||||
} from "@/lib/graphql/Query/Breadcrumbs/HotelPage.graphql"
|
||||
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 { breadcrumbsRefsSchema, breadcrumbsSchema } from "./output"
|
||||
import { getTags } from "./utils"
|
||||
|
||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||
import type {
|
||||
BreadcrumbsRefsSchema,
|
||||
RawBreadcrumbsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/breadcrumbs"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const meter = metrics.getMeter("trpc.breadcrumbs")
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
interface BreadcrumbsPageData<T> {
|
||||
dataKey: keyof T
|
||||
refQuery: string
|
||||
query: string
|
||||
}
|
||||
|
||||
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(
|
||||
"contentstack.breadcrumbs refs validation error",
|
||||
JSON.stringify({
|
||||
error: validatedRefsData.error,
|
||||
})
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
getBreadcrumbsRefsSuccessCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.breadcrumbs refs get success",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
|
||||
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(
|
||||
"contentstack.breadcrumbs get not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
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 = {
|
||||
lang: ctx.lang,
|
||||
uid: ctx.uid,
|
||||
}
|
||||
|
||||
switch (ctx.contentType) {
|
||||
case PageContentTypeEnum.accountPage:
|
||||
return await getBreadcrumbs<{
|
||||
account_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "account_page",
|
||||
refQuery: GetMyPagesBreadcrumbsRefs,
|
||||
query: GetMyPagesBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.collectionPage:
|
||||
return await getBreadcrumbs<{
|
||||
collection_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "collection_page",
|
||||
refQuery: GetCollectionPageBreadcrumbsRefs,
|
||||
query: GetCollectionPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.contentPage:
|
||||
return await getBreadcrumbs<{
|
||||
content_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "content_page",
|
||||
refQuery: GetContentPageBreadcrumbsRefs,
|
||||
query: GetContentPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.destinationOverviewPage:
|
||||
return await getBreadcrumbs<{
|
||||
destination_overview_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "destination_overview_page",
|
||||
refQuery: GetDestinationOverviewPageBreadcrumbsRefs,
|
||||
query: GetDestinationOverviewPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.destinationCountryPage:
|
||||
return await getBreadcrumbs<{
|
||||
destination_country_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "destination_country_page",
|
||||
refQuery: GetDestinationCountryPageBreadcrumbsRefs,
|
||||
query: GetDestinationCountryPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.destinationCityPage:
|
||||
return await getBreadcrumbs<{
|
||||
destination_city_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "destination_city_page",
|
||||
refQuery: GetDestinationCityPageBreadcrumbsRefs,
|
||||
query: GetDestinationCityPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.hotelPage:
|
||||
return await getBreadcrumbs<{
|
||||
hotel_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "hotel_page",
|
||||
refQuery: GetHotelPageBreadcrumbsRefs,
|
||||
query: GetHotelPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
case PageContentTypeEnum.loyaltyPage:
|
||||
return await getBreadcrumbs<{
|
||||
loyalty_page: RawBreadcrumbsSchema
|
||||
}>(
|
||||
{
|
||||
dataKey: "loyalty_page",
|
||||
refQuery: GetLoyaltyPageBreadcrumbsRefs,
|
||||
query: GetLoyaltyPageBreadcrumbs,
|
||||
},
|
||||
variables
|
||||
)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
import { generateTag, generateTags } from "@/utils/generateTag"
|
||||
|
||||
import type { Edges } from "@/types/requests/utils/edges"
|
||||
import type { NodeRefs } from "@/types/requests/utils/refs"
|
||||
import type { BreadcrumbsRefsSchema } from "@/types/trpc/routers/contentstack/breadcrumbs"
|
||||
|
||||
export const affix = "breadcrumbs"
|
||||
|
||||
// TODO: Make these editable in CMS?
|
||||
export const homeBreadcrumbs: {
|
||||
[key in keyof typeof Lang]: { href: string; title: string; uid: string }
|
||||
} = {
|
||||
[Lang.da]: {
|
||||
href: "/da",
|
||||
title: "Hjem",
|
||||
uid: "da",
|
||||
},
|
||||
[Lang.de]: {
|
||||
href: "/de",
|
||||
title: "Heim",
|
||||
uid: "de",
|
||||
},
|
||||
[Lang.en]: {
|
||||
href: "/en",
|
||||
title: "Home",
|
||||
uid: "en",
|
||||
},
|
||||
[Lang.fi]: {
|
||||
href: "/fi",
|
||||
title: "Koti",
|
||||
uid: "fi",
|
||||
},
|
||||
[Lang.no]: {
|
||||
href: "/no",
|
||||
title: "Hjem",
|
||||
uid: "no",
|
||||
},
|
||||
[Lang.sv]: {
|
||||
href: "/sv",
|
||||
title: "Hem",
|
||||
uid: "sv",
|
||||
},
|
||||
}
|
||||
|
||||
export function getConnections(data: BreadcrumbsRefsSchema) {
|
||||
const connections: Edges<NodeRefs>[] = []
|
||||
|
||||
if (data.web?.breadcrumbs) {
|
||||
connections.push(data.web.breadcrumbs.parentsConnection)
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { collectionPageQueryRouter } from "./query"
|
||||
|
||||
export const collectionPageRouter = mergeRouters(collectionPageQueryRouter)
|
||||
@@ -0,0 +1,160 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import {
|
||||
cardGridRefsSchema,
|
||||
cardsGridSchema,
|
||||
} from "../schemas/blocks/cardsGrid"
|
||||
import {
|
||||
dynamicContentRefsSchema,
|
||||
dynamicContentSchema as blockDynamicContentSchema,
|
||||
} from "../schemas/blocks/dynamicContent"
|
||||
import {
|
||||
shortcutsRefsSchema,
|
||||
shortcutsSchema,
|
||||
} from "../schemas/blocks/shortcuts"
|
||||
import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid"
|
||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||
import {
|
||||
linkAndTitleSchema,
|
||||
linkConnectionRefs,
|
||||
} from "../schemas/linkConnection"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { CollectionPageEnum } from "@/types/enums/collectionPage"
|
||||
|
||||
// Block schemas
|
||||
export const collectionPageCards = z
|
||||
.object({
|
||||
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.CardsGrid),
|
||||
})
|
||||
.merge(cardsGridSchema)
|
||||
|
||||
export const collectionPageShortcuts = z
|
||||
.object({
|
||||
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.Shortcuts),
|
||||
})
|
||||
.merge(shortcutsSchema)
|
||||
|
||||
export const collectionPageUspGrid = z
|
||||
.object({
|
||||
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.UspGrid),
|
||||
})
|
||||
.merge(uspGridSchema)
|
||||
|
||||
export const collectionPageDynamicContent = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
CollectionPageEnum.ContentStack.blocks.DynamicContent
|
||||
),
|
||||
})
|
||||
.merge(blockDynamicContentSchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
collectionPageCards,
|
||||
collectionPageDynamicContent,
|
||||
collectionPageShortcuts,
|
||||
collectionPageUspGrid,
|
||||
])
|
||||
|
||||
const navigationLinksSchema = z
|
||||
.array(linkAndTitleSchema)
|
||||
.nullable()
|
||||
.transform((data) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((item) => !!item.link)
|
||||
.map((item) => ({
|
||||
url: item.link!.url,
|
||||
title: item.title || item.link!.title,
|
||||
}))
|
||||
})
|
||||
|
||||
const topPrimaryButtonSchema = linkAndTitleSchema
|
||||
.nullable()
|
||||
.transform((data) => {
|
||||
if (!data?.link) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
url: data.link.url,
|
||||
title: data.title || data.link.title || null,
|
||||
}
|
||||
})
|
||||
|
||||
// Content Page Schema and types
|
||||
export const collectionPageSchema = z.object({
|
||||
collection_page: z.object({
|
||||
hero_image: tempImageVaultAssetSchema,
|
||||
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
|
||||
title: z.string(),
|
||||
header: z.object({
|
||||
heading: z.string(),
|
||||
preamble: z.string(),
|
||||
top_primary_button: topPrimaryButtonSchema,
|
||||
navigation_links: navigationLinksSchema,
|
||||
}),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
/** REFS */
|
||||
const collectionPageCardsRefs = z
|
||||
.object({
|
||||
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.CardsGrid),
|
||||
})
|
||||
.merge(cardGridRefsSchema)
|
||||
|
||||
const collectionPageShortcutsRefs = z
|
||||
.object({
|
||||
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.Shortcuts),
|
||||
})
|
||||
.merge(shortcutsRefsSchema)
|
||||
|
||||
const collectionPageUspGridRefs = z
|
||||
.object({
|
||||
__typename: z.literal(CollectionPageEnum.ContentStack.blocks.UspGrid),
|
||||
})
|
||||
.merge(uspGridRefsSchema)
|
||||
|
||||
const contentPageDynamicContentRefs = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
CollectionPageEnum.ContentStack.blocks.DynamicContent
|
||||
),
|
||||
})
|
||||
.merge(dynamicContentRefsSchema)
|
||||
|
||||
const collectionPageBlockRefsItem = z.discriminatedUnion("__typename", [
|
||||
collectionPageShortcutsRefs,
|
||||
contentPageDynamicContentRefs,
|
||||
collectionPageCardsRefs,
|
||||
collectionPageUspGridRefs,
|
||||
])
|
||||
|
||||
const collectionPageHeaderRefs = z.object({
|
||||
navigation_links: z.array(linkConnectionRefs),
|
||||
top_primary_button: linkConnectionRefs.nullable(),
|
||||
})
|
||||
|
||||
export const collectionPageRefsSchema = z.object({
|
||||
collection_page: z.object({
|
||||
header: collectionPageHeaderRefs,
|
||||
blocks: discriminatedUnionArray(
|
||||
collectionPageBlockRefsItem.options
|
||||
).nullable(),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { GetCollectionPage } from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
|
||||
|
||||
import { collectionPageSchema } from "./output"
|
||||
import {
|
||||
fetchCollectionPageRefs,
|
||||
generatePageTags,
|
||||
getCollectionPageCounter,
|
||||
validateCollectionPageRefs,
|
||||
} from "./utils"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { GetCollectionPageSchema } from "@/types/trpc/routers/contentstack/collectionPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export const collectionPageQueryRouter = router({
|
||||
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
|
||||
const { lang, uid } = ctx
|
||||
|
||||
const collectionPageRefsData = await fetchCollectionPageRefs(lang, uid)
|
||||
|
||||
const collectionPageRefs = validateCollectionPageRefs(
|
||||
collectionPageRefsData,
|
||||
lang,
|
||||
uid
|
||||
)
|
||||
if (!collectionPageRefs) {
|
||||
return null
|
||||
}
|
||||
const tags = generatePageTags(collectionPageRefs, lang)
|
||||
|
||||
getCollectionPageCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.collectionPage start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
const response = await request<GetCollectionPageSchema>(
|
||||
GetCollectionPage,
|
||||
{ locale: lang, uid },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const collectionPage = collectionPageSchema.safeParse(response.data)
|
||||
if (!collectionPage.success) {
|
||||
console.error(
|
||||
`Failed to validate CollectionPage Data - (lang: ${lang}, uid: ${uid})`
|
||||
)
|
||||
console.error(collectionPage.error?.format())
|
||||
return null
|
||||
}
|
||||
|
||||
const tracking: TrackingSDKPageData = {
|
||||
pageId: collectionPage.data.collection_page.system.uid,
|
||||
domainLanguage: lang,
|
||||
publishDate: collectionPage.data.collection_page.system.updated_at,
|
||||
createDate: collectionPage.data.collection_page.system.created_at,
|
||||
channel: TrackingChannelEnum["collection-page"],
|
||||
pageType: "collectionpage",
|
||||
pageName: collectionPage.data.trackingProps.url,
|
||||
siteSections: collectionPage.data.trackingProps.url,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
return {
|
||||
collectionPage: collectionPage.data.collection_page,
|
||||
tracking,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { GetCollectionPageRefs } from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
|
||||
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
|
||||
|
||||
import { collectionPageRefsSchema } from "./output"
|
||||
|
||||
import { CollectionPageEnum } from "@/types/enums/collectionPage"
|
||||
import { System } from "@/types/requests/system"
|
||||
import {
|
||||
CollectionPageRefs,
|
||||
GetCollectionPageRefsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/collectionPage"
|
||||
|
||||
const meter = metrics.getMeter("trpc.collectionPage")
|
||||
// OpenTelemetry metrics: CollectionPage
|
||||
|
||||
export const getCollectionPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.collectionPage.get"
|
||||
)
|
||||
|
||||
const getCollectionPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.collectionPage.get"
|
||||
)
|
||||
const getCollectionPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.collectionPage.get-fail"
|
||||
)
|
||||
const getCollectionPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.collectionPage.get-success"
|
||||
)
|
||||
|
||||
export async function fetchCollectionPageRefs(lang: Lang, uid: string) {
|
||||
getCollectionPageRefsCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.collectionPage.refs start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
const refsResponse = await request<GetCollectionPageRefsSchema>(
|
||||
GetCollectionPageRefs,
|
||||
{ locale: lang, uid },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getCollectionPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
code: notFoundError.code,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.collectionPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
uid,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
return refsResponse.data
|
||||
}
|
||||
|
||||
export function validateCollectionPageRefs(
|
||||
data: GetCollectionPageRefsSchema,
|
||||
lang: Lang,
|
||||
uid: string
|
||||
) {
|
||||
const validatedData = collectionPageRefsSchema.safeParse(data)
|
||||
if (!validatedData.success) {
|
||||
getCollectionPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.collectionPage.refs validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getCollectionPageRefsSuccessCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.collectionPage.refs success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
return validatedData.data
|
||||
}
|
||||
|
||||
export function generatePageTags(
|
||||
validatedData: CollectionPageRefs,
|
||||
lang: Lang
|
||||
): string[] {
|
||||
const connections = getConnections(validatedData)
|
||||
return [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedData.collection_page.system.uid),
|
||||
].flat()
|
||||
}
|
||||
|
||||
export function getConnections({ collection_page }: CollectionPageRefs) {
|
||||
const connections: System["system"][] = [collection_page.system]
|
||||
if (collection_page.blocks) {
|
||||
collection_page.blocks.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case CollectionPageEnum.ContentStack.blocks.Shortcuts: {
|
||||
if (block.shortcuts.shortcuts.length) {
|
||||
connections.push(...block.shortcuts.shortcuts)
|
||||
}
|
||||
break
|
||||
}
|
||||
case CollectionPageEnum.ContentStack.blocks.CardsGrid: {
|
||||
if (block.cards_grid.length) {
|
||||
connections.push(...block.cards_grid)
|
||||
}
|
||||
break
|
||||
}
|
||||
case CollectionPageEnum.ContentStack.blocks.UspGrid: {
|
||||
if (block.usp_grid.length) {
|
||||
connections.push(...block.usp_grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { contentPageQueryRouter } from "./query"
|
||||
|
||||
export const contentPageRouter = mergeRouters(contentPageQueryRouter)
|
||||
@@ -0,0 +1,335 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import {
|
||||
accordionRefsSchema,
|
||||
accordionSchema,
|
||||
} from "../schemas/blocks/accordion"
|
||||
import {
|
||||
cardGridRefsSchema,
|
||||
cardsGridSchema,
|
||||
} from "../schemas/blocks/cardsGrid"
|
||||
import {
|
||||
contentRefsSchema as blockContentRefsSchema,
|
||||
contentSchema as blockContentSchema,
|
||||
} from "../schemas/blocks/content"
|
||||
import {
|
||||
dynamicContentRefsSchema,
|
||||
dynamicContentSchema as blockDynamicContentSchema,
|
||||
} from "../schemas/blocks/dynamicContent"
|
||||
import { hotelListingSchema } from "../schemas/blocks/hotelListing"
|
||||
import {
|
||||
shortcutsRefsSchema,
|
||||
shortcutsSchema,
|
||||
} from "../schemas/blocks/shortcuts"
|
||||
import { tableSchema } from "../schemas/blocks/table"
|
||||
import { textColsRefsSchema, textColsSchema } from "../schemas/blocks/textCols"
|
||||
import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid"
|
||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||
import {
|
||||
linkAndTitleSchema,
|
||||
linkConnectionRefs,
|
||||
} from "../schemas/linkConnection"
|
||||
import {
|
||||
contentRefsSchema as sidebarContentRefsSchema,
|
||||
contentSchema as sidebarContentSchema,
|
||||
} from "../schemas/sidebar/content"
|
||||
import { dynamicContentSchema as sidebarDynamicContentSchema } from "../schemas/sidebar/dynamicContent"
|
||||
import {
|
||||
joinLoyaltyContactRefsSchema,
|
||||
joinLoyaltyContactSchema,
|
||||
} from "../schemas/sidebar/joinLoyaltyContact"
|
||||
import {
|
||||
quickLinksRefschema,
|
||||
quickLinksSchema,
|
||||
} from "../schemas/sidebar/quickLinks"
|
||||
import {
|
||||
scriptedCardRefschema,
|
||||
scriptedCardsSchema,
|
||||
} from "../schemas/sidebar/scriptedCard"
|
||||
import {
|
||||
teaserCardRefschema,
|
||||
teaserCardsSchema,
|
||||
} from "../schemas/sidebar/teaserCard"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { ContentPageEnum } from "@/types/enums/contentPage"
|
||||
|
||||
// Block schemas
|
||||
export const contentPageCards = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.CardsGrid),
|
||||
})
|
||||
.merge(cardsGridSchema)
|
||||
|
||||
export const contentPageContent = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Content),
|
||||
})
|
||||
.merge(blockContentSchema)
|
||||
|
||||
export const contentPageDynamicContent = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.DynamicContent),
|
||||
})
|
||||
.merge(blockDynamicContentSchema)
|
||||
|
||||
export const contentPageShortcuts = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Shortcuts),
|
||||
})
|
||||
.merge(shortcutsSchema)
|
||||
|
||||
export const contentPageTextCols = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.TextCols),
|
||||
})
|
||||
.merge(textColsSchema)
|
||||
|
||||
export const contentPageUspGrid = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.UspGrid),
|
||||
})
|
||||
.merge(uspGridSchema)
|
||||
|
||||
export const contentPageTable = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Table),
|
||||
})
|
||||
.merge(tableSchema)
|
||||
|
||||
export const contentPageAccordion = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Accordion),
|
||||
})
|
||||
.merge(accordionSchema)
|
||||
|
||||
export const contentPageHotelListing = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.HotelListing),
|
||||
})
|
||||
.merge(hotelListingSchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
contentPageAccordion,
|
||||
contentPageCards,
|
||||
contentPageContent,
|
||||
contentPageDynamicContent,
|
||||
contentPageShortcuts,
|
||||
contentPageTable,
|
||||
contentPageTextCols,
|
||||
contentPageUspGrid,
|
||||
contentPageHotelListing,
|
||||
])
|
||||
|
||||
export const contentPageSidebarContent = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.Content),
|
||||
})
|
||||
.merge(sidebarContentSchema)
|
||||
|
||||
export const contentPageSidebarDynamicContent = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.DynamicContent),
|
||||
})
|
||||
.merge(sidebarDynamicContentSchema)
|
||||
|
||||
export const contentPageJoinLoyaltyContact = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
ContentPageEnum.ContentStack.sidebar.JoinLoyaltyContact
|
||||
),
|
||||
})
|
||||
.merge(joinLoyaltyContactSchema)
|
||||
|
||||
export const contentPageSidebarScriptedCard = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.ScriptedCard),
|
||||
})
|
||||
.merge(scriptedCardsSchema)
|
||||
|
||||
export const contentPageSidebarTeaserCard = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.TeaserCard),
|
||||
})
|
||||
.merge(teaserCardsSchema)
|
||||
|
||||
export const contentPageSidebarQuicklinks = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.QuickLinks),
|
||||
})
|
||||
.merge(quickLinksSchema)
|
||||
|
||||
export const sidebarSchema = z.discriminatedUnion("__typename", [
|
||||
contentPageSidebarContent,
|
||||
contentPageSidebarDynamicContent,
|
||||
contentPageJoinLoyaltyContact,
|
||||
contentPageSidebarScriptedCard,
|
||||
contentPageSidebarTeaserCard,
|
||||
contentPageSidebarQuicklinks,
|
||||
])
|
||||
|
||||
const navigationLinksSchema = z
|
||||
.array(linkAndTitleSchema)
|
||||
.nullable()
|
||||
.transform((data) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((item) => !!item.link)
|
||||
.map((item) => ({
|
||||
url: item.link!.url,
|
||||
title: item.title || item.link!.title,
|
||||
}))
|
||||
})
|
||||
|
||||
const topPrimaryButtonSchema = linkAndTitleSchema
|
||||
.nullable()
|
||||
.transform((data) => {
|
||||
if (!data?.link) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
url: data.link.url,
|
||||
title: data.title || data.link.title || null,
|
||||
}
|
||||
})
|
||||
|
||||
// Content Page Schema and types
|
||||
export const contentPageSchema = z.object({
|
||||
content_page: z.object({
|
||||
hero_image: tempImageVaultAssetSchema,
|
||||
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
|
||||
sidebar: discriminatedUnionArray(sidebarSchema.options).nullable(),
|
||||
title: z.string(),
|
||||
header: z.object({
|
||||
heading: z.string(),
|
||||
preamble: z.string(),
|
||||
top_primary_button: topPrimaryButtonSchema,
|
||||
navigation_links: navigationLinksSchema,
|
||||
}),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
/** REFS */
|
||||
const contentPageCardsRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.CardsGrid),
|
||||
})
|
||||
.merge(cardGridRefsSchema)
|
||||
|
||||
const contentPageBlockContentRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Content),
|
||||
})
|
||||
.merge(blockContentRefsSchema)
|
||||
|
||||
const contentPageDynamicContentRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.DynamicContent),
|
||||
})
|
||||
.merge(dynamicContentRefsSchema)
|
||||
|
||||
const contentPageShortcutsRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Shortcuts),
|
||||
})
|
||||
.merge(shortcutsRefsSchema)
|
||||
|
||||
const contentPageTextColsRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.TextCols),
|
||||
})
|
||||
.merge(textColsRefsSchema)
|
||||
|
||||
const contentPageUspGridRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.UspGrid),
|
||||
})
|
||||
.merge(uspGridRefsSchema)
|
||||
|
||||
const contentPageAccordionRefs = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.blocks.Accordion),
|
||||
})
|
||||
.merge(accordionRefsSchema)
|
||||
|
||||
const contentPageBlockRefsItem = z.discriminatedUnion("__typename", [
|
||||
contentPageAccordionRefs,
|
||||
contentPageBlockContentRefs,
|
||||
contentPageShortcutsRefs,
|
||||
contentPageCardsRefs,
|
||||
contentPageDynamicContentRefs,
|
||||
contentPageTextColsRefs,
|
||||
contentPageUspGridRefs,
|
||||
])
|
||||
|
||||
const contentPageSidebarContentRef = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.Content),
|
||||
})
|
||||
.merge(sidebarContentRefsSchema)
|
||||
|
||||
const contentPageSidebarJoinLoyaltyContactRef = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
ContentPageEnum.ContentStack.sidebar.JoinLoyaltyContact
|
||||
),
|
||||
})
|
||||
.merge(joinLoyaltyContactRefsSchema)
|
||||
|
||||
const contentPageSidebarScriptedCardRef = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.ScriptedCard),
|
||||
})
|
||||
.merge(scriptedCardRefschema)
|
||||
|
||||
const contentPageSidebarTeaserCardRef = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.TeaserCard),
|
||||
})
|
||||
.merge(teaserCardRefschema)
|
||||
|
||||
const contentPageSidebarQuickLinksRef = z
|
||||
.object({
|
||||
__typename: z.literal(ContentPageEnum.ContentStack.sidebar.QuickLinks),
|
||||
})
|
||||
.merge(quickLinksRefschema)
|
||||
|
||||
const contentPageSidebarRefsItem = z.discriminatedUnion("__typename", [
|
||||
contentPageSidebarContentRef,
|
||||
contentPageSidebarJoinLoyaltyContactRef,
|
||||
contentPageSidebarScriptedCardRef,
|
||||
contentPageSidebarTeaserCardRef,
|
||||
contentPageSidebarQuickLinksRef,
|
||||
])
|
||||
|
||||
const contentPageHeaderRefs = z.object({
|
||||
navigation_links: z.array(linkConnectionRefs),
|
||||
top_primary_button: linkConnectionRefs.nullable(),
|
||||
})
|
||||
|
||||
export const contentPageRefsSchema = z.object({
|
||||
content_page: z.object({
|
||||
header: contentPageHeaderRefs,
|
||||
blocks: discriminatedUnionArray(
|
||||
contentPageBlockRefsItem.options
|
||||
).nullable(),
|
||||
sidebar: discriminatedUnionArray(
|
||||
contentPageSidebarRefsItem.options
|
||||
).nullable(),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { batchRequest } from "@/lib/graphql/batchRequest"
|
||||
import {
|
||||
GetContentPage,
|
||||
GetContentPageBlocksBatch1,
|
||||
GetContentPageBlocksBatch2,
|
||||
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
|
||||
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
|
||||
|
||||
import { contentPageSchema } from "./output"
|
||||
import {
|
||||
createChannel,
|
||||
createPageType,
|
||||
fetchContentPageRefs,
|
||||
generatePageTags,
|
||||
getContentPageCounter,
|
||||
} from "./utils"
|
||||
|
||||
import type { TrackingSDKPageData } from "@/types/components/tracking"
|
||||
import type { GetContentPageSchema } from "@/types/trpc/routers/contentstack/contentPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export const contentPageQueryRouter = router({
|
||||
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
|
||||
const { lang, uid } = ctx
|
||||
|
||||
const contentPageRefs = await fetchContentPageRefs(lang, uid)
|
||||
|
||||
if (!contentPageRefs) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tags = generatePageTags(contentPageRefs, lang)
|
||||
|
||||
getContentPageCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.contentPage start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
const contentPageRequest = await batchRequest<GetContentPageSchema>([
|
||||
{
|
||||
document: GetContentPage,
|
||||
variables: { locale: lang, uid },
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
document: GetContentPageBlocksBatch1,
|
||||
variables: { locale: lang, uid },
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
document: GetContentPageBlocksBatch2,
|
||||
variables: { locale: lang, uid },
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const contentPage = contentPageSchema.safeParse(contentPageRequest.data)
|
||||
if (!contentPage.success) {
|
||||
console.error(
|
||||
`Failed to validate Contentpage Data - (lang: ${lang}, uid: ${uid})`
|
||||
)
|
||||
console.error(contentPage.error?.format())
|
||||
return null
|
||||
}
|
||||
|
||||
const tracking: TrackingSDKPageData = {
|
||||
pageId: contentPage.data.content_page.system.uid,
|
||||
domainLanguage: lang,
|
||||
publishDate: contentPage.data.content_page.system.updated_at,
|
||||
createDate: contentPage.data.content_page.system.created_at,
|
||||
channel: createChannel(contentPage.data.content_page.system.uid),
|
||||
pageType: createPageType(contentPage.data.content_page.system.uid),
|
||||
pageName: contentPage.data.trackingProps.url,
|
||||
siteSections: contentPage.data.trackingProps.url,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
return {
|
||||
contentPage: contentPage.data.content_page,
|
||||
tracking,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import { batchRequest } from "@/lib/graphql/batchRequest"
|
||||
import {
|
||||
GetContentPageBlocksRefs,
|
||||
GetContentPageRefs,
|
||||
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
|
||||
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
|
||||
|
||||
import { contentPageRefsSchema } from "./output"
|
||||
|
||||
import { TrackingChannelEnum } from "@/types/components/tracking"
|
||||
import { ContentPageEnum } from "@/types/enums/contentPage"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type {
|
||||
ContentPageRefs,
|
||||
GetContentPageRefsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/contentPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentPage")
|
||||
// OpenTelemetry metrics: ContentPage
|
||||
|
||||
export const getContentPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.contentPage.get"
|
||||
)
|
||||
|
||||
const getContentPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.contentPage.get"
|
||||
)
|
||||
const getContentPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.contentPage.get-fail"
|
||||
)
|
||||
const getContentPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.contentPage.get-success"
|
||||
)
|
||||
|
||||
export async function fetchContentPageRefs(lang: Lang, uid: string) {
|
||||
getContentPageRefsCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.contentPage.refs start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
const res = await batchRequest<GetContentPageRefsSchema>([
|
||||
{
|
||||
document: GetContentPageRefs,
|
||||
variables: { locale: lang, uid },
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
document: GetContentPageBlocksRefs,
|
||||
variables: { locale: lang, uid },
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid + 1)],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
if (!res.data) {
|
||||
const notFoundError = notFound(res)
|
||||
getContentPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
code: notFoundError.code,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.contentPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
uid,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
const validatedData = contentPageRefsSchema.safeParse(res.data)
|
||||
if (!validatedData.success) {
|
||||
getContentPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.contentPage.refs validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getContentPageRefsSuccessCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.contentPage.refs success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
return validatedData.data
|
||||
}
|
||||
|
||||
export function generatePageTags(
|
||||
validatedData: ContentPageRefs,
|
||||
lang: Lang
|
||||
): string[] {
|
||||
const connections = getConnections(validatedData)
|
||||
return [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedData.content_page.system.uid),
|
||||
].flat()
|
||||
}
|
||||
|
||||
export function getConnections({ content_page }: ContentPageRefs) {
|
||||
const connections: System["system"][] = [content_page.system]
|
||||
if (content_page.blocks) {
|
||||
content_page.blocks.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case ContentPageEnum.ContentStack.blocks.Accordion: {
|
||||
if (block.accordion.length) {
|
||||
connections.push(...block.accordion)
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentPageEnum.ContentStack.blocks.Content:
|
||||
{
|
||||
if (block.content.length) {
|
||||
// TS has trouble infering the filtered types
|
||||
// @ts-ignore
|
||||
connections.push(...block.content)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ContentPageEnum.ContentStack.blocks.CardsGrid: {
|
||||
if (block.cards_grid.length) {
|
||||
connections.push(...block.cards_grid)
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentPageEnum.ContentStack.blocks.DynamicContent: {
|
||||
if (block.dynamic_content.link) {
|
||||
connections.push(block.dynamic_content.link)
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentPageEnum.ContentStack.blocks.Shortcuts: {
|
||||
if (block.shortcuts.shortcuts.length) {
|
||||
connections.push(...block.shortcuts.shortcuts)
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentPageEnum.ContentStack.blocks.TextCols: {
|
||||
if (block.text_cols.length) {
|
||||
connections.push(...block.text_cols)
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentPageEnum.ContentStack.blocks.UspGrid: {
|
||||
if (block.usp_grid.length) {
|
||||
connections.push(...block.usp_grid)
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentPageEnum.ContentStack.blocks.CardsGrid: {
|
||||
if (block.cards_grid.length) {
|
||||
block.cards_grid.forEach((card) => {
|
||||
connections.push(card)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (content_page.sidebar) {
|
||||
content_page.sidebar.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case ContentPageEnum.ContentStack.sidebar.Content:
|
||||
if (block.content.length) {
|
||||
connections.push(...block.content)
|
||||
}
|
||||
break
|
||||
case ContentPageEnum.ContentStack.sidebar.JoinLoyaltyContact:
|
||||
if (block.join_loyalty_contact?.button) {
|
||||
connections.push(block.join_loyalty_contact.button)
|
||||
}
|
||||
break
|
||||
case ContentPageEnum.ContentStack.sidebar.ScriptedCard:
|
||||
if (block.scripted_card?.length) {
|
||||
connections.push(...block.scripted_card)
|
||||
}
|
||||
break
|
||||
case ContentPageEnum.ContentStack.sidebar.TeaserCard:
|
||||
if (block.teaser_card?.length) {
|
||||
connections.push(...block.teaser_card)
|
||||
}
|
||||
break
|
||||
case ContentPageEnum.ContentStack.sidebar.QuickLinks:
|
||||
if (block.shortcuts.shortcuts.length) {
|
||||
connections.push(...block.shortcuts.shortcuts)
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
return connections
|
||||
}
|
||||
|
||||
const signupContentPageUid = "blt0e6bd6c4d7224f07"
|
||||
const signupVerifyContentPageUid = "blt3247a2a29b34a8e8"
|
||||
|
||||
export function createPageType(uid: string): string {
|
||||
switch (uid) {
|
||||
case signupContentPageUid:
|
||||
return "memberprofilecreatepage"
|
||||
case signupVerifyContentPageUid:
|
||||
return "memberprofilecreatesuccesspage"
|
||||
default:
|
||||
return "staticcontentpage"
|
||||
}
|
||||
}
|
||||
|
||||
export function createChannel(uid: string): TrackingChannelEnum {
|
||||
switch (uid) {
|
||||
case signupContentPageUid:
|
||||
case signupVerifyContentPageUid:
|
||||
return TrackingChannelEnum["scandic-friends"]
|
||||
default:
|
||||
return TrackingChannelEnum["static-content-page"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { destinationCityPageQueryRouter } from "./query"
|
||||
|
||||
export const destinationCityPageRouter = mergeRouters(
|
||||
destinationCityPageQueryRouter
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const getHotelListDataInput = z.object({
|
||||
cityIdentifier: z.string(),
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import {
|
||||
accordionRefsSchema,
|
||||
accordionSchema,
|
||||
} from "../schemas/blocks/accordion"
|
||||
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
|
||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
linkUnionSchema,
|
||||
transformPageLink,
|
||||
} from "../schemas/pageLinks"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import type { ImageVaultAsset } from "@/types/components/imageVault"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import { DestinationCityPageEnum } from "@/types/enums/destinationCityPage"
|
||||
|
||||
export const destinationCityListDataSchema = z
|
||||
.object({
|
||||
all_destination_city_page: z.object({
|
||||
items: z.array(
|
||||
z
|
||||
.object({
|
||||
heading: z.string(),
|
||||
preamble: z.string(),
|
||||
experiences: z
|
||||
.object({
|
||||
destination_experiences: z.array(z.string()),
|
||||
})
|
||||
.transform(
|
||||
({ destination_experiences }) => destination_experiences
|
||||
),
|
||||
images: z
|
||||
.array(z.object({ image: tempImageVaultAssetSchema }))
|
||||
.transform((images) =>
|
||||
images
|
||||
.map((image) => image.image)
|
||||
.filter((image): image is ImageVaultAsset => !!image)
|
||||
),
|
||||
url: z.string(),
|
||||
system: systemSchema,
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
...data,
|
||||
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
|
||||
}
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform(
|
||||
({ all_destination_city_page }) => all_destination_city_page.items?.[0]
|
||||
)
|
||||
|
||||
export const destinationCityPageContent = z
|
||||
.object({
|
||||
__typename: z.literal(DestinationCityPageEnum.ContentStack.blocks.Content),
|
||||
})
|
||||
.merge(contentSchema)
|
||||
|
||||
export const destinationCityPageAccordion = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationCityPageEnum.ContentStack.blocks.Accordion
|
||||
),
|
||||
})
|
||||
.merge(accordionSchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
destinationCityPageAccordion,
|
||||
destinationCityPageContent,
|
||||
])
|
||||
|
||||
export const destinationCityPageSchema = z
|
||||
.object({
|
||||
destination_city_page: z.object({
|
||||
title: z.string(),
|
||||
destination_settings: z
|
||||
.object({
|
||||
city_denmark: z.string().optional().nullable(),
|
||||
city_finland: z.string().optional().nullable(),
|
||||
city_germany: z.string().optional().nullable(),
|
||||
city_poland: z.string().optional().nullable(),
|
||||
city_norway: z.string().optional().nullable(),
|
||||
city_sweden: z.string().optional().nullable(),
|
||||
})
|
||||
.transform(
|
||||
({
|
||||
city_denmark,
|
||||
city_finland,
|
||||
city_germany,
|
||||
city_norway,
|
||||
city_poland,
|
||||
city_sweden,
|
||||
}) => {
|
||||
const cities = [
|
||||
city_denmark,
|
||||
city_finland,
|
||||
city_germany,
|
||||
city_poland,
|
||||
city_norway,
|
||||
city_sweden,
|
||||
].filter((city): city is string => Boolean(city))
|
||||
|
||||
return { city: cities[0] }
|
||||
}
|
||||
),
|
||||
heading: z.string(),
|
||||
preamble: z.string(),
|
||||
experiences: z
|
||||
.object({
|
||||
destination_experiences: z.array(z.string()),
|
||||
})
|
||||
.transform(({ destination_experiences }) => destination_experiences),
|
||||
images: z
|
||||
.array(z.object({ image: tempImageVaultAssetSchema }))
|
||||
.transform((images) =>
|
||||
images
|
||||
.map((image) => image.image)
|
||||
.filter((image): image is ImageVaultAsset => !!image)
|
||||
),
|
||||
has_sidepeek: z.boolean().default(false),
|
||||
sidepeek_button_text: z.string().default(""),
|
||||
sidepeek_content: z.object({
|
||||
heading: z.string(),
|
||||
content: z.object({
|
||||
json: z.any(),
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkUnionSchema.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
const destinationCityPage = data.destination_city_page
|
||||
const system = destinationCityPage.system
|
||||
const trackingUrl = data.trackingProps.url
|
||||
|
||||
const tracking: TrackingSDKPageData = {
|
||||
pageId: system.uid,
|
||||
domainLanguage: system.locale,
|
||||
publishDate: system.updated_at,
|
||||
createDate: system.created_at,
|
||||
channel: TrackingChannelEnum["destination-page"],
|
||||
pageType: "staticcontentpage",
|
||||
pageName: trackingUrl,
|
||||
siteSections: trackingUrl,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
return {
|
||||
destinationCityPage,
|
||||
tracking,
|
||||
}
|
||||
})
|
||||
|
||||
export const cityPageUrlsSchema = z
|
||||
.object({
|
||||
all_destination_city_page: z.object({
|
||||
items: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.string(),
|
||||
destination_settings: z
|
||||
.object({
|
||||
city_denmark: z.string().optional().nullable(),
|
||||
city_finland: z.string().optional().nullable(),
|
||||
city_germany: z.string().optional().nullable(),
|
||||
city_poland: z.string().optional().nullable(),
|
||||
city_norway: z.string().optional().nullable(),
|
||||
city_sweden: z.string().optional().nullable(),
|
||||
})
|
||||
.transform(
|
||||
({
|
||||
city_denmark,
|
||||
city_finland,
|
||||
city_germany,
|
||||
city_norway,
|
||||
city_poland,
|
||||
city_sweden,
|
||||
}) => {
|
||||
const cities = [
|
||||
city_denmark,
|
||||
city_finland,
|
||||
city_germany,
|
||||
city_poland,
|
||||
city_norway,
|
||||
city_sweden,
|
||||
].filter((city): city is string => Boolean(city))
|
||||
|
||||
return { city: cities[0] }
|
||||
}
|
||||
),
|
||||
system: systemSchema,
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
city: data.destination_settings.city,
|
||||
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
|
||||
}
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform(({ all_destination_city_page }) => all_destination_city_page.items)
|
||||
|
||||
/** REFS */
|
||||
const destinationCityPageContentRefs = z
|
||||
.object({
|
||||
__typename: z.literal(DestinationCityPageEnum.ContentStack.blocks.Content),
|
||||
})
|
||||
.merge(contentRefsSchema)
|
||||
|
||||
const destinationCityPageAccordionRefs = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationCityPageEnum.ContentStack.blocks.Accordion
|
||||
),
|
||||
})
|
||||
.merge(accordionRefsSchema)
|
||||
|
||||
const blocksRefsSchema = z.discriminatedUnion("__typename", [
|
||||
destinationCityPageAccordionRefs,
|
||||
destinationCityPageContentRefs,
|
||||
])
|
||||
|
||||
export const destinationCityPageRefsSchema = z.object({
|
||||
destination_city_page: z.object({
|
||||
sidepeek_content: z.object({
|
||||
content: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
GetDestinationCityPage,
|
||||
GetDestinationCityPageRefs,
|
||||
} from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.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 { getCityByCityIdentifier } from "../../hotels/utils"
|
||||
import {
|
||||
destinationCityPageRefsSchema,
|
||||
destinationCityPageSchema,
|
||||
} from "./output"
|
||||
import {
|
||||
getDestinationCityPageCounter,
|
||||
getDestinationCityPageFailCounter,
|
||||
getDestinationCityPageRefsCounter,
|
||||
getDestinationCityPageRefsFailCounter,
|
||||
getDestinationCityPageRefsSuccessCounter,
|
||||
getDestinationCityPageSuccessCounter,
|
||||
} from "./telemetry"
|
||||
import { generatePageTags } from "./utils"
|
||||
|
||||
import type {
|
||||
GetDestinationCityPageData,
|
||||
GetDestinationCityPageRefsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/destinationCityPage"
|
||||
|
||||
export const destinationCityPageQueryRouter = router({
|
||||
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
|
||||
const { lang, uid, serviceToken } = ctx
|
||||
|
||||
getDestinationCityPageRefsCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.destinationCityPage.refs start",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
|
||||
const refsResponse = await request<GetDestinationCityPageRefsSchema>(
|
||||
GetDestinationCityPageRefs,
|
||||
{ locale: lang, uid },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getDestinationCityPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCityPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedRefsData = destinationCityPageRefsSchema.safeParse(
|
||||
refsResponse.data
|
||||
)
|
||||
if (!validatedRefsData.success) {
|
||||
getDestinationCityPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedRefsData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCityPage.refs validation error",
|
||||
JSON.stringify({ query: { lang, uid }, error: validatedRefsData.error })
|
||||
)
|
||||
return null
|
||||
}
|
||||
getDestinationCityPageRefsSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationCityPage.refs success",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
|
||||
const tags = generatePageTags(validatedRefsData.data, lang)
|
||||
|
||||
getDestinationCityPageCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationCityPage start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
const response = await request<GetDestinationCityPageData>(
|
||||
GetDestinationCityPage,
|
||||
{
|
||||
locale: lang,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getDestinationCityPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCityPage not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedResponse = destinationCityPageSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedResponse.success) {
|
||||
getDestinationCityPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedResponse.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCityPage validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedResponse.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
const cityIdentifier =
|
||||
validatedResponse.data.destinationCityPage.destination_settings.city
|
||||
const city = await getCityByCityIdentifier(cityIdentifier, serviceToken)
|
||||
|
||||
if (!city) {
|
||||
getDestinationCityPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find city with cityIdentifier: ${cityIdentifier}`,
|
||||
})
|
||||
|
||||
console.error(
|
||||
"contentstack.destinationCityPage not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: `Couldn't find city with cityIdentifier: ${cityIdentifier}`,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getDestinationCityPageSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationCityPage success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
...validatedResponse.data,
|
||||
cityIdentifier,
|
||||
city,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentstack.destinationCityPage")
|
||||
|
||||
export const getDestinationCityPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCityPage.get"
|
||||
)
|
||||
export const getDestinationCityPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCityPage.get-fail"
|
||||
)
|
||||
export const getDestinationCityPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCityPage.get-success"
|
||||
)
|
||||
|
||||
export const getDestinationCityPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCityPage.get"
|
||||
)
|
||||
export const getDestinationCityPageSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCityPage.get-success"
|
||||
)
|
||||
export const getDestinationCityPageFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCityPage.get-fail"
|
||||
)
|
||||
|
||||
export const getCityPageUrlsCounter = meter.createCounter(
|
||||
"trpc.contentstack.cityPageUrls.get"
|
||||
)
|
||||
export const getCityPageUrlsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.cityPageUrls.get-success"
|
||||
)
|
||||
export const getCityPageUrlsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.cityPageUrls.get-fail"
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
import { GetCityPageUrls } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPageUrl.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
|
||||
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
|
||||
|
||||
import { cityPageUrlsSchema } from "./output"
|
||||
import {
|
||||
getCityPageUrlsCounter,
|
||||
getCityPageUrlsFailCounter,
|
||||
getCityPageUrlsSuccessCounter,
|
||||
} from "./telemetry"
|
||||
|
||||
import { DestinationCityPageEnum } from "@/types/enums/destinationCityPage"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type {
|
||||
DestinationCityPageRefs,
|
||||
GetCityPageUrlsData,
|
||||
} from "@/types/trpc/routers/contentstack/destinationCityPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export function generatePageTags(
|
||||
validatedData: DestinationCityPageRefs,
|
||||
lang: Lang
|
||||
): string[] {
|
||||
const connections = getConnections(validatedData)
|
||||
return [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedData.destination_city_page.system.uid),
|
||||
].flat()
|
||||
}
|
||||
|
||||
export function getConnections({
|
||||
destination_city_page,
|
||||
}: DestinationCityPageRefs) {
|
||||
const connections: System["system"][] = [destination_city_page.system]
|
||||
if (destination_city_page.blocks) {
|
||||
destination_city_page.blocks.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case DestinationCityPageEnum.ContentStack.blocks.Accordion: {
|
||||
if (block.accordion.length) {
|
||||
connections.push(...block.accordion)
|
||||
}
|
||||
break
|
||||
}
|
||||
case DestinationCityPageEnum.ContentStack.blocks.Content:
|
||||
{
|
||||
if (block.content.length) {
|
||||
// TS has trouble infering the filtered types
|
||||
// @ts-ignore
|
||||
connections.push(...block.content)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
if (destination_city_page.sidepeek_content) {
|
||||
destination_city_page.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
|
||||
({ node }) => {
|
||||
connections.push(node.system)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
export async function getCityPageUrls(lang: Lang) {
|
||||
getCityPageUrlsCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.cityPageUrls start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
const tag = `${lang}:city_page_urls`
|
||||
const response = await request<GetCityPageUrlsData>(
|
||||
GetCityPageUrls,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [tag],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
getCityPageUrlsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: `Destination city pages not found for lang: ${lang}`,
|
||||
})
|
||||
console.error(
|
||||
"contentstack.cityPageUrls not found error",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const validatedResponse = cityPageUrlsSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedResponse.success) {
|
||||
getCityPageUrlsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedResponse.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.cityPageUrls validation error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: validatedResponse.error,
|
||||
})
|
||||
)
|
||||
return []
|
||||
}
|
||||
getCityPageUrlsSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.cityPageUrls success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
return validatedResponse.data
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { destinationCountryPageQueryRouter } from "./query"
|
||||
|
||||
export const destinationCountryPageRouter = mergeRouters(
|
||||
destinationCountryPageQueryRouter
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Country } from "@/types/enums/country"
|
||||
|
||||
export const getCityPagesInput = z.object({
|
||||
country: z.nativeEnum(Country),
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import {
|
||||
accordionRefsSchema,
|
||||
accordionSchema,
|
||||
} from "../schemas/blocks/accordion"
|
||||
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
|
||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
linkUnionSchema,
|
||||
transformPageLink,
|
||||
} from "../schemas/pageLinks"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import type { ImageVaultAsset } from "@/types/components/imageVault"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import { Country } from "@/types/enums/country"
|
||||
import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage"
|
||||
|
||||
export const destinationCountryPageContent = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationCountryPageEnum.ContentStack.blocks.Content
|
||||
),
|
||||
})
|
||||
.merge(contentSchema)
|
||||
|
||||
export const destinationCountryPageAccordion = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationCountryPageEnum.ContentStack.blocks.Accordion
|
||||
),
|
||||
})
|
||||
.merge(accordionSchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
destinationCountryPageAccordion,
|
||||
destinationCountryPageContent,
|
||||
])
|
||||
|
||||
export const destinationCountryPageSchema = z
|
||||
.object({
|
||||
destination_country_page: z.object({
|
||||
title: z.string(),
|
||||
destination_settings: z.object({
|
||||
country: z.nativeEnum(Country),
|
||||
}),
|
||||
heading: z.string(),
|
||||
preamble: z.string(),
|
||||
experiences: z
|
||||
.object({
|
||||
destination_experiences: z.array(z.string()),
|
||||
})
|
||||
.transform(({ destination_experiences }) => destination_experiences),
|
||||
images: z
|
||||
.array(z.object({ image: tempImageVaultAssetSchema }))
|
||||
.transform((images) =>
|
||||
images
|
||||
.map((image) => image.image)
|
||||
.filter((image): image is ImageVaultAsset => !!image)
|
||||
),
|
||||
has_sidepeek: z.boolean().default(false),
|
||||
sidepeek_button_text: z.string().default(""),
|
||||
sidepeek_content: z.object({
|
||||
heading: z.string(),
|
||||
content: z.object({
|
||||
json: z.any(),
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkUnionSchema.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
const countryPageData = data.destination_country_page
|
||||
const system = countryPageData.system
|
||||
const trackingUrl = data.trackingProps.url
|
||||
const tracking: TrackingSDKPageData = {
|
||||
pageId: system.uid,
|
||||
domainLanguage: system.locale,
|
||||
publishDate: system.updated_at,
|
||||
createDate: system.created_at,
|
||||
channel: TrackingChannelEnum["destination-page"],
|
||||
pageType: "staticcontentpage",
|
||||
pageName: trackingUrl,
|
||||
siteSections: trackingUrl,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
return {
|
||||
destinationCountryPage: countryPageData,
|
||||
tracking,
|
||||
}
|
||||
})
|
||||
|
||||
export const countryPageUrlsSchema = z
|
||||
.object({
|
||||
all_destination_country_page: z.object({
|
||||
items: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.string(),
|
||||
destination_settings: z.object({
|
||||
country: z.string(),
|
||||
}),
|
||||
system: systemSchema,
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
country: data.destination_settings.country,
|
||||
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
|
||||
}
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform(
|
||||
({ all_destination_country_page }) => all_destination_country_page.items
|
||||
)
|
||||
|
||||
/** REFS */
|
||||
const destinationCountryPageContentRefs = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationCountryPageEnum.ContentStack.blocks.Content
|
||||
),
|
||||
})
|
||||
.merge(contentRefsSchema)
|
||||
|
||||
const destinationCountryPageAccordionRefs = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationCountryPageEnum.ContentStack.blocks.Accordion
|
||||
),
|
||||
})
|
||||
.merge(accordionRefsSchema)
|
||||
|
||||
const blocksRefsSchema = z.discriminatedUnion("__typename", [
|
||||
destinationCountryPageAccordionRefs,
|
||||
destinationCountryPageContentRefs,
|
||||
])
|
||||
export const destinationCountryPageRefsSchema = z.object({
|
||||
destination_country_page: z.object({
|
||||
sidepeek_content: z.object({
|
||||
content: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
GetDestinationCountryPage,
|
||||
GetDestinationCountryPageRefs,
|
||||
} from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentStackBaseWithServiceProcedure,
|
||||
contentStackUidWithServiceProcedure,
|
||||
router,
|
||||
} from "@/server/trpc"
|
||||
import { toApiLang } from "@/server/utils"
|
||||
|
||||
import { generateTag } from "@/utils/generateTag"
|
||||
|
||||
import { getCityPagesInput } from "./input"
|
||||
import {
|
||||
destinationCountryPageRefsSchema,
|
||||
destinationCountryPageSchema,
|
||||
} from "./output"
|
||||
import {
|
||||
getDestinationCountryPageCounter,
|
||||
getDestinationCountryPageFailCounter,
|
||||
getDestinationCountryPageRefsCounter,
|
||||
getDestinationCountryPageRefsFailCounter,
|
||||
getDestinationCountryPageRefsSuccessCounter,
|
||||
getDestinationCountryPageSuccessCounter,
|
||||
} from "./telemetry"
|
||||
import { generatePageTags, getCityPages } from "./utils"
|
||||
|
||||
import { ApiCountry } from "@/types/enums/country"
|
||||
import type {
|
||||
GetDestinationCountryPageData,
|
||||
GetDestinationCountryPageRefsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/destinationCountryPage"
|
||||
|
||||
export const destinationCountryPageQueryRouter = router({
|
||||
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
|
||||
const { lang, uid, serviceToken } = ctx
|
||||
const apiLang = toApiLang(lang)
|
||||
|
||||
getDestinationCountryPageRefsCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.destinationCountryPage.refs start",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
|
||||
const refsResponse = await request<GetDestinationCountryPageRefsSchema>(
|
||||
GetDestinationCountryPageRefs,
|
||||
{ locale: lang, uid },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getDestinationCountryPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCountryPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedRefsData = destinationCountryPageRefsSchema.safeParse(
|
||||
refsResponse.data
|
||||
)
|
||||
if (!validatedRefsData.success) {
|
||||
getDestinationCountryPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedRefsData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCountryPage.refs validation error",
|
||||
JSON.stringify({ query: { lang, uid }, error: validatedRefsData.error })
|
||||
)
|
||||
return null
|
||||
}
|
||||
getDestinationCountryPageRefsSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationCountryPage.refs success",
|
||||
JSON.stringify({ query: { lang, uid } })
|
||||
)
|
||||
|
||||
const tags = generatePageTags(validatedRefsData.data, lang)
|
||||
|
||||
getDestinationCountryPageCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationCountryPage start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
const response = await request<GetDestinationCountryPageData>(
|
||||
GetDestinationCountryPage,
|
||||
{
|
||||
locale: lang,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getDestinationCountryPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCountryPage not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedResponse = destinationCountryPageSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!validatedResponse.success) {
|
||||
getDestinationCountryPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedResponse.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationCountryPage validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedResponse.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
const country =
|
||||
validatedResponse.data.destinationCountryPage.destination_settings.country
|
||||
|
||||
getDestinationCountryPageSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationCountryPage success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
...validatedResponse.data,
|
||||
translatedCountry: ApiCountry[lang][country],
|
||||
}
|
||||
}),
|
||||
cityPages: contentStackBaseWithServiceProcedure
|
||||
.input(getCityPagesInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { lang, serviceToken } = ctx
|
||||
const { country } = input
|
||||
|
||||
const cities = await getCityPages(lang, serviceToken, country)
|
||||
|
||||
return cities
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentstack.destinationCountryPage")
|
||||
|
||||
export const getDestinationCountryPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCountryPage.get"
|
||||
)
|
||||
export const getDestinationCountryPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCountryPage.get-fail"
|
||||
)
|
||||
export const getDestinationCountryPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCountryPage.get-success"
|
||||
)
|
||||
|
||||
export const getDestinationCountryPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCountryPage.get"
|
||||
)
|
||||
export const getDestinationCountryPageSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCountryPage.get-success"
|
||||
)
|
||||
export const getDestinationCountryPageFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationCountryPage.get-fail"
|
||||
)
|
||||
|
||||
export const getCityListDataCounter = meter.createCounter(
|
||||
"trpc.contentstack.cityListData.get"
|
||||
)
|
||||
export const getCityListDataSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.cityListData.get-success"
|
||||
)
|
||||
export const getCityListDataFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.cityListData.get-fail"
|
||||
)
|
||||
|
||||
export const getCountryPageUrlsCounter = meter.createCounter(
|
||||
"trpc.contentstack.getCountryPageUrls"
|
||||
)
|
||||
|
||||
export const getCountryPageUrlsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.getCountryPageUrls-success"
|
||||
)
|
||||
|
||||
export const getCountryPageUrlsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.getCountryPageUrls-fail"
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
import { env } from "@/env/server"
|
||||
import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql"
|
||||
import { GetCountryPageUrls } from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPageUrl.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { toApiLang } from "@/server/utils"
|
||||
|
||||
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
|
||||
|
||||
import { getCitiesByCountry } from "../../hotels/utils"
|
||||
import { destinationCityListDataSchema } from "../destinationCityPage/output"
|
||||
import { countryPageUrlsSchema } from "./output"
|
||||
import {
|
||||
getCityListDataCounter,
|
||||
getCityListDataFailCounter,
|
||||
getCityListDataSuccessCounter,
|
||||
getCountryPageUrlsCounter,
|
||||
getCountryPageUrlsFailCounter,
|
||||
getCountryPageUrlsSuccessCounter,
|
||||
} from "./telemetry"
|
||||
|
||||
import { ApiCountry, type Country } from "@/types/enums/country"
|
||||
import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage"
|
||||
import type { RequestOptionsWithOutBody } from "@/types/fetch"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage"
|
||||
import type {
|
||||
DestinationCountryPageRefs,
|
||||
GetCountryPageUrlsData,
|
||||
} from "@/types/trpc/routers/contentstack/destinationCountryPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export function generatePageTags(
|
||||
validatedData: DestinationCountryPageRefs,
|
||||
lang: Lang
|
||||
): string[] {
|
||||
const connections = getConnections(validatedData)
|
||||
return [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedData.destination_country_page.system.uid),
|
||||
].flat()
|
||||
}
|
||||
|
||||
export function getConnections({
|
||||
destination_country_page,
|
||||
}: DestinationCountryPageRefs) {
|
||||
const connections: System["system"][] = [destination_country_page.system]
|
||||
if (destination_country_page.blocks) {
|
||||
destination_country_page.blocks.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case DestinationCountryPageEnum.ContentStack.blocks.Accordion: {
|
||||
if (block.accordion.length) {
|
||||
connections.push(...block.accordion)
|
||||
}
|
||||
break
|
||||
}
|
||||
case DestinationCountryPageEnum.ContentStack.blocks.Content:
|
||||
{
|
||||
if (block.content.length) {
|
||||
// TS has trouble infering the filtered types
|
||||
// @ts-ignore
|
||||
connections.push(...block.content)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
if (destination_country_page.sidepeek_content) {
|
||||
destination_country_page.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
|
||||
({ node }) => {
|
||||
connections.push(node.system)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
export async function getCityListDataByCityIdentifier(
|
||||
lang: Lang,
|
||||
cityIdentifier: string
|
||||
) {
|
||||
getCityListDataCounter.add(1, { lang, cityIdentifier })
|
||||
console.info(
|
||||
"contentstack.cityListData start",
|
||||
JSON.stringify({ query: { lang, cityIdentifier } })
|
||||
)
|
||||
const tag = `${lang}:city_list_data:${cityIdentifier}`
|
||||
const response = await request<GetDestinationCityListDataResponse>(
|
||||
GetDestinationCityListData,
|
||||
{
|
||||
locale: lang,
|
||||
cityIdentifier,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [tag],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
getCityListDataFailCounter.add(1, {
|
||||
lang,
|
||||
cityIdentifier,
|
||||
error_type: "not_found",
|
||||
error: `Destination city page not found for cityIdentifier: ${cityIdentifier}`,
|
||||
})
|
||||
console.error(
|
||||
"contentstack.cityListData not found error",
|
||||
JSON.stringify({ query: { lang, cityIdentifier } })
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const validatedResponse = destinationCityListDataSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!validatedResponse.success) {
|
||||
getCityListDataFailCounter.add(1, {
|
||||
lang,
|
||||
cityIdentifier,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedResponse.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.cityListData validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, cityIdentifier },
|
||||
error: validatedResponse.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getCityListDataSuccessCounter.add(1, { lang, cityIdentifier })
|
||||
console.info(
|
||||
"contentstack.cityListData success",
|
||||
JSON.stringify({ query: { lang, cityIdentifier } })
|
||||
)
|
||||
|
||||
return validatedResponse.data
|
||||
}
|
||||
|
||||
export async function getCityPages(
|
||||
lang: Lang,
|
||||
serviceToken: string,
|
||||
country: Country
|
||||
) {
|
||||
const apiLang = toApiLang(lang)
|
||||
const params = new URLSearchParams({
|
||||
language: apiLang,
|
||||
})
|
||||
const options: RequestOptionsWithOutBody = {
|
||||
// needs to clear default option as only
|
||||
// cache or next.revalidate is permitted
|
||||
cache: undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${serviceToken}`,
|
||||
},
|
||||
next: {
|
||||
revalidate: env.CACHE_TIME_HOTELS,
|
||||
},
|
||||
}
|
||||
const apiCountry = ApiCountry[lang][country]
|
||||
const cities = await getCitiesByCountry([apiCountry], options, params, lang)
|
||||
|
||||
const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
|
||||
|
||||
const cityPages = await Promise.all(
|
||||
publishedCities.map(async (city) => {
|
||||
if (!city.cityIdentifier) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await getCityListDataByCityIdentifier(
|
||||
lang,
|
||||
city.cityIdentifier
|
||||
)
|
||||
return data ? { ...data, cityName: city.name } : null
|
||||
})
|
||||
)
|
||||
|
||||
return cityPages
|
||||
.flat()
|
||||
.filter((city): city is NonNullable<typeof city> => !!city)
|
||||
}
|
||||
|
||||
export async function getCountryPageUrls(lang: Lang) {
|
||||
getCountryPageUrlsCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.countryPageUrls start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
const tag = `${lang}:country_page_urls`
|
||||
const response = await request<GetCountryPageUrlsData>(
|
||||
GetCountryPageUrls,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [tag],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
getCountryPageUrlsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: `Country pages not found for lang: ${lang}`,
|
||||
})
|
||||
console.error(
|
||||
"contentstack.countryPageUrls not found error",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const validatedCountryPageUrls = countryPageUrlsSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!validatedCountryPageUrls.success) {
|
||||
getCountryPageUrlsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedCountryPageUrls.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.countryPageUrls validation error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: validatedCountryPageUrls.error,
|
||||
})
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
getCountryPageUrlsSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.countryPageUrls success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
return validatedCountryPageUrls.data
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { destinationOverviewPageQueryRouter } from "./query"
|
||||
|
||||
export const destinationOverviewPageRouter = mergeRouters(
|
||||
destinationOverviewPageQueryRouter
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import {
|
||||
cardGalleryRefsSchema,
|
||||
cardGallerySchema,
|
||||
} from "../schemas/blocks/cardGallery"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { DestinationOverviewPageEnum } from "@/types/enums/destinationOverviewPage"
|
||||
|
||||
const destinationOverviewPageCardGallery = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationOverviewPageEnum.ContentStack.blocks.CardGallery
|
||||
),
|
||||
})
|
||||
.merge(cardGallerySchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
destinationOverviewPageCardGallery,
|
||||
])
|
||||
|
||||
export const destinationOverviewPageSchema = z.object({
|
||||
destination_overview_page: z.object({
|
||||
title: z.string(),
|
||||
blocks: discriminatedUnionArray(blocksSchema.options),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
/** REFS */
|
||||
const destinationOverviewPageCardGalleryRef = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
DestinationOverviewPageEnum.ContentStack.blocks.CardGallery
|
||||
),
|
||||
})
|
||||
.merge(cardGalleryRefsSchema)
|
||||
|
||||
const blocksRefsSchema = z.discriminatedUnion("__typename", [
|
||||
destinationOverviewPageCardGalleryRef,
|
||||
])
|
||||
|
||||
export const destinationOverviewPageRefsSchema = z.object({
|
||||
destination_overview_page: z.object({
|
||||
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import { env } from "@/env/server"
|
||||
import {
|
||||
GetDestinationOverviewPage,
|
||||
GetDestinationOverviewPageRefs,
|
||||
} from "@/lib/graphql/Query/DestinationOverviewPage/DestinationOverviewPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentstackExtendedProcedureUID,
|
||||
router,
|
||||
serviceProcedure,
|
||||
} from "@/server/trpc"
|
||||
import { toApiLang } from "@/server/utils"
|
||||
|
||||
import { generateTag } from "@/utils/generateTag"
|
||||
|
||||
import {
|
||||
getCitiesByCountry,
|
||||
getCountries,
|
||||
getHotelIdsByCityId,
|
||||
} from "../../hotels/utils"
|
||||
import { getCityPageUrls } from "../destinationCityPage/utils"
|
||||
import { getCountryPageUrls } from "../destinationCountryPage/utils"
|
||||
import {
|
||||
destinationOverviewPageRefsSchema,
|
||||
destinationOverviewPageSchema,
|
||||
} from "./output"
|
||||
import {
|
||||
getDestinationOverviewPageCounter,
|
||||
getDestinationOverviewPageFailCounter,
|
||||
getDestinationOverviewPageRefsCounter,
|
||||
getDestinationOverviewPageRefsFailCounter,
|
||||
getDestinationOverviewPageRefsSuccessCounter,
|
||||
getDestinationOverviewPageSuccessCounter,
|
||||
} from "./telemetry"
|
||||
|
||||
import type { DestinationsData } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { RequestOptionsWithOutBody } from "@/types/fetch"
|
||||
import type {
|
||||
GetDestinationOverviewPageData,
|
||||
GetDestinationOverviewPageRefsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/destinationOverviewPage"
|
||||
|
||||
export const destinationOverviewPageQueryRouter = router({
|
||||
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
|
||||
const { lang, uid } = ctx
|
||||
|
||||
getDestinationOverviewPageRefsCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationOverviewPage.refs start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
const refsResponse = await request<GetDestinationOverviewPageRefsSchema>(
|
||||
GetDestinationOverviewPageRefs,
|
||||
{
|
||||
locale: lang,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getDestinationOverviewPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationOverviewPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedRefsData = destinationOverviewPageRefsSchema.safeParse(
|
||||
refsResponse.data
|
||||
)
|
||||
|
||||
if (!validatedRefsData.success) {
|
||||
getDestinationOverviewPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedRefsData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationOverviewPage.refs validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedRefsData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getDestinationOverviewPageRefsSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationOverviewPage.refs success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
getDestinationOverviewPageCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationOverviewPage start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
const response = await request<GetDestinationOverviewPageData>(
|
||||
GetDestinationOverviewPage,
|
||||
{
|
||||
locale: lang,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getDestinationOverviewPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationOverviewPage not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const destinationOverviewPage = destinationOverviewPageSchema.safeParse(
|
||||
response.data
|
||||
)
|
||||
|
||||
if (!destinationOverviewPage.success) {
|
||||
getDestinationOverviewPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(destinationOverviewPage.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.destinationOverviewPage validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: destinationOverviewPage.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getDestinationOverviewPageSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.destinationOverviewPage success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
const system = destinationOverviewPage.data.destination_overview_page.system
|
||||
const tracking: TrackingSDKPageData = {
|
||||
pageId: system.uid,
|
||||
domainLanguage: lang,
|
||||
publishDate: system.updated_at,
|
||||
createDate: system.created_at,
|
||||
channel: TrackingChannelEnum["destination-overview-page"],
|
||||
pageType: "staticcontentpage",
|
||||
pageName: destinationOverviewPage.data.trackingProps.url,
|
||||
siteSections: destinationOverviewPage.data.trackingProps.url,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
return {
|
||||
destinationOverviewPage:
|
||||
destinationOverviewPage.data.destination_overview_page,
|
||||
tracking,
|
||||
}
|
||||
}),
|
||||
destinations: router({
|
||||
get: serviceProcedure.query(async function ({ ctx }) {
|
||||
const apiLang = toApiLang(ctx.lang)
|
||||
const params = new URLSearchParams({
|
||||
language: apiLang,
|
||||
})
|
||||
|
||||
const options: RequestOptionsWithOutBody = {
|
||||
// needs to clear default option as only
|
||||
// cache or next.revalidate is permitted
|
||||
cache: undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
next: {
|
||||
revalidate: env.CACHE_TIME_HOTELS,
|
||||
},
|
||||
}
|
||||
const countries = await getCountries(options, params, ctx.lang)
|
||||
const countryPages = await getCountryPageUrls(ctx.lang)
|
||||
|
||||
if (!countries) {
|
||||
return null
|
||||
}
|
||||
|
||||
const countryNames = countries.data.map((country) => country.name)
|
||||
|
||||
const citiesByCountry = await getCitiesByCountry(
|
||||
countryNames,
|
||||
options,
|
||||
params,
|
||||
ctx.lang,
|
||||
true
|
||||
)
|
||||
|
||||
const cityPages = await getCityPageUrls(ctx.lang)
|
||||
|
||||
const destinations: DestinationsData = await Promise.all(
|
||||
Object.entries(citiesByCountry).map(async ([country, cities]) => {
|
||||
const citiesWithHotelCount = await Promise.all(
|
||||
cities.map(async (city) => {
|
||||
const hotelIdsParams = new URLSearchParams({
|
||||
language: apiLang,
|
||||
city: city.id,
|
||||
})
|
||||
|
||||
const hotels = await getHotelIdsByCityId(
|
||||
city.id,
|
||||
options,
|
||||
hotelIdsParams
|
||||
)
|
||||
|
||||
const cityPage = cityPages.find(
|
||||
(cityPage) => cityPage.city === city.cityIdentifier
|
||||
)
|
||||
|
||||
return {
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
hotelIds: hotels,
|
||||
hotelCount: hotels?.length ?? 0,
|
||||
url: cityPage?.url,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const countryPage = countryPages.find(
|
||||
(countryPage) => countryPage.country === country
|
||||
)
|
||||
|
||||
return {
|
||||
country,
|
||||
countryUrl: countryPage?.url,
|
||||
numberOfHotels: citiesWithHotelCount.reduce(
|
||||
(acc, city) => acc + city.hotelCount,
|
||||
0
|
||||
),
|
||||
cities: citiesWithHotelCount,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return destinations.sort((a, b) => a.country.localeCompare(b.country))
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentstack.destinationOverviewPage")
|
||||
|
||||
export const getDestinationOverviewPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationOverviewPage.get"
|
||||
)
|
||||
export const getDestinationOverviewPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationOverviewPage.get-fail"
|
||||
)
|
||||
export const getDestinationOverviewPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationOverviewPage.get-success"
|
||||
)
|
||||
|
||||
export const getDestinationOverviewPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationOverviewPage.get"
|
||||
)
|
||||
export const getDestinationOverviewPageSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationOverviewPage.get-success"
|
||||
)
|
||||
export const getDestinationOverviewPageFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.destinationOverviewPage.get-fail"
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { hotelPageQueryRouter } from "./query"
|
||||
|
||||
export const hotelPageRouter = mergeRouters(hotelPageQueryRouter)
|
||||
138
apps/scandic-web/server/routers/contentstack/hotelPage/output.ts
Normal file
138
apps/scandic-web/server/routers/contentstack/hotelPage/output.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import {
|
||||
activitiesCardRefSchema,
|
||||
activitiesCardSchema,
|
||||
} from "../schemas/blocks/activitiesCard"
|
||||
import { hotelFaqRefsSchema, hotelFaqSchema } from "../schemas/blocks/hotelFaq"
|
||||
import { spaPageRefSchema, spaPageSchema } from "../schemas/blocks/spaPage"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { HotelPageEnum } from "@/types/enums/hotelPage"
|
||||
import type {
|
||||
ActivitiesCard,
|
||||
SpaPage,
|
||||
} from "@/types/trpc/routers/contentstack/hotelPage"
|
||||
|
||||
const contentBlockActivities = z
|
||||
.object({
|
||||
__typename: z.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard),
|
||||
})
|
||||
.merge(activitiesCardSchema)
|
||||
|
||||
const contentBlockSpaPage = z
|
||||
.object({
|
||||
__typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage),
|
||||
})
|
||||
.merge(spaPageSchema)
|
||||
|
||||
export const contentBlock = z.discriminatedUnion("__typename", [
|
||||
contentBlockActivities,
|
||||
contentBlockSpaPage,
|
||||
])
|
||||
|
||||
export const hotelPageSchema = z.object({
|
||||
hotel_page: z
|
||||
.object({
|
||||
hotel_navigation: z
|
||||
.object({
|
||||
overview: z.string().optional(),
|
||||
rooms: z.string().optional(),
|
||||
restaurant_bar: z.string().optional(),
|
||||
conferences_meetings: z.string().optional(),
|
||||
health_wellness: z.string().optional(),
|
||||
activities: z.string().optional(),
|
||||
offers: z.string().optional(),
|
||||
faq: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
content: discriminatedUnionArray(contentBlock.options)
|
||||
.nullable()
|
||||
.transform((data) => {
|
||||
let spaPage: SpaPage | undefined
|
||||
let activitiesCards: ActivitiesCard[] = []
|
||||
|
||||
data?.map((block) => {
|
||||
switch (block.typename) {
|
||||
case HotelPageEnum.ContentStack.blocks.ActivitiesCard:
|
||||
activitiesCards.push(block)
|
||||
break
|
||||
case HotelPageEnum.ContentStack.blocks.SpaPage:
|
||||
spaPage = block
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return { spaPage, activitiesCards }
|
||||
}),
|
||||
faq: hotelFaqSchema.nullable(),
|
||||
hotel_page_id: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform(({ hotel_navigation, ...rest }) => ({
|
||||
tabValues: hotel_navigation,
|
||||
...rest,
|
||||
})),
|
||||
})
|
||||
|
||||
/** REFS */
|
||||
const hotelPageActivitiesCardRefs = z
|
||||
.object({
|
||||
__typename: z.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard),
|
||||
})
|
||||
.merge(activitiesCardRefSchema)
|
||||
|
||||
const hotelPageSpaPageRefs = z
|
||||
.object({
|
||||
__typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage),
|
||||
})
|
||||
.merge(spaPageRefSchema)
|
||||
|
||||
const hotelPageBlockRefsItem = z.discriminatedUnion("__typename", [
|
||||
hotelPageActivitiesCardRefs,
|
||||
hotelPageSpaPageRefs,
|
||||
])
|
||||
|
||||
export const hotelPageRefsSchema = z.object({
|
||||
hotel_page: z.object({
|
||||
content: discriminatedUnionArray(hotelPageBlockRefsItem.options).nullable(),
|
||||
faq: hotelFaqRefsSchema.nullable(),
|
||||
system: systemSchema,
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const hotelPageUrlsSchema = z
|
||||
.object({
|
||||
all_hotel_page: z.object({
|
||||
items: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.string(),
|
||||
hotel_page_id: z.string(),
|
||||
system: systemSchema,
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
|
||||
hotelId: data.hotel_page_id,
|
||||
}
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform(({ all_hotel_page }) => all_hotel_page.items)
|
||||
@@ -0,0 +1,86 @@
|
||||
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
|
||||
|
||||
import { generateTag } from "@/utils/generateTag"
|
||||
|
||||
import { hotelPageSchema } from "./output"
|
||||
import {
|
||||
getHotelPageCounter,
|
||||
getHotelPageFailCounter,
|
||||
getHotelPageSuccessCounter,
|
||||
} from "./telemetry"
|
||||
|
||||
import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
|
||||
|
||||
export const hotelPageQueryRouter = router({
|
||||
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
|
||||
const { lang, uid } = ctx
|
||||
getHotelPageCounter.add(1, { lang, uid: `${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: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getHotelPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.hotelPage not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedHotelPage = hotelPageSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedHotelPage.success) {
|
||||
getHotelPageFailCounter.add(1, {
|
||||
lang,
|
||||
uid: `${uid}`,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedHotelPage.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.hotelPage validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedHotelPage.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getHotelPageSuccessCounter.add(1, { lang, uid: `${uid}` })
|
||||
console.info(
|
||||
"contentstack.hotelPage success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
return validatedHotelPage.data.hotel_page
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentstack.hotelPage")
|
||||
|
||||
export const getHotelPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPage.get"
|
||||
)
|
||||
export const getHotelPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPage.get-fail"
|
||||
)
|
||||
export const getHotelPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPage.get-success"
|
||||
)
|
||||
|
||||
export const getHotelPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPage.get"
|
||||
)
|
||||
export const getHotelPageSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPage.get-success"
|
||||
)
|
||||
export const getHotelPageFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPage.get-fail"
|
||||
)
|
||||
|
||||
export const getHotelPageUrlsCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPageUrls.get"
|
||||
)
|
||||
export const getHotelPageUrlsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPageUrls.get-success"
|
||||
)
|
||||
export const getHotelPageUrlsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.hotelPageUrls.get-fail"
|
||||
)
|
||||
192
apps/scandic-web/server/routers/contentstack/hotelPage/utils.ts
Normal file
192
apps/scandic-web/server/routers/contentstack/hotelPage/utils.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { GetHotelPageRefs } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
|
||||
import { GetHotelPageUrls } from "@/lib/graphql/Query/HotelPage/HotelPageUrl.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
|
||||
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
|
||||
|
||||
import { hotelPageRefsSchema, hotelPageUrlsSchema } from "./output"
|
||||
import {
|
||||
getHotelPageRefsCounter,
|
||||
getHotelPageRefsFailCounter,
|
||||
getHotelPageRefsSuccessCounter,
|
||||
getHotelPageUrlsCounter,
|
||||
getHotelPageUrlsFailCounter,
|
||||
getHotelPageUrlsSuccessCounter,
|
||||
} from "./telemetry"
|
||||
|
||||
import { HotelPageEnum } from "@/types/enums/hotelPage"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type {
|
||||
GetHotelPageRefsSchema,
|
||||
GetHotelPageUrlsData,
|
||||
HotelPageRefs,
|
||||
} from "@/types/trpc/routers/contentstack/hotelPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export async function fetchHotelPageRefs(lang: Lang, uid: string) {
|
||||
getHotelPageRefsCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.hotelPage.refs start",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
const refsResponse = await request<GetHotelPageRefsSchema>(
|
||||
GetHotelPageRefs,
|
||||
{ locale: lang, uid },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getHotelPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
code: notFoundError.code,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.hotelPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang,
|
||||
uid,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
return refsResponse.data
|
||||
}
|
||||
|
||||
export function validateHotelPageRefs(
|
||||
data: GetHotelPageRefsSchema,
|
||||
lang: Lang,
|
||||
uid: string
|
||||
) {
|
||||
const validatedData = hotelPageRefsSchema.safeParse(data)
|
||||
if (!validatedData.success) {
|
||||
getHotelPageRefsFailCounter.add(1, {
|
||||
lang,
|
||||
uid,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.hotelPage.refs validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
error: validatedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getHotelPageRefsSuccessCounter.add(1, { lang, uid })
|
||||
console.info(
|
||||
"contentstack.hotelPage.refs success",
|
||||
JSON.stringify({
|
||||
query: { lang, uid },
|
||||
})
|
||||
)
|
||||
|
||||
return validatedData.data
|
||||
}
|
||||
|
||||
export function generatePageTags(
|
||||
validatedData: HotelPageRefs,
|
||||
lang: Lang
|
||||
): string[] {
|
||||
const connections = getConnections(validatedData)
|
||||
return [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedData.hotel_page.system.uid),
|
||||
].flat()
|
||||
}
|
||||
|
||||
export function getConnections({ hotel_page }: HotelPageRefs) {
|
||||
const connections: System["system"][] = [hotel_page.system]
|
||||
if (hotel_page.content) {
|
||||
hotel_page.content.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case HotelPageEnum.ContentStack.blocks.ActivitiesCard: {
|
||||
if (block.upcoming_activities_card.length) {
|
||||
connections.push(...block.upcoming_activities_card)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hotel_page.faq) {
|
||||
connections.push(...hotel_page.faq)
|
||||
}
|
||||
})
|
||||
}
|
||||
return connections
|
||||
}
|
||||
|
||||
export async function getHotelPageUrls(lang: Lang) {
|
||||
getHotelPageUrlsCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.hotelPageUrls start",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
const tags = [`${lang}:hotel_page_urls`]
|
||||
const response = await request<GetHotelPageUrlsData>(
|
||||
GetHotelPageUrls,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
getHotelPageUrlsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "not_found",
|
||||
error: `Hotel pages not found for lang: ${lang}`,
|
||||
})
|
||||
console.error(
|
||||
"contentstack.hotelPageUrls not found error",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const validatedHotelPageUrls = hotelPageUrlsSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedHotelPageUrls.success) {
|
||||
getHotelPageUrlsFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedHotelPageUrls.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.hotelPageUrls validation error",
|
||||
JSON.stringify({
|
||||
query: { lang },
|
||||
error: validatedHotelPageUrls.error,
|
||||
})
|
||||
)
|
||||
return []
|
||||
}
|
||||
getHotelPageUrlsSuccessCounter.add(1, { lang })
|
||||
console.info(
|
||||
"contentstack.hotelPageUrl success",
|
||||
JSON.stringify({ query: { lang } })
|
||||
)
|
||||
|
||||
return validatedHotelPageUrls.data
|
||||
}
|
||||
39
apps/scandic-web/server/routers/contentstack/index.ts
Normal file
39
apps/scandic-web/server/routers/contentstack/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { router } from "@/server/trpc"
|
||||
|
||||
import { accountPageRouter } from "./accountPage"
|
||||
import { baseRouter } from "./base"
|
||||
import { breadcrumbsRouter } from "./breadcrumbs"
|
||||
import { collectionPageRouter } from "./collectionPage"
|
||||
import { contentPageRouter } from "./contentPage"
|
||||
import { destinationCityPageRouter } from "./destinationCityPage"
|
||||
import { destinationCountryPageRouter } from "./destinationCountryPage"
|
||||
import { destinationOverviewPageRouter } from "./destinationOverviewPage"
|
||||
import { hotelPageRouter } from "./hotelPage"
|
||||
import { languageSwitcherRouter } from "./languageSwitcher"
|
||||
import { loyaltyLevelRouter } from "./loyaltyLevel"
|
||||
import { loyaltyPageRouter } from "./loyaltyPage"
|
||||
import { metadataRouter } from "./metadata"
|
||||
import { pageSettingsRouter } from "./pageSettings"
|
||||
import { partnerRouter } from "./partner"
|
||||
import { rewardRouter } from "./reward"
|
||||
import { startPageRouter } from "./startPage"
|
||||
|
||||
export const contentstackRouter = router({
|
||||
accountPage: accountPageRouter,
|
||||
base: baseRouter,
|
||||
breadcrumbs: breadcrumbsRouter,
|
||||
hotelPage: hotelPageRouter,
|
||||
languageSwitcher: languageSwitcherRouter,
|
||||
loyaltyPage: loyaltyPageRouter,
|
||||
collectionPage: collectionPageRouter,
|
||||
contentPage: contentPageRouter,
|
||||
destinationOverviewPage: destinationOverviewPageRouter,
|
||||
destinationCountryPage: destinationCountryPageRouter,
|
||||
destinationCityPage: destinationCityPageRouter,
|
||||
metadata: metadataRouter,
|
||||
pageSettings: pageSettingsRouter,
|
||||
rewards: rewardRouter,
|
||||
loyaltyLevels: loyaltyLevelRouter,
|
||||
startPage: startPageRouter,
|
||||
partner: partnerRouter,
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { languageSwitcherQueryRouter } from "./query"
|
||||
|
||||
export const languageSwitcherRouter = mergeRouters(languageSwitcherQueryRouter)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
export const getLanguageSwitcherInput = z
|
||||
.object({
|
||||
lang: z.nativeEnum(Lang),
|
||||
pathName: z.string(),
|
||||
})
|
||||
.optional()
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const link = z
|
||||
.object({ url: z.string().optional(), isExternal: z.boolean() })
|
||||
.optional()
|
||||
.nullable()
|
||||
|
||||
export const validateLanguageSwitcherData = z.object({
|
||||
da: link,
|
||||
de: link,
|
||||
en: link,
|
||||
fi: link,
|
||||
no: link,
|
||||
sv: link,
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { baseUrls } from "@/constants/routes/baseUrls"
|
||||
import { batchRequest } from "@/lib/graphql/batchRequest"
|
||||
import {
|
||||
GetDaDeEnUrlsAccountPage,
|
||||
GetFiNoSvUrlsAccountPage,
|
||||
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsCollectionPage,
|
||||
GetFiNoSvUrlsCollectionPage,
|
||||
} from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsContentPage,
|
||||
GetFiNoSvUrlsContentPage,
|
||||
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsCurrentBlocksPage,
|
||||
GetFiNoSvUrlsCurrentBlocksPage,
|
||||
} from "@/lib/graphql/Query/Current/LanguageSwitcher.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsDestinationCityPage,
|
||||
GetFiNoSvUrlsDestinationCityPage,
|
||||
} from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsDestinationCountryPage,
|
||||
GetFiNoSvUrlsDestinationCountryPage,
|
||||
} from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsDestinationOverviewPage,
|
||||
GetFiNoSvUrlsDestinationOverviewPage,
|
||||
} from "@/lib/graphql/Query/DestinationOverviewPage/DestinationOverviewPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsHotelPage,
|
||||
GetFiNoSvUrlsHotelPage,
|
||||
} from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsLoyaltyPage,
|
||||
GetFiNoSvUrlsLoyaltyPage,
|
||||
} from "@/lib/graphql/Query/LoyaltyPage/LoyaltyPage.graphql"
|
||||
import {
|
||||
GetDaDeEnUrlsStartPage,
|
||||
GetFiNoSvUrlsStartPage,
|
||||
} from "@/lib/graphql/Query/StartPage/StartPage.graphql"
|
||||
import { internalServerError } from "@/server/errors/trpc"
|
||||
import { publicProcedure, router } from "@/server/trpc"
|
||||
|
||||
import { getUidAndContentTypeByPath } from "@/services/cms/getUidAndContentTypeByPath"
|
||||
import { generateTag } from "@/utils/generateTag"
|
||||
|
||||
import { getLanguageSwitcherInput } from "./input"
|
||||
import { validateLanguageSwitcherData } from "./output"
|
||||
import { languageSwitcherAffix } from "./utils"
|
||||
|
||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||
import type {
|
||||
LanguageSwitcherData,
|
||||
LanguageSwitcherQueryDataRaw,
|
||||
} from "@/types/requests/languageSwitcher"
|
||||
|
||||
interface LanguageSwitcherVariables {
|
||||
contentType: string
|
||||
uid: string
|
||||
}
|
||||
|
||||
const meter = metrics.getMeter("trpc.contentstack.languageSwitcher")
|
||||
const getLanguageSwitcherCounter = meter.createCounter(
|
||||
"trpc.contentstack.languageSwitcher.get"
|
||||
)
|
||||
const getLanguageSwitcherSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.languageSwitcher.get-success"
|
||||
)
|
||||
const getLanguageSwitcherFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.languageSwitcher.get-fail"
|
||||
)
|
||||
|
||||
async function getLanguageSwitcher(options: LanguageSwitcherVariables) {
|
||||
const variables = { uid: options.uid }
|
||||
const tagsDaDeEn = [
|
||||
generateTag(Lang.da, options.uid, languageSwitcherAffix),
|
||||
generateTag(Lang.de, options.uid, languageSwitcherAffix),
|
||||
generateTag(Lang.en, options.uid, languageSwitcherAffix),
|
||||
]
|
||||
const tagsFiNoSv = [
|
||||
generateTag(Lang.fi, options.uid, languageSwitcherAffix),
|
||||
generateTag(Lang.no, options.uid, languageSwitcherAffix),
|
||||
generateTag(Lang.sv, options.uid, languageSwitcherAffix),
|
||||
]
|
||||
let daDeEnDocument = null
|
||||
let fiNoSvDocument = null
|
||||
switch (options.contentType) {
|
||||
case PageContentTypeEnum.accountPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsAccountPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsAccountPage
|
||||
break
|
||||
case PageContentTypeEnum.currentBlocksPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsCurrentBlocksPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsCurrentBlocksPage
|
||||
break
|
||||
case PageContentTypeEnum.loyaltyPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsLoyaltyPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsLoyaltyPage
|
||||
break
|
||||
case PageContentTypeEnum.hotelPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsHotelPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsHotelPage
|
||||
break
|
||||
case PageContentTypeEnum.contentPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsContentPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsContentPage
|
||||
break
|
||||
case PageContentTypeEnum.collectionPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsCollectionPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsCollectionPage
|
||||
break
|
||||
case PageContentTypeEnum.destinationOverviewPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsDestinationOverviewPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsDestinationOverviewPage
|
||||
break
|
||||
case PageContentTypeEnum.destinationCountryPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsDestinationCountryPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsDestinationCountryPage
|
||||
break
|
||||
case PageContentTypeEnum.destinationCityPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsDestinationCityPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsDestinationCityPage
|
||||
break
|
||||
case PageContentTypeEnum.startPage:
|
||||
daDeEnDocument = GetDaDeEnUrlsStartPage
|
||||
fiNoSvDocument = GetFiNoSvUrlsStartPage
|
||||
break
|
||||
default:
|
||||
console.error(`type: [${options.contentType}]`)
|
||||
console.error(`Trying to get a content type that is not supported`)
|
||||
throw internalServerError()
|
||||
}
|
||||
|
||||
if (daDeEnDocument && fiNoSvDocument) {
|
||||
return await batchRequest<LanguageSwitcherQueryDataRaw>([
|
||||
{
|
||||
document: daDeEnDocument,
|
||||
variables,
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: tagsDaDeEn,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
document: fiNoSvDocument,
|
||||
variables,
|
||||
options: {
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: tagsFiNoSv,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
throw internalServerError()
|
||||
}
|
||||
|
||||
export const languageSwitcherQueryRouter = router({
|
||||
get: publicProcedure
|
||||
.input(getLanguageSwitcherInput)
|
||||
.query(async ({ input, ctx }) => {
|
||||
let uid = ctx.uid
|
||||
let contentType = ctx.contentType
|
||||
let lang = ctx.lang ?? input?.lang
|
||||
|
||||
if (input) {
|
||||
const data = await getUidAndContentTypeByPath(input.pathName)
|
||||
uid = data.uid
|
||||
contentType = data.contentType ?? ctx.contentType
|
||||
}
|
||||
|
||||
if (!uid || !lang) {
|
||||
return { lang: lang, urls: baseUrls }
|
||||
}
|
||||
|
||||
getLanguageSwitcherCounter.add(1, {
|
||||
uid: uid,
|
||||
lang: lang,
|
||||
contentType: contentType,
|
||||
})
|
||||
console.info(
|
||||
"contentstack.languageSwitcher start",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
uid: uid,
|
||||
lang: lang,
|
||||
contentType: contentType,
|
||||
},
|
||||
})
|
||||
)
|
||||
const res = await getLanguageSwitcher({
|
||||
contentType: contentType!,
|
||||
uid: uid,
|
||||
})
|
||||
|
||||
const urls = Object.keys(res.data).reduce<LanguageSwitcherData>(
|
||||
(acc, key) => {
|
||||
const item = res.data[key as Lang]
|
||||
|
||||
const url = item
|
||||
? item.web?.original_url || `/${key}${item.url}`
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[key]: { url, isExternal: !!item?.web?.original_url },
|
||||
}
|
||||
},
|
||||
{} as LanguageSwitcherData
|
||||
)
|
||||
|
||||
const validatedLanguageSwitcherData =
|
||||
validateLanguageSwitcherData.safeParse(urls)
|
||||
|
||||
if (!validatedLanguageSwitcherData.success) {
|
||||
getLanguageSwitcherFailCounter.add(1, {
|
||||
uid: uid,
|
||||
lang: lang,
|
||||
contentType: contentType,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedLanguageSwitcherData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.languageSwitcher validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
uid: uid,
|
||||
lang: lang,
|
||||
contentType: contentType,
|
||||
},
|
||||
error: validatedLanguageSwitcherData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getLanguageSwitcherSuccessCounter.add(1, {
|
||||
uid: uid,
|
||||
lang: lang,
|
||||
contentType: contentType,
|
||||
})
|
||||
console.info(
|
||||
"contentstack.languageSwitcher success",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
uid: uid,
|
||||
lang: lang,
|
||||
contentType: contentType,
|
||||
},
|
||||
})
|
||||
)
|
||||
return {
|
||||
lang: lang,
|
||||
urls,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const languageSwitcherAffix = "languageSwitcher"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { loyaltyLevelQueryRouter } from "./query"
|
||||
|
||||
export const loyaltyLevelRouter = mergeRouters(loyaltyLevelQueryRouter)
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
|
||||
export const loyaltyLevelInput = z.object({
|
||||
level: z.nativeEnum(MembershipLevelEnum),
|
||||
lang: z.nativeEnum(Lang).optional(),
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
|
||||
export const validateLoyaltyLevelsSchema = z
|
||||
.object({
|
||||
all_loyalty_level: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
level_id: z.nativeEnum(MembershipLevelEnum),
|
||||
name: z.string(),
|
||||
user_facing_tag: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
required_nights: z.number().optional().nullable(),
|
||||
required_points: z.number(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => data.all_loyalty_level.items)
|
||||
|
||||
export type LoyaltyLevelsResponse = z.input<typeof validateLoyaltyLevelsSchema>
|
||||
|
||||
export type LoyaltyLevel = z.output<typeof validateLoyaltyLevelsSchema>[number]
|
||||
@@ -0,0 +1,169 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
import { cache } from "react"
|
||||
|
||||
import {
|
||||
type MembershipLevel,
|
||||
MembershipLevelEnum,
|
||||
} from "@/constants/membershipLevels"
|
||||
import {
|
||||
GetAllLoyaltyLevels,
|
||||
GetLoyaltyLevel,
|
||||
} from "@/lib/graphql/Query/LoyaltyLevels.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import { contentstackBaseProcedure, router } from "@/server/trpc"
|
||||
|
||||
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
|
||||
|
||||
import { loyaltyLevelInput } from "./input"
|
||||
import {
|
||||
type LoyaltyLevel,
|
||||
type LoyaltyLevelsResponse,
|
||||
validateLoyaltyLevelsSchema,
|
||||
} from "./output"
|
||||
|
||||
import type { Context } from "@/server/context"
|
||||
|
||||
const meter = metrics.getMeter("trpc.loyaltyLevel")
|
||||
// OpenTelemetry metrics: Loyalty Level
|
||||
const getAllLoyaltyLevelCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyLevel.all"
|
||||
)
|
||||
|
||||
const getAllLoyaltyLevelSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyLevel.all-success"
|
||||
)
|
||||
const getAllLoyaltyLevelFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyLevel.all-fail"
|
||||
)
|
||||
|
||||
const getByLevelLoyaltyLevelCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyLevel.byLevel"
|
||||
)
|
||||
|
||||
const getByLevelLoyaltyLevelSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyLevel.byLevel-success"
|
||||
)
|
||||
const getByLevelLoyaltyLevelFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyLevel.byLevel-fail"
|
||||
)
|
||||
|
||||
export const getAllLoyaltyLevels = cache(async (ctx: Context) => {
|
||||
getAllLoyaltyLevelCounter.add(1)
|
||||
|
||||
// Ideally we should fetch all available tiers from API, but since they
|
||||
// are static, we can just use the enum values. We want to know which
|
||||
// levels we are fetching so that we can use tags to cache them
|
||||
const allLevelIds = Object.values(MembershipLevelEnum)
|
||||
|
||||
const tags = allLevelIds.map((levelId) =>
|
||||
generateLoyaltyConfigTag(ctx.lang, "loyalty_level", levelId)
|
||||
)
|
||||
|
||||
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
|
||||
GetAllLoyaltyLevels,
|
||||
{ lang: ctx.lang, level_ids: allLevelIds },
|
||||
{ next: { tags }, cache: "force-cache" }
|
||||
)
|
||||
|
||||
if (!loyaltyLevelsConfigResponse.data) {
|
||||
getAllLoyaltyLevelFailCounter.add(1)
|
||||
const notFoundError = notFound(loyaltyLevelsConfigResponse)
|
||||
console.error(
|
||||
"contentstack.loyaltyLevels not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: ctx.lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
|
||||
loyaltyLevelsConfigResponse.data
|
||||
)
|
||||
if (!validatedLoyaltyLevels.success) {
|
||||
getAllLoyaltyLevelFailCounter.add(1)
|
||||
console.error(validatedLoyaltyLevels.error)
|
||||
console.error(
|
||||
"contentstack.rewards validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: ctx.lang,
|
||||
},
|
||||
error: validatedLoyaltyLevels.error,
|
||||
})
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
getAllLoyaltyLevelSuccessCounter.add(1)
|
||||
return validatedLoyaltyLevels.data
|
||||
})
|
||||
|
||||
export const getLoyaltyLevel = cache(
|
||||
async (ctx: Context, level_id: MembershipLevel) => {
|
||||
getByLevelLoyaltyLevelCounter.add(1, {
|
||||
query: JSON.stringify({ lang: ctx.lang, level_id }),
|
||||
})
|
||||
|
||||
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
|
||||
GetLoyaltyLevel,
|
||||
{ lang: ctx.lang, level_id },
|
||||
{
|
||||
next: {
|
||||
tags: [generateLoyaltyConfigTag(ctx.lang, "loyalty_level", level_id)],
|
||||
},
|
||||
cache: "force-cache",
|
||||
}
|
||||
)
|
||||
if (
|
||||
!loyaltyLevelsConfigResponse.data ||
|
||||
!loyaltyLevelsConfigResponse.data.all_loyalty_level.items.length
|
||||
) {
|
||||
getByLevelLoyaltyLevelFailCounter.add(1)
|
||||
const notFoundError = notFound(loyaltyLevelsConfigResponse)
|
||||
console.error(
|
||||
"contentstack.loyaltyLevel not found error",
|
||||
JSON.stringify({
|
||||
query: { lang: ctx.lang, level_id },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
|
||||
loyaltyLevelsConfigResponse.data
|
||||
)
|
||||
if (!validatedLoyaltyLevels.success) {
|
||||
getByLevelLoyaltyLevelFailCounter.add(1)
|
||||
console.error(validatedLoyaltyLevels.error)
|
||||
console.error(
|
||||
"contentstack.loyaltyLevel validation error",
|
||||
JSON.stringify({
|
||||
query: { lang: ctx.lang, level_id },
|
||||
error: validatedLoyaltyLevels.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getByLevelLoyaltyLevelSuccessCounter.add(1)
|
||||
const result: LoyaltyLevel = validatedLoyaltyLevels.data[0]
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
export const loyaltyLevelQueryRouter = router({
|
||||
byLevel: contentstackBaseProcedure
|
||||
.input(loyaltyLevelInput)
|
||||
.query(async function ({ ctx, input }) {
|
||||
return getLoyaltyLevel(ctx, input.level)
|
||||
}),
|
||||
all: contentstackBaseProcedure.query(async function ({ ctx }) {
|
||||
return getAllLoyaltyLevels(ctx)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { loyaltyPageQueryRouter } from "./query"
|
||||
|
||||
export const loyaltyPageRouter = mergeRouters(loyaltyPageQueryRouter)
|
||||
@@ -0,0 +1,195 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
|
||||
|
||||
import {
|
||||
cardGridRefsSchema,
|
||||
cardsGridSchema,
|
||||
} from "../schemas/blocks/cardsGrid"
|
||||
import {
|
||||
contentRefsSchema as blockContentRefsSchema,
|
||||
contentSchema as blockContentSchema,
|
||||
} from "../schemas/blocks/content"
|
||||
import {
|
||||
dynamicContentRefsSchema,
|
||||
dynamicContentSchema as blockDynamicContentSchema,
|
||||
} from "../schemas/blocks/dynamicContent"
|
||||
import {
|
||||
shortcutsRefsSchema,
|
||||
shortcutsSchema,
|
||||
} from "../schemas/blocks/shortcuts"
|
||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||
import {
|
||||
contentRefsSchema as sidebarContentRefsSchema,
|
||||
contentSchema as sidebarContentSchema,
|
||||
} from "../schemas/sidebar/content"
|
||||
import { dynamicContentSchema as sidebarDynamicContentSchema } from "../schemas/sidebar/dynamicContent"
|
||||
import {
|
||||
joinLoyaltyContactRefsSchema,
|
||||
joinLoyaltyContactSchema,
|
||||
} from "../schemas/sidebar/joinLoyaltyContact"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import { LoyaltyPageEnum } from "@/types/enums/loyaltyPage"
|
||||
|
||||
// LoyaltyPage Refs
|
||||
const extendedCardGridRefsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.CardsGrid),
|
||||
})
|
||||
.merge(cardGridRefsSchema)
|
||||
|
||||
const extendedContentRefsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Content),
|
||||
})
|
||||
.merge(blockContentRefsSchema)
|
||||
|
||||
const extendedDynamicContentRefsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.DynamicContent),
|
||||
})
|
||||
.merge(dynamicContentRefsSchema)
|
||||
|
||||
const extendedShortcutsRefsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Shortcuts),
|
||||
})
|
||||
.merge(shortcutsRefsSchema)
|
||||
|
||||
const blocksRefsSchema = z.discriminatedUnion("__typename", [
|
||||
extendedCardGridRefsSchema,
|
||||
extendedContentRefsSchema,
|
||||
extendedDynamicContentRefsSchema,
|
||||
extendedShortcutsRefsSchema,
|
||||
])
|
||||
|
||||
const contentSidebarRefsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.Content),
|
||||
})
|
||||
.merge(sidebarContentRefsSchema)
|
||||
|
||||
const extendedJoinLoyaltyContactRefsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
LoyaltyPageEnum.ContentStack.sidebar.JoinLoyaltyContact
|
||||
),
|
||||
})
|
||||
.merge(joinLoyaltyContactRefsSchema)
|
||||
|
||||
const sidebarRefsSchema = z.discriminatedUnion("__typename", [
|
||||
contentSidebarRefsSchema,
|
||||
extendedJoinLoyaltyContactRefsSchema,
|
||||
z.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.DynamicContent),
|
||||
}),
|
||||
])
|
||||
|
||||
export const loyaltyPageRefsSchema = z.object({
|
||||
loyalty_page: z.object({
|
||||
blocks: discriminatedUnionArray(blocksRefsSchema.options).optional(),
|
||||
sidebar: discriminatedUnionArray(sidebarRefsSchema.options)
|
||||
.optional()
|
||||
.transform((data) => {
|
||||
if (data) {
|
||||
return data.filter(
|
||||
(block) =>
|
||||
block.__typename !==
|
||||
LoyaltyPageEnum.ContentStack.sidebar.DynamicContent
|
||||
)
|
||||
}
|
||||
return data
|
||||
}),
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
// LoyaltyPage
|
||||
export const extendedCardsGridSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.CardsGrid),
|
||||
})
|
||||
.merge(cardsGridSchema)
|
||||
|
||||
export const extendedContentSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Content),
|
||||
})
|
||||
.merge(blockContentSchema)
|
||||
|
||||
export const extendedDynamicContentSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.DynamicContent),
|
||||
})
|
||||
.merge(blockDynamicContentSchema)
|
||||
|
||||
export const extendedShortcutsSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.blocks.Shortcuts),
|
||||
})
|
||||
.merge(shortcutsSchema)
|
||||
|
||||
export const blocksSchema = z.discriminatedUnion("__typename", [
|
||||
extendedCardsGridSchema,
|
||||
extendedContentSchema,
|
||||
extendedDynamicContentSchema,
|
||||
extendedShortcutsSchema,
|
||||
])
|
||||
|
||||
const contentSidebarSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.Content),
|
||||
})
|
||||
.merge(sidebarContentSchema)
|
||||
|
||||
const dynamicContentSidebarSchema = z
|
||||
.object({
|
||||
__typename: z.literal(LoyaltyPageEnum.ContentStack.sidebar.DynamicContent),
|
||||
})
|
||||
.merge(sidebarDynamicContentSchema)
|
||||
|
||||
export const joinLoyaltyContactSidebarSchema = z
|
||||
.object({
|
||||
__typename: z.literal(
|
||||
LoyaltyPageEnum.ContentStack.sidebar.JoinLoyaltyContact
|
||||
),
|
||||
})
|
||||
.merge(joinLoyaltyContactSchema)
|
||||
|
||||
export const sidebarSchema = z.discriminatedUnion("__typename", [
|
||||
contentSidebarSchema,
|
||||
dynamicContentSidebarSchema,
|
||||
joinLoyaltyContactSidebarSchema,
|
||||
])
|
||||
|
||||
export const loyaltyPageSchema = z.object({
|
||||
loyalty_page: z
|
||||
.object({
|
||||
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
|
||||
heading: z.string().optional(),
|
||||
hero_image: tempImageVaultAssetSchema,
|
||||
preamble: z.string().optional(),
|
||||
sidebar: discriminatedUnionArray(sidebarSchema.options).nullable(),
|
||||
title: z.string().optional(),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
blocks: data.blocks ? data.blocks : [],
|
||||
heading: data.heading,
|
||||
heroImage: data.hero_image,
|
||||
preamble: data.preamble,
|
||||
sidebar: data.sidebar ? data.sidebar : [],
|
||||
system: data.system,
|
||||
}
|
||||
}),
|
||||
trackingProps: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import {
|
||||
GetLoyaltyPage,
|
||||
GetLoyaltyPageRefs,
|
||||
} from "@/lib/graphql/Query/LoyaltyPage/LoyaltyPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
|
||||
|
||||
import {
|
||||
generateRefsResponseTag,
|
||||
generateTag,
|
||||
generateTagsFromSystem,
|
||||
} from "@/utils/generateTag"
|
||||
|
||||
import { loyaltyPageRefsSchema, loyaltyPageSchema } from "./output"
|
||||
import { getConnections } from "./utils"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type {
|
||||
GetLoyaltyPageRefsSchema,
|
||||
GetLoyaltyPageSchema,
|
||||
} from "@/types/trpc/routers/contentstack/loyaltyPage"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const meter = metrics.getMeter("trpc.loyaltyPage")
|
||||
// OpenTelemetry metrics: LoyaltyPage
|
||||
const getLoyaltyPageRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyPage.get"
|
||||
)
|
||||
const getLoyaltyPageRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyPage.get-success"
|
||||
)
|
||||
const getLoyaltyPageRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyPage.get-fail"
|
||||
)
|
||||
const getLoyaltyPageCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyPage.get"
|
||||
)
|
||||
const getLoyaltyPageSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyPage.get-success"
|
||||
)
|
||||
const getLoyaltyPageFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.loyaltyPage.get-fail"
|
||||
)
|
||||
|
||||
export const loyaltyPageQueryRouter = router({
|
||||
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
|
||||
const { lang, uid } = ctx
|
||||
const metricsVariables = { lang, uid }
|
||||
const variables = { locale: lang, uid }
|
||||
getLoyaltyPageRefsCounter.add(1, metricsVariables)
|
||||
console.info(
|
||||
"contentstack.loyaltyPage.refs start",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
})
|
||||
)
|
||||
const refsResponse = await request<GetLoyaltyPageRefsSchema>(
|
||||
GetLoyaltyPageRefs,
|
||||
variables,
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateRefsResponseTag(lang, uid)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getLoyaltyPageRefsFailCounter.add(1, {
|
||||
...metricsVariables,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
code: notFoundError.code,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.loyaltyPage.refs not found error",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedLoyaltyPageRefs = loyaltyPageRefsSchema.safeParse(
|
||||
refsResponse.data
|
||||
)
|
||||
if (!validatedLoyaltyPageRefs.success) {
|
||||
getLoyaltyPageRefsFailCounter.add(1, {
|
||||
...metricsVariables,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedLoyaltyPageRefs.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.loyaltyPage.refs validation error",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
error: validatedLoyaltyPageRefs.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
getLoyaltyPageRefsSuccessCounter.add(1, metricsVariables)
|
||||
console.info(
|
||||
"contentstack.loyaltyPage.refs success",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
})
|
||||
)
|
||||
|
||||
const connections = getConnections(validatedLoyaltyPageRefs.data)
|
||||
|
||||
const tags = [
|
||||
generateTagsFromSystem(lang, connections),
|
||||
generateTag(lang, validatedLoyaltyPageRefs.data.loyalty_page.system.uid),
|
||||
].flat()
|
||||
getLoyaltyPageCounter.add(1, metricsVariables)
|
||||
console.info(
|
||||
"contentstack.loyaltyPage start",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
})
|
||||
)
|
||||
const response = await request<GetLoyaltyPageSchema>(
|
||||
GetLoyaltyPage,
|
||||
variables,
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: { tags },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
getLoyaltyPageFailCounter.add(1, {
|
||||
...metricsVariables,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.loyaltyPage not found error",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFound(response)
|
||||
}
|
||||
|
||||
const validatedLoyaltyPage = loyaltyPageSchema.safeParse(response.data)
|
||||
if (!validatedLoyaltyPage.success) {
|
||||
getLoyaltyPageFailCounter.add(1, {
|
||||
...metricsVariables,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedLoyaltyPage.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.loyaltyPage validation error",
|
||||
JSON.stringify({
|
||||
query: metricsVariables,
|
||||
error: validatedLoyaltyPage.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const loyaltyPage = validatedLoyaltyPage.data.loyalty_page
|
||||
|
||||
const loyaltyTrackingData: TrackingSDKPageData = {
|
||||
pageId: loyaltyPage.system.uid,
|
||||
domainLanguage: lang,
|
||||
publishDate: loyaltyPage.system.updated_at,
|
||||
createDate: loyaltyPage.system.created_at,
|
||||
channel: TrackingChannelEnum["scandic-friends"],
|
||||
pageType: "loyaltycontentpage",
|
||||
pageName: validatedLoyaltyPage.data.trackingProps.url,
|
||||
siteSections: validatedLoyaltyPage.data.trackingProps.url,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
getLoyaltyPageSuccessCounter.add(1, metricsVariables)
|
||||
console.info(
|
||||
"contentstack.loyaltyPage success",
|
||||
JSON.stringify({ query: metricsVariables })
|
||||
)
|
||||
|
||||
// Assert LoyaltyPage type to get correct typings for RTE fields
|
||||
return {
|
||||
loyaltyPage,
|
||||
tracking: loyaltyTrackingData,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { LoyaltyPageEnum } from "@/types/enums/loyaltyPage"
|
||||
import type { System } from "@/types/requests/system"
|
||||
import type { LoyaltyPageRefs } from "@/types/trpc/routers/contentstack/loyaltyPage"
|
||||
|
||||
export function getConnections({ loyalty_page }: LoyaltyPageRefs) {
|
||||
const connections: System["system"][] = [loyalty_page.system]
|
||||
|
||||
if (loyalty_page.blocks) {
|
||||
loyalty_page.blocks.forEach((block) => {
|
||||
switch (block.__typename) {
|
||||
case LoyaltyPageEnum.ContentStack.blocks.CardsGrid:
|
||||
if (block.cards_grid.length) {
|
||||
connections.push(...block.cards_grid)
|
||||
}
|
||||
break
|
||||
case LoyaltyPageEnum.ContentStack.blocks.Content:
|
||||
if (block.content.length) {
|
||||
// TS has trouble infering the filtered types
|
||||
// @ts-ignore
|
||||
connections.push(...block.content)
|
||||
}
|
||||
break
|
||||
case LoyaltyPageEnum.ContentStack.blocks.DynamicContent:
|
||||
if (block.dynamic_content.link) {
|
||||
connections.push(block.dynamic_content.link)
|
||||
}
|
||||
break
|
||||
case LoyaltyPageEnum.ContentStack.blocks.Shortcuts:
|
||||
if (block.shortcuts.shortcuts.length) {
|
||||
connections.push(...block.shortcuts.shortcuts)
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (loyalty_page.sidebar) {
|
||||
loyalty_page.sidebar.forEach((block) => {
|
||||
switch (block?.__typename) {
|
||||
case LoyaltyPageEnum.ContentStack.sidebar.Content:
|
||||
if (block.content.length) {
|
||||
// TS has trouble infering the filtered types
|
||||
// @ts-ignore
|
||||
connections.push(...block.content)
|
||||
}
|
||||
break
|
||||
case LoyaltyPageEnum.ContentStack.sidebar.JoinLoyaltyContact:
|
||||
if (block.join_loyalty_contact?.button) {
|
||||
connections.push(block.join_loyalty_contact.button)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { metadataQueryRouter } from "./query"
|
||||
|
||||
export const metadataRouter = mergeRouters(metadataQueryRouter)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const getMetadataInput = z.object({
|
||||
subpage: z.string().optional(),
|
||||
})
|
||||
116
apps/scandic-web/server/routers/contentstack/metadata/output.ts
Normal file
116
apps/scandic-web/server/routers/contentstack/metadata/output.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { attributesSchema as hotelAttributesSchema } from "../../hotels/schemas/hotel"
|
||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
import { getDescription, getImage, getTitle } from "./utils"
|
||||
|
||||
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(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
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(),
|
||||
destination_settings: z
|
||||
.object({
|
||||
city_denmark: z.string().optional().nullable(),
|
||||
city_finland: z.string().optional().nullable(),
|
||||
city_germany: z.string().optional().nullable(),
|
||||
city_poland: z.string().optional().nullable(),
|
||||
city_norway: z.string().optional().nullable(),
|
||||
city_sweden: z.string().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(),
|
||||
cityName: z.string().optional().nullable(),
|
||||
cityFilter: z.string().optional().nullable(),
|
||||
cityFilterType: z.enum(["facility", "surroundings"]).optional().nullable(),
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
202
apps/scandic-web/server/routers/contentstack/metadata/query.ts
Normal file
202
apps/scandic-web/server/routers/contentstack/metadata/query.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
import { cache } from "react"
|
||||
|
||||
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 { GetDestinationCityPageMetadata } from "@/lib/graphql/Query/DestinationCityPage/Metadata.graphql"
|
||||
import { GetDestinationCountryPageMetadata } from "@/lib/graphql/Query/DestinationCountryPage/Metadata.graphql"
|
||||
import { GetDestinationOverviewPageMetadata } from "@/lib/graphql/Query/DestinationOverviewPage/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 { getHotel } from "../../hotels/query"
|
||||
import { getMetadataInput } from "./input"
|
||||
import { metadataSchema } from "./output"
|
||||
import { affix, getCityData } from "./utils"
|
||||
|
||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
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 } })
|
||||
)
|
||||
|
||||
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(
|
||||
"contentstack.metadata validation error",
|
||||
JSON.stringify({
|
||||
error: validatedMetadata.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
transformMetadataSuccessCounter.add(1)
|
||||
console.info("contentstack.metadata transform success")
|
||||
|
||||
return validatedMetadata.data
|
||||
}
|
||||
|
||||
export const metadataQueryRouter = router({
|
||||
get: contentStackUidWithServiceProcedure
|
||||
.input(getMetadataInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const variables = {
|
||||
lang: ctx.lang,
|
||||
uid: ctx.uid,
|
||||
}
|
||||
|
||||
switch (ctx.contentType) {
|
||||
case PageContentTypeEnum.accountPage:
|
||||
const accountPageResponse = await fetchMetadata<{
|
||||
account_page: RawMetadataSchema
|
||||
}>(GetAccountPageMetadata, variables)
|
||||
return getTransformedMetadata(accountPageResponse.account_page)
|
||||
case PageContentTypeEnum.collectionPage:
|
||||
const collectionPageResponse = await fetchMetadata<{
|
||||
collection_page: RawMetadataSchema
|
||||
}>(GetCollectionPageMetadata, variables)
|
||||
return getTransformedMetadata(collectionPageResponse.collection_page)
|
||||
case PageContentTypeEnum.contentPage:
|
||||
const contentPageResponse = await fetchMetadata<{
|
||||
content_page: RawMetadataSchema
|
||||
}>(GetContentPageMetadata, variables)
|
||||
return getTransformedMetadata(contentPageResponse.content_page)
|
||||
case PageContentTypeEnum.destinationOverviewPage:
|
||||
const destinationOverviewPageResponse = await fetchMetadata<{
|
||||
destination_overview_page: RawMetadataSchema
|
||||
}>(GetDestinationOverviewPageMetadata, variables)
|
||||
return getTransformedMetadata(
|
||||
destinationOverviewPageResponse.destination_overview_page
|
||||
)
|
||||
case PageContentTypeEnum.destinationCountryPage:
|
||||
const destinationCountryPageResponse = await fetchMetadata<{
|
||||
destination_country_page: RawMetadataSchema
|
||||
}>(GetDestinationCountryPageMetadata, variables)
|
||||
return getTransformedMetadata(
|
||||
destinationCountryPageResponse.destination_country_page
|
||||
)
|
||||
case PageContentTypeEnum.destinationCityPage:
|
||||
const destinationCityPageResponse = await fetchMetadata<{
|
||||
destination_city_page: RawMetadataSchema
|
||||
}>(GetDestinationCityPageMetadata, variables)
|
||||
const cityData = await getCityData(
|
||||
destinationCityPageResponse.destination_city_page,
|
||||
input,
|
||||
ctx.serviceToken,
|
||||
ctx.lang
|
||||
)
|
||||
return getTransformedMetadata({
|
||||
...destinationCityPageResponse.destination_city_page,
|
||||
...cityData,
|
||||
})
|
||||
case PageContentTypeEnum.loyaltyPage:
|
||||
const loyaltyPageResponse = await fetchMetadata<{
|
||||
loyalty_page: RawMetadataSchema
|
||||
}>(GetLoyaltyPageMetadata, variables)
|
||||
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
|
||||
case PageContentTypeEnum.hotelPage:
|
||||
const hotelPageResponse = await fetchMetadata<{
|
||||
hotel_page: RawMetadataSchema
|
||||
}>(GetHotelPageMetadata, variables)
|
||||
const hotelPageData = hotelPageResponse.hotel_page
|
||||
const hotelData = hotelPageData.hotel_page_id
|
||||
? await getHotel(
|
||||
{
|
||||
hotelId: hotelPageData.hotel_page_id,
|
||||
isCardOnlyPayment: false,
|
||||
language: ctx.lang,
|
||||
},
|
||||
ctx.serviceToken
|
||||
)
|
||||
: null
|
||||
|
||||
return getTransformedMetadata({
|
||||
...hotelPageData,
|
||||
hotelData: hotelData?.hotel,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}),
|
||||
})
|
||||
245
apps/scandic-web/server/routers/contentstack/metadata/utils.ts
Normal file
245
apps/scandic-web/server/routers/contentstack/metadata/utils.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { getFiltersFromHotels } from "@/stores/hotel-data/helper"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import {
|
||||
getCityByCityIdentifier,
|
||||
getHotelIdsByCityIdentifier,
|
||||
getHotelsByHotelIds,
|
||||
} from "../../hotels/utils"
|
||||
|
||||
import { RTETypeEnum } from "@/types/rte/enums"
|
||||
import type {
|
||||
MetadataInputSchema,
|
||||
RawMetadataSchema,
|
||||
} from "@/types/trpc/routers/contentstack/metadata"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export const affix = "metadata"
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// 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 async function getTitle(data: RawMetadataSchema) {
|
||||
const intl = await getIntl()
|
||||
const metadata = data.web?.seo_metadata
|
||||
if (metadata?.title) {
|
||||
return metadata.title
|
||||
}
|
||||
if (data.hotelData) {
|
||||
return intl.formatMessage(
|
||||
{ id: "Stay at {hotelName} | Hotel in {destination}" },
|
||||
{
|
||||
hotelName: data.hotelData.name,
|
||||
destination: data.hotelData.address.city,
|
||||
}
|
||||
)
|
||||
}
|
||||
if (data.system.content_type_uid === "destination_city_page") {
|
||||
if (data.cityName) {
|
||||
if (data.cityFilter) {
|
||||
if (data.cityFilterType === "facility") {
|
||||
return intl.formatMessage(
|
||||
{ id: "Hotels with {filter} in {cityName}" },
|
||||
{ cityName: data.cityName, filter: data.cityFilter }
|
||||
)
|
||||
} else if (data.cityFilterType === "surroundings") {
|
||||
return intl.formatMessage(
|
||||
{ id: "Hotels near {filter} in {cityName}" },
|
||||
{ cityName: data.cityName, filter: data.cityFilter }
|
||||
)
|
||||
}
|
||||
}
|
||||
return intl.formatMessage(
|
||||
{ id: "Hotels in {city}" },
|
||||
{ city: data.cityName }
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
export async function getCityData(
|
||||
data: RawMetadataSchema,
|
||||
input: MetadataInputSchema,
|
||||
serviceToken: string,
|
||||
lang: Lang
|
||||
) {
|
||||
const destinationSettings = data.destination_settings
|
||||
const cityFilter = input.subpage
|
||||
let cityIdentifier
|
||||
let cityData
|
||||
let filterType
|
||||
|
||||
if (destinationSettings) {
|
||||
const {
|
||||
city_sweden,
|
||||
city_norway,
|
||||
city_denmark,
|
||||
city_finland,
|
||||
city_germany,
|
||||
city_poland,
|
||||
} = destinationSettings
|
||||
const cities = [
|
||||
city_denmark,
|
||||
city_finland,
|
||||
city_germany,
|
||||
city_poland,
|
||||
city_norway,
|
||||
city_sweden,
|
||||
].filter((city): city is string => Boolean(city))
|
||||
|
||||
cityIdentifier = cities[0]
|
||||
if (cityIdentifier) {
|
||||
cityData = await getCityByCityIdentifier(cityIdentifier, serviceToken)
|
||||
const hotelIds = await getHotelIdsByCityIdentifier(
|
||||
cityIdentifier,
|
||||
serviceToken
|
||||
)
|
||||
|
||||
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
|
||||
|
||||
if (cityFilter) {
|
||||
const allFilters = getFiltersFromHotels(hotels)
|
||||
const facilityFilter = allFilters.facilityFilters.find(
|
||||
(f) => f.slug === cityFilter
|
||||
)
|
||||
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||
(f) => f.slug === cityFilter
|
||||
)
|
||||
|
||||
if (facilityFilter) {
|
||||
filterType = "facility"
|
||||
} else if (surroudingsFilter) {
|
||||
filterType = "surroundings"
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cityName: cityData?.name, cityFilter, cityFilterType: filterType }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { pageSettingsQueryRouter } from "./query"
|
||||
|
||||
export const pageSettingsRouter = mergeRouters(pageSettingsQueryRouter)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const pageSettingsSchema = z.object({
|
||||
hide_booking_widget: z.boolean(),
|
||||
})
|
||||
|
||||
export type PageSettingsSchema = z.output<typeof pageSettingsSchema>
|
||||
|
||||
export const getPageSettingsSchema = z.object({
|
||||
page: z.object({
|
||||
settings: pageSettingsSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
export type GetPageSettingsSchema = z.output<typeof getPageSettingsSchema>
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
GetAccountPageSettings,
|
||||
GetCollectionPageSettings,
|
||||
GetContentPageSettings,
|
||||
GetCurrentBlocksPageSettings,
|
||||
GetDestinationCityPageSettings,
|
||||
GetDestinationCountryPageSettings,
|
||||
GetDestinationOverviewPageSettings,
|
||||
GetHotelPageSettings,
|
||||
GetLoyaltyPageSettings,
|
||||
GetStartPageSettings,
|
||||
} from "@/lib/graphql/Query/PageSettings.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { contentstackBaseProcedure, router } from "@/server/trpc"
|
||||
import { langInput } from "@/server/utils"
|
||||
|
||||
import { generateTag } from "@/utils/generateTag"
|
||||
|
||||
import { type GetPageSettingsSchema, getPageSettingsSchema } from "./output"
|
||||
import { affix } from "./utils"
|
||||
|
||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||
|
||||
export const pageSettingsQueryRouter = router({
|
||||
get: contentstackBaseProcedure
|
||||
.input(langInput)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { contentType, uid } = ctx
|
||||
const lang = input.lang ?? ctx.lang
|
||||
|
||||
// This condition is to handle 404 page case and booking flow
|
||||
if (!contentType || !uid) {
|
||||
console.log("No proper params defined: ", contentType, uid)
|
||||
return null
|
||||
}
|
||||
|
||||
let GetPageSettings = ""
|
||||
|
||||
switch (contentType) {
|
||||
case PageContentTypeEnum.accountPage:
|
||||
GetPageSettings = GetAccountPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.collectionPage:
|
||||
GetPageSettings = GetCollectionPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.contentPage:
|
||||
GetPageSettings = GetContentPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.currentBlocksPage:
|
||||
GetPageSettings = GetCurrentBlocksPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.destinationCityPage:
|
||||
GetPageSettings = GetDestinationCityPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.destinationCountryPage:
|
||||
GetPageSettings = GetDestinationCountryPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.destinationOverviewPage:
|
||||
GetPageSettings = GetDestinationOverviewPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.hotelPage:
|
||||
GetPageSettings = GetHotelPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.loyaltyPage:
|
||||
GetPageSettings = GetLoyaltyPageSettings
|
||||
break
|
||||
case PageContentTypeEnum.startPage:
|
||||
GetPageSettings = GetStartPageSettings
|
||||
break
|
||||
}
|
||||
|
||||
if (!GetPageSettings) {
|
||||
console.error(
|
||||
"[pageSettings] No proper Content type defined: ",
|
||||
contentType
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await request<GetPageSettingsSchema>(
|
||||
GetPageSettings,
|
||||
{
|
||||
uid: uid,
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: [generateTag(lang, uid, affix)],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const result = getPageSettingsSchema.safeParse(response.data)
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Page settings fetch error: ", result.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const affix = "pageSettings"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { partnerQueryRouter } from "./query"
|
||||
|
||||
export const partnerRouter = mergeRouters(partnerQueryRouter)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const link = z.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
export const validateSasTierComparisonSchema = z
|
||||
.object({
|
||||
all_sas_tier_comparison: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
scandic_column_title: z.string(),
|
||||
sas_column_title: z.string(),
|
||||
tier_matches: z.array(
|
||||
z.object({
|
||||
scandic_friends_tier_name: z.string(),
|
||||
sas_eb_tier_name: z.string(),
|
||||
title: z.string(),
|
||||
content: z.object({
|
||||
json: z.any(), // json
|
||||
}),
|
||||
link: link.optional(),
|
||||
})
|
||||
),
|
||||
cta: link.optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => data.all_sas_tier_comparison.items.at(0))
|
||||
@@ -0,0 +1,86 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
import { cache } from "react"
|
||||
|
||||
import { GetAllSasTierComparison } from "@/lib/graphql/Query/SASTierComparison.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import { contentstackBaseProcedure, router } from "@/server/trpc"
|
||||
|
||||
import { validateSasTierComparisonSchema } from "./output"
|
||||
|
||||
import type { SasTierComparisonResponse } from "@/types/trpc/routers/contentstack/partner"
|
||||
import type { Context } from "@/server/context"
|
||||
|
||||
const meter = metrics.getMeter("trpc.partner")
|
||||
const getSasTierComparisonCounter = meter.createCounter(
|
||||
"trpc.contentstack.partner.getSasTierComparison"
|
||||
)
|
||||
|
||||
const getSasTierComparisonSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.partner.getSasTierComparison-success"
|
||||
)
|
||||
const getSasTierComparisonFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.partner.getSasTierComparison-fail"
|
||||
)
|
||||
|
||||
export const getSasTierComparison = cache(async (ctx: Context) => {
|
||||
getSasTierComparisonCounter.add(1)
|
||||
|
||||
const tag = `${ctx.lang}:sas_tier_comparison`
|
||||
const sasTierComparisonConfigResponse =
|
||||
await request<SasTierComparisonResponse>(
|
||||
GetAllSasTierComparison,
|
||||
{ lang: ctx.lang },
|
||||
{
|
||||
next: {
|
||||
tags: [tag],
|
||||
},
|
||||
cache: "force-cache",
|
||||
}
|
||||
)
|
||||
|
||||
if (!sasTierComparisonConfigResponse.data) {
|
||||
getSasTierComparisonFailCounter.add(1)
|
||||
const notFoundError = notFound(sasTierComparisonConfigResponse)
|
||||
console.error(
|
||||
"contentstack.sas not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: ctx.lang,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedSasTierComparison = validateSasTierComparisonSchema.safeParse(
|
||||
sasTierComparisonConfigResponse.data
|
||||
)
|
||||
|
||||
if (!validatedSasTierComparison.success) {
|
||||
getSasTierComparisonFailCounter.add(1)
|
||||
console.error(validatedSasTierComparison.error)
|
||||
console.error(
|
||||
"contentstack.sas validation error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
lang: ctx.lang,
|
||||
},
|
||||
error: validatedSasTierComparison.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getSasTierComparisonSuccessCounter.add(1)
|
||||
return validatedSasTierComparison.data
|
||||
})
|
||||
|
||||
export const partnerQueryRouter = router({
|
||||
getSasTierComparison: contentstackBaseProcedure.query(async function ({
|
||||
ctx,
|
||||
}) {
|
||||
return getSasTierComparison(ctx)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { rewardQueryRouter } from "./query"
|
||||
|
||||
export const rewardRouter = mergeRouters(rewardQueryRouter)
|
||||
24
apps/scandic-web/server/routers/contentstack/reward/input.ts
Normal file
24
apps/scandic-web/server/routers/contentstack/reward/input.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
|
||||
export const rewardsByLevelInput = z.object({
|
||||
level_id: z.nativeEnum(MembershipLevelEnum),
|
||||
unique: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const rewardsAllInput = z
|
||||
.object({ unique: z.boolean() })
|
||||
.default({ unique: false })
|
||||
|
||||
export const rewardsUpdateInput = z.array(
|
||||
z.object({
|
||||
rewardId: z.string(),
|
||||
couponCode: z.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export const rewardsRedeemInput = z.object({
|
||||
rewardId: z.string(),
|
||||
couponCode: z.string().optional(),
|
||||
})
|
||||
298
apps/scandic-web/server/routers/contentstack/reward/output.ts
Normal file
298
apps/scandic-web/server/routers/contentstack/reward/output.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
linkUnionSchema,
|
||||
transformPageLink,
|
||||
} from "../schemas/pageLinks"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
const Coupon = z.object({
|
||||
code: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
createdAt: z.string().datetime({ offset: true }).optional(),
|
||||
customer: z.object({
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
claimedAt: z.string().datetime({ offset: true }).optional(),
|
||||
redeemedAt: z
|
||||
.date({ coerce: true })
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (value?.getFullYear() === 1) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}),
|
||||
type: z.string().optional(),
|
||||
value: z.number().optional(),
|
||||
pool: z.string().optional(),
|
||||
cfUnwrapped: z.boolean().default(false),
|
||||
})
|
||||
|
||||
const SurpriseReward = z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.literal("coupon"),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
endsAt: z.string().datetime({ offset: true }).optional(),
|
||||
coupons: z.array(Coupon).optional(),
|
||||
operaRewardId: z.string().default(""),
|
||||
})
|
||||
|
||||
export const validateApiRewardSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.literal("custom"),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
operaRewardId: z.string().default(""),
|
||||
}),
|
||||
SurpriseReward,
|
||||
])
|
||||
),
|
||||
})
|
||||
.transform((data) => data.data)
|
||||
|
||||
enum TierKey {
|
||||
tier1 = MembershipLevelEnum.L1,
|
||||
tier2 = MembershipLevelEnum.L2,
|
||||
tier3 = MembershipLevelEnum.L3,
|
||||
tier4 = MembershipLevelEnum.L4,
|
||||
tier5 = MembershipLevelEnum.L5,
|
||||
tier6 = MembershipLevelEnum.L6,
|
||||
tier7 = MembershipLevelEnum.L7,
|
||||
}
|
||||
|
||||
type Key = keyof typeof TierKey
|
||||
|
||||
export const validateApiTierRewardsSchema = z.record(
|
||||
z.nativeEnum(TierKey).transform((data) => {
|
||||
return TierKey[data as unknown as Key]
|
||||
}),
|
||||
z.array(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
operaRewardId: z.string().default(""),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export const validateCmsRewardsSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
taxonomies: z.array(
|
||||
z.object({
|
||||
term_uid: z.string().optional().default(""),
|
||||
})
|
||||
),
|
||||
label: z.string().optional(),
|
||||
reward_id: z.string(),
|
||||
grouped_label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
grouped_description: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform((data) => data.data.all_reward.items)
|
||||
|
||||
export const validateCmsRewardsWithRedeemSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
taxonomies: z.array(
|
||||
z.object({
|
||||
term_uid: z.string().optional().default(""),
|
||||
})
|
||||
),
|
||||
label: z.string().optional(),
|
||||
reward_id: z.string(),
|
||||
grouped_label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
redeem_description: z.object({
|
||||
json: z.any(), // JSON
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkUnionSchema.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
grouped_description: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform((data) => data.data.all_reward.items)
|
||||
|
||||
export type ApiReward = z.output<typeof validateApiRewardSchema>[number]
|
||||
|
||||
export type SurpriseReward = z.output<typeof SurpriseReward>
|
||||
|
||||
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
|
||||
|
||||
export type CmsRewardsWithRedeemResponse = z.input<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>
|
||||
|
||||
export const rewardWithRedeemRefsSchema = z.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
redeem_description: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
system: systemSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface GetRewardWithRedeemRefsSchema
|
||||
extends z.input<typeof rewardWithRedeemRefsSchema> {}
|
||||
|
||||
export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0]
|
||||
|
||||
export type CMSRewardWithRedeem = z.output<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>[0]
|
||||
|
||||
export type Reward = CMSReward & {
|
||||
id: string | undefined
|
||||
rewardType: string | undefined
|
||||
redeemLocation: string | undefined
|
||||
rewardTierLevel: string | undefined
|
||||
operaRewardId: string
|
||||
couponCode: string | undefined
|
||||
}
|
||||
|
||||
export type RewardWithRedeem = CMSRewardWithRedeem & {
|
||||
id: string | undefined
|
||||
rewardType: string | undefined
|
||||
redeemLocation: string | undefined
|
||||
rewardTierLevel: string | undefined
|
||||
operaRewardId: string
|
||||
couponCode: string | undefined
|
||||
}
|
||||
|
||||
export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> {
|
||||
coupons: { couponCode?: string | undefined; expiresAt?: string }[]
|
||||
}
|
||||
|
||||
// New endpoint related types and schemas.
|
||||
const BaseReward = z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
|
||||
const BenefitReward = BaseReward.merge(
|
||||
z.object({
|
||||
rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility
|
||||
rewardTierLevel: z.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
const CouponData = z.object({
|
||||
couponCode: z.string().optional(),
|
||||
unwrapped: z.boolean().default(false),
|
||||
state: z.enum(["claimed", "redeemed", "viewed"]),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
})
|
||||
|
||||
const CouponReward = BaseReward.merge(
|
||||
z.object({
|
||||
rewardType: z.enum(["Surprise", "Campaign", "Member-voucher"]),
|
||||
operaRewardId: z.string().default(""),
|
||||
coupon: z
|
||||
.array(CouponData)
|
||||
.optional()
|
||||
.transform((val) => val || []),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Schema for the new /profile/v1/Reward endpoint.
|
||||
*
|
||||
* TODO: Once we fully migrate to the new endpoint:
|
||||
* 1. Remove the data transform and use the categorized structure directly.
|
||||
* 2. Simplify surprise filtering in the query.
|
||||
*/
|
||||
export const validateCategorizedRewardsSchema = z
|
||||
.object({
|
||||
benefits: z.array(BenefitReward),
|
||||
coupons: z.array(CouponReward),
|
||||
})
|
||||
.transform((data) => [
|
||||
...data.benefits.map((benefit) => ({
|
||||
...benefit,
|
||||
type: "custom" as const, // Added for legacy compatibility.
|
||||
})),
|
||||
...data.coupons.map((coupon) => ({
|
||||
...coupon,
|
||||
type: "coupon" as const, // Added for legacy compatibility.
|
||||
})),
|
||||
])
|
||||
|
||||
export type CategorizedApiReward = z.output<
|
||||
typeof validateCategorizedRewardsSchema
|
||||
>[number]
|
||||
|
||||
export const validateApiAllTiersSchema = z.record(
|
||||
z.nativeEnum(TierKey).transform((data) => {
|
||||
return TierKey[data as unknown as Key]
|
||||
}),
|
||||
z.array(BenefitReward)
|
||||
)
|
||||
|
||||
export type RedeemLocation = "Non-redeemable" | "On-site" | "Online"
|
||||
519
apps/scandic-web/server/routers/contentstack/reward/query.ts
Normal file
519
apps/scandic-web/server/routers/contentstack/reward/query.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentStackBaseWithProtectedProcedure,
|
||||
contentStackBaseWithServiceProcedure,
|
||||
protectedProcedure,
|
||||
router,
|
||||
} from "@/server/trpc"
|
||||
import { langInput } from "@/server/utils"
|
||||
|
||||
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
|
||||
import {
|
||||
rewardsAllInput,
|
||||
rewardsByLevelInput,
|
||||
rewardsRedeemInput,
|
||||
rewardsUpdateInput,
|
||||
} from "./input"
|
||||
import {
|
||||
type Reward,
|
||||
type Surprise,
|
||||
validateApiRewardSchema,
|
||||
validateCategorizedRewardsSchema,
|
||||
} from "./output"
|
||||
import {
|
||||
getAllCachedApiRewards,
|
||||
getAllRewardCounter,
|
||||
getAllRewardFailCounter,
|
||||
getAllRewardSuccessCounter,
|
||||
getByLevelRewardCounter,
|
||||
getByLevelRewardFailCounter,
|
||||
getByLevelRewardSuccessCounter,
|
||||
getCachedAllTierRewards,
|
||||
getCmsRewards,
|
||||
getCurrentRewardCounter,
|
||||
getCurrentRewardFailCounter,
|
||||
getCurrentRewardSuccessCounter,
|
||||
getNonRedeemedRewardIds,
|
||||
getRedeemCounter,
|
||||
getRedeemFailCounter,
|
||||
getRedeemSuccessCounter,
|
||||
getUniqueRewardIds,
|
||||
getUnwrapSurpriseCounter,
|
||||
getUnwrapSurpriseFailCounter,
|
||||
getUnwrapSurpriseSuccessCounter,
|
||||
} from "./utils"
|
||||
|
||||
const ONE_HOUR = 60 * 60
|
||||
|
||||
export const rewardQueryRouter = router({
|
||||
all: contentStackBaseWithServiceProcedure
|
||||
.input(rewardsAllInput)
|
||||
.query(async function ({ input, ctx }) {
|
||||
getAllRewardCounter.add(1)
|
||||
|
||||
const allApiRewards = env.USE_NEW_REWARDS_ENDPOINT
|
||||
? await getCachedAllTierRewards(ctx.serviceToken)
|
||||
: await getAllCachedApiRewards(ctx.serviceToken)
|
||||
|
||||
if (!allApiRewards) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rewardIds = Object.values(allApiRewards)
|
||||
.flatMap((level) => level.map((reward) => reward?.rewardId))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
const contentStackRewards = await getCmsRewards(
|
||||
ctx.lang,
|
||||
getUniqueRewardIds(rewardIds)
|
||||
)
|
||||
|
||||
if (!contentStackRewards) {
|
||||
return []
|
||||
}
|
||||
|
||||
const loyaltyLevelsConfig = await getAllLoyaltyLevels(ctx)
|
||||
const levelsWithRewards = Object.entries(allApiRewards).map(
|
||||
([level, rewards]) => {
|
||||
const combinedRewards = rewards
|
||||
.filter((r) => (input.unique ? r?.rewardTierLevel === level : true))
|
||||
.map((reward) => {
|
||||
const contentStackReward = contentStackRewards.find((r) => {
|
||||
return r.reward_id === reward?.rewardId
|
||||
})
|
||||
|
||||
if (contentStackReward) {
|
||||
return contentStackReward
|
||||
} else {
|
||||
console.error("No contentStackReward found", reward?.rewardId)
|
||||
}
|
||||
})
|
||||
.filter((reward): reward is Reward => Boolean(reward))
|
||||
|
||||
const levelConfig = loyaltyLevelsConfig.find(
|
||||
(l) => l.level_id === level
|
||||
)
|
||||
|
||||
if (!levelConfig) {
|
||||
getAllRewardFailCounter.add(1)
|
||||
|
||||
console.error("contentstack.loyaltyLevels level not found")
|
||||
throw notFound()
|
||||
}
|
||||
return { ...levelConfig, rewards: combinedRewards }
|
||||
}
|
||||
)
|
||||
|
||||
getAllRewardSuccessCounter.add(1)
|
||||
return levelsWithRewards
|
||||
}),
|
||||
byLevel: contentStackBaseWithServiceProcedure
|
||||
.input(rewardsByLevelInput)
|
||||
.query(async function ({ input, ctx }) {
|
||||
getByLevelRewardCounter.add(1)
|
||||
const { level_id } = input
|
||||
|
||||
const allUpcomingApiRewards = env.USE_NEW_REWARDS_ENDPOINT
|
||||
? await getCachedAllTierRewards(ctx.serviceToken)
|
||||
: await getAllCachedApiRewards(ctx.serviceToken)
|
||||
|
||||
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
|
||||
getByLevelRewardFailCounter.add(1)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let apiRewards = allUpcomingApiRewards[level_id]!
|
||||
|
||||
if (input.unique) {
|
||||
apiRewards = allUpcomingApiRewards[level_id]!.filter(
|
||||
(reward) => reward?.rewardTierLevel === level_id
|
||||
)
|
||||
}
|
||||
|
||||
const rewardIds = apiRewards
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
const [contentStackRewards, loyaltyLevelsConfig] = await Promise.all([
|
||||
getCmsRewards(ctx.lang, rewardIds),
|
||||
getLoyaltyLevel(ctx, input.level_id),
|
||||
])
|
||||
|
||||
if (!contentStackRewards) {
|
||||
return null
|
||||
}
|
||||
|
||||
const levelsWithRewards = apiRewards
|
||||
.map((reward) => {
|
||||
const contentStackReward = contentStackRewards.find((r) => {
|
||||
return r.reward_id === reward?.rewardId
|
||||
})
|
||||
|
||||
if (contentStackReward) {
|
||||
return contentStackReward
|
||||
} else {
|
||||
console.info("No contentStackReward found", reward?.rewardId)
|
||||
}
|
||||
})
|
||||
.filter((reward): reward is Reward => Boolean(reward))
|
||||
|
||||
getByLevelRewardSuccessCounter.add(1)
|
||||
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
|
||||
}),
|
||||
current: contentStackBaseWithProtectedProcedure
|
||||
.input(langInput.optional()) // lang is required for client, but not for server
|
||||
.query(async function ({ ctx }) {
|
||||
getCurrentRewardCounter.add(1)
|
||||
|
||||
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
|
||||
const endpoint = isNewEndpoint
|
||||
? api.endpoints.v1.Profile.Reward.reward
|
||||
: api.endpoints.v1.Profile.reward
|
||||
|
||||
const apiResponse = await api.get(endpoint, {
|
||||
cache: undefined, // override defaultOptions
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
||||
},
|
||||
next: { revalidate: ONE_HOUR },
|
||||
})
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
getCurrentRewardFailCounter.add(1, {
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.reward error ",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
|
||||
const validatedApiRewards = isNewEndpoint
|
||||
? validateCategorizedRewardsSchema.safeParse(data)
|
||||
: validateApiRewardSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiRewards.success) {
|
||||
getCurrentRewardFailCounter.add(1, {
|
||||
locale: ctx.lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedApiRewards.error),
|
||||
})
|
||||
console.error(validatedApiRewards.error)
|
||||
console.error(
|
||||
"contentstack.rewards validation error",
|
||||
JSON.stringify({
|
||||
query: { locale: ctx.lang },
|
||||
error: validatedApiRewards.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardIds = getNonRedeemedRewardIds(validatedApiRewards.data)
|
||||
|
||||
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
|
||||
|
||||
if (!cmsRewards) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wrappedSurprisesIds = validatedApiRewards.data
|
||||
.filter(
|
||||
(reward) =>
|
||||
reward.type === "coupon" &&
|
||||
reward.rewardType === "Surprise" &&
|
||||
"coupon" in reward &&
|
||||
reward.coupon.some(({ unwrapped }) => !unwrapped)
|
||||
)
|
||||
.map(({ rewardId }) => rewardId)
|
||||
|
||||
const rewards = cmsRewards
|
||||
.filter(
|
||||
(cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id)
|
||||
)
|
||||
.map((cmsReward) => {
|
||||
const apiReward = validatedApiRewards.data.find(
|
||||
({ rewardId }) => rewardId === cmsReward.reward_id
|
||||
)
|
||||
|
||||
const redeemableCoupons =
|
||||
(apiReward &&
|
||||
"coupon" in apiReward &&
|
||||
apiReward.coupon.filter(
|
||||
(coupon) => coupon.state !== "redeemed" && coupon.unwrapped
|
||||
)) ||
|
||||
[]
|
||||
|
||||
const firstRedeemableCouponToExpire = redeemableCoupons.reduce(
|
||||
(earliest, coupon) => {
|
||||
if (dt(coupon.expiresAt).isBefore(dt(earliest.expiresAt))) {
|
||||
return coupon
|
||||
}
|
||||
return earliest
|
||||
},
|
||||
redeemableCoupons[0]
|
||||
)?.couponCode
|
||||
|
||||
return {
|
||||
...cmsReward,
|
||||
id: apiReward?.id,
|
||||
rewardType: apiReward?.rewardType,
|
||||
redeemLocation: apiReward?.redeemLocation,
|
||||
rewardTierLevel:
|
||||
apiReward && "rewardTierLevel" in apiReward
|
||||
? apiReward.rewardTierLevel
|
||||
: undefined,
|
||||
operaRewardId:
|
||||
apiReward && "operaRewardId" in apiReward
|
||||
? apiReward.operaRewardId
|
||||
: "",
|
||||
couponCode: firstRedeemableCouponToExpire,
|
||||
}
|
||||
})
|
||||
|
||||
getCurrentRewardSuccessCounter.add(1)
|
||||
|
||||
return { rewards }
|
||||
}),
|
||||
surprises: contentStackBaseWithProtectedProcedure
|
||||
.input(langInput.optional()) // lang is required for client, but not for server
|
||||
.query(async ({ ctx }) => {
|
||||
getCurrentRewardCounter.add(1)
|
||||
|
||||
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
|
||||
const endpoint = isNewEndpoint
|
||||
? api.endpoints.v1.Profile.Reward.reward
|
||||
: api.endpoints.v1.Profile.reward
|
||||
|
||||
const apiResponse = await api.get(endpoint, {
|
||||
cache: undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
||||
},
|
||||
next: { revalidate: ONE_HOUR },
|
||||
})
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
getCurrentRewardFailCounter.add(1, {
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.reward error ",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedApiRewards = isNewEndpoint
|
||||
? validateCategorizedRewardsSchema.safeParse(data)
|
||||
: validateApiRewardSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiRewards.success) {
|
||||
getCurrentRewardFailCounter.add(1, {
|
||||
locale: ctx.lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedApiRewards.error),
|
||||
})
|
||||
console.error(validatedApiRewards.error)
|
||||
console.error(
|
||||
"contentstack.surprises validation error",
|
||||
JSON.stringify({
|
||||
query: { locale: ctx.lang },
|
||||
error: validatedApiRewards.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardIds = validatedApiRewards.data
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter((rewardId): rewardId is string => !!rewardId)
|
||||
.sort()
|
||||
|
||||
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
|
||||
|
||||
if (!cmsRewards) {
|
||||
return null
|
||||
}
|
||||
|
||||
getCurrentRewardSuccessCounter.add(1)
|
||||
|
||||
const surprises: Surprise[] = 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
|
||||
)
|
||||
|
||||
if (!reward) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...reward,
|
||||
id: surprise.id,
|
||||
rewardType: surprise.rewardType,
|
||||
rewardTierLevel: undefined,
|
||||
redeemLocation: surprise.redeemLocation,
|
||||
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
|
||||
}
|
||||
})
|
||||
.flatMap((surprises) => (surprises ? [surprises] : []))
|
||||
|
||||
return surprises
|
||||
}),
|
||||
unwrap: protectedProcedure
|
||||
.input(rewardsUpdateInput)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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
|
||||
}),
|
||||
redeem: protectedProcedure
|
||||
.input(rewardsRedeemInput)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
getRedeemCounter.add(1)
|
||||
|
||||
const { rewardId, couponCode } = input
|
||||
|
||||
const apiResponse = await api.post(
|
||||
api.endpoints.v1.Profile.Reward.redeem,
|
||||
{
|
||||
body: {
|
||||
rewardId,
|
||||
couponCode,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
getRedeemFailCounter.add(1, {
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.redeem error ",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getRedeemSuccessCounter.add(1)
|
||||
|
||||
return true
|
||||
}),
|
||||
})
|
||||
368
apps/scandic-web/server/routers/contentstack/reward/utils.ts
Normal file
368
apps/scandic-web/server/routers/contentstack/reward/utils.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
import { unstable_cache } from "next/cache"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
|
||||
import {
|
||||
GetRewards as GetRewardsWithReedem,
|
||||
GetRewardsRef as GetRewardsWithRedeemRef,
|
||||
} from "@/lib/graphql/Query/RewardsWithRedeem.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
|
||||
import { generateLoyaltyConfigTag, generateTag } from "@/utils/generateTag"
|
||||
|
||||
import {
|
||||
type ApiReward,
|
||||
type CategorizedApiReward,
|
||||
type CmsRewardsResponse,
|
||||
type CmsRewardsWithRedeemResponse,
|
||||
type GetRewardWithRedeemRefsSchema,
|
||||
rewardWithRedeemRefsSchema,
|
||||
validateApiAllTiersSchema,
|
||||
validateApiTierRewardsSchema,
|
||||
validateCmsRewardsSchema,
|
||||
validateCmsRewardsWithRedeemSchema,
|
||||
} from "./output"
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const meter = metrics.getMeter("trpc.reward")
|
||||
export const getAllRewardCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all"
|
||||
)
|
||||
export const getAllRewardFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all-fail"
|
||||
)
|
||||
export const getAllRewardSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all-success"
|
||||
)
|
||||
export const getCurrentRewardCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.current"
|
||||
)
|
||||
export const getCurrentRewardFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.current-fail"
|
||||
)
|
||||
export const getCurrentRewardSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.current-success"
|
||||
)
|
||||
export const getByLevelRewardCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.byLevel"
|
||||
)
|
||||
export const getByLevelRewardFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.byLevel-fail"
|
||||
)
|
||||
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"
|
||||
)
|
||||
export const getRedeemCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.redeem"
|
||||
)
|
||||
export const getRedeemFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.redeem-fail"
|
||||
)
|
||||
export const getRedeemSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.redeem-success"
|
||||
)
|
||||
|
||||
export const getAllCMSRewardRefsCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all"
|
||||
)
|
||||
export const getAllCMSRewardRefsFailCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all-fail"
|
||||
)
|
||||
export const getAllCMSRewardRefsSuccessCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all-success"
|
||||
)
|
||||
|
||||
const ONE_HOUR = 60 * 60
|
||||
|
||||
export function getUniqueRewardIds(rewardIds: string[]) {
|
||||
const uniqueRewardIds = new Set(rewardIds)
|
||||
return Array.from(uniqueRewardIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the legacy profile/v1/Profile/tierRewards endpoint.
|
||||
* TODO: Delete when the new endpoint is out in production.
|
||||
*/
|
||||
export const getAllCachedApiRewards = unstable_cache(
|
||||
async function (token) {
|
||||
const apiResponse = await api.get(api.endpoints.v1.Profile.tierRewards, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
getAllRewardFailCounter.add(1, {
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.rewards.tierRewards error ",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw apiResponse
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedApiTierRewards = validateApiTierRewardsSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiTierRewards.success) {
|
||||
getAllRewardFailCounter.add(1, {
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedApiTierRewards.error),
|
||||
})
|
||||
console.error(validatedApiTierRewards.error)
|
||||
console.error(
|
||||
"api.rewards validation error",
|
||||
JSON.stringify({
|
||||
error: validatedApiTierRewards.error,
|
||||
})
|
||||
)
|
||||
throw validatedApiTierRewards.error
|
||||
}
|
||||
|
||||
return validatedApiTierRewards.data
|
||||
},
|
||||
["getAllApiRewards"],
|
||||
{ revalidate: ONE_HOUR }
|
||||
)
|
||||
|
||||
/**
|
||||
* Cached for 1 hour.
|
||||
*/
|
||||
export const getCachedAllTierRewards = unstable_cache(
|
||||
async function (token) {
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Profile.Reward.allTiers,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
getAllRewardFailCounter.add(1, {
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.rewards.allTiers error ",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw apiResponse
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedApiAllTierRewards = validateApiAllTiersSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiAllTierRewards.success) {
|
||||
getAllRewardFailCounter.add(1, {
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedApiAllTierRewards.error),
|
||||
})
|
||||
console.error(validatedApiAllTierRewards.error)
|
||||
console.error(
|
||||
"api.rewards validation error",
|
||||
JSON.stringify({
|
||||
error: validatedApiAllTierRewards.error,
|
||||
})
|
||||
)
|
||||
throw validatedApiAllTierRewards.error
|
||||
}
|
||||
|
||||
return validatedApiAllTierRewards.data
|
||||
},
|
||||
["getApiAllTierRewards"],
|
||||
{ revalidate: ONE_HOUR }
|
||||
)
|
||||
|
||||
export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
|
||||
const tags = rewardIds.map((id) =>
|
||||
generateLoyaltyConfigTag(lang, "reward", id)
|
||||
)
|
||||
|
||||
let cmsRewardsResponse
|
||||
if (env.USE_NEW_REWARD_MODEL) {
|
||||
getAllCMSRewardRefsCounter.add(1, { lang, rewardIds })
|
||||
console.info(
|
||||
"contentstack.reward.refs start",
|
||||
JSON.stringify({
|
||||
query: { lang, rewardIds },
|
||||
})
|
||||
)
|
||||
const refsResponse = await request<GetRewardWithRedeemRefsSchema>(
|
||||
GetRewardsWithRedeemRef,
|
||||
{
|
||||
locale: lang,
|
||||
rewardIds,
|
||||
},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: rewardIds.map((rewardId) => generateTag(lang, rewardId)),
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!refsResponse.data) {
|
||||
const notFoundError = notFound(refsResponse)
|
||||
getAllCMSRewardRefsFailCounter.add(1, {
|
||||
lang,
|
||||
rewardIds,
|
||||
error_type: "not_found",
|
||||
error: JSON.stringify({ code: notFoundError.code }),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.reward.refs not found error",
|
||||
JSON.stringify({
|
||||
query: { lang, rewardIds },
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedRefsData = rewardWithRedeemRefsSchema.safeParse(refsResponse)
|
||||
|
||||
if (!validatedRefsData.success) {
|
||||
getAllCMSRewardRefsFailCounter.add(1, {
|
||||
lang,
|
||||
rewardIds,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedRefsData.error),
|
||||
})
|
||||
console.error(
|
||||
"contentstack.reward.refs validation error",
|
||||
JSON.stringify({
|
||||
query: { lang, rewardIds },
|
||||
error: validatedRefsData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
getAllCMSRewardRefsSuccessCounter.add(1, { lang, rewardIds })
|
||||
console.info(
|
||||
"contentstack.startPage.refs success",
|
||||
JSON.stringify({
|
||||
query: { lang, rewardIds },
|
||||
})
|
||||
)
|
||||
|
||||
cmsRewardsResponse = await request<CmsRewardsWithRedeemResponse>(
|
||||
GetRewardsWithReedem,
|
||||
{
|
||||
locale: lang,
|
||||
rewardIds,
|
||||
},
|
||||
{ next: { tags }, cache: "force-cache" }
|
||||
)
|
||||
} else {
|
||||
cmsRewardsResponse = await request<CmsRewardsResponse>(
|
||||
GetRewards,
|
||||
{
|
||||
locale: lang,
|
||||
rewardIds,
|
||||
},
|
||||
{ next: { tags }, cache: "force-cache" }
|
||||
)
|
||||
}
|
||||
|
||||
if (!cmsRewardsResponse.data) {
|
||||
getAllRewardFailCounter.add(1, {
|
||||
lang,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(cmsRewardsResponse.data),
|
||||
})
|
||||
const notFoundError = notFound(cmsRewardsResponse)
|
||||
console.error(
|
||||
"contentstack.rewards not found error",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
locale: lang,
|
||||
rewardIds,
|
||||
},
|
||||
error: { code: notFoundError.code },
|
||||
})
|
||||
)
|
||||
throw notFoundError
|
||||
}
|
||||
|
||||
const validatedCmsRewards = env.USE_NEW_REWARD_MODEL
|
||||
? validateCmsRewardsWithRedeemSchema.safeParse(cmsRewardsResponse)
|
||||
: validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
|
||||
|
||||
if (!validatedCmsRewards.success) {
|
||||
getAllRewardFailCounter.add(1, {
|
||||
locale: lang,
|
||||
rewardIds,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedCmsRewards.error),
|
||||
})
|
||||
console.error(validatedCmsRewards.error)
|
||||
console.error(
|
||||
"contentstack.rewards validation error",
|
||||
JSON.stringify({
|
||||
query: { locale: lang, rewardIds },
|
||||
error: validatedCmsRewards.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return validatedCmsRewards.data
|
||||
}
|
||||
|
||||
export function getNonRedeemedRewardIds(
|
||||
rewards: Array<ApiReward | CategorizedApiReward>
|
||||
) {
|
||||
return rewards
|
||||
.filter((reward) => {
|
||||
if ("coupon" in reward && reward.coupon.length > 0) {
|
||||
if (reward.coupon.every((coupon) => coupon.state === "redeemed")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter((rewardId): rewardId is string => !!rewardId)
|
||||
.sort()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
linkUnionSchema,
|
||||
transformPageLink,
|
||||
} from "../pageLinks"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
|
||||
export const accordionItemsSchema = z.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
answer: z.object({
|
||||
json: z.any(), // JSON
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkUnionSchema.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export type Accordion = z.infer<typeof accordionSchema>
|
||||
|
||||
enum AccordionEnum {
|
||||
ContentPageBlocksAccordionBlockAccordionsGlobalAccordion = "ContentPageBlocksAccordionBlockAccordionsGlobalAccordion",
|
||||
ContentPageBlocksAccordionBlockAccordionsSpecificAccordion = "ContentPageBlocksAccordionBlockAccordionsSpecificAccordion",
|
||||
DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion = "DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion",
|
||||
DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion = "DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion",
|
||||
DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion = "DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion",
|
||||
DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion = "DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion",
|
||||
}
|
||||
|
||||
export const accordionSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.Accordion)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.Accordion),
|
||||
accordion: z
|
||||
.object({
|
||||
title: z.string().optional().default(""),
|
||||
accordions: z.array(
|
||||
z.object({
|
||||
__typename: z.nativeEnum(AccordionEnum),
|
||||
global_accordion: z
|
||||
.object({
|
||||
global_accordionConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
questions: accordionItemsSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
specific_accordion: z
|
||||
.object({
|
||||
questions: accordionItemsSchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
...data,
|
||||
accordions: data.accordions.flatMap((acc) => {
|
||||
switch (acc.__typename) {
|
||||
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsGlobalAccordion:
|
||||
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion:
|
||||
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion:
|
||||
return (
|
||||
acc.global_accordion?.global_accordionConnection.edges.flatMap(
|
||||
({ node: accordionConnection }) => {
|
||||
return accordionConnection.questions
|
||||
}
|
||||
) || []
|
||||
)
|
||||
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsSpecificAccordion:
|
||||
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion:
|
||||
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion:
|
||||
return acc.specific_accordion?.questions || []
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const globalAccordionConnectionRefs = z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
questions: z.array(
|
||||
z.object({
|
||||
answer: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const specificAccordionConnectionRefs = z.object({
|
||||
questions: z.array(
|
||||
z.object({
|
||||
answer: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const accordionRefsSchema = z.object({
|
||||
accordion: z
|
||||
.object({
|
||||
accordions: z.array(
|
||||
z.object({
|
||||
__typename: z.nativeEnum(AccordionEnum),
|
||||
global_accordion: z
|
||||
.object({
|
||||
global_accordionConnection: globalAccordionConnectionRefs,
|
||||
})
|
||||
.optional(),
|
||||
specific_accordion: specificAccordionConnectionRefs.optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => {
|
||||
return data.accordions.flatMap((accordion) => {
|
||||
switch (accordion.__typename) {
|
||||
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsGlobalAccordion:
|
||||
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsGlobalAccordion:
|
||||
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsGlobalAccordion:
|
||||
return (
|
||||
accordion.global_accordion?.global_accordionConnection.edges.flatMap(
|
||||
({ node: accordionConnection }) => {
|
||||
return accordionConnection.questions.flatMap((question) =>
|
||||
question.answer.embedded_itemsConnection.edges.flatMap(
|
||||
({ node }) => node.system
|
||||
)
|
||||
)
|
||||
}
|
||||
) || []
|
||||
)
|
||||
case AccordionEnum.ContentPageBlocksAccordionBlockAccordionsSpecificAccordion:
|
||||
case AccordionEnum.DestinationCityPageBlocksAccordionBlockAccordionsSpecificAccordion:
|
||||
case AccordionEnum.DestinationCountryPageBlocksAccordionBlockAccordionsSpecificAccordion:
|
||||
return (
|
||||
accordion.specific_accordion?.questions.flatMap((question) =>
|
||||
question.answer.embedded_itemsConnection.edges.flatMap(
|
||||
({ node }) => node.system
|
||||
)
|
||||
) || []
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../imageVault"
|
||||
import { contentPageRefSchema, contentPageSchema } from "../pageLinks"
|
||||
|
||||
import { HotelPageEnum } from "@/types/enums/hotelPage"
|
||||
|
||||
export const activitiesCardSchema = z.object({
|
||||
typename: z
|
||||
.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard)
|
||||
.optional()
|
||||
.default(HotelPageEnum.ContentStack.blocks.ActivitiesCard),
|
||||
upcoming_activities_card: z
|
||||
.object({
|
||||
background_image: tempImageVaultAssetSchema,
|
||||
body_text: z.string(),
|
||||
cta_text: z.string(),
|
||||
sidepeek_cta_text: z.string(),
|
||||
heading: z.string(),
|
||||
scripted_title: z.string().optional(),
|
||||
sidepeek_slug: z.string(),
|
||||
hotel_page_activities_content_pageConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.discriminatedUnion("__typename", [
|
||||
contentPageSchema.extend({
|
||||
header: z.object({
|
||||
preamble: z.string(),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
let contentPage = { href: "", preamble: "" }
|
||||
if (data.hotel_page_activities_content_pageConnection.edges.length) {
|
||||
const page =
|
||||
data.hotel_page_activities_content_pageConnection.edges[0].node
|
||||
contentPage.preamble = page.header.preamble
|
||||
if (page.web.original_url) {
|
||||
contentPage.href = page.web.original_url
|
||||
} else {
|
||||
contentPage.href = removeMultipleSlashes(
|
||||
`/${page.system.locale}/${page.url}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundImage: data.background_image,
|
||||
bodyText: data.body_text,
|
||||
contentPage,
|
||||
ctaText: data.cta_text,
|
||||
sidepeekCtaText: data.sidepeek_cta_text,
|
||||
sidepeekSlug: data.sidepeek_slug,
|
||||
heading: data.heading,
|
||||
scriptedTopTitle: data.scripted_title,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const activitiesCardRefSchema = z.object({
|
||||
upcoming_activities_card: z
|
||||
.object({
|
||||
hotel_page_activities_content_pageConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.discriminatedUnion("__typename", [contentPageRefSchema]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
return (
|
||||
data.hotel_page_activities_content_pageConnection.edges.flatMap(
|
||||
({ node }) => node.system
|
||||
) || []
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
contentCardRefSchema,
|
||||
contentCardSchema,
|
||||
transformContentCard,
|
||||
} from "./cards/contentCard"
|
||||
import { buttonSchema } from "./utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "./utils/linkConnection"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import {
|
||||
type CardGalleryFilter,
|
||||
CardGalleryFilterEnum,
|
||||
} from "@/types/enums/cardGallery"
|
||||
|
||||
export const cardGallerySchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.CardGallery)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.CardGallery),
|
||||
card_gallery: z
|
||||
.object({
|
||||
heading: z.string().optional(),
|
||||
link: buttonSchema.optional(),
|
||||
card_groups: z.array(
|
||||
z.object({
|
||||
filter_identifier: z.nativeEnum(CardGalleryFilterEnum),
|
||||
filter_label: z.string(),
|
||||
cardConnection: z.object({
|
||||
edges: z.array(z.object({ node: contentCardSchema })),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => {
|
||||
const filterCategories = data.card_groups.reduce<
|
||||
Array<{
|
||||
identifier: CardGalleryFilter
|
||||
label: string
|
||||
}>
|
||||
>((acc, group) => {
|
||||
const identifier = group.filter_identifier
|
||||
if (!acc.some((category) => category.identifier === identifier)) {
|
||||
acc.push({
|
||||
identifier,
|
||||
label: group.filter_label,
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return {
|
||||
heading: data.heading,
|
||||
filterCategories,
|
||||
cards: data.card_groups.flatMap((group) =>
|
||||
group.cardConnection.edges
|
||||
.map((edge) => transformContentCard(edge.node))
|
||||
.filter((card): card is NonNullable<typeof card> => card !== null)
|
||||
.map((card) => ({
|
||||
...card,
|
||||
filterId: group.filter_identifier,
|
||||
}))
|
||||
),
|
||||
defaultFilter:
|
||||
data.card_groups[0]?.filter_identifier ??
|
||||
filterCategories[0]?.identifier,
|
||||
link:
|
||||
data.link?.href && data.link.title
|
||||
? { href: data.link.href, text: data.link.title }
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const cardGalleryRefsSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.CardGallery)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.CardGallery),
|
||||
card_gallery: z.object({
|
||||
card_groups: z.array(
|
||||
z.object({
|
||||
cardConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: contentCardRefSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
),
|
||||
link: linkConnectionRefsSchema.optional(),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../../imageVault"
|
||||
import { systemSchema } from "../../system"
|
||||
import { buttonSchema } from "../utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "../utils/linkConnection"
|
||||
|
||||
import { CardsEnum } from "@/types/enums/cards"
|
||||
|
||||
export const contentCardSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.ContentCard),
|
||||
title: z.string(),
|
||||
heading: z.string(),
|
||||
image: tempImageVaultAssetSchema,
|
||||
body_text: z.string(),
|
||||
promo_text: z.string().optional(),
|
||||
has_card_link: z.boolean(),
|
||||
card_link: buttonSchema,
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
export const contentCardRefSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.ContentCard),
|
||||
card_link: linkConnectionRefsSchema,
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
export function transformContentCard(card: typeof contentCardSchema._type) {
|
||||
// Return null if image or image URL is missing
|
||||
if (!card.image?.url) return null
|
||||
|
||||
return {
|
||||
__typename: card.__typename,
|
||||
title: card.title,
|
||||
heading: card.heading,
|
||||
image: card.image,
|
||||
bodyText: card.body_text,
|
||||
promoText: card.promo_text,
|
||||
link: card.has_card_link
|
||||
? {
|
||||
href: card.card_link.href,
|
||||
openInNewTab: card.card_link.openInNewTab,
|
||||
isExternal: card.card_link.isExternal,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../../imageVault"
|
||||
import { systemSchema } from "../../system"
|
||||
import { buttonSchema } from "../utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "../utils/linkConnection"
|
||||
|
||||
import { INFO_CARD_THEMES } from "@/types/components/blocks/infoCard"
|
||||
import { CardsEnum } from "@/types/enums/cards"
|
||||
|
||||
export const infoCardBlockSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.InfoCard),
|
||||
scripted_top_title: z.string().optional(),
|
||||
heading: z.string().optional().default(""),
|
||||
body_text: z.string().optional().default(""),
|
||||
image: tempImageVaultAssetSchema,
|
||||
theme: z.enum(INFO_CARD_THEMES).nullable(),
|
||||
title: z.string().optional(),
|
||||
primary_button: buttonSchema.optional().nullable(),
|
||||
secondary_button: buttonSchema.optional().nullable(),
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
export function transformInfoCardBlock(card: typeof infoCardBlockSchema._type) {
|
||||
return {
|
||||
__typename: card.__typename,
|
||||
scriptedTopTitle: card.scripted_top_title,
|
||||
heading: card.heading,
|
||||
bodyText: card.body_text,
|
||||
image: card.image,
|
||||
theme: card.theme,
|
||||
title: card.title,
|
||||
primaryButton: card.primary_button?.href ? card.primary_button : undefined,
|
||||
secondaryButton: card.secondary_button?.href
|
||||
? card.secondary_button
|
||||
: undefined,
|
||||
system: card.system,
|
||||
}
|
||||
}
|
||||
|
||||
export const infoCardBlockRefsSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.InfoCard),
|
||||
primary_button: linkConnectionRefsSchema,
|
||||
secondary_button: linkConnectionRefsSchema,
|
||||
system: systemSchema,
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../../imageVault"
|
||||
import { systemSchema } from "../../system"
|
||||
import { buttonSchema } from "../utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "../utils/linkConnection"
|
||||
|
||||
import { CardsEnum } from "@/types/enums/cards"
|
||||
|
||||
export const loyaltyCardBlockSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.LoyaltyCard),
|
||||
body_text: z.string().optional(),
|
||||
heading: z.string().optional().default(""),
|
||||
// JSON - ImageVault Image
|
||||
image: tempImageVaultAssetSchema,
|
||||
link: buttonSchema,
|
||||
system: systemSchema,
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
export const loyaltyCardBlockRefsSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.LoyaltyCard),
|
||||
link: linkConnectionRefsSchema,
|
||||
system: systemSchema,
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../../imageVault"
|
||||
import {
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
transformPageLink,
|
||||
} from "../../pageLinks"
|
||||
import { systemSchema } from "../../system"
|
||||
import { imageSchema } from "../image"
|
||||
import { imageContainerSchema } from "../imageContainer"
|
||||
import { buttonSchema } from "../utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "../utils/linkConnection"
|
||||
|
||||
import { CardsEnum } from "@/types/enums/cards"
|
||||
|
||||
export const teaserCardBlockSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.TeaserCard),
|
||||
heading: z.string().default(""),
|
||||
body_text: z.string().default(""),
|
||||
image: tempImageVaultAssetSchema,
|
||||
primary_button: buttonSchema,
|
||||
secondary_button: buttonSchema,
|
||||
has_primary_button: z.boolean().default(false),
|
||||
has_secondary_button: z.boolean().default(false),
|
||||
has_sidepeek_button: z.boolean().default(false),
|
||||
sidepeek_button: z
|
||||
.object({
|
||||
call_to_action_text: z.string().optional().default(""),
|
||||
})
|
||||
.optional(),
|
||||
sidepeek_content: z
|
||||
.object({
|
||||
heading: z.string(),
|
||||
content: z.object({
|
||||
json: z.any(),
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z
|
||||
.discriminatedUnion("__typename", [
|
||||
imageContainerSchema,
|
||||
imageSchema,
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
])
|
||||
.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
has_primary_button: z.boolean().default(false),
|
||||
primary_button: buttonSchema,
|
||||
has_secondary_button: z.boolean().default(false),
|
||||
secondary_button: buttonSchema,
|
||||
})
|
||||
.optional()
|
||||
.transform((data) => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
primary_button: data.has_primary_button
|
||||
? data.primary_button
|
||||
: undefined,
|
||||
secondary_button: data.has_secondary_button
|
||||
? data.secondary_button
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
export function transformTeaserCardBlock(
|
||||
card: typeof teaserCardBlockSchema._type
|
||||
) {
|
||||
return {
|
||||
__typename: card.__typename,
|
||||
body_text: card.body_text,
|
||||
heading: card.heading,
|
||||
primaryButton: card.has_primary_button ? card.primary_button : undefined,
|
||||
secondaryButton: card.has_secondary_button
|
||||
? card.secondary_button
|
||||
: undefined,
|
||||
sidePeekButton: card.has_sidepeek_button ? card.sidepeek_button : undefined,
|
||||
sidePeekContent: card.has_sidepeek_button
|
||||
? card.sidepeek_content
|
||||
: undefined,
|
||||
image: card.image,
|
||||
system: card.system,
|
||||
}
|
||||
}
|
||||
|
||||
export const teaserCardBlockRefsSchema = z.object({
|
||||
__typename: z.literal(CardsEnum.TeaserCard),
|
||||
primary_button: linkConnectionRefsSchema,
|
||||
secondary_button: linkConnectionRefsSchema,
|
||||
system: systemSchema,
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../imageVault"
|
||||
import { systemSchema } from "../system"
|
||||
import {
|
||||
infoCardBlockRefsSchema,
|
||||
infoCardBlockSchema,
|
||||
transformInfoCardBlock,
|
||||
} from "./cards/infoCard"
|
||||
import {
|
||||
loyaltyCardBlockRefsSchema,
|
||||
loyaltyCardBlockSchema,
|
||||
} from "./cards/loyaltyCard"
|
||||
import {
|
||||
teaserCardBlockRefsSchema,
|
||||
teaserCardBlockSchema,
|
||||
transformTeaserCardBlock,
|
||||
} from "./cards/teaserCard"
|
||||
import { buttonSchema } from "./utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "./utils/linkConnection"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import { CardsGridEnum, CardsGridLayoutEnum } from "@/types/enums/cardsGrid"
|
||||
import { scriptedCardThemeEnum } from "@/types/enums/scriptedCard"
|
||||
|
||||
export const cardBlockSchema = z.object({
|
||||
__typename: z.literal(CardsGridEnum.cards.Card),
|
||||
// JSON - ImageVault Image
|
||||
background_image: tempImageVaultAssetSchema,
|
||||
body_text: z.string().optional().default(""),
|
||||
has_primary_button: z.boolean().default(false),
|
||||
has_secondary_button: z.boolean().default(false),
|
||||
heading: z.string().optional().default(""),
|
||||
primary_button: buttonSchema,
|
||||
scripted_top_title: z.string().optional(),
|
||||
secondary_button: buttonSchema,
|
||||
system: systemSchema,
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
export function transformCardBlock(card: typeof cardBlockSchema._type) {
|
||||
return {
|
||||
__typename: card.__typename,
|
||||
backgroundImage: card.background_image,
|
||||
body_text: card.body_text,
|
||||
heading: card.heading,
|
||||
primaryButton: card.has_primary_button ? card.primary_button : undefined,
|
||||
scripted_top_title: card.scripted_top_title,
|
||||
secondaryButton: card.has_secondary_button
|
||||
? card.secondary_button
|
||||
: undefined,
|
||||
system: card.system,
|
||||
title: card.title,
|
||||
}
|
||||
}
|
||||
|
||||
export const cardsGridSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.CardsGrid)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.CardsGrid),
|
||||
cards_grid: z
|
||||
.object({
|
||||
cardConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.discriminatedUnion("__typename", [
|
||||
cardBlockSchema,
|
||||
loyaltyCardBlockSchema,
|
||||
teaserCardBlockSchema,
|
||||
infoCardBlockSchema,
|
||||
]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
layout: z.nativeEnum(CardsGridLayoutEnum),
|
||||
preamble: z.string().optional().default(""),
|
||||
theme: z.nativeEnum(scriptedCardThemeEnum).nullable(),
|
||||
title: z.string().optional().default(""),
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
layout: data.layout,
|
||||
preamble: data.preamble,
|
||||
theme: data.theme,
|
||||
title: data.title,
|
||||
cards: data.cardConnection.edges.map((card) => {
|
||||
if (card.node.__typename === CardsGridEnum.cards.Card) {
|
||||
return transformCardBlock(card.node)
|
||||
} else if (card.node.__typename === CardsGridEnum.cards.TeaserCard) {
|
||||
return transformTeaserCardBlock(card.node)
|
||||
} else if (card.node.__typename === CardsGridEnum.cards.InfoCard) {
|
||||
return transformInfoCardBlock(card.node)
|
||||
} else {
|
||||
return {
|
||||
__typename: card.node.__typename,
|
||||
body_text: card.node.body_text,
|
||||
heading: card.node.heading,
|
||||
image: card.node.image,
|
||||
link: card.node.link,
|
||||
system: card.node.system,
|
||||
title: card.node.title,
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const cardBlockRefsSchema = z.object({
|
||||
__typename: z.literal(CardsGridEnum.cards.Card),
|
||||
primary_button: linkConnectionRefsSchema,
|
||||
secondary_button: linkConnectionRefsSchema,
|
||||
system: systemSchema,
|
||||
})
|
||||
|
||||
export function transformCardBlockRefs(
|
||||
card:
|
||||
| typeof cardBlockRefsSchema._type
|
||||
| typeof teaserCardBlockRefsSchema._type
|
||||
| typeof infoCardBlockRefsSchema._type
|
||||
) {
|
||||
const cards = [card.system]
|
||||
if (card.primary_button) {
|
||||
cards.push(card.primary_button)
|
||||
}
|
||||
if (card.secondary_button) {
|
||||
cards.push(card.secondary_button)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
export const cardGridRefsSchema = z.object({
|
||||
cards_grid: z
|
||||
.object({
|
||||
cardConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.discriminatedUnion("__typename", [
|
||||
cardBlockRefsSchema,
|
||||
loyaltyCardBlockRefsSchema,
|
||||
teaserCardBlockRefsSchema,
|
||||
infoCardBlockRefsSchema,
|
||||
]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
return data.cardConnection.edges
|
||||
.map(({ node }) => {
|
||||
if (
|
||||
node.__typename === CardsGridEnum.cards.Card ||
|
||||
node.__typename === CardsGridEnum.cards.TeaserCard ||
|
||||
node.__typename === CardsGridEnum.cards.InfoCard
|
||||
) {
|
||||
return transformCardBlockRefs(node)
|
||||
} else {
|
||||
const loyaltyCards = [node.system]
|
||||
if (node.link) {
|
||||
loyaltyCards.push(node.link)
|
||||
}
|
||||
return loyaltyCards
|
||||
}
|
||||
})
|
||||
.flat()
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
contentCardRefSchema,
|
||||
contentCardSchema,
|
||||
transformContentCard,
|
||||
} from "./cards/contentCard"
|
||||
import { buttonSchema } from "./utils/buttonLinkSchema"
|
||||
import { linkConnectionRefsSchema } from "./utils/linkConnection"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import {
|
||||
type CarouselCardFilter,
|
||||
CarouselCardFilterEnum,
|
||||
} from "@/types/enums/carouselCards"
|
||||
|
||||
const commonFields = {
|
||||
heading: z.string().optional(),
|
||||
link: buttonSchema.optional(),
|
||||
} as const
|
||||
|
||||
const carouselCardsWithFilters = z.object({
|
||||
...commonFields,
|
||||
enable_filters: z.literal(true),
|
||||
card_groups: z.array(
|
||||
z.object({
|
||||
filter_identifier: z.nativeEnum(CarouselCardFilterEnum),
|
||||
filter_label: z.string(),
|
||||
cardConnection: z.object({
|
||||
edges: z.array(z.object({ node: contentCardSchema })),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const carouselCardsWithoutFilters = z.object({
|
||||
...commonFields,
|
||||
enable_filters: z.literal(false),
|
||||
card_groups: z.array(
|
||||
z.object({
|
||||
filter_identifier: z.null(),
|
||||
filter_label: z.string(),
|
||||
cardConnection: z.object({
|
||||
edges: z.array(z.object({ node: contentCardSchema })),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const carouselCardsSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.CarouselCards)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.CarouselCards),
|
||||
carousel_cards: z
|
||||
.discriminatedUnion("enable_filters", [
|
||||
carouselCardsWithFilters,
|
||||
carouselCardsWithoutFilters,
|
||||
])
|
||||
.transform((data) => {
|
||||
if (!data.enable_filters) {
|
||||
return {
|
||||
heading: data.heading,
|
||||
enableFilters: false,
|
||||
filterCategories: [],
|
||||
cards: data.card_groups
|
||||
.flatMap((group) =>
|
||||
group.cardConnection.edges.map((edge) =>
|
||||
transformContentCard(edge.node)
|
||||
)
|
||||
)
|
||||
.filter((card): card is NonNullable<typeof card> => card !== null),
|
||||
link:
|
||||
data.link?.href && data.link.title
|
||||
? { href: data.link.href, text: data.link.title }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const filterCategories = data.card_groups.reduce<
|
||||
Array<{
|
||||
identifier: CarouselCardFilter
|
||||
label: string
|
||||
}>
|
||||
>((acc, group) => {
|
||||
const identifier = group.filter_identifier
|
||||
if (!acc.some((category) => category.identifier === identifier)) {
|
||||
acc.push({
|
||||
identifier,
|
||||
label: group.filter_label,
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return {
|
||||
heading: data.heading,
|
||||
enableFilters: true,
|
||||
filterCategories,
|
||||
cards: data.card_groups.flatMap((group) =>
|
||||
group.cardConnection.edges
|
||||
.map((edge) => transformContentCard(edge.node))
|
||||
.filter((card): card is NonNullable<typeof card> => card !== null)
|
||||
.map((card) => ({
|
||||
...card,
|
||||
filterId: group.filter_identifier,
|
||||
}))
|
||||
),
|
||||
defaultFilter:
|
||||
data.card_groups[0]?.filter_identifier ??
|
||||
filterCategories[0]?.identifier,
|
||||
link:
|
||||
data.link?.href && data.link.title
|
||||
? { href: data.link.href, text: data.link.title }
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const carouselCardsRefsSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.CarouselCards)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.CarouselCards),
|
||||
carousel_cards: z.object({
|
||||
card_groups: z.array(
|
||||
z.object({
|
||||
cardConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: contentCardRefSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
),
|
||||
link: linkConnectionRefsSchema.optional(),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
transformPageLink,
|
||||
} from "../pageLinks"
|
||||
import { imageRefsSchema, imageSchema } from "./image"
|
||||
import {
|
||||
imageContainerRefsSchema,
|
||||
imageContainerSchema,
|
||||
} from "./imageContainer"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import { ContentEnum } from "@/types/enums/content"
|
||||
|
||||
export const contentSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.Content)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.Content),
|
||||
content: z
|
||||
.object({
|
||||
content: z.object({
|
||||
json: z.any(), // JSON
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z
|
||||
.discriminatedUnion("__typename", [
|
||||
imageContainerSchema,
|
||||
imageSchema,
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
])
|
||||
.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
return data.content
|
||||
}),
|
||||
})
|
||||
|
||||
export const contentRefsSchema = z.object({
|
||||
content: z
|
||||
.object({
|
||||
content: z.object({
|
||||
embedded_itemsConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.discriminatedUnion("__typename", [
|
||||
imageRefsSchema,
|
||||
imageContainerRefsSchema,
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
return data.content.embedded_itemsConnection.edges
|
||||
.filter(({ node }) => node.__typename !== ContentEnum.blocks.SysAsset)
|
||||
.map(({ node }) => {
|
||||
if ("system" in node) {
|
||||
return node.system
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((node) => !!node)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
transformPageLink,
|
||||
} from "../pageLinks"
|
||||
import { imageSchema } from "./image"
|
||||
import { imageContainerSchema } from "./imageContainer"
|
||||
|
||||
export const contentEmbedsSchema = z
|
||||
.discriminatedUnion("__typename", [
|
||||
imageContainerSchema,
|
||||
imageSchema,
|
||||
accountPageSchema,
|
||||
collectionPageSchema,
|
||||
contentPageSchema,
|
||||
destinationCityPageSchema,
|
||||
destinationCountryPageSchema,
|
||||
destinationOverviewPageSchema,
|
||||
hotelPageSchema,
|
||||
loyaltyPageSchema,
|
||||
startPageSchema,
|
||||
])
|
||||
.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
linkUnionSchema,
|
||||
transformPageLink,
|
||||
} from "../pageLinks"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import { DynamicContentEnum } from "@/types/enums/dynamicContent"
|
||||
|
||||
export const dynamicContentSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.DynamicContent)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.DynamicContent),
|
||||
dynamic_content: z.object({
|
||||
component: z.enum(DynamicContentEnum.Blocks.enums),
|
||||
subtitle: z.string().optional().default(""),
|
||||
title: z.string().optional().default(""),
|
||||
link: z
|
||||
.object({
|
||||
text: z.string().optional().default(""),
|
||||
linkConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkUnionSchema.transform((data) => {
|
||||
const link = transformPageLink(data)
|
||||
if (link) {
|
||||
return link
|
||||
}
|
||||
return data
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.linkConnection?.edges.length) {
|
||||
const link = data.linkConnection.edges?.[0]?.node
|
||||
return {
|
||||
href: link.url,
|
||||
text: data.text,
|
||||
title: link.title,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const dynamicContentRefsSchema = z.object({
|
||||
dynamic_content: z.object({
|
||||
link: z
|
||||
.object({
|
||||
linkConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: linkRefsUnionSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.linkConnection?.edges.length) {
|
||||
return data.linkConnection.edges[0].node.system
|
||||
}
|
||||
return null
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../imageVault"
|
||||
import { systemSchema } from "../system"
|
||||
import { buttonSchema } from "./utils/buttonLinkSchema"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
|
||||
export const fullWidthCampaignSchema = z.object({
|
||||
full_width_campaign: z
|
||||
.object({
|
||||
full_width_campaignConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
background_image: tempImageVaultAssetSchema,
|
||||
heading: z.string().optional(),
|
||||
body_text: z.string().optional(),
|
||||
scripted_top_title: z.string().optional(),
|
||||
has_primary_button: z.boolean().default(false),
|
||||
primary_button: buttonSchema,
|
||||
has_secondary_button: z.boolean().default(false),
|
||||
secondary_button: buttonSchema,
|
||||
system: systemSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
return data.full_width_campaignConnection.edges[0]?.node || null
|
||||
}),
|
||||
})
|
||||
|
||||
export const fullWidthCampaignBlockSchema = z
|
||||
.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.FullWidthCampaign)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.FullWidthCampaign),
|
||||
})
|
||||
.merge(fullWidthCampaignSchema)
|
||||
|
||||
export const fullWidthCampaignBlockRefsSchema = z.object({
|
||||
full_width_campaign: z.object({
|
||||
full_width_campaignConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.discriminatedUnion("__typename", [
|
||||
pageLinks.accountPageRefSchema,
|
||||
pageLinks.contentPageRefSchema,
|
||||
pageLinks.loyaltyPageRefSchema,
|
||||
pageLinks.collectionPageRefSchema,
|
||||
pageLinks.hotelPageRefSchema,
|
||||
pageLinks.destinationCityPageRefSchema,
|
||||
pageLinks.destinationCountryPageRefSchema,
|
||||
pageLinks.destinationOverviewPageRefSchema,
|
||||
]),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
accordionItemsSchema,
|
||||
globalAccordionConnectionRefs,
|
||||
specificAccordionConnectionRefs,
|
||||
} from "./accordion"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import { HotelPageEnum } from "@/types/enums/hotelPage"
|
||||
|
||||
export const hotelFaqSchema = z
|
||||
.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.Accordion)
|
||||
.optional()
|
||||
.default(BlocksEnums.block.Accordion),
|
||||
title: z.string().optional().default(""),
|
||||
global_faqConnection: z
|
||||
.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
questions: accordionItemsSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
specific_faq: z
|
||||
.object({
|
||||
questions: accordionItemsSchema,
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const array = []
|
||||
array.push(
|
||||
data.global_faqConnection?.edges.flatMap(({ node: faqConnection }) => {
|
||||
return faqConnection.questions
|
||||
}) || []
|
||||
)
|
||||
array.push(data.specific_faq?.questions || [])
|
||||
return { ...data, accordions: array.flat(2) }
|
||||
})
|
||||
|
||||
export const hotelFaqRefsSchema = z
|
||||
.object({
|
||||
__typename: z
|
||||
.literal(HotelPageEnum.ContentStack.blocks.Faq)
|
||||
.optional()
|
||||
.default(HotelPageEnum.ContentStack.blocks.Faq),
|
||||
global_faqConnection: globalAccordionConnectionRefs.optional(),
|
||||
specific_faq: specificAccordionConnectionRefs.optional().nullable(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const array = []
|
||||
array.push(
|
||||
data.global_faqConnection?.edges.flatMap(({ node: faqConnection }) => {
|
||||
return faqConnection.questions.flatMap((question) =>
|
||||
question.answer.embedded_itemsConnection.edges.flatMap(
|
||||
({ node }) => node.system
|
||||
)
|
||||
)
|
||||
}) || []
|
||||
)
|
||||
array.push(
|
||||
data.specific_faq?.questions.flatMap((question) =>
|
||||
question.answer.embedded_itemsConnection.edges.flatMap(
|
||||
({ node }) => node.system
|
||||
)
|
||||
) || []
|
||||
)
|
||||
return array.flat(2)
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { BlocksEnums } from "@/types/enums/blocks"
|
||||
import { Country } from "@/types/enums/country"
|
||||
|
||||
export const locationFilterSchema = z
|
||||
.object({
|
||||
country: z.nativeEnum(Country).nullable(),
|
||||
city_denmark: z.string().optional().nullable(),
|
||||
city_finland: z.string().optional().nullable(),
|
||||
city_germany: z.string().optional().nullable(),
|
||||
city_poland: z.string().optional().nullable(),
|
||||
city_norway: z.string().optional().nullable(),
|
||||
city_sweden: z.string().optional().nullable(),
|
||||
excluded: z.array(z.string()),
|
||||
})
|
||||
.transform((data) => {
|
||||
const cities = [
|
||||
data.city_denmark,
|
||||
data.city_finland,
|
||||
data.city_germany,
|
||||
data.city_poland,
|
||||
data.city_norway,
|
||||
data.city_sweden,
|
||||
].filter((city): city is string => Boolean(city))
|
||||
|
||||
// When there are multiple city values, we return null as the filter is invalid.
|
||||
if (cities.length > 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
country: cities.length ? null : data.country,
|
||||
city: cities.length ? cities[0] : null,
|
||||
excluded: data.excluded,
|
||||
}
|
||||
})
|
||||
|
||||
export const hotelListingSchema = z.object({
|
||||
typename: z
|
||||
.literal(BlocksEnums.block.HotelListing)
|
||||
.default(BlocksEnums.block.HotelListing),
|
||||
hotel_listing: z
|
||||
.object({
|
||||
heading: z.string().optional(),
|
||||
location_filter: locationFilterSchema,
|
||||
manual_filter: z
|
||||
.object({
|
||||
hotels: z.array(z.string()),
|
||||
})
|
||||
.transform((data) => ({ hotels: data.hotels.filter(Boolean) })),
|
||||
content_type: z.enum(["hotel", "restaurant", "meeting"]),
|
||||
})
|
||||
.transform(({ heading, location_filter, manual_filter, content_type }) => {
|
||||
return {
|
||||
heading,
|
||||
locationFilter: location_filter,
|
||||
hotelsToInclude: manual_filter.hotels,
|
||||
contentType: content_type,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ContentEnum } from "@/types/enums/content"
|
||||
|
||||
export const imageSchema = z.object({
|
||||
__typename: z.literal(ContentEnum.blocks.SysAsset),
|
||||
content_type: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
dimension: z
|
||||
.object({
|
||||
height: z.number(),
|
||||
width: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
metadata: z.any(), // JSON
|
||||
// system for SysAssets is not the same type
|
||||
// as for all other types eventhough they have
|
||||
// the exact same structure, that's why systemSchema
|
||||
// is not used as that correlates to the
|
||||
// EntrySystemField type
|
||||
system: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
title: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
})
|
||||
|
||||
export const imageRefsSchema = z.object({
|
||||
__typename: z.literal(ContentEnum.blocks.SysAsset),
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { tempImageVaultAssetSchema } from "../imageVault"
|
||||
import { systemSchema } from "../system"
|
||||
|
||||
import { ContentEnum } from "@/types/enums/content"
|
||||
|
||||
export const imageContainerSchema = z.object({
|
||||
__typename: z.literal(ContentEnum.blocks.ImageContainer),
|
||||
// JSON - ImageVault Image
|
||||
image_left: tempImageVaultAssetSchema,
|
||||
// JSON - ImageVault Image
|
||||
image_right: tempImageVaultAssetSchema,
|
||||
system: systemSchema,
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
export const imageContainerRefsSchema = z.object({
|
||||
__typename: z.literal(ContentEnum.blocks.ImageContainer),
|
||||
system: systemSchema,
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user