Feat/BOOK-424 campaign banner
Approved-by: Bianca Widstam
This commit is contained in:
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
.languageModal {
|
.languageModal {
|
||||||
position: fixed;
|
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;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
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;
|
right: auto;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
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;
|
right: auto;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
.closeModalBtn {
|
.closeModalBtn {
|
||||||
position: fixed;
|
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);
|
right: var(--Space-x15);
|
||||||
background-color: var(--SAS-Default);
|
background-color: var(--SAS-Default);
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import TrpcProvider from "@/lib/trpc/Provider"
|
|||||||
|
|
||||||
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
|
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
|
||||||
import { BookingFlowProviders } from "@/components/BookingFlowProviders"
|
import { BookingFlowProviders } from "@/components/BookingFlowProviders"
|
||||||
|
import CampaignBanner from "@/components/CampaignBanner"
|
||||||
import ChatbotScript from "@/components/ChatbotScript"
|
import ChatbotScript from "@/components/ChatbotScript"
|
||||||
import CookieBotConsent from "@/components/CookieBot"
|
import CookieBotConsent from "@/components/CookieBot"
|
||||||
import Footer from "@/components/Footer"
|
import Footer from "@/components/Footer"
|
||||||
@@ -76,6 +77,7 @@ export default async function RootLayout(
|
|||||||
<BookingFlowProviders>
|
<BookingFlowProviders>
|
||||||
<RouteChange />
|
<RouteChange />
|
||||||
<SitewideAlert />
|
<SitewideAlert />
|
||||||
|
<CampaignBanner />
|
||||||
<Header />
|
<Header />
|
||||||
{bookingwidget}
|
{bookingwidget}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
--sitewide-alert-height: 0px; /* Will be overridden when a sitewide alert is visible */
|
--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-mobile-height: 73px;
|
||||||
--main-menu-desktop-height: 125px;
|
--main-menu-desktop-height: 125px;
|
||||||
--booking-widget-mobile-height: 75px;
|
--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;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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;
|
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||||
background-color: var(--Surface-Primary-Default);
|
background-color: var(--Surface-Primary-Default);
|
||||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
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;
|
right: auto;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
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;
|
right: auto;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
top: calc(
|
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;
|
right: -100vw;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
.header .dropdown {
|
.header .dropdown {
|
||||||
right: -100vw;
|
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;
|
bottom: 0;
|
||||||
transition: right 0.3s;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(140px + max(var(--sitewide-alert-height), 25px));
|
top: calc(140px + max(var(--alert-and-banner-height), 25px));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - 200px);
|
height: calc(100% - 200px);
|
||||||
z-index: 10010;
|
z-index: 10010;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
.container[data-datepicker-open="true"] .hideWrapper {
|
.container[data-datepicker-open="true"] .hideWrapper {
|
||||||
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
gap: var(--Spacing-x3);
|
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%;
|
width: 100%;
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
82
packages/trpc/lib/graphql/Fragments/Banner.graphql
Normal file
82
packages/trpc/lib/graphql/Fragments/Banner.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
transformCardBlock,
|
transformCardBlock,
|
||||||
transformCardBlockRefs,
|
transformCardBlockRefs,
|
||||||
} from "../schemas/blocks/cardsGrid"
|
} from "../schemas/blocks/cardsGrid"
|
||||||
|
import { linkConnectionRefsSchema } from "../schemas/blocks/utils/linkConnection"
|
||||||
import {
|
import {
|
||||||
linkRefsUnionSchema,
|
linkRefsUnionSchema,
|
||||||
linkUnionSchema,
|
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(
|
.transform(
|
||||||
({
|
({
|
||||||
@@ -673,13 +674,12 @@ export const siteConfigSchema = z
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sitewide_alert } = data.all_site_config.items[0]
|
const sitewideAlertWeb =
|
||||||
|
data.all_site_config.items[0].sitewide_alert.alerts?.find((alert) =>
|
||||||
const sitewideAlertWeb = sitewide_alert.alerts?.find((alert) =>
|
alert.alertConnection.edges[0]?.node.visible_on?.includes(
|
||||||
alert.alertConnection.edges[0]?.node.visible_on?.includes(
|
AlertVisibleOnEnum.WEB
|
||||||
AlertVisibleOnEnum.WEB
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sitewideAlert: sitewideAlertWeb?.alertConnection.edges[0]?.node || null,
|
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({
|
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
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
GetSiteConfig,
|
GetSiteConfig,
|
||||||
GetSiteConfigRef,
|
GetSiteConfigRef,
|
||||||
} from "../../../graphql/Query/SiteConfig.graphql"
|
} from "../../../graphql/Query/SiteConfig.graphql"
|
||||||
// import { router } from "../../.."
|
import {
|
||||||
|
GetSitewideCampaignBanner,
|
||||||
|
GetSitewideCampaignBannerRef,
|
||||||
|
} from "../../../graphql/Query/SitewideCampaignBanner.graphql"
|
||||||
import { request } from "../../../graphql/request"
|
import { request } from "../../../graphql/request"
|
||||||
import { contentstackBaseProcedure } from "../../../procedures"
|
import { contentstackBaseProcedure } from "../../../procedures"
|
||||||
import { langInput } from "../../../utils"
|
import { langInput } from "../../../utils"
|
||||||
@@ -27,6 +30,8 @@ import {
|
|||||||
headerSchema,
|
headerSchema,
|
||||||
siteConfigRefSchema,
|
siteConfigRefSchema,
|
||||||
siteConfigSchema,
|
siteConfigSchema,
|
||||||
|
sitewideCampaignBannerRefSchema,
|
||||||
|
sitewideCampaignBannerSchema,
|
||||||
validateContactConfigSchema,
|
validateContactConfigSchema,
|
||||||
validateFooterConfigSchema,
|
validateFooterConfigSchema,
|
||||||
validateFooterRefConfigSchema,
|
validateFooterRefConfigSchema,
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
getConnections,
|
getConnections,
|
||||||
getFooterConnections,
|
getFooterConnections,
|
||||||
getSiteConfigConnections,
|
getSiteConfigConnections,
|
||||||
|
getSitewideCampaignBannerConnections,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
|
|
||||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
@@ -48,6 +54,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
GetSiteConfigData,
|
GetSiteConfigData,
|
||||||
GetSiteConfigRefData,
|
GetSiteConfigRefData,
|
||||||
|
GetSitewideCampaignBannerData,
|
||||||
|
GetSitewideCampaignBannerRefData,
|
||||||
} from "../../../types/siteConfig"
|
} from "../../../types/siteConfig"
|
||||||
|
|
||||||
const getContactConfig = cache(async (lang: Lang) => {
|
const getContactConfig = cache(async (lang: Lang) => {
|
||||||
@@ -248,6 +256,97 @@ export const baseQueryRouter = router({
|
|||||||
|
|
||||||
return validatedFooterConfig.data
|
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
|
siteConfig: contentstackBaseProcedure
|
||||||
.input(langInput)
|
.input(langInput)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { NodeRefs } from "../../../types/refs"
|
|||||||
import type {
|
import type {
|
||||||
AlertOutput,
|
AlertOutput,
|
||||||
GetSiteConfigRefData,
|
GetSiteConfigRefData,
|
||||||
|
GetSitewideCampaignBannerRefData,
|
||||||
} from "../../../types/siteConfig"
|
} from "../../../types/siteConfig"
|
||||||
import type { System } from "../schemas/system"
|
import type { System } from "../schemas/system"
|
||||||
import type { ContactConfig } from "./output"
|
import type { ContactConfig } from "./output"
|
||||||
@@ -138,3 +139,21 @@ export const safeUnion = <T extends z.ZodTypeAny>(schema: T) =>
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}, schema)
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
linkRefsUnionSchema,
|
linkRefsUnionSchema,
|
||||||
linkUnionSchema,
|
linkUnionSchema,
|
||||||
@@ -8,7 +10,7 @@ import {
|
|||||||
} from "./pageLinks"
|
} from "./pageLinks"
|
||||||
|
|
||||||
const titleSchema = z.object({
|
const titleSchema = z.object({
|
||||||
title: z.string().optional().default(""),
|
title: nullableStringValidator,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const linkConnectionSchema = z
|
export const linkConnectionSchema = z
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
alertSchema,
|
alertSchema,
|
||||||
siteConfigRefSchema,
|
siteConfigRefSchema,
|
||||||
siteConfigSchema,
|
siteConfigSchema,
|
||||||
|
sitewideCampaignBannerRefSchema,
|
||||||
|
sitewideCampaignBannerSchema,
|
||||||
} from "../routers/contentstack/base/output"
|
} from "../routers/contentstack/base/output"
|
||||||
|
|
||||||
export type GetSiteConfigRefData = z.infer<typeof siteConfigRefSchema>
|
export type GetSiteConfigRefData = z.infer<typeof siteConfigRefSchema>
|
||||||
@@ -23,3 +25,10 @@ export type AlertPhoneContact = {
|
|||||||
export type Alert = Omit<AlertOutput, "phoneContact"> & {
|
export type Alert = Omit<AlertOutput, "phoneContact"> & {
|
||||||
phoneContact: AlertPhoneContact | null
|
phoneContact: AlertPhoneContact | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GetSitewideCampaignBannerRefData = z.infer<
|
||||||
|
typeof sitewideCampaignBannerRefSchema
|
||||||
|
>
|
||||||
|
export type GetSitewideCampaignBannerData = z.output<
|
||||||
|
typeof sitewideCampaignBannerSchema
|
||||||
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user