Feat/BOOK-424 campaign banner
Approved-by: Bianca Widstam
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
74
apps/scandic-web/components/CampaignBanner/Desktop.tsx
Normal file
74
apps/scandic-web/components/CampaignBanner/Desktop.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
apps/scandic-web/components/CampaignBanner/Mobile.tsx
Normal file
112
apps/scandic-web/components/CampaignBanner/Mobile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
21
apps/scandic-web/components/CampaignBanner/constants.ts
Normal file
21
apps/scandic-web/components/CampaignBanner/constants.ts
Normal 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
|
||||
130
apps/scandic-web/components/CampaignBanner/index.tsx
Normal file
130
apps/scandic-web/components/CampaignBanner/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
apps/scandic-web/components/CampaignBanner/types.ts
Normal file
9
apps/scandic-web/components/CampaignBanner/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface CampaignBannerProps {
|
||||
tag: string
|
||||
text: string
|
||||
link: {
|
||||
url: string
|
||||
title: string
|
||||
} | null
|
||||
bookingCode?: string | null
|
||||
}
|
||||
45
apps/scandic-web/components/CampaignBanner/utils.ts
Normal file
45
apps/scandic-web/components/CampaignBanner/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
93
apps/scandic-web/components/MarqueeText/index.tsx
Normal file
93
apps/scandic-web/components/MarqueeText/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
apps/scandic-web/components/MarqueeText/marqueeText.module.css
Normal file
103
apps/scandic-web/components/MarqueeText/marqueeText.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user