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:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View 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>

View 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
)
}

View 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)
}
}

View 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

View File

@@ -0,0 +1,9 @@
import { mergeRouters } from "@/server/trpc"
import { bookingMutationRouter } from "./mutation"
import { bookingQueryRouter } from "./query"
export const bookingRouter = mergeRouters(
bookingMutationRouter,
bookingQueryRouter
)

View 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

View 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
}),
})

View 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,
}))

View 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
}),
})

View 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,
}
}

View File

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

View File

@@ -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,
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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
}

View File

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

View 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,
})
),
}),
})

View 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,
}
}),
})

View 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"
)

View 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
}

View File

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

View File

@@ -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()
})

View File

@@ -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 []
}
}),
})

View File

@@ -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
}

View File

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

View File

@@ -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,
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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
}

View File

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

View File

@@ -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,
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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"]
}
}

View File

@@ -0,0 +1,7 @@
import { mergeRouters } from "@/server/trpc"
import { destinationCityPageQueryRouter } from "./query"
export const destinationCityPageRouter = mergeRouters(
destinationCityPageQueryRouter
)

View File

@@ -0,0 +1,5 @@
import { z } from "zod"
export const getHotelListDataInput = z.object({
cityIdentifier: z.string(),
})

View File

@@ -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,
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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"
)

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
import { mergeRouters } from "@/server/trpc"
import { destinationCountryPageQueryRouter } from "./query"
export const destinationCountryPageRouter = mergeRouters(
destinationCountryPageQueryRouter
)

View File

@@ -0,0 +1,7 @@
import { z } from "zod"
import { Country } from "@/types/enums/country"
export const getCityPagesInput = z.object({
country: z.nativeEnum(Country),
})

View File

@@ -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,
}),
})

View File

@@ -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
}),
})

View File

@@ -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"
)

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
import { mergeRouters } from "@/server/trpc"
import { destinationOverviewPageQueryRouter } from "./query"
export const destinationOverviewPageRouter = mergeRouters(
destinationOverviewPageQueryRouter
)

View File

@@ -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,
}),
})

View File

@@ -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))
}),
}),
})

View File

@@ -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"
)

View File

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

View 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)

View File

@@ -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
}),
})

View File

@@ -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"
)

View 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
}

View 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,
})

View File

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

View File

@@ -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()

View File

@@ -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,
})

View File

@@ -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,
}
}),
})

View File

@@ -0,0 +1 @@
export const languageSwitcherAffix = "languageSwitcher"

View File

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

View File

@@ -0,0 +1,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(),
})

View File

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

View File

@@ -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)
}),
})

View File

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

View File

@@ -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(),
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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
}

View File

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

View File

@@ -0,0 +1,5 @@
import { z } from "zod"
export const getMetadataInput = z.object({
subpage: z.string().optional(),
})

View 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
})

View 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
}
}),
})

View 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
}

View File

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

View File

@@ -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>

View File

@@ -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
}),
})

View File

@@ -0,0 +1 @@
export const affix = "pageSettings"

View File

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

View File

@@ -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))

View File

@@ -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)
}),
})

View File

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

View File

@@ -0,0 +1,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(),
})

View 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"

View 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
}),
})

View 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()
}

View File

@@ -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
)
) || []
)
}
})
}),
})

View File

@@ -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
) || []
)
}),
})

View File

@@ -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(),
}),
})

View File

@@ -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,
}
}

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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()
}),
})

View File

@@ -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(),
}),
})

View File

@@ -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)
}),
})

View File

@@ -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
})

View File

@@ -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
}),
}),
})

View File

@@ -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,
]),
})
),
}),
}),
})

View File

@@ -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)
})

View File

@@ -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,
}
}),
})

View File

@@ -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),
})

View File

@@ -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