Extract shared Banner for portal notices

Locale and NDA notices shared layout chrome but were not kit
Callouts. Pull the full-bleed band into Banner and rename the
feature wrappers so the names match the role.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-30 15:37:33 +02:00
parent 51ba02cedd
commit 404b9e347a
6 changed files with 171 additions and 154 deletions

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { XIcon } from "@phosphor-icons/react";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import type { ReactNode } from "react";
import type { VariantProps } from "tailwind-variants/lite";
import { banner } from "./variants";
export type BannerProps = {
color: NonNullable<VariantProps<typeof banner>["color"]>;
icon: ReactNode;
message: ReactNode;
actions: ReactNode;
dismissLabel: string;
onDismiss: () => void;
dismissDisabled?: boolean;
};
// Presentational full-bleed portal banner: tinted band, TopBar-aligned column,
// leading icon + message, actions, and dual dismiss (mobile in the message row,
// desktop beside actions).
export function Banner({
color,
icon,
message,
actions,
dismissLabel,
onDismiss,
dismissDisabled = false,
}: BannerProps) {
const slots = banner({ color });
return (
<aside className={slots.root()} role="status">
<div className={slots.inner()}>
<div className={slots.content()}>
<span aria-hidden className={slots.icon()}>
{icon}
</span>
<div className={slots.message()}>{message}</div>
<IconButton
size={1}
variant="ghost"
color="neutral"
aria-label={dismissLabel}
disabled={dismissDisabled}
className={slots.dismissMobile()}
onClick={onDismiss}
>
<XIcon />
</IconButton>
</div>
<div className={slots.actions()}>
{actions}
<IconButton
size={1}
variant="ghost"
color="neutral"
aria-label={dismissLabel}
disabled={dismissDisabled}
className={slots.dismissDesktop()}
onClick={onDismiss}
>
<XIcon />
</IconButton>
</div>
</div>
</aside>
);
}

View File

@@ -20,21 +20,33 @@
import { tv } from "tailwind-variants/lite"; import { tv } from "tailwind-variants/lite";
// Full-bleed locale-mismatch banner under the TopBar: message row + actions, // Full-bleed portal banner under the TopBar: message row + actions, stacking on
// stacking on small screens. Sky (info) so it reads apart from the amber // small screens. Content column matches TopBar / page layout (px-8 + centered
// unsigned-NDA callout when both are visible. Content column matches TopBar / // max-w-5xl). Color tints the band and leading icon (sky = info, amber = warning).
// page layout (px-8 + centered max-w-5xl). export const banner = tv({
export const localeMismatchCallout = tv({
slots: { slots: {
root: "w-full bg-sky-3 px-8 py-2.5 max-md:px-4 max-md:py-3", root: "w-full px-8 py-2.5 max-md:px-4 max-md:py-3",
inner: inner:
"mx-auto flex w-full max-w-5xl items-center gap-3 max-md:flex-col max-md:items-stretch max-md:gap-3", "mx-auto flex w-full max-w-5xl items-center gap-3 max-md:flex-col max-md:items-stretch max-md:gap-3",
content: "flex min-w-0 flex-1 items-start gap-2", content: "flex min-w-0 flex-1 items-start gap-2",
icon: "mt-0.5 size-4 shrink-0 text-sky-11", icon: "mt-0.5 shrink-0 [&_svg]:size-4",
message: "min-w-0 flex-1", message: "min-w-0 flex-1",
dismissMobile: "md:hidden", dismissMobile: "md:hidden",
actions: "flex shrink-0 items-center gap-2 max-md:flex-col max-md:items-stretch", // Full-width CTAs on small screens; leave the trailing dismiss control alone.
action: "max-md:w-full", actions:
"flex shrink-0 items-center gap-2 max-md:flex-col max-md:items-stretch [&>*:not(:last-child)]:max-md:w-full",
dismissDesktop: "max-md:hidden", dismissDesktop: "max-md:hidden",
}, },
variants: {
color: {
sky: {
root: "bg-sky-3",
icon: "text-sky-11",
},
amber: {
root: "bg-amber-3",
icon: "text-amber-11",
},
},
},
}); });

View File

