diff --git a/apps/site/app/[locale]/layout.tsx b/apps/site/app/[locale]/layout.tsx index 5e1e440c5b974..609e1305c7be4 100644 --- a/apps/site/app/[locale]/layout.tsx +++ b/apps/site/app/[locale]/layout.tsx @@ -7,7 +7,9 @@ import { NextIntlClientProvider } from 'next-intl'; import BaseLayout from '#site/layouts/Base'; import { VERCEL_ENV } from '#site/next.constants.mjs'; import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts'; +import { siteConfig } from '#site/next.json.mjs'; import { ThemeProvider } from '#site/providers/themeProvider'; +import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner'; import type { FC, PropsWithChildren } from 'react'; @@ -15,6 +17,10 @@ import '#site/styles/index.css'; const fontClasses = classNames(IBM_PLEX_MONO.variable, OPEN_SANS.variable); +const banner = siteConfig.websiteBanners.index; + +const bannerPreHydrationScript = `try{var s=document.currentScript;if(s&&localStorage.getItem(s.dataset.storageKey)===s.dataset.banner){document.documentElement.dataset.bannerDismissed='true'}}catch{}`; + type RootLayoutProps = PropsWithChildren<{ params: Promise<{ locale: string }>; }>; @@ -32,6 +38,16 @@ const RootLayout: FC = async ({ children, params }) => { lang={hrefLang} suppressHydrationWarning > + + {banner && ( + + )} + diff --git a/apps/site/components/withBanner.tsx b/apps/site/components/withBanner.tsx index 1023051e1d0d5..24128c4f46afe 100644 --- a/apps/site/components/withBanner.tsx +++ b/apps/site/components/withBanner.tsx @@ -1,9 +1,13 @@ +'use client'; + import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; import Link from '#site/components/Link'; import { siteConfig } from '#site/next.json.mjs'; +import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner'; import { dateIsBetween } from '#site/util/date'; import type { FC } from 'react'; @@ -12,13 +16,32 @@ const WithBanner: FC<{ section: string }> = ({ section }) => { const banner = siteConfig.websiteBanners[section]; const t = useTranslations(); - if (banner && dateIsBetween(banner.startDate, banner.endDate)) { + const [dismissed, setDismissed] = useState(false); + + // The pre-hydration script hides a dismissed banner before the first paint. + // This effect then synchronizes that state with React after hydration. + useEffect(() => { + if (banner) { + setDismissed( + localStorage.getItem(BANNER_DISMISSAL_STORAGE_KEY) === banner.text + ); + } + }, [banner]); + + if (banner && !dismissed && dateIsBetween(banner.startDate, banner.endDate)) { const bannerType = banner.type || 'default'; + const onClose = () => { + localStorage.setItem(BANNER_DISMISSAL_STORAGE_KEY, banner.text); + setDismissed(true); + }; + return ( {banner.link ? ( {banner.text} diff --git a/apps/site/styles/index.css b/apps/site/styles/index.css index 35adb9095d290..ed0a75d4448ba 100644 --- a/apps/site/styles/index.css +++ b/apps/site/styles/index.css @@ -10,6 +10,10 @@ @import '@node-core/ui-components/src/styles/index.css'; @import '@node-core/rehype-shiki/index.css'; +html[data-banner-dismissed='true'] [data-dismissible-banner] { + display: none; +} + /** * To enhance readability for Korean users, line spacing is increased, * line breaks in the middle of words are prevented, and long words are diff --git a/apps/site/util/banner.ts b/apps/site/util/banner.ts new file mode 100644 index 0000000000000..fc35e7ba45fa4 --- /dev/null +++ b/apps/site/util/banner.ts @@ -0,0 +1 @@ +export const BANNER_DISMISSAL_STORAGE_KEY = 'banner-dismissal'; diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index b05e7e5119a64..76a3b62338c4a 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@node-core/ui-components", - "version": "1.7.1", + "version": "1.7.2", "type": "module", "exports": { "./*": { diff --git a/packages/ui-components/src/Common/Banner/index.module.css b/packages/ui-components/src/Common/Banner/index.module.css index d55d5359cf8c8..0f7f382e972d5 100644 --- a/packages/ui-components/src/Common/Banner/index.module.css +++ b/packages/ui-components/src/Common/Banner/index.module.css @@ -1,7 +1,8 @@ @reference "../../styles/index.css"; .banner { - @apply flex + @apply relative + flex w-full flex-row items-center @@ -40,3 +41,15 @@ .warning { @apply bg-warning-600; } + +.closeButton { + @apply absolute + right-4 + text-lg; + + span { + @apply text-white/60 + transition-colors + hover:text-white; + } +} diff --git a/packages/ui-components/src/Common/Banner/index.stories.tsx b/packages/ui-components/src/Common/Banner/index.stories.tsx index 60fc4c4d2e336..1ca61a6312971 100644 --- a/packages/ui-components/src/Common/Banner/index.stories.tsx +++ b/packages/ui-components/src/Common/Banner/index.stories.tsx @@ -26,4 +26,12 @@ export const Warning: Story = { }, }; +export const Dismissible: Story = { + args: { + children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + type: 'default', + onClose: () => {}, + }, +}; + export default { component: Banner } as Meta; diff --git a/packages/ui-components/src/Common/Banner/index.tsx b/packages/ui-components/src/Common/Banner/index.tsx index b28ceade24816..6d0fd5a165dfe 100644 --- a/packages/ui-components/src/Common/Banner/index.tsx +++ b/packages/ui-components/src/Common/Banner/index.tsx @@ -1,21 +1,40 @@ -import type { FC, HTMLAttributes, PropsWithChildren } from 'react'; +import classNames from 'classnames'; + +import type { + FC, + HTMLAttributes, + MouseEventHandler, + PropsWithChildren, +} from 'react'; import styles from './index.module.css'; export type BannerProps = { type?: 'default' | 'warning' | 'error'; + onClose?: MouseEventHandler; } & HTMLAttributes; const Banner: FC> = ({ type = 'default', + onClose, children, ...props }) => (
{children} + {onClose && ( + + )}
);