- );
-}
diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx
index 1cdd083538..ddfe0746f3 100644
--- a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx
+++ b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx
@@ -4,7 +4,6 @@ import { FrameworkEditorFramework, Policy, Task } from '@db';
import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
import { ComplianceOverview } from './ComplianceOverview';
import { FrameworksOverview } from './FrameworksOverview';
-import { OffboardingBanner } from './OffboardingBanner';
import { ToDoOverview } from './ToDoOverview';
import { FrameworkInstanceWithComplianceScore } from './types';
@@ -71,7 +70,6 @@ export const Overview = ({
return (
-
0,
+ // The card keeps its own look and has no dismiss affordance, so ignore onDismiss.
+ render: () => ,
+ };
+}
diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx
new file mode 100644
index 0000000000..cda2a0e3ff
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { ChevronDown, ChevronUp } from '@trycompai/design-system/icons';
+import type { ReactNode } from 'react';
+
+const MAX_PEEK_LAYERS = 2;
+
+/**
+ * Groups multiple notification nudges. Collapsed, it renders the top nudge on a
+ * "pile" — a sliver of the cards behind it peeks out — with a toggle chip
+ * overlaid on the bottom edge to fan the whole stack open. Expanded, every
+ * nudge is shown in a vertical list.
+ */
+export function NudgeCenter({
+ count,
+ expanded,
+ onToggle,
+ children,
+}: {
+ count: number;
+ expanded: boolean;
+ onToggle: () => void;
+ children: ReactNode;
+}) {
+ const peekLayers = Math.min(count - 1, MAX_PEEK_LAYERS);
+
+ const toggle = (positionClass: string) => (
+
+ );
+
+ if (expanded) {
+ return (
+
+
{children}
+ {toggle('')}
+
+ );
+ }
+
+ // Collapsed: top nudge on a pile, with the toggle chip overlaid on the
+ // bottom-center edge. Padding reserves room for the peeks + the chip overhang.
+ return (
+
+ {/* `isolate` keeps the stack's own stacking context so the peek layers
+ sit just behind the top card (not behind an ancestor background). */}
+
,
+ Button: ({ children }: any) => {children},
+}));
+
+vi.mock('@trycompai/design-system/icons', () => ({
+ Close: () => x,
+ WarningAlt: () => !,
+ ChevronDown: () => v,
+ ChevronUp: () => ^,
+ ArrowRight: () => →,
+}));
+
+import { OverviewNudges } from './OverviewNudges';
+
+const TRUST_PERMS = { trust: ['read', 'update'] };
+
+function setOffboarding(members: { memberId: string; name: string }[]) {
+ mockUseApiSWR.mockReturnValue({ data: { data: { members } }, error: undefined });
+}
+
+function setFrameworkUpdates(items: { frameworkInstanceId: string }[]) {
+ mockUseFrameworkUpdateStatuses.mockReturnValue({ data: items, error: undefined });
+}
+
+describe('OverviewNudges', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.localStorage.clear();
+ setOffboarding([]); // default: no offboarding
+ setFrameworkUpdates([]); // default: no framework updates
+ setMockPermissions(TRUST_PERMS);
+ });
+
+ const server = (over?: Partial<{ isTrustNdaEnabled: boolean; isConfigured: boolean }>) => ({
+ trust: { isTrustNdaEnabled: true, isConfigured: false, ...over },
+ });
+
+ it('shows the trust nudge when enabled, not configured, and user can update', () => {
+ render();
+ expect(screen.getByText('Showcase your security posture')).toBeInTheDocument();
+ });
+
+ it('hides the trust nudge when already configured', () => {
+ render();
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ });
+
+ it('hides the trust nudge when the feature flag is off', () => {
+ render();
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ });
+
+ it('hides the trust nudge without trust:update', () => {
+ setMockPermissions({ trust: ['read'] });
+ render();
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ });
+
+ it('collapses to the top nudge with a stack control when several apply', () => {
+ setOffboarding([{ memberId: 'm1', name: 'Jo' }]);
+ render();
+ // Offboarding (priority 10) is shown; trust waits behind the stack.
+ expect(screen.getByText(/offboarding completion/)).toBeInTheDocument();
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ // The user is told more are waiting.
+ expect(screen.getByText('2 notices')).toBeInTheDocument();
+ });
+
+ it('expands the stack to reveal every waiting nudge, then collapses', () => {
+ setOffboarding([{ memberId: 'm1', name: 'Jo' }]);
+ render();
+
+ fireEvent.click(screen.getByText('2 notices'));
+ expect(screen.getByText(/offboarding completion/)).toBeInTheDocument();
+ expect(screen.getByText('Showcase your security posture')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Show less'));
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ expect(screen.getByText('2 notices')).toBeInTheDocument();
+ });
+
+ it('shows no stack control when only one nudge applies', () => {
+ render();
+ expect(screen.getByText('Showcase your security posture')).toBeInTheDocument();
+ expect(screen.queryByText(/\d+ notices/)).not.toBeInTheDocument();
+ });
+
+ it('dismissing the trust nudge hides it and persists', () => {
+ const { unmount } = render();
+ fireEvent.click(screen.getByLabelText('Dismiss'));
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ unmount();
+ render();
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ });
+
+ it('dismissing offboarding hides it for the session without persisting', () => {
+ setOffboarding([{ memberId: 'm1', name: 'Jo' }]);
+ // isConfigured: true so the trust nudge does not appear after offboarding is dismissed
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByText(/offboarding completion/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText('Dismiss'));
+ expect(screen.queryByText(/offboarding completion/)).not.toBeInTheDocument();
+ expect(
+ window.localStorage.getItem('overview-nudge-dismissed:offboarding:org_123'),
+ ).toBeNull();
+
+ // Not persisted → reappears on remount.
+ unmount();
+ render();
+ expect(screen.getByText(/offboarding completion/)).toBeInTheDocument();
+ });
+
+ it('renders nothing while offboarding is loading and trust is ineligible', () => {
+ mockUseApiSWR.mockReturnValue({ data: undefined, error: undefined }); // SWR loading
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('shows the framework updates nudge when it is the only one eligible', () => {
+ setFrameworkUpdates([{ frameworkInstanceId: 'fi_1' }]);
+ // isConfigured: true → trust off; no offboarding → framework is the only nudge.
+ render();
+ expect(screen.getByText('framework updates available')).toBeInTheDocument();
+ expect(screen.queryByText(/\d+ notices/)).not.toBeInTheDocument();
+ });
+
+ it('orders framework updates last in the stack (offboarding, trust, framework)', () => {
+ setOffboarding([{ memberId: 'm1', name: 'Jo' }]);
+ setFrameworkUpdates([{ frameworkInstanceId: 'fi_1' }]);
+ render();
+
+ // Collapsed: only offboarding (priority 10) on top; the other two wait.
+ expect(screen.getByText(/offboarding completion/)).toBeInTheDocument();
+ expect(screen.queryByText('framework updates available')).not.toBeInTheDocument();
+ expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument();
+ expect(screen.getByText('3 notices')).toBeInTheDocument();
+
+ // Expanded: all three shown, framework updates rendered after the trust nudge.
+ fireEvent.click(screen.getByText('3 notices'));
+ expect(screen.getByText(/offboarding completion/)).toBeInTheDocument();
+ const trustEl = screen.getByText('Showcase your security posture');
+ const frameworkEl = screen.getByText('framework updates available');
+ expect(
+ trustEl.compareDocumentPosition(frameworkEl) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it('excludes framework updates from the count while its data is loading', () => {
+ setOffboarding([{ memberId: 'm1', name: 'Jo' }]);
+ mockUseFrameworkUpdateStatuses.mockReturnValue({ data: undefined, error: undefined });
+ render();
+ // offboarding + trust = 2; framework not ready, so it doesn't inflate the count.
+ expect(screen.getByText('2 notices')).toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx
new file mode 100644
index 0000000000..d3eab5128a
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useFrameworkUpdatesNudge } from './FrameworkUpdatesNudge';
+import { NudgeCenter } from './NudgeCenter';
+import { useOffboardingNudge } from './OffboardingNudge';
+import { useTrustPortalSetupNudge } from './TrustPortalSetupNudge';
+import type { NudgeState, ServerNudgeData } from './types';
+
+const dismissKey = (id: string, orgId: string) =>
+ `overview-nudge-dismissed:${id}:${orgId}`;
+
+export function OverviewNudges({
+ orgId,
+ server,
+}: {
+ orgId: string;
+ server: ServerNudgeData;
+}) {
+ // Hooks called unconditionally, in stable priority order.
+ const offboarding = useOffboardingNudge();
+ const frameworkUpdates = useFrameworkUpdatesNudge();
+ const trust = useTrustPortalSetupNudge({ orgId, server });
+ const candidates = [offboarding, frameworkUpdates, trust];
+
+ // Stable across renders unless a persistable nudge is added/removed.
+ const persistableIds = candidates
+ .filter((c) => c.persistDismissal)
+ .map((c) => c.id)
+ .join(',');
+
+ const [dismissed, setDismissed] = useState>(new Set());
+ const [expanded, setExpanded] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ const next = new Set();
+ for (const id of persistableIds.split(',').filter(Boolean)) {
+ if (window.localStorage.getItem(dismissKey(id, orgId)) === '1') {
+ next.add(id);
+ }
+ }
+ setDismissed(next);
+ }, [orgId, persistableIds]);
+
+ const visible = candidates
+ .filter((c) => c.ready && c.eligible && !dismissed.has(c.id))
+ .sort((a, b) => a.priority - b.priority);
+
+ // Collapse the tray whenever there's no longer more than one to fan out.
+ useEffect(() => {
+ if (visible.length <= 1 && expanded) setExpanded(false);
+ }, [visible.length, expanded]);
+
+ if (!mounted || visible.length === 0) return null;
+
+ const dismiss = (nudge: NudgeState) => () => {
+ if (nudge.persistDismissal) {
+ window.localStorage.setItem(dismissKey(nudge.id, orgId), '1');
+ }
+ setDismissed((prev) => new Set(prev).add(nudge.id));
+ };
+
+ const body =
+ visible.length === 1 ? (
+ visible[0].render(dismiss(visible[0]))
+ ) : (
+ setExpanded((prev) => !prev)}
+ >
+ {(expanded ? visible : visible.slice(0, 1)).map((nudge) => (
+
{nudge.render(dismiss(nudge))}
+ ))}
+
+ );
+
+ // Match the page's centered content width so nudges align with everything else.
+ return
{body}
;
+}
diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx
new file mode 100644
index 0000000000..ac4edb4f2f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { usePermissions } from '@/hooks/use-permissions';
+import {
+ Alert,
+ AlertAction,
+ AlertDescription,
+ AlertTitle,
+ Button,
+} from '@trycompai/design-system';
+import { ArrowRight, Close } from '@trycompai/design-system/icons';
+import Link from 'next/link';
+import type { NudgeState, ServerNudgeData } from './types';
+
+export function useTrustPortalSetupNudge({
+ orgId,
+ server,
+}: {
+ orgId: string;
+ server: ServerNudgeData;
+}): NudgeState {
+ const { hasPermission } = usePermissions();
+ const canSetup = hasPermission('trust', 'update');
+ const { isTrustNdaEnabled, isConfigured } = server.trust;
+
+ return {
+ id: 'trust-portal-setup',
+ priority: 20,
+ persistDismissal: true,
+ ready: true, // server data already resolved
+ eligible: isTrustNdaEnabled && !isConfigured && canSetup,
+ render: (onDismiss) => (
+
+ ),
+ };
+}
+
+function TrustPortalSetupNudgeView({
+ orgId,
+ onDismiss,
+}: {
+ orgId: string;
+ onDismiss: () => void;
+}) {
+ return (
+
+
+ Showcase your security posture
+
+
+
+ Show prospects and vendors your compliance progress — enable the
+ frameworks you’re working on and your published policies appear
+ automatically.
+
+
+
+ } render={}>
+ Set it up
+
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts b/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts
new file mode 100644
index 0000000000..f3d4c92c3d
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts
@@ -0,0 +1,27 @@
+import type { ReactNode } from 'react';
+
+/** Server-resolved data the Overview page passes into the nudge host. */
+export interface ServerNudgeData {
+ trust: {
+ isTrustNdaEnabled: boolean;
+ isConfigured: boolean;
+ };
+}
+
+/**
+ * One candidate nudge. The host picks the lowest-`priority` candidate that is
+ * `ready && eligible && !dismissed` and renders it — at most one at a time.
+ */
+export interface NudgeState {
+ id: string;
+ /** Lower number wins (shown first). */
+ priority: number;
+ /** true → dismissal persists in localStorage; false → session-only. */
+ persistDismissal: boolean;
+ /** false while underlying data is still loading. */
+ ready: boolean;
+ /** Has something to show AND the user is allowed to act on it. */
+ eligible: boolean;
+ /** Renders the nudge UI; called once for the single visible nudge. */
+ render: (onDismiss: () => void) => ReactNode;
+}
diff --git a/apps/app/src/app/(app)/[orgId]/overview/page.tsx b/apps/app/src/app/(app)/[orgId]/overview/page.tsx
index 221477b79d..4a2dc4c64b 100644
--- a/apps/app/src/app/(app)/[orgId]/overview/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/overview/page.tsx
@@ -1,9 +1,12 @@
+import { getFeatureFlags } from '@/app/posthog';
import { serverApi } from '@/lib/api-server';
+import { auth } from '@/utils/auth';
import type { FrameworkEditorFramework, Policy, Task } from '@db';
import { PageHeader, PageLayout } from '@trycompai/design-system';
-import { FrameworkUpdatesBanner } from './components/FrameworkUpdatesBanner';
+import { headers } from 'next/headers';
import { Overview } from './components/Overview';
import { OverviewTabs } from './components/OverviewTabs';
+import { OverviewNudges } from './nudges/OverviewNudges';
import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
export async function generateMetadata() {
@@ -34,16 +37,32 @@ interface ScoresResponse {
export default async function OverviewPage({ params }: { params: Promise<{ orgId: string }> }) {
const { orgId: organizationId } = await params;
- const [scoresRes, frameworksRes, availableRes] = await Promise.all([
+ const requestHeaders = await headers();
+ const session = await auth.api.getSession({ headers: requestHeaders });
+
+ const [scoresRes, frameworksRes, availableRes, settingsRes] = await Promise.all([
serverApi.get('/v1/frameworks/scores'),
serverApi.get<{ data: FrameworkWithScore[] }>('/v1/frameworks?includeControls=true&includeScores=true'),
serverApi.get<{ data: FrameworkEditorFramework[] }>('/v1/frameworks/available'),
+ serverApi.get<{ isConfigured?: boolean }>('/v1/trust-portal/settings'),
]);
const scores = scoresRes.data;
const frameworksData = frameworksRes.data?.data ?? [];
const allFrameworks = availableRes.data?.data ?? [];
+ let isTrustNdaEnabled = false;
+ if (session?.user?.id) {
+ const flags = await getFeatureFlags(session.user.id, {
+ groups: { organization: organizationId },
+ });
+ isTrustNdaEnabled =
+ flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true';
+ }
+
+ // Fail closed: if we can't determine state, don't nudge.
+ const isTrustConfigured = settingsRes.data?.isConfigured ?? true;
+
const frameworksWithControls = frameworksData.map(
({ complianceScore: _score, ...fw }: FrameworkWithScore) => fw,
);
@@ -54,7 +73,10 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId
return (
<>
-
+ } />}>
({
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock('@trycompai/design-system', () => ({
+ Alert: ({ children }: any) =>
{children}
,
+ AlertTitle: ({ children }: any) =>
{children}
,
+ AlertDescription: ({ children }: any) =>
{children}
,
+}));
+
+import { TrustPortalGettingStarted } from './TrustPortalGettingStarted';
+
+describe('TrustPortalGettingStarted', () => {
+ it('renders the live shared portal URL', () => {
+ render();
+ expect(screen.getByText(/trust.inc\/org_123/)).toBeInTheDocument();
+ });
+
+ it('renders the getting-started heading', () => {
+ render();
+ expect(
+ screen.getByText(/finish setting up your trust portal/i),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the setup steps', () => {
+ render();
+ expect(screen.getByText(/frameworks you/i)).toBeInTheDocument();
+ expect(screen.getByText(/published policies show automatically/i)).toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx
new file mode 100644
index 0000000000..f8e5fcd65f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx
@@ -0,0 +1,34 @@
+import { Alert, AlertDescription, AlertTitle } from '@trycompai/design-system';
+import Link from 'next/link';
+
+const STEPS = [
+ 'Enable the frameworks you’re working toward to show prospects and vendors your compliance progress — no certificate needed yet.',
+ 'Your published policies show automatically — drafts and in-progress updates stay private.',
+ 'Add a custom domain and contact email to make it your own.',
+];
+
+export function TrustPortalGettingStarted({ portalUrl }: { portalUrl: string }) {
+ return (
+
+
+ Finish setting up your Trust Portal
+
+
+
+ Your Trust Portal is at{' '}
+
+ {portalUrl}
+
+ . Put it to work so prospects and vendors can see where you stand:
+
+
+ {/* variant="info" renders an icon, so Alert is a 2-col grid; place the
+ list in the text column like the title/description slots above. */}
+
+ {STEPS.map((step) => (
+
{step}
+ ))}
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx
index b980b263c3..2dac763620 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx
@@ -3,6 +3,7 @@ import { Button, PageHeader, PageLayout } from '@trycompai/design-system';
import { Launch } from '@trycompai/design-system/icons';
import type { Metadata } from 'next';
import Link from 'next/link';
+import { TrustPortalGettingStarted } from './components/TrustPortalGettingStarted';
import { TrustPortalSwitch } from './portal-settings/components/TrustPortalSwitch';
export default async function TrustPage({
@@ -26,6 +27,7 @@ export default async function TrustPage({
]);
const settings = settingsRes.data as any;
+ const isTrustConfigured = settings?.isConfigured ?? true;
const customLinks = Array.isArray(customLinksRes.data)
? customLinksRes.data
: [];
@@ -96,6 +98,7 @@ export default async function TrustPage({
/>
}
>
+ {!isTrustConfigured && }