Add Trust Center top bar to compliance portal

Build the Trust Center top navigation in a Relay-wired layout route. The
portal now mounts a Relay environment and provider, and the root layout
loads a query whose fragment feeds the TopBar: brand, ghost-pill nav with
an active state from the router, and a guest "Get Access" button versus an
authenticated user menu. Placeholder section routes keep the nav links and
active state functional until real pages land.

Wire the Relay tagged-template transform via @rolldown/plugin-babel and
set the router basename to the /trust/{slug} path prefix so the app
resolves under its served path.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-26 21:55:09 +02:00
parent a6be5464a5
commit 68eaabe0ea
15 changed files with 471 additions and 5 deletions

View File

@@ -25,6 +25,8 @@
"relay-runtime": "^21.0.1"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",

View File

@@ -14,8 +14,13 @@
import { RouterProvider } from "react-router";
import { RelayProvider } from "#/lib/relay/RelayProvider";
import { router } from "#/routes";
export function App() {
return <RouterProvider router={router} />;
return (
<RelayProvider>
<RouterProvider router={router} />
</RelayProvider>
);
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { LockSimpleIcon } from "@phosphor-icons/react";
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Link } from "@probo/ui/src/v2/Button/Link";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { graphql, useFragment } from "react-relay";
import { Link as RouterLink, useLocation } from "react-router";
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
import { TopBarUserMenu } from "./TopBarUserMenu";
import { topBar } from "./variants";
const NAV_ITEMS = [
{ to: "/documents", label: "Documents" },
{ to: "/subprocessors", label: "Subprocessors" },
{ to: "/updates", label: "Updates" },
{ to: "/requests", label: "Requests" },
] as const;
const topBarFragment = graphql`
fragment TopBar_query on Query {
viewer {
id
...TopBarUserMenu_identity
}
currentTrustCenter @required(action: THROW) {
organization {
name
logo {
downloadUrl
}
}
}
}
`;
interface TopBarProps {
queryKey: TopBar_query$key;
}
function isActive(pathname: string, to: string): boolean {
return pathname === to || pathname.startsWith(`${to}/`);
}
export function TopBar({ queryKey }: TopBarProps) {
const data = useFragment(topBarFragment, queryKey);
const { pathname } = useLocation();
const { organization } = data.currentTrustCenter;
const organizationName = organization.name;
const logoUrl = organization.logo?.downloadUrl ?? undefined;
const slots = topBar();
return (
<header className={slots.bar()}>
<div className={slots.inner()}>
<RouterLink to="/" className={slots.brand()}>
<Avatar
size={1}
variant="soft"
color="neutral"
radius="small"
src={logoUrl}
fallback={organizationName.charAt(0) || "?"}
className={slots.logo()}
/>
<Text size={2} weight="medium" color="neutral" highContrast>
{organizationName}
</Text>
<Text size={2} color="neutral">
Compliance Portal
</Text>
</RouterLink>
<div className={slots.spacer()} />
<nav className={slots.nav()}>
{NAV_ITEMS.map(item => (
<Link
key={item.to}
to={item.to}
variant="ghost"
color="neutral"
size={2}
active={isActive(pathname, item.to)}
>
{item.label}
</Link>
))}
{data.viewer == null
? (
<Button variant="solid" color="neutral" highContrast iconStart={<LockSimpleIcon />}>
Get Access
</Button>
)
: <TopBarUserMenu identityKey={data.viewer} />}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { AvatarSkeleton } from "@probo/ui/src/v2/Avatar/AvatarSkeleton";
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
import { topBar } from "./variants";
const NAV_ITEM_KEYS = ["documents", "subprocessors", "updates", "requests"] as const;
// Loading placeholder paired with TopBar: reuses the same layout slots with
// skeleton primitives. Imports no Relay / Base UI, so it renders instantly.
export function TopBarSkeleton() {
const slots = topBar();
return (
<div className={slots.bar()}>
<div className={slots.inner()}>
<div className={slots.brand()}>
<AvatarSkeleton size={1} radius="small" />
<TextSkeleton size={2} className="w-20" />
</div>
<div className={slots.spacer()} />
<nav className={slots.nav()}>
{NAV_ITEM_KEYS.map(key => (
<TextSkeleton key={key} size={2} className="mx-3 w-16" />
))}
<ButtonSkeleton size={2} />
</nav>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { CaretDownIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown";
import { DropdownGroupLabel } from "@probo/ui/src/v2/Dropdown/DropdownGroupLabel";
import { DropdownItem } from "@probo/ui/src/v2/Dropdown/DropdownItem";
import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup";
import { DropdownSeparator } from "@probo/ui/src/v2/Dropdown/DropdownSeparator";
import { DropdownTrigger } from "@probo/ui/src/v2/Dropdown/DropdownTrigger";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { graphql, useFragment } from "react-relay";
import type { TopBarUserMenu_identity$key } from "./__generated__/TopBarUserMenu_identity.graphql";
import { topBarUserMenuTrigger } from "./variants";
const topBarUserMenuFragment = graphql`
fragment TopBarUserMenu_identity on Identity {
fullName
email
}
`;
interface TopBarUserMenuProps {
identityKey: TopBarUserMenu_identity$key;
}
export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
const identity = useFragment(topBarUserMenuFragment, identityKey);
return (
<Dropdown>
<DropdownTrigger
render={(
<button type="button" className={topBarUserMenuTrigger()} aria-label={identity.fullName}>
<Avatar
size={1}
variant="soft"
color="gold"
radius="small"
fallback={<UserIcon />}
/>
<Text size={2} weight="medium" color="neutral" highContrast>
{identity.fullName}
</Text>
<CaretDownIcon className="size-4 text-sand-11" />
</button>
)}
/>
<DropdownPopup align="end">
<DropdownGroupLabel>{identity.email}</DropdownGroupLabel>
<DropdownSeparator />
<DropdownItem color="error" iconStart={<SignOutIcon />}>
Sign out
</DropdownItem>
</DropdownPopup>
</Dropdown>
);
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Trust Center top navigation bar. Slots are shared by the live TopBar and its
// skeleton so the loading placeholder is structurally identical.
export const topBar = tv({
slots: {
bar: "flex h-14 items-center bg-sand-1 px-8",
inner: "mx-auto flex w-full max-w-[1024px] items-center gap-10",
brand: "flex items-center gap-2",
logo: "shrink-0",
spacer: "h-px flex-1",
nav: "flex items-center gap-1",
},
});
// Rounded pill that opens the authenticated user menu.
export const topBarUserMenuTrigger = tv({
base: [
"flex h-8 items-center gap-2 rounded-full py-1 pr-2.5 pl-1",
"cursor-pointer outline-none transition-colors select-none",
"hover:bg-sand-3 data-[popup-open]:bg-sand-3",
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
],
});

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { PropsWithChildren } from "react";
import { RelayEnvironmentProvider } from "react-relay";
import { environment } from "#/lib/relay/environment";
export function RelayProvider({ children }: PropsWithChildren) {
return (
<RelayEnvironmentProvider environment={environment}>
{children}
</RelayEnvironmentProvider>
);
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { makeFetchQuery } from "@probo/relay";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { buildEndpoint } from "#/lib/http/endpoint";
const store = new Store(new RecordSource(), {
queryCacheExpirationTime: 1 * 60 * 1000,
gcReleaseBufferSize: 20,
});
export const environment = new Environment({
configName: "complianceportal",
network: Network.create(makeFetchQuery(buildEndpoint())),
store,
});

View File

@@ -12,11 +12,30 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay";
import { Outlet } from "react-router";
export default function MainLayout() {
import { TopBar } from "#/components/TopBar/TopBar";
import type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql";
export const mainLayoutQuery = graphql`
query MainLayoutQuery {
...TopBar_query
}
`;
interface MainLayoutProps {
queryRef: PreloadedQuery<MainLayoutQuery>;
}
export function MainLayout({ queryRef }: MainLayoutProps) {
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
return (
<div className="min-h-screen bg-sand-1">
<TopBar queryKey={data} />
<Outlet />
</div>
);

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql";
import { MainLayout, mainLayoutQuery } from "./MainLayout";
import { MainLayoutSkeleton } from "./MainLayoutSkeleton";
export default function MainLayoutLoader() {
const [queryRef, loadQuery] = useQueryLoader<MainLayoutQuery>(mainLayoutQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <MainLayoutSkeleton />;
}
return <MainLayout queryRef={queryRef} />;
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { TopBarSkeleton } from "#/components/TopBar/TopBarSkeleton";
export function MainLayoutSkeleton() {
return (
<div className="min-h-screen bg-sand-1">
<TopBarSkeleton />
</div>
);
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Heading } from "@probo/ui/src/v2/typography/Heading";
import { useLocation } from "react-router";
// Temporary stub for the Trust Center sections so the top-bar nav links resolve
// and the active state renders. Real pages replace these per section.
export default function PlaceholderPage() {
const { pathname } = useLocation();
const segment = pathname.replace(/^\//, "").split("/")[0] ?? "";
const title = segment.charAt(0).toUpperCase() + segment.slice(1);
return (
<main className="mx-auto w-full max-w-[1024px] px-8 py-10">
<Heading>{title}</Heading>
</main>
);
}

View File

@@ -17,19 +17,40 @@ import { type AppRoute, routeFromAppRoute } from "@probo/routes";
import { createBrowserRouter } from "react-router";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { getPathPrefix } from "#/lib/http/pathPrefix";
const routes = [
{
path: "/",
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/MainLayout")),
Component: lazy(() => import("#/pages/MainLayoutLoader")),
children: [
{
index: true,
Component: lazy(() => import("#/pages/HomePage")),
},
{
path: "documents",
Component: lazy(() => import("#/pages/PlaceholderPage")),
},
{
path: "subprocessors",
Component: lazy(() => import("#/pages/PlaceholderPage")),
},
{
path: "updates",
Component: lazy(() => import("#/pages/PlaceholderPage")),
},
{
path: "requests",
Component: lazy(() => import("#/pages/PlaceholderPage")),
},
],
},
] satisfies AppRoute[];
export const router = createBrowserRouter(routes.map(routeFromAppRoute));
// The portal is served under a /trust/{slug} path prefix (or a bare custom
// domain). Match the router basename to that prefix so the routes resolve.
export const router = createBrowserRouter(routes.map(routeFromAppRoute), {
basename: getPathPrefix() || "/",
});

View File

@@ -14,13 +14,16 @@
import { fileURLToPath, URL } from "node:url";
import babel from "@rolldown/plugin-babel";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
// @vitejs/plugin-react@6 (Vite 8) no longer runs Babel, so the Relay tagged
// template transform is applied via @rolldown/plugin-babel instead.
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react(), babel({ plugins: ["relay"] }), tailwindcss()],
build: {
assetsDir: "assets",
},

2
package-lock.json generated
View File

@@ -44,6 +44,8 @@
"relay-runtime": "^21.0.1"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",