import { z, ZodError, ZodIssueCode } from "zod" import { Lang } from "@scandic-hotels/common/constants/language" import { logger } from "@scandic-hotels/common/logger" import { removeMultipleSlashes } from "@scandic-hotels/common/utils/url" import { AlertTypeEnum } from "../../../types/alertType" import { discriminatedUnion } from "../../../utils/discriminatedUnion" import { cardBlockRefsSchema, cardBlockSchema, transformCardBlock, transformCardBlockRefs, } from "../schemas/blocks/cardsGrid" import { linkRefsUnionSchema, linkUnionSchema, transformPageLink, transformPageLinkRef, } from "../schemas/pageLinks" import { systemSchema } from "../schemas/system" 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 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 {} export type HeaderData = z.output // eslint-disable-next-line @typescript-eslint/no-unused-vars 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 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 } // eslint-disable-next-line @typescript-eslint/no-unused-vars 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.nullish().transform((val) => val ?? []), app_downloads: z.object({ title: z.string(), links: validateLinksWithType .nullish() .transform((val) => val ?? []), }), secondary_links: validateSecondaryLinks .nullish() .transform((val) => val ?? []), social_media: z.object({ links: validateLinksWithType .nullish() .transform((val) => val ?? []), }), tertiary_links: validateLinks.nullish().transform((val) => val ?? []), }) ), }), }) .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) { logger.error(`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, } }) // TODO When original IconName enum is moved to common we should use it enum IconName { Gift = "Gift", InfoCircle = "InfoCircle", PriceTag = "PriceTag", } 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) { logger.error(`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, }) ), }), })