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