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";
// Full-bleed locale-mismatch banner under the TopBar: message row + actions,
// stacking on small screens. Sky (info) so it reads apart from the amber
// unsigned-NDA callout when both are visible. Content column matches TopBar /
// page layout (px-8 + centered max-w-5xl).
export const localeMismatchCallout = tv({
// Full-bleed portal banner under the TopBar: message row + actions, stacking on
// small screens. Content column matches TopBar / page layout (px-8 + centered
// max-w-5xl). Color tints the band and leading icon (sky = info, amber = warning).
export const banner = tv({
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:
"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-sky-11",
icon: "mt-0.5 shrink-0 [&_svg]:size-4",
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",
action: "max-md:w-full",
// Full-width CTAs on small screens; leave the trailing dismiss control alone.
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",
},
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
// SOFTWARE.
import { GlobeIcon, XIcon } from "@phosphor-icons/react";
import { GlobeIcon } from "@phosphor-icons/react";
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 { useDeferredValue, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { Banner } from "#/components/Banner/Banner";
import { DEFAULT_NAMESPACE } from "#/lib/i18n/backend";
import {
isUrlLocale,
@@ -36,24 +36,23 @@ import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
import { useLocale } from "#/lib/i18n/useLocale";
import { useUpdateLocale } from "#/lib/i18n/useUpdateLocale";
import type { LocaleMismatchCallout_identity$key } from "./__generated__/LocaleMismatchCallout_identity.graphql";
import { localeMismatchCallout } from "./variants";
import type { LocaleMismatchBanner_identity$key } from "./__generated__/LocaleMismatchBanner_identity.graphql";
const localeMismatchCalloutFragment = graphql`
fragment LocaleMismatchCallout_identity on Identity {
const localeMismatchBannerFragment = graphql`
fragment LocaleMismatchBanner_identity on Identity {
locale
}
`;
interface LocaleMismatchCalloutProps {
identityKey: LocaleMismatchCallout_identity$key;
interface LocaleMismatchBannerProps {
identityKey: LocaleMismatchBanner_identity$key;
}
// Full-bleed notice when the URL locale differs from the signed-in identity
// preference. Dismissed state is React-only (no localStorage/cookies).
export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) {
export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps) {
const { t, i18n } = useTranslation();
const identity = useFragment(localeMismatchCalloutFragment, identityKey);
const identity = useFragment(localeMismatchBannerFragment, identityKey);
const urlLocale = useLocale();
const [changeLocale, isChanging] = useChangeLocale();
const [updateLocale, isUpdating] = useUpdateLocale();
@@ -95,7 +94,6 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
const urlLabel = URL_LOCALE_LABELS[urlLocale];
const savedLabel = URL_LOCALE_LABELS[savedLocale];
const busy = isChanging || isUpdating;
const slots = localeMismatchCallout();
const switchToSaved = () => {
void changeLocale(savedLocale, { persist: false });
@@ -108,32 +106,21 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
};
return (
<aside className={slots.root()} role="status">
<div className={slots.inner()}>
<div className={slots.content()}>
<GlobeIcon weight="fill" className={slots.icon()} aria-hidden />
<Text size={2} color="neutral" highContrast className={slots.message()}>
{t("locale.mismatch.message", { language: urlLabel })}
</Text>
<IconButton
size={1}
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()}>
<Banner
color="sky"
icon={<GlobeIcon weight="fill" />}
message={(
<Text size={2} color="neutral" highContrast>
{t("locale.mismatch.message", { language: urlLabel })}
</Text>
)}
actions={(
<>
<Button
size={1}
variant="outline"
color="sky"
disabled={busy}
className={slots.action()}
onClick={switchToSaved}
>
{t("locale.mismatch.switchToMine", {
@@ -149,24 +136,15 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
color="neutral"
highContrast
disabled={busy}
className={slots.action()}
onClick={adoptUrlLocale}
>
{t("locale.mismatch.useThis", { language: urlLabel })}
</Button>
<IconButton
size={1}
variant="ghost"
color="neutral"
aria-label={t("locale.mismatch.dismiss")}
disabled={busy}
className={slots.dismissDesktop()}
onClick={() => setDismissed(true)}
>
<XIcon />
</IconButton>
</div>
</div>
</aside>
</>
)}
dismissLabel={t("locale.mismatch.dismiss")}
onDismiss={() => setDismissed(true)}
dismissDisabled={busy}
/>
);
}

View File

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

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