@@ -18,14 +18,14 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { GlobeIcon, XIcon } from "@phosphor-icons/react"; import { GlobeIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button"; import { Button } from "@probo/ui/src/v2/Button/Button";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text"; import { Text } from "@probo/ui/src/v2/typography/Text";
import { useDeferredValue, useEffect, useState } from "react"; import { useDeferredValue, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay"; import { graphql, useFragment } from "react-relay";
import { Banner } from "#/components/Banner/Banner";
import { DEFAULT_NAMESPACE } from "#/lib/i18n/backend"; import { DEFAULT_NAMESPACE } from "#/lib/i18n/backend";
import { import {
isUrlLocale, isUrlLocale,
@@ -36,24 +36,23 @@ import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
import { useLocale } from "#/lib/i18n/useLocale"; import { useLocale } from "#/lib/i18n/useLocale";
import { useUpdateLocale } from "#/lib/i18n/useUpdateLocale"; import { useUpdateLocale } from "#/lib/i18n/useUpdateLocale";
import type { LocaleMismatchCallout_identity$key } from "./__generated__/LocaleMismatchCallout_identity.graphql"; import type { LocaleMismatchBanner_identity$key } from "./__generated__/LocaleMismatchBanner_identity.graphql";
import { localeMismatchCallout } from "./variants";
const localeMismatchCalloutFragment = graphql` const localeMismatchBannerFragment = graphql`
fragment LocaleMismatchCallout_identity on Identity { fragment LocaleMismatchBanner_identity on Identity {
locale locale
} }
`; `;
interface LocaleMismatchCalloutProps { interface LocaleMismatchBannerProps {
identityKey: LocaleMismatchCallout_identity$key; identityKey: LocaleMismatchBanner_identity$key;
} }
// Full-bleed notice when the URL locale differs from the signed-in identity // Full-bleed notice when the URL locale differs from the signed-in identity
// preference. Dismissed state is React-only (no localStorage/cookies). // preference. Dismissed state is React-only (no localStorage/cookies).
export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) { export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const identity = useFragment(localeMismatchCalloutFragment, identityKey); const identity = useFragment(localeMismatchBannerFragment, identityKey);
const urlLocale = useLocale(); const urlLocale = useLocale();
const [changeLocale, isChanging] = useChangeLocale(); const [changeLocale, isChanging] = useChangeLocale();
const [updateLocale, isUpdating] = useUpdateLocale(); const [updateLocale, isUpdating] = useUpdateLocale();
@@ -95,7 +94,6 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
const urlLabel = URL_LOCALE_LABELS[urlLocale]; const urlLabel = URL_LOCALE_LABELS[urlLocale];
const savedLabel = URL_LOCALE_LABELS[savedLocale]; const savedLabel = URL_LOCALE_LABELS[savedLocale];
const busy = isChanging || isUpdating; const busy = isChanging || isUpdating;
const slots = localeMismatchCallout();
const switchToSaved = () => { const switchToSaved = () => {
void changeLocale(savedLocale, { persist: false }); void changeLocale(savedLocale, { persist: false });
@@ -108,32 +106,21 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
}; };
return ( return (
<aside className={slots.root()} role="status"> <Banner
<div className={slots.inner()}> color="sky"
<div className={slots.content()}> icon={<GlobeIcon weight="fill" />}
<GlobeIcon weight="fill" className={slots.icon()} aria-hidden /> message={(
<Text size={2} color="neutral" highContrast className={slots.message()}> <Text size={2} color="neutral" highContrast>
{t("locale.mismatch.message", { language: urlLabel })} {t("locale.mismatch.message", { language: urlLabel })}
</Text> </Text>
<IconButton )}
size={1} actions={(
variant="ghost" <>
color="neutral"
aria-label={t("locale.mismatch.dismiss")}
disabled={busy}
className={slots.dismissMobile()}
onClick={() => setDismissed(true)}
>
<XIcon />
</IconButton>
</div>
<div className={slots.actions()}>
<Button <Button
size={1} size={1}
variant="outline" variant="outline"
color="sky" color="sky"
disabled={busy} disabled={busy}
className={slots.action()}
onClick={switchToSaved} onClick={switchToSaved}
> >
{t("locale.mismatch.switchToMine", { {t("locale.mismatch.switchToMine", {
@@ -149,24 +136,15 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
color="neutral" color="neutral"
highContrast highContrast
disabled={busy} disabled={busy}
className={slots.action()}
onClick={adoptUrlLocale} onClick={adoptUrlLocale}
> >
{t("locale.mismatch.useThis", { language: urlLabel })} {t("locale.mismatch.useThis", { language: urlLabel })}
</Button> </Button>
<IconButton </>
size={1} )}
variant="ghost" dismissLabel={t("locale.mismatch.dismiss")}
color="neutral" onDismiss={() => setDismissed(true)}
aria-label={t("locale.mismatch.dismiss")} dismissDisabled={busy}
disabled={busy} />
className={slots.dismissDesktop()}
onClick={() => setDismissed(true)}
>
<XIcon />
</IconButton>
</div>
</div>
</aside>
); );
} }

View File

@@ -18,21 +18,20 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { FileTextIcon, XIcon } from "@phosphor-icons/react"; import { FileTextIcon } from "@phosphor-icons/react";
import { Link } from "@probo/ui/src/v2/Button/Link"; import { Link } from "@probo/ui/src/v2/Button/Link";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text"; import { Text } from "@probo/ui/src/v2/typography/Text";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay"; import { graphql, useFragment } from "react-relay";
import { Banner } from "#/components/Banner/Banner";
import { useLocalizedPath } from "#/lib/i18n/useLocale"; import { useLocalizedPath } from "#/lib/i18n/useLocale";
import type { UnsignedNDACallout_compliancePortal$key } from "./__generated__/UnsignedNDACallout_compliancePortal.graphql"; import type { UnsignedNDABanner_compliancePortal$key } from "./__generated__/UnsignedNDABanner_compliancePortal.graphql";
import { unsignedNDACallout } from "./variants";
const unsignedNDACalloutFragment = graphql` const unsignedNDABannerFragment = graphql`
fragment UnsignedNDACallout_compliancePortal on CompliancePortal { fragment UnsignedNDABanner_compliancePortal on CompliancePortal {
nonDisclosureAgreement { nonDisclosureAgreement {
viewerSignature { viewerSignature {
status status
@@ -41,16 +40,16 @@ const unsignedNDACalloutFragment = graphql`
} }
`; `;
interface UnsignedNDACalloutProps { interface UnsignedNDABannerProps {
compliancePortalKey: UnsignedNDACallout_compliancePortal$key; compliancePortalKey: UnsignedNDABanner_compliancePortal$key;
} }
// Full-bleed notice when a signed-in user still needs to complete the portal // Full-bleed notice when a signed-in user still needs to complete the portal
// NDA. Dismissed state is React-only (no localStorage/cookies). // NDA. Dismissed state is React-only (no localStorage/cookies).
export function UnsignedNDACallout({ compliancePortalKey }: UnsignedNDACalloutProps) { export function UnsignedNDABanner({ compliancePortalKey }: UnsignedNDABannerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const localizedPath = useLocalizedPath(); const localizedPath = useLocalizedPath();
const portal = useFragment(unsignedNDACalloutFragment, compliancePortalKey); const portal = useFragment(unsignedNDABannerFragment, compliancePortalKey);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const signature = portal.nonDisclosureAgreement?.viewerSignature; const signature = portal.nonDisclosureAgreement?.viewerSignature;
@@ -62,49 +61,28 @@ export function UnsignedNDACallout({ compliancePortalKey }: UnsignedNDACalloutPr
} }
const ndaHref = `${localizedPath("/nda")}?continue=${encodeURIComponent(window.location.href)}`; const ndaHref = `${localizedPath("/nda")}?continue=${encodeURIComponent(window.location.href)}`;
const slots = unsignedNDACallout();
return ( return (
<aside className={slots.root()} role="status"> <Banner
<div className={slots.inner()}> color="amber"
<div className={slots.content()}> icon={<FileTextIcon weight="fill" />}
<FileTextIcon weight="fill" className={slots.icon()} aria-hidden /> message={(
<Text size={2} color="neutral" highContrast className={slots.message()}> <Text size={2} color="neutral" highContrast>
{t("nda.unsignedBanner.message")} {t("nda.unsignedBanner.message")}
</Text> </Text>
<IconButton )}
size={1} actions={(
variant="ghost" <Link
color="neutral" to={ndaHref}
aria-label={t("nda.unsignedBanner.dismiss")} size={1}
className={slots.dismissMobile()} variant="solid"
onClick={() => setDismissed(true)} color="amber"
> >
<XIcon /> {t("nda.unsignedBanner.sign")}
</IconButton> </Link>
</div> )}
<div className={slots.actions()}> dismissLabel={t("nda.unsignedBanner.dismiss")}
<Link onDismiss={() => setDismissed(true)}
to={ndaHref} />
size={1}
variant="solid"
color="amber"
className={slots.cta()}
>
{t("nda.unsignedBanner.sign")}
</Link>
<IconButton
size={1}
variant="ghost"
color="neutral"
aria-label={t("nda.unsignedBanner.dismiss")}
className={slots.dismissDesktop()}
onClick={() => setDismissed(true)}
>
<XIcon />
</IconButton>
</div>
</div>
</aside>
); );
} }

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Full-bleed unsigned-NDA banner under the TopBar: message row + CTA/dismiss,
// stacking on small screens. Amber (warning) so it reads apart from the sky
// locale-mismatch callout when both are visible. Content column matches TopBar /
// page layout (px-8 + centered max-w-5xl).
export const unsignedNDACallout = tv({
slots: {
root: "w-full bg-amber-3 px-8 py-2.5 max-md:px-4 max-md:py-3",
inner:
"mx-auto flex w-full max-w-5xl items-center gap-3 max-md:flex-col max-md:items-stretch max-md:gap-3",
content: "flex min-w-0 flex-1 items-start gap-2",
icon: "mt-0.5 size-4 shrink-0 text-amber-11",
message: "min-w-0 flex-1",
dismissMobile: "md:hidden",
actions: "flex shrink-0 items-center gap-2 max-md:flex-col max-md:items-stretch",
cta: "max-md:w-full",
dismissDesktop: "max-md:hidden",
},
});

View File

@@ -23,10 +23,10 @@ import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery } from "react-relay";
import { Outlet, useMatch } from "react-router"; import { Outlet, useMatch } from "react-router";
import { LocaleMismatchCallout } from "#/components/LocaleMismatchCallout/LocaleMismatchCallout"; import { LocaleMismatchBanner } from "#/components/LocaleMismatchBanner/LocaleMismatchBanner";
import { PoweredBy } from "#/components/PoweredBy/PoweredBy"; import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
import { TopBar } from "#/components/TopBar/TopBar"; import { TopBar } from "#/components/TopBar/TopBar";
import { UnsignedNDACallout } from "#/components/UnsignedNDACallout/UnsignedNDACallout"; import { UnsignedNDABanner } from "#/components/UnsignedNDABanner/UnsignedNDABanner";
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest"; import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
import { useEnsureViewerLocale } from "#/lib/i18n/useEnsureViewerLocale"; import { useEnsureViewerLocale } from "#/lib/i18n/useEnsureViewerLocale";
import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider"; import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider";
@@ -37,11 +37,11 @@ export const mainLayoutQuery = graphql`
query MainLayoutQuery { query MainLayoutQuery {
viewer { viewer {
__typename __typename
...LocaleMismatchCallout_identity ...LocaleMismatchBanner_identity
...useEnsureViewerLocale_identity ...useEnsureViewerLocale_identity
} }
currentCompliancePortal { currentCompliancePortal {
...UnsignedNDACallout_compliancePortal ...UnsignedNDABanner_compliancePortal
} }
...TopBar_query ...TopBar_query
...SubscribeDialogProvider_query ...SubscribeDialogProvider_query
@@ -76,10 +76,10 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
> >
<TopBar queryKey={data} /> <TopBar queryKey={data} />
{data.viewer != null {data.viewer != null
? <LocaleMismatchCallout identityKey={data.viewer} /> ? <LocaleMismatchBanner identityKey={data.viewer} />
: null} : null}
{data.viewer != null && data.currentCompliancePortal != null {data.viewer != null && data.currentCompliancePortal != null
? <UnsignedNDACallout compliancePortalKey={data.currentCompliancePortal} /> ? <UnsignedNDABanner compliancePortalKey={data.currentCompliancePortal} />
: null} : null}
<div <div
className={ className={