From 4c10989e8efe211cea1216bddb956499e71c5284 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 29 Oct 2025 12:47:40 +0000 Subject: [PATCH] Feat/BOOK-424 campaign banner Approved-by: Bianca Widstam --- .../languageSwitcher.module.css | 2 +- .../Menu/MobileMenu/mobile-menu.module.css | 2 +- .../Menu/UserMenu/user-menu.module.css | 4 +- apps/scandic-web/app/[lang]/(live)/layout.tsx | 2 + apps/scandic-web/app/globals.css | 4 + .../components/CampaignBanner/Desktop.tsx | 74 ++++++++++ .../components/CampaignBanner/Mobile.tsx | 112 +++++++++++++++ .../CampaignBanner/campaignBanner.module.css | 97 +++++++++++++ .../components/CampaignBanner/constants.ts | 21 +++ .../components/CampaignBanner/index.tsx | 130 ++++++++++++++++++ .../components/CampaignBanner/types.ts | 9 ++ .../components/CampaignBanner/utils.ts | 45 ++++++ .../destinationSearch.module.css | 2 +- .../MainMenu/MobileMenu/mobileMenu.module.css | 2 +- .../myPagesMobileMenu.module.css | 2 +- .../navigationMenuItem.module.css | 2 +- .../languageSwitcher.module.css | 2 +- .../components/MarqueeText/index.tsx | 93 +++++++++++++ .../MarqueeText/marqueeText.module.css | 103 ++++++++++++++ .../SearchList/Dialog/dialog.module.css | 2 +- .../DatePicker/date-picker.module.css | 2 +- .../BookingWidget/bookingWidget.module.css | 2 +- .../trpc/lib/graphql/Fragments/Banner.graphql | 82 +++++++++++ .../Query/SitewideCampaignBanner.graphql | 34 +++++ .../lib/routers/contentstack/base/output.ts | 111 +++++++++++++-- .../lib/routers/contentstack/base/query.ts | 101 +++++++++++++- .../lib/routers/contentstack/base/utils.ts | 19 +++ .../contentstack/schemas/linkConnection.ts | 4 +- packages/trpc/lib/types/siteConfig.ts | 9 ++ 29 files changed, 1052 insertions(+), 22 deletions(-) create mode 100644 apps/scandic-web/components/CampaignBanner/Desktop.tsx create mode 100644 apps/scandic-web/components/CampaignBanner/Mobile.tsx create mode 100644 apps/scandic-web/components/CampaignBanner/campaignBanner.module.css create mode 100644 apps/scandic-web/components/CampaignBanner/constants.ts create mode 100644 apps/scandic-web/components/CampaignBanner/index.tsx create mode 100644 apps/scandic-web/components/CampaignBanner/types.ts create mode 100644 apps/scandic-web/components/CampaignBanner/utils.ts create mode 100644 apps/scandic-web/components/MarqueeText/index.tsx create mode 100644 apps/scandic-web/components/MarqueeText/marqueeText.module.css create mode 100644 packages/trpc/lib/graphql/Fragments/Banner.graphql create mode 100644 packages/trpc/lib/graphql/Query/SitewideCampaignBanner.graphql diff --git a/apps/partner-sas/components/LanguageSwitcher/languageSwitcher.module.css b/apps/partner-sas/components/LanguageSwitcher/languageSwitcher.module.css index ecdf408c2..ccb81d356 100644 --- a/apps/partner-sas/components/LanguageSwitcher/languageSwitcher.module.css +++ b/apps/partner-sas/components/LanguageSwitcher/languageSwitcher.module.css @@ -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; diff --git a/apps/partner-sas/components/Menu/MobileMenu/mobile-menu.module.css b/apps/partner-sas/components/Menu/MobileMenu/mobile-menu.module.css index 7dd3721f9..856ee82e3 100644 --- a/apps/partner-sas/components/Menu/MobileMenu/mobile-menu.module.css +++ b/apps/partner-sas/components/Menu/MobileMenu/mobile-menu.module.css @@ -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%; diff --git a/apps/partner-sas/components/Menu/UserMenu/user-menu.module.css b/apps/partner-sas/components/Menu/UserMenu/user-menu.module.css index 1eade99e1..61822d2ec 100644 --- a/apps/partner-sas/components/Menu/UserMenu/user-menu.module.css +++ b/apps/partner-sas/components/Menu/UserMenu/user-menu.module.css @@ -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; diff --git a/apps/scandic-web/app/[lang]/(live)/layout.tsx b/apps/scandic-web/app/[lang]/(live)/layout.tsx index 0ce1b6079..fb2a814c7 100644 --- a/apps/scandic-web/app/[lang]/(live)/layout.tsx +++ b/apps/scandic-web/app/[lang]/(live)/layout.tsx @@ -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( +
{bookingwidget} {children} diff --git a/apps/scandic-web/app/globals.css b/apps/scandic-web/app/globals.css index a19cee6ec..bd3e03b7d 100644 --- a/apps/scandic-web/app/globals.css +++ b/apps/scandic-web/app/globals.css @@ -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; diff --git a/apps/scandic-web/components/CampaignBanner/Desktop.tsx b/apps/scandic-web/components/CampaignBanner/Desktop.tsx new file mode 100644 index 000000000..df5e391c8 --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/Desktop.tsx @@ -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 ( +
+ + {tag} + + + + + {text} + {bookingCode ? ( + + + + {intl.formatMessage( + { + id: "campaignBanner.codeWithBookingCode", + defaultMessage: "Code: {bookingCode}", + }, + { bookingCode } + )} + + + ) : null} + + + + {link ? ( + + trackClick("BW read more")} + > + + {link.title || + intl.formatMessage({ + id: "common.readMore", + defaultMessage: "Read more", + })} + + + + ) : null} + +
+ ) +} diff --git a/apps/scandic-web/components/CampaignBanner/Mobile.tsx b/apps/scandic-web/components/CampaignBanner/Mobile.tsx new file mode 100644 index 000000000..e6a32897a --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/Mobile.tsx @@ -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 ( + + {bookingCode ? ( +

+ + + {tag} + + + + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} + <> ∙ {text} + + + {intl.formatMessage( + { + id: "campaignBanner.codeWithBookingCode", + defaultMessage: "Code: {bookingCode}", + }, + { bookingCode } + )} + + + + + +

+ ) : ( + <> + + {tag} + + + + {text} + + {link ? ( + + + {link.title || + intl.formatMessage({ + id: "common.readMore", + defaultMessage: "Read more", + })} + + + ) : null} + + + )} +
+ ) +} + +function InnerContent({ + link, + bookingCode, + children, +}: React.PropsWithChildren>) { + return link ? ( + trackClick("BW campaign banner")} + > + {children} + + ) : ( +
+ {children} +
+ ) +} diff --git a/apps/scandic-web/components/CampaignBanner/campaignBanner.module.css b/apps/scandic-web/components/CampaignBanner/campaignBanner.module.css new file mode 100644 index 000000000..e48d9aaad --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/campaignBanner.module.css @@ -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 */ + } +} diff --git a/apps/scandic-web/components/CampaignBanner/constants.ts b/apps/scandic-web/components/CampaignBanner/constants.ts new file mode 100644 index 000000000..ef62055ff --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/constants.ts @@ -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 diff --git a/apps/scandic-web/components/CampaignBanner/index.tsx b/apps/scandic-web/components/CampaignBanner/index.tsx new file mode 100644 index 000000000..9d657bbb8 --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/index.tsx @@ -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(null) + const isMobile = useMediaQuery("(max-width: 767px)") + const [closedPaths, setClosedPaths] = useState>(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 ( +
{ + campaignBannerRef.current = node + return updateHeightRefCallback(node) + }} + > +
+ {isMobile ? ( + + ) : ( + + )} + + + +
+
+ ) +} diff --git a/apps/scandic-web/components/CampaignBanner/types.ts b/apps/scandic-web/components/CampaignBanner/types.ts new file mode 100644 index 000000000..c29e4c760 --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/types.ts @@ -0,0 +1,9 @@ +export interface CampaignBannerProps { + tag: string + text: string + link: { + url: string + title: string + } | null + bookingCode?: string | null +} diff --git a/apps/scandic-web/components/CampaignBanner/utils.ts b/apps/scandic-web/components/CampaignBanner/utils.ts new file mode 100644 index 000000000..8e523698d --- /dev/null +++ b/apps/scandic-web/components/CampaignBanner/utils.ts @@ -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, + 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 +} diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/DestinationSearch/destinationSearch.module.css b/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/DestinationSearch/destinationSearch.module.css index 7122a4480..84d0a002d 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/DestinationSearch/destinationSearch.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/DestinationSearch/destinationSearch.module.css @@ -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); diff --git a/apps/scandic-web/components/Header/MainMenu/MobileMenu/mobileMenu.module.css b/apps/scandic-web/components/Header/MainMenu/MobileMenu/mobileMenu.module.css index 8843d4030..3577f6353 100644 --- a/apps/scandic-web/components/Header/MainMenu/MobileMenu/mobileMenu.module.css +++ b/apps/scandic-web/components/Header/MainMenu/MobileMenu/mobileMenu.module.css @@ -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%; diff --git a/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/myPagesMobileMenu.module.css b/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/myPagesMobileMenu.module.css index 8df2f8a8d..bb9704d3a 100644 --- a/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/myPagesMobileMenu.module.css +++ b/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/myPagesMobileMenu.module.css @@ -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%; diff --git a/apps/scandic-web/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/navigationMenuItem.module.css b/apps/scandic-web/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/navigationMenuItem.module.css index f3f83ed04..7bf741d95 100644 --- a/apps/scandic-web/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/navigationMenuItem.module.css +++ b/apps/scandic-web/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/navigationMenuItem.module.css @@ -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; diff --git a/apps/scandic-web/components/LanguageSwitcher/languageSwitcher.module.css b/apps/scandic-web/components/LanguageSwitcher/languageSwitcher.module.css index 546347cf1..beea3dc52 100644 --- a/apps/scandic-web/components/LanguageSwitcher/languageSwitcher.module.css +++ b/apps/scandic-web/components/LanguageSwitcher/languageSwitcher.module.css @@ -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; } diff --git a/apps/scandic-web/components/MarqueeText/index.tsx b/apps/scandic-web/components/MarqueeText/index.tsx new file mode 100644 index 000000000..272967428 --- /dev/null +++ b/apps/scandic-web/components/MarqueeText/index.tsx @@ -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> { + backgroundColor: string + textWrapperClassName?: string +} + +export function MarqueeText({ + backgroundColor, + children, + className, + textWrapperClassName, + ...props +}: MarqueeTextProps) { + const textContainerRef = useRef(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 ( +
+
+ {children} +
+
+ ) +} diff --git a/apps/scandic-web/components/MarqueeText/marqueeText.module.css b/apps/scandic-web/components/MarqueeText/marqueeText.module.css new file mode 100644 index 000000000..0b5cbc587 --- /dev/null +++ b/apps/scandic-web/components/MarqueeText/marqueeText.module.css @@ -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; + } +} diff --git a/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/Search/SearchList/Dialog/dialog.module.css b/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/Search/SearchList/Dialog/dialog.module.css index 341e7cb2e..91abb1b4f 100644 --- a/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/Search/SearchList/Dialog/dialog.module.css +++ b/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/Search/SearchList/Dialog/dialog.module.css @@ -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; diff --git a/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css b/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css index e1f2caceb..c8a4567ac 100644 --- a/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css +++ b/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css @@ -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)); } } diff --git a/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css b/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css index 23703e7a4..d14e5ef12 100644 --- a/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css +++ b/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css @@ -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; diff --git a/packages/trpc/lib/graphql/Fragments/Banner.graphql b/packages/trpc/lib/graphql/Fragments/Banner.graphql new file mode 100644 index 000000000..4f73f049e --- /dev/null +++ b/packages/trpc/lib/graphql/Fragments/Banner.graphql @@ -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 + } +} diff --git a/packages/trpc/lib/graphql/Query/SitewideCampaignBanner.graphql b/packages/trpc/lib/graphql/Query/SitewideCampaignBanner.graphql new file mode 100644 index 000000000..2a8c7f2f3 --- /dev/null +++ b/packages/trpc/lib/graphql/Query/SitewideCampaignBanner.graphql @@ -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 + } + } + } +} diff --git a/packages/trpc/lib/routers/contentstack/base/output.ts b/packages/trpc/lib/routers/contentstack/base/output.ts index fb29324d2..299432680 100644 --- a/packages/trpc/lib/routers/contentstack/base/output.ts +++ b/packages/trpc/lib/routers/contentstack/base/output.ts @@ -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 + }), +}) diff --git a/packages/trpc/lib/routers/contentstack/base/query.ts b/packages/trpc/lib/routers/contentstack/base/query.ts index 278212aaf..8129521e6 100644 --- a/packages/trpc/lib/routers/contentstack/base/query.ts +++ b/packages/trpc/lib/routers/contentstack/base/query.ts @@ -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( + 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( + 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 }) => { diff --git a/packages/trpc/lib/routers/contentstack/base/utils.ts b/packages/trpc/lib/routers/contentstack/base/utils.ts index 8ed5db2e8..2b0599817 100644 --- a/packages/trpc/lib/routers/contentstack/base/utils.ts +++ b/packages/trpc/lib/routers/contentstack/base/utils.ts @@ -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 = (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 +} diff --git a/packages/trpc/lib/routers/contentstack/schemas/linkConnection.ts b/packages/trpc/lib/routers/contentstack/schemas/linkConnection.ts index d234ad59c..c7a975789 100644 --- a/packages/trpc/lib/routers/contentstack/schemas/linkConnection.ts +++ b/packages/trpc/lib/routers/contentstack/schemas/linkConnection.ts @@ -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 diff --git a/packages/trpc/lib/types/siteConfig.ts b/packages/trpc/lib/types/siteConfig.ts index f83a6d1a7..49e5838ec 100644 --- a/packages/trpc/lib/types/siteConfig.ts +++ b/packages/trpc/lib/types/siteConfig.ts @@ -4,6 +4,8 @@ import type { alertSchema, siteConfigRefSchema, siteConfigSchema, + sitewideCampaignBannerRefSchema, + sitewideCampaignBannerSchema, } from "../routers/contentstack/base/output" export type GetSiteConfigRefData = z.infer @@ -23,3 +25,10 @@ export type AlertPhoneContact = { export type Alert = Omit & { phoneContact: AlertPhoneContact | null } + +export type GetSitewideCampaignBannerRefData = z.infer< + typeof sitewideCampaignBannerRefSchema +> +export type GetSitewideCampaignBannerData = z.output< + typeof sitewideCampaignBannerSchema +>