Feat/BOOK-424 campaign banner

Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2025-10-29 12:47:40 +00:00
parent 377c8886ad
commit 4c10989e8e
29 changed files with 1052 additions and 22 deletions

View File

@@ -78,7 +78,7 @@
.languageModal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
top: calc(var(--main-menu-mobile-height) + var(--alert-and-banner-height));
left: 0;
right: 0;
bottom: 0;

View File

@@ -69,7 +69,7 @@
.modal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
top: calc(var(--main-menu-mobile-height) + var(--alert-and-banner-height));
right: auto;
bottom: 0;
width: 100%;

View File

@@ -48,7 +48,7 @@
.modal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
top: calc(var(--main-menu-mobile-height) + var(--alert-and-banner-height));
right: auto;
bottom: 0;
width: 100%;
@@ -68,7 +68,7 @@
.closeModalBtn {
position: fixed;
top: calc(var(--Space-x2) + var(--sitewide-alert-height));
top: calc(var(--Space-x2) + var(--alert-and-banner-height));
right: var(--Space-x15);
background-color: var(--SAS-Default);
border: none;

View File

@@ -18,6 +18,7 @@ import TrpcProvider from "@/lib/trpc/Provider"
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
import { BookingFlowProviders } from "@/components/BookingFlowProviders"
import CampaignBanner from "@/components/CampaignBanner"
import ChatbotScript from "@/components/ChatbotScript"
import CookieBotConsent from "@/components/CookieBot"
import Footer from "@/components/Footer"
@@ -76,6 +77,7 @@ export default async function RootLayout(
<BookingFlowProviders>
<RouteChange />
<SitewideAlert />
<CampaignBanner />
<Header />
{bookingwidget}
{children}

View File

@@ -15,6 +15,10 @@
);
--sitewide-alert-height: 0px; /* Will be overridden when a sitewide alert is visible */
--campaign-banner-height: 0px; /* Will be overridden when a campaign banner is visible */
--alert-and-banner-height: calc(
var(--sitewide-alert-height) + var(--campaign-banner-height)
);
--main-menu-mobile-height: 73px;
--main-menu-desktop-height: 125px;
--booking-widget-mobile-height: 75px;

View File

@@ -0,0 +1,74 @@
"use client"
import NextLink from "next/link"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackClick } from "@scandic-hotels/tracking/base"
import { MarqueeText } from "@/components/MarqueeText"
import styles from "./campaignBanner.module.css"
import type { CampaignBannerProps } from "@/components/CampaignBanner/types"
export function DesktopCampaignBanner({
tag,
text,
link,
bookingCode,
}: CampaignBannerProps) {
const intl = useIntl()
return (
<div className={styles.innerContent}>
<Typography variant="Tag/sm">
<span className={styles.tag}>{tag}</span>
</Typography>
<MarqueeText
backgroundColor="var(--Surface-Brand-Primary-3-Default)"
className={styles.marquee}
textWrapperClassName={styles.marqueeText}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.text}>
{text}
{bookingCode ? (
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.bookingCode}>
<MaterialIcon color="CurrentColor" icon="sell" size={16} />
{intl.formatMessage(
{
id: "campaignBanner.codeWithBookingCode",
defaultMessage: "Code: {bookingCode}",
},
{ bookingCode }
)}
</span>
</Typography>
) : null}
</span>
</Typography>
{link ? (
<Typography variant="Link/sm">
<NextLink
href={link.url}
className={styles.link}
onClick={() => trackClick("BW read more")}
>
<span>
{link.title ||
intl.formatMessage({
id: "common.readMore",
defaultMessage: "Read more",
})}
</span>
</NextLink>
</Typography>
) : null}
</MarqueeText>
</div>
)
}

View File

@@ -0,0 +1,112 @@
"use client"
import { cx } from "class-variance-authority"
import NextLink from "next/link"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackClick } from "@scandic-hotels/tracking/base"
import { MarqueeText } from "@/components/MarqueeText"
import styles from "./campaignBanner.module.css"
import type { CampaignBannerProps } from "./types"
export function MobileCampaignBanner({
tag,
text,
link,
bookingCode,
}: CampaignBannerProps) {
const intl = useIntl()
return (
<InnerContent link={link} bookingCode={bookingCode}>
{bookingCode ? (
<p>
<Typography variant="Title/Overline/sm">
<span className={cx(styles.tag, styles.withBookingCode)}>
{tag}
</span>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<> {text} </>
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.bookingCode}>
{intl.formatMessage(
{
id: "campaignBanner.codeWithBookingCode",
defaultMessage: "Code: {bookingCode}",
},
{ bookingCode }
)}
<MaterialIcon
icon="arrow_forward"
color="CurrentColor"
size={16}
/>
</span>
</Typography>
</span>
</Typography>
</p>
) : (
<>
<Typography variant="Tag/sm">
<span className={styles.tag}>{tag}</span>
</Typography>
<MarqueeText
backgroundColor="var(--Surface-Brand-Primary-3-Default)"
className={styles.marquee}
textWrapperClassName={styles.marqueeText}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>{text}</span>
</Typography>
{link ? (
<Typography variant="Link/sm">
<span>
{link.title ||
intl.formatMessage({
id: "common.readMore",
defaultMessage: "Read more",
})}
</span>
</Typography>
) : null}
</MarqueeText>
</>
)}
</InnerContent>
)
}
function InnerContent({
link,
bookingCode,
children,
}: React.PropsWithChildren<Pick<CampaignBannerProps, "link" | "bookingCode">>) {
return link ? (
<NextLink
href={link.url}
className={cx(styles.innerContent, {
[styles.withBookingCode]: !!bookingCode,
})}
onClick={() => trackClick("BW campaign banner")}
>
{children}
</NextLink>
) : (
<div
className={cx(styles.innerContent, {
[styles.withBookingCode]: !!bookingCode,
})}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,97 @@
.campaignBanner {
width: 100%;
z-index: var(--header-z-index);
position: relative;
background-color: var(--Surface-Brand-Primary-3-Default);
}
.content {
position: relative;
display: grid;
grid-template-columns: 1fr max-content;
width: 100%;
max-width: var(--max-width-navigation);
margin: 0 auto;
}
.innerContent {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-decoration: none;
color: var(--Text-Inverted);
padding: var(--Space-x025) 0;
&:not(.withBookingCode) {
gap: var(--Space-x15);
}
}
.text {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.marquee {
padding: 4px 4px 4px 0; /* Adjustment to handle overflow for link focus */
}
.marqueeText {
gap: var(--Space-x15);
}
.tag {
display: inline-flex;
justify-content: center;
align-items: center;
white-space: nowrap;
&:not(.withBookingCode) {
height: 24px;
padding: 0 var(--Space-x1);
flex-shrink: 0;
border-radius: var(--Corner-radius-sm);
border: 1px solid var(--Border-Inverted);
}
}
.closeButton {
flex-shrink: 0;
z-index: 1;
}
.link {
color: var(--Text-Inverted);
}
.bookingCode {
display: inline-flex;
align-items: center;
gap: var(--Space-x05);
& > span {
display: flex;
}
}
@media screen and (max-width: 767px) {
.innerContent {
padding-left: var(--Space-x2);
}
}
@media screen and (min-width: 768px) {
.content {
max-width: var(--max-width-page);
gap: var(--Space-x15);
}
.marquee {
gap: var(--Space-x2);
}
.closeButton {
margin-right: -12px; /* Adjusts the position because of banners max width */
}
}

View File

@@ -0,0 +1,21 @@
import { Lang } from "@scandic-hotels/common/constants/language"
const SHARED_HIDE_ROUTES = [
"/hotelreservation/select-hotel/map",
"/hotelreservation/alternative-hotels/map",
"/hotelreservation/select-rate",
"/hotelreservation/details",
"/hotelreservation/booking-confirmation",
"/hotelreservation/my-stay",
]
export const CAMPAIGN_BANNER_HIDE_CONDITIONS = {
routes: {
[Lang.en]: [...SHARED_HIDE_ROUTES],
[Lang.sv]: [...SHARED_HIDE_ROUTES],
[Lang.no]: [...SHARED_HIDE_ROUTES],
[Lang.fi]: [...SHARED_HIDE_ROUTES],
[Lang.da]: [...SHARED_HIDE_ROUTES],
[Lang.de]: [...SHARED_HIDE_ROUTES],
},
} as const

View File

@@ -0,0 +1,130 @@
"use client"
import { usePathname } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { trackClick } from "@scandic-hotels/tracking/base"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "@/hooks/useLang"
import { DesktopCampaignBanner } from "./Desktop"
import { MobileCampaignBanner } from "./Mobile"
import { shouldShowCampaignBanner } from "./utils"
import styles from "./campaignBanner.module.css"
export default function CampaignBanner() {
const lang = useLang()
const intl = useIntl()
const pathname = usePathname()
const campaignBannerRef = useRef<HTMLDivElement>(null)
const isMobile = useMediaQuery("(max-width: 767px)")
const [closedPaths, setClosedPaths] = useState<Set<string>>(new Set())
const [
{ data: siteConfig, isLoading: siteConfigLoading },
{ data: campaignBanner, isLoading: campaignBannerLoading },
] = trpc.useQueries((t) => [
t.contentstack.base.siteConfig({ lang }, { refetchInterval: 60_000 }),
t.contentstack.base.sitewideCampaignBanner.get(
{ lang },
{ refetchInterval: 360_000 }
),
])
const isOnSamePage = pathname === campaignBanner?.link?.url
const sitewideAlertType = siteConfig?.sitewideAlert?.type || null
const shouldShowBanner = shouldShowCampaignBanner(
pathname,
lang,
closedPaths,
sitewideAlertType
)
const isVisible =
!siteConfigLoading &&
!campaignBannerLoading &&
!!campaignBanner &&
shouldShowBanner
const updateHeightRefCallback = useCallback((node: HTMLDivElement | null) => {
if (node) {
const debouncedUpdate = debounce(([entry]) => {
const height = entry.contentRect.height
document.documentElement.style.setProperty(
"--campaign-banner-height",
`${height}px`
)
}, 100)
const observer = new ResizeObserver(debouncedUpdate)
observer.observe(node)
return () => {
if (node) {
observer.unobserve(node)
}
observer.disconnect()
}
}
}, [])
useEffect(() => {
if (!isVisible) {
document.documentElement.style.removeProperty("--campaign-banner-height")
}
}, [isVisible])
if (!isVisible) {
return null
}
function handleClose() {
trackClick("BW close")
setClosedPaths((prev) => new Set(prev).add(pathname))
}
return (
<div
className={styles.campaignBanner}
ref={(node) => {
campaignBannerRef.current = node
return updateHeightRefCallback(node)
}}
>
<div className={styles.content}>
{isMobile ? (
<MobileCampaignBanner
tag={campaignBanner.tag}
text={campaignBanner.text}
link={isOnSamePage ? null : campaignBanner.link}
bookingCode={campaignBanner.booking_code}
/>
) : (
<DesktopCampaignBanner
tag={campaignBanner.tag}
text={campaignBanner.text}
link={isOnSamePage ? null : campaignBanner.link}
bookingCode={campaignBanner.booking_code}
/>
)}
<IconButton
className={styles.closeButton}
theme="Inverted"
style="Muted"
onPress={handleClose}
aria-label={intl.formatMessage({
id: "campaignBanner.dismissBanner",
defaultMessage: "Dismiss banner",
})}
>
<MaterialIcon color="CurrentColor" icon="close" />
</IconButton>
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export interface CampaignBannerProps {
tag: string
text: string
link: {
url: string
title: string
} | null
bookingCode?: string | null
}

View File

@@ -0,0 +1,45 @@
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import {
removeMultipleSlashes,
removeTrailingSlash,
} from "@scandic-hotels/common/utils/url"
import { CAMPAIGN_BANNER_HIDE_CONDITIONS } from "@/components/CampaignBanner/constants"
import type { Lang } from "@scandic-hotels/common/constants/language"
export function shouldShowCampaignBanner(
pathname: string,
lang: Lang,
closedPaths: Set<string>,
sitewideAlertType: AlertTypeEnum | null
): boolean {
const cleanPathname = removeTrailingSlash(removeMultipleSlashes(pathname))
// Check if the banner should not be visible on the current path based on hide conditions.
const isOnHideRoute = CAMPAIGN_BANNER_HIDE_CONDITIONS.routes[lang].some(
(route) => {
const fullRoute = removeTrailingSlash(
removeMultipleSlashes(`/${lang}${route}`)
)
return cleanPathname === fullRoute
}
)
if (isOnHideRoute) {
return false
}
// The campaign banner should not be visible if there is an alarming sitewide alert
if (sitewideAlertType === AlertTypeEnum.Alarm) {
return false
}
// Check if the user closed the banner and should therefore not be visible on the current path.
// This is saved in a local state and is reset on page reload.
if (closedPaths.has(pathname)) {
return false
}
return true
}

View File

@@ -48,7 +48,7 @@
bottom: 0;
left: 0;
right: 0;
height: calc(100dvh - max(var(--sitewide-alert-height), 20px));
height: calc(100dvh - max(var(--alert-and-banner-height), 20px));
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);

View File

@@ -66,7 +66,7 @@
.modal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
top: calc(var(--main-menu-mobile-height) + var(--alert-and-banner-height));
right: auto;
bottom: 0;
width: 100%;

View File

@@ -10,7 +10,7 @@
.modal {
position: fixed;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
top: calc(var(--main-menu-mobile-height) + var(--alert-and-banner-height));
right: auto;
bottom: 0;
width: 100%;

View File

@@ -28,7 +28,7 @@
position: fixed;
width: 100%;
top: calc(
var(--main-menu-mobile-height) + var(--sitewide-alert-height) + 1px
var(--main-menu-mobile-height) + var(--alert-and-banner-height) + 1px
);
right: -100vw;
bottom: 0;

View File

@@ -53,7 +53,7 @@
.header .dropdown {
right: -100vw;
top: calc(var(--main-menu-mobile-height) + var(--sitewide-alert-height));
top: calc(var(--main-menu-mobile-height) + var(--alert-and-banner-height));
bottom: 0;
transition: right 0.3s;
}

View File

@@ -0,0 +1,93 @@
"use client"
import { cx } from "class-variance-authority"
import { useEffect, useRef, useState } from "react"
import styles from "./marqueeText.module.css"
interface MarqueeTextProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>> {
backgroundColor: string
textWrapperClassName?: string
}
export function MarqueeText({
backgroundColor,
children,
className,
textWrapperClassName,
...props
}: MarqueeTextProps) {
const textContainerRef = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({
containerWidth: 0,
contentWidth: 0,
isOverflowing: false,
})
useEffect(() => {
const element = textContainerRef.current
const parentElement = element?.parentElement
if (!parentElement) {
return
}
const resizeObserver = new ResizeObserver(() => {
const containerWidth = element.clientWidth
const contentWidth = element.scrollWidth
const isOverflowing = contentWidth > containerWidth
setDimensions({
containerWidth,
contentWidth,
isOverflowing,
})
if (isOverflowing && containerWidth > 0) {
const scrollDistance = contentWidth - containerWidth
parentElement.style.setProperty(
"--scroll-distance",
`${scrollDistance}px`
)
// Calculate dynamic animation duration based on scroll distance
// This is done to avoid long scrolling durations for small distances and vice versa
// Base formula: minimum 2s, add 50ms per pixel of scroll distance
const baseDuration = 2
const durationPerPixel = 0.05
const calculatedDuration = Math.max(
baseDuration,
baseDuration + scrollDistance * durationPerPixel
)
parentElement.style.setProperty(
"--animation-duration",
`${calculatedDuration}s`
)
}
})
resizeObserver.observe(element)
return () => resizeObserver.disconnect()
}, [])
return (
<div
className={cx(styles.marqueeText, className)}
style={
{ "--marquee-background-color": backgroundColor } as React.CSSProperties
}
{...props}
>
<div
ref={textContainerRef}
className={cx(styles.textWrapper, textWrapperClassName, {
[styles.overflowing]: dimensions.isOverflowing,
})}
>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
.marqueeText {
background-color: var(--marquee-background-color);
position: relative;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
&:has(.overflowing) {
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 12px;
background: linear-gradient(
to right,
var(--marquee-background-color) 0%,
transparent 100%
);
z-index: 2;
pointer-events: none;
opacity: 0;
animation: leftShadow var(--animation-duration, 8s) linear infinite;
}
&::after {
content: "";
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 12px;
background: linear-gradient(
to left,
var(--marquee-background-color) 0%,
transparent 100%
);
z-index: 2;
pointer-events: none;
opacity: 1;
animation: rightShadow var(--animation-duration, 8s) linear infinite;
}
&:has(.overflowing:hover)::before,
&:has(.overflowing:hover)::after {
animation-play-state: paused;
}
}
}
.textWrapper {
display: flex;
scrollbar-width: none;
align-items: center;
scroll-behavior: smooth;
* {
flex-shrink: 0;
white-space: nowrap;
}
&.overflowing {
animation: autoScrollText var(--animation-duration, 8s) ease-in-out infinite;
&:hover {
animation-play-state: paused;
}
}
}
@keyframes autoScrollText {
0%,
15% {
transform: translateX(0);
}
80%,
100% {
transform: translateX(calc(-1 * var(--scroll-distance, 50px)));
}
}
@keyframes leftShadow {
0%,
16% {
opacity: 0;
}
17%,
100% {
opacity: 1;
}
}
@keyframes rightShadow {
0%,
79% {
opacity: 1;
}
80%,
100% {
opacity: 0;
}
}

View File

@@ -9,7 +9,7 @@
overflow-y: auto;
padding: var(--Spacing-x2) var(--Spacing-x3);
position: fixed;
top: calc(140px + max(var(--sitewide-alert-height), 25px));
top: calc(140px + max(var(--alert-and-banner-height), 25px));
width: 100%;
height: calc(100% - 200px);
z-index: 10010;

View File

@@ -46,7 +46,7 @@
.container[data-datepicker-open="true"] .hideWrapper {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
top: calc(max(var(--sitewide-alert-height), 20px));
top: calc(max(var(--alert-and-banner-height), 20px));
}
}

View File

@@ -15,7 +15,7 @@
background-color: var(--UI-Input-Controls-Surface-Normal);
border-radius: 0;
gap: var(--Spacing-x3);
height: calc(100dvh - max(var(--sitewide-alert-height), 20px));
height: calc(100dvh - max(var(--alert-and-banner-height), 20px));
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;

View File

@@ -0,0 +1,82 @@
#import "./PageLink/AccountPageLink.graphql"
#import "./PageLink/CampaignOverviewPageLink.graphql"
#import "./PageLink/CampaignPageLink.graphql"
#import "./PageLink/CollectionPageLink.graphql"
#import "./PageLink/ContentPageLink.graphql"
#import "./PageLink/DestinationCityPageLink.graphql"
#import "./PageLink/DestinationCountryPageLink.graphql"
#import "./PageLink/DestinationOverviewPageLink.graphql"
#import "./PageLink/HotelPageLink.graphql"
#import "./PageLink/LoyaltyPageLink.graphql"
#import "./PageLink/StartPageLink.graphql"
#import "./PageLink/PromoCampaignPageLink.graphql"
#import "./AccountPage/Ref.graphql"
#import "./CampaignOverviewPage/Ref.graphql"
#import "./CampaignPage/Ref.graphql"
#import "./CollectionPage/Ref.graphql"
#import "./ContentPage/Ref.graphql"
#import "./DestinationCityPage/Ref.graphql"
#import "./DestinationCountryPage/Ref.graphql"
#import "./DestinationOverviewPage/Ref.graphql"
#import "./HotelPage/Ref.graphql"
#import "./LoyaltyPage/Ref.graphql"
#import "./StartPage/Ref.graphql"
#import "./PromoCampaignPage/Ref.graphql"
fragment Banner on Banner {
tag
text
link {
title
linkConnection {
edges {
node {
__typename
...AccountPageLink
...CampaignOverviewPageLink
...CampaignPageLink
...CollectionPageLink
...ContentPageLink
...DestinationCityPageLink
...DestinationCountryPageLink
...DestinationOverviewPageLink
...HotelPageLink
...LoyaltyPageLink
...StartPageLink
...PromoCampaignPageLink
}
}
}
}
booking_code
visible_on
}
fragment BannerRef on Banner {
link {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CampaignOverviewPageRef
...CampaignPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
...StartPageRef
...PromoCampaignPageRef
}
}
}
}
visible_on
system {
...System
}
}

View File

@@ -0,0 +1,34 @@
#import "../Fragments/System.graphql"
#import "../Fragments/Banner.graphql"
query GetSitewideCampaignBanner($locale: String!) {
all_sitewide_campaign_banner(limit: 1, locale: $locale) {
items {
bannerConnection {
edges {
node {
...Banner
}
}
}
}
}
}
query GetSitewideCampaignBannerRef($locale: String!) {
all_sitewide_campaign_banner(limit: 1, locale: $locale) {
items {
bannerConnection {
edges {
node {
...BannerRef
}
}
}
system {
...System
}
}
}
}

View File

@@ -16,6 +16,7 @@ import {
transformCardBlock,
transformCardBlockRefs,
} from "../schemas/blocks/cardsGrid"
import { linkConnectionRefsSchema } from "../schemas/blocks/utils/linkConnection"
import {
linkRefsUnionSchema,
linkUnionSchema,
@@ -594,7 +595,7 @@ export const alertSchema = z
}),
}),
}),
visible_on: z.array(z.string()).nullable().default([]),
visible_on: z.array(z.string()).nullish().default([]),
})
.transform(
({
@@ -673,13 +674,12 @@ export const siteConfigSchema = z
}
}
const { sitewide_alert } = data.all_site_config.items[0]
const sitewideAlertWeb = sitewide_alert.alerts?.find((alert) =>
alert.alertConnection.edges[0]?.node.visible_on?.includes(
AlertVisibleOnEnum.WEB
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,
@@ -709,7 +709,7 @@ const alertConnectionRefSchema = z.object({
}),
})
),
visible_on: z.array(z.string()).nullable().default([]),
visible_on: z.array(z.string()).nullish().default([]),
})
export const siteConfigRefSchema = z.object({
@@ -737,3 +737,98 @@ export const siteConfigRefSchema = z.object({
),
}),
})
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
}),
})

View File

@@ -11,7 +11,10 @@ import {
GetSiteConfig,
GetSiteConfigRef,
} from "../../../graphql/Query/SiteConfig.graphql"
// import { router } from "../../.."
import {
GetSitewideCampaignBanner,
GetSitewideCampaignBannerRef,
} from "../../../graphql/Query/SitewideCampaignBanner.graphql"
import { request } from "../../../graphql/request"
import { contentstackBaseProcedure } from "../../../procedures"
import { langInput } from "../../../utils"
@@ -27,6 +30,8 @@ import {
headerSchema,
siteConfigRefSchema,
siteConfigSchema,
sitewideCampaignBannerRefSchema,
sitewideCampaignBannerSchema,
validateContactConfigSchema,
validateFooterConfigSchema,
validateFooterRefConfigSchema,
@@ -36,6 +41,7 @@ import {
getConnections,
getFooterConnections,
getSiteConfigConnections,
getSitewideCampaignBannerConnections,
} from "./utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
@@ -48,6 +54,8 @@ import type {
import type {
GetSiteConfigData,
GetSiteConfigRefData,
GetSitewideCampaignBannerData,
GetSitewideCampaignBannerRefData,
} from "../../../types/siteConfig"
const getContactConfig = cache(async (lang: Lang) => {
@@ -248,6 +256,97 @@ export const baseQueryRouter = router({
return validatedFooterConfig.data
}),
sitewideCampaignBanner: router({
get: contentstackBaseProcedure
.input(langInput)
.query(async ({ input, ctx }) => {
const lang = input.lang ?? ctx.lang
const getSitewideCampaignBannerRefsCounter = createCounter(
"trpc.contentstack",
"sitewideCampaignBanner.get.refs"
)
const metricsGetSitewideCampaignBannerRefs =
getSitewideCampaignBannerRefsCounter.init({
lang,
})
metricsGetSitewideCampaignBannerRefs.start()
const responseRef = await request<GetSitewideCampaignBannerRefData>(
GetSitewideCampaignBannerRef,
{ locale: lang },
{
key: generateRefsResponseTag(lang, "sitewide_campaign_banner"),
ttl: "max",
}
)
if (!responseRef.data) {
const notFoundError = notFound(responseRef)
metricsGetSitewideCampaignBannerRefs.noDataError()
throw notFoundError
}
const validatedSitewideCampaignBannerRef =
sitewideCampaignBannerRefSchema.safeParse(responseRef.data)
if (!validatedSitewideCampaignBannerRef.success) {
metricsGetSitewideCampaignBannerRefs.validationError(
validatedSitewideCampaignBannerRef.error
)
return null
}
const connections = getSitewideCampaignBannerConnections(
validatedSitewideCampaignBannerRef.data
)
const tags = [generateTagsFromSystem(lang, connections)].flat()
metricsGetSitewideCampaignBannerRefs.success()
const getSitewideCampaignBannerCounter = createCounter(
"trpc.contentstack",
"sitewideCampaignBanner.get"
)
const metricsGetSitewideCampaignBanner =
getSitewideCampaignBannerCounter.init({
lang,
})
metricsGetSitewideCampaignBanner.start()
const sitewideCampaignBannerResponse =
await request<GetSitewideCampaignBannerData>(
GetSitewideCampaignBanner,
{ locale: lang },
{ key: tags, ttl: "max" }
)
if (!sitewideCampaignBannerResponse.data) {
const notFoundError = notFound(sitewideCampaignBannerResponse)
metricsGetSitewideCampaignBanner.noDataError()
throw notFoundError
}
const validatedSitewideCampaignBanner =
sitewideCampaignBannerSchema.safeParse(
sitewideCampaignBannerResponse.data
)
if (!validatedSitewideCampaignBanner.success) {
metricsGetSitewideCampaignBanner.validationError(
validatedSitewideCampaignBanner.error
)
return null
}
metricsGetSitewideCampaignBanner.success()
return validatedSitewideCampaignBanner.data
}),
}),
siteConfig: contentstackBaseProcedure
.input(langInput)
.query(async ({ input, ctx }) => {

View File

@@ -12,6 +12,7 @@ import type { NodeRefs } from "../../../types/refs"
import type {
AlertOutput,
GetSiteConfigRefData,
GetSitewideCampaignBannerRefData,
} from "../../../types/siteConfig"
import type { System } from "../schemas/system"
import type { ContactConfig } from "./output"
@@ -138,3 +139,21 @@ export const safeUnion = <T extends z.ZodTypeAny>(schema: T) =>
return null
}
}, schema)
export function getSitewideCampaignBannerConnections(
refs: GetSitewideCampaignBannerRefData
) {
const system = refs.all_sitewide_campaign_banner?.system
const banner =
refs.all_sitewide_campaign_banner?.bannerConnection.edges[0]?.node
const connections: System["system"][] = []
if (system) {
connections.push(system)
}
if (banner?.system) {
connections.push(banner.system)
}
return connections
}

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import {
linkRefsUnionSchema,
linkUnionSchema,
@@ -8,7 +10,7 @@ import {
} from "./pageLinks"
const titleSchema = z.object({
title: z.string().optional().default(""),
title: nullableStringValidator,
})
export const linkConnectionSchema = z

View File

@@ -4,6 +4,8 @@ import type {
alertSchema,
siteConfigRefSchema,
siteConfigSchema,
sitewideCampaignBannerRefSchema,
sitewideCampaignBannerSchema,
} from "../routers/contentstack/base/output"
export type GetSiteConfigRefData = z.infer<typeof siteConfigRefSchema>
@@ -23,3 +25,10 @@ export type AlertPhoneContact = {
export type Alert = Omit<AlertOutput, "phoneContact"> & {
phoneContact: AlertPhoneContact | null
}
export type GetSitewideCampaignBannerRefData = z.infer<
typeof sitewideCampaignBannerRefSchema
>
export type GetSitewideCampaignBannerData = z.output<
typeof sitewideCampaignBannerSchema
>