import { z, ZodError, ZodIssueCode } from "zod" import { AlertTypeEnum, AlertVisibleOnEnum, } from "@scandic-hotels/common/constants/alert" import { Lang } from "@scandic-hotels/common/constants/language" import { logger } from "@scandic-hotels/common/logger" import { removeMultipleSlashes } from "@scandic-hotels/common/utils/url" import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator" import { discriminatedUnion } from "../../../utils/discriminatedUnion" import { infoCardBlockRefsSchema, infoCardBlockSchema, transformCardBlockRefs, transformInfoCardBlock, } from "../schemas/blocks/cardsGrid" import { linkConnectionRefsSchema } from "../schemas/blocks/utils/linkConnection" import { linkRefsUnionSchema, linkUnionSchema, rawLinkUnionSchema, transformPageLink, transformPageLinkRef, } from "../schemas/pageLinks" import { systemSchema } from "../schemas/system" // 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 } // eslint-disable-next-line @typescript-eslint/no-unused-vars const validateNavigationItem = z.object({ links: z.array(z.object({ href: z.string(), title: z.string() })), title: z.string(), }) export type NavigationItem = z.infer 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: infoCardBlockRefsSchema, }) ), }), 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 internalOrExternalLinkSchema = z .object({ is_contentstack_link: z.boolean().nullish(), external_link: z .object({ href: nullableStringValidator, title: z.string().nullish(), }) .nullish(), title: nullableStringValidator, linkConnection: z.object({ edges: z.array( z.object({ node: linkUnionSchema.transform((data) => { const link = transformPageLink(data) if (link) { return link } return data }), }) ), }), }) .transform( ({ is_contentstack_link, external_link, linkConnection, title }) => { if (is_contentstack_link !== false && linkConnection.edges.length) { const linkRef = linkConnection.edges[0].node return { title: title || linkRef.title, url: linkRef.url, } } else if (is_contentstack_link === false && external_link?.href) { return { title: title || external_link.title || "", url: external_link.href, } } else { return { title, } } } ) const linkSchema = z .object({ linkConnection: z.object({ edges: z.array( z.object({ node: discriminatedUnion(rawLinkUnionSchema.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: nullableStringValidator, }) /** * 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( internalOrExternalLinkSchema, z .object({ cardConnection: z.object({ edges: z.array( z.object({ node: infoCardBlockSchema, }) ), }), see_all_link: internalOrExternalLinkSchema, submenu: z.array( z.object({ links: z.array(internalOrExternalLinkSchema), title: nullableStringValidator, }) ), }) .transform((data) => { let card = null if (data.cardConnection.edges.length) { card = transformInfoCardBlock(data.cardConnection.edges[0].node) } return { card, seeAllLink: data.see_all_link, submenu: data.submenu, } }) ) .transform(({ title, url, card, seeAllLink, submenu }) => { return { title, link: submenu.length ? null : { url }, seeAllLink: submenu.length ? seeAllLink : null, card, submenu, } }) // 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( internalOrExternalLinkSchema, 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 }), }) ), }), }), }), visible_on: z.array(z.string()).nullish().default([]), }) .transform( ({ type, heading, text, phone_contact, has_link, link, has_sidepeek_button, sidepeek_button, sidepeek_content, visible_on, }) => { const hasLink = has_link && link.link return { type, text, heading, visible_on, 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({ alerts: z .array( z.object({ booking_widget_disabled: z.boolean(), alertConnection: z.object({ edges: z.array( z.object({ node: alertSchema, }) ), }), }) ) .nullable(), }), }) ) .max(1), }), }) .transform((data) => { if (!data.all_site_config.items.length) { return { sitewideAlert: null, bookingWidgetDisabled: false, } } const sitewideAlertWeb = data.all_site_config.items[0].sitewide_alert.alerts?.find((alert) => alert.alertConnection.edges[0]?.node.visible_on?.includes( AlertVisibleOnEnum.WEB ) ) return { sitewideAlert: sitewideAlertWeb?.alertConnection.edges[0]?.node || null, bookingWidgetDisabled: sitewideAlertWeb?.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, }), }) ), visible_on: z.array(z.string()).nullish().default([]), }) export const siteConfigRefSchema = z.object({ all_site_config: z.object({ items: z.array( z.object({ sitewide_alert: z .object({ alerts: z .array( z.object({ alertConnection: alertConnectionRefSchema, }) ) .nullable(), }) .transform((data) => { const sitewideAlertWeb = data.alerts?.find((alert) => alert.alertConnection.visible_on?.includes(AlertVisibleOnEnum.WEB) ) return { alert: sitewideAlertWeb || null } }), system: systemSchema, }) ), }), }) const bannerSchema = z .object({ tag: z.string(), text: z.string(), link: linkAndTitleSchema, booking_code: z.string().nullish(), visible_on: z.array(z.string()).nullish().default([]), }) .transform(({ tag, text, link, visible_on, booking_code }) => { const linkUrl = link.link?.url || null return { tag, text, link: linkUrl ? { url: linkUrl, title: link.title, } : null, booking_code, visible_on, } }) const bannerRefSchema = z .object({ link: linkConnectionRefsSchema, visible_on: z.array(z.string()).nullish().default([]), system: systemSchema, }) .transform(({ link, visible_on, system }) => { return { linkSystem: link, visible_on, system, } }) export const sitewideCampaignBannerSchema = z .object({ all_sitewide_campaign_banner: z.object({ items: z .array( z.object({ bannerConnection: z.object({ edges: z.array( z.object({ node: bannerSchema, }) ), }), }) ) .max(1), }), }) .transform((data) => { if (!data.all_sitewide_campaign_banner.items.length) { return null } const sitewideCampaignBannerWeb = data.all_sitewide_campaign_banner.items[0].bannerConnection.edges.find( (banner) => banner.node.visible_on?.includes(AlertVisibleOnEnum.WEB) ) return sitewideCampaignBannerWeb?.node ?? null }) export const sitewideCampaignBannerRefSchema = z.object({ all_sitewide_campaign_banner: z .object({ items: z.array( z.object({ bannerConnection: z.object({ edges: z.array( z.object({ node: bannerRefSchema, }) ), }), system: systemSchema, }) ), }) .transform((data) => { const webBanner = data.items.find((item) => { const bannerNode = item.bannerConnection.edges[0]?.node return bannerNode?.visible_on?.includes(AlertVisibleOnEnum.WEB) }) return webBanner ?? null }), })