"use client" /** * MarketingBanner — product marketing promos. * * • layout="hero" — copy left, visual right from 20rem (Campus More from * Exxat rail and compact Whats New cards). Copy only on a phone-width * stack. Cover media paints behind the copy at every size. * • layout="floating" — brand gradient card, same chrome as floating sheet. * - floatingVariant="card" — icon + copy * - floatingVariant="media" — full-bleed media header + copy on gradient */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { FLOATING_SHEET_DESKTOP_WIDTH } from "../../lib/floating-sheet-panel" import { cn } from "../../lib/utils" import { Button } from "./button" import { Tip } from "./tip" export type MarketingBannerCorner = "bottom-left" | "bottom-right" export type MarketingBannerFloatingVariant = "card" | "media" export type MarketingBannerTone = "surface" | "tint" | "gradient" const MARKETING_FLOATING_CHROME = "overflow-hidden rounded-xl border border-brand-dark shadow-[var(--shadow-sheet-panel)]" /** * Opaque brand ramp, readable with `text-brand-foreground` at every stop. * * The ramp used to open on `brand-color-light`, which is the focus-ring tone * (L 0.78) rather than a surface: white ink on it measures 2.04:1, so any copy * landing in the top third of the card failed AA. It now starts at `brand` * (4.89:1 on the Prism ramp, better on the default one) and descends, so the * lightest pixel under the ink still clears normal-text contrast. */ const MARKETING_BRAND_GRADIENT = "bg-gradient-to-b from-brand via-brand-dark to-brand-deep text-brand-foreground" export interface MarketingBannerAction { label: string href?: string onClick?: () => void /** * Renders the action as done rather than removing it. * * For an offer that can only be taken once — a beta opt-in is the case this * exists for — dropping the button on success leaves the banner looking as * though nothing happened, and swapping only its label leaves something that * still invites a second click it will not honour. */ disabled?: boolean } const marketingBannerRootVariants = cva("relative isolate transition-[color,background-color,border-color,box-shadow] duration-150 ease-out", { variants: { layout: { hero: [ "@container/banner grid w-full max-w-none gap-5 overflow-visible rounded-xl border p-5", // Columns are set on the instance below, only when there is a visual // to put in the second one. They used to live here, which meant a // text-only promo reserved 12rem for nothing: in a half-width column // that left the copy 230px of a 512px banner and wrapped every headline. // `grid-cols-1` also lives there so it cannot win a source-order fight // against the two-column query and stack the illustration under the copy. "lg:gap-8 lg:p-7", ], floating: [ "fixed z-40 flex flex-col", MARKETING_FLOATING_CHROME, FLOATING_SHEET_DESKTOP_WIDTH, "bottom-4", ], }, tone: { surface: "border-border bg-card text-foreground", tint: [ "border-brand/12 bg-brand/6 text-foreground", "dark:border-brand/20 dark:bg-brand/10", ], gradient: [MARKETING_BRAND_GRADIENT, "border-brand-dark"], }, }, defaultVariants: { layout: "hero", tone: "tint", }, }) const marketingBannerCornerClass: Record = { "bottom-left": "left-4", "bottom-right": "right-4", } const marketingTitleClassName = "font-heading font-semibold tracking-tight text-balance" export interface MarketingBannerProps extends Omit, "title">, VariantProps { layout: "hero" | "floating" title: string /** * Heading level for the hero title. Defaults to `h3`, which is right when the * banner sits inside a section that already has its own heading — but a * banner placed directly under the page `h1` skips a level there, and axe * (correctly) calls that a heading-order violation. Set it to match the * outline of the page you are dropping the banner into. */ titleAs?: "h2" | "h3" | "h4" eyebrow?: string eyebrowIcon?: string children?: React.ReactNode illustration?: React.ReactNode media?: React.ReactNode /** * `"contain"` (default) centers `media`/`illustration` at its own size — * right for vector art (`FeaturedProduct`'s `ProductIllustration`) that is * drawn to a deliberate size and looks wrong stretched. * * `"fill"` stretches the visual column to the row's full height and bleeds * it to the card's own top/end/bottom edges, fading in from a soft * mask rather than a hard vertical seam — right for a single UI component * (a floating Leo window, one KPI card) that illustrates the update * without needing the whole card (`WhatsNewSection`'s default). The child * supplies its own `object-cover`; this only sizes the frame around it. * * `"cover"` drops the two-column split entirely and lets `media` fill the * *whole* card as a background, with a scrim gradient the copy sits on — * right when the screenshot itself is the story (a full dashboard, a * whole search result) rather than one component floating beside the * copy. */ mediaFit?: "contain" | "fill" | "cover" /** * Clamps the description to two lines. A one-off hero selling a single * idea (`FeaturedProduct`'s tagline) wants the whole sentence; a row of * many banners side by side (`WhatsNewSection`) wants every card the same * height regardless of whose copy ran long, the way a table row does not * grow to fit its longest cell. */ compactDescription?: boolean /** * Shorter hero chrome for digest rows (`WhatsNewSection`): tighter padding, * smaller title, no media-column height floor. Featured / marketing heroes * leave this off. */ compact?: boolean floatingVariant?: MarketingBannerFloatingVariant tone?: MarketingBannerTone primaryAction?: MarketingBannerAction secondaryAction?: MarketingBannerAction /** Renders after primary/secondary on the same actions row (e.g. carousel chevrons). */ actionsTrailing?: React.ReactNode primaryActionFullWidth?: boolean dismissible?: boolean onDismiss?: () => void corner?: MarketingBannerCorner decorativeOverlay?: React.ReactNode } function MarketingBannerAction({ action, priority, fullWidth = false, onBrandSurface = false, }: { action: MarketingBannerAction priority: "primary" | "secondary" fullWidth?: boolean onBrandSurface?: boolean }) { const isPrimary = priority === "primary" const variant = isPrimary ? (onBrandSurface ? "outline" : "default") : "outline" const className = cn( "shrink-0", fullWidth && isPrimary && "w-full", onBrandSurface && isPrimary && "border-foreground bg-card !text-foreground shadow-sm hover:bg-muted hover:!text-foreground aria-expanded:bg-muted aria-expanded:!text-foreground", onBrandSurface && !isPrimary && "border-brand-foreground/40 bg-brand-deep text-brand-foreground hover:bg-brand-deep/90", ) if (action.href && !action.disabled) { return ( ) } return ( ) } function MarketingBannerDismiss({ title, onClick, className, side = "bottom", highContrast = false, }: { title: string onClick: () => void className?: string side?: "bottom" | "left" | "top" /** Readable on brand gradient / media headers. */ highContrast?: boolean }) { return (