Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
defd94e
feat(trust): add pure isTrustPortalConfigured detection helper
Marfuen May 28, 2026
3d194f8
refactor(trust): clarify framework-flags contract and test labels
Marfuen May 28, 2026
593f2dd
feat(trust): expose derived isConfigured on trust-portal settings
Marfuen May 28, 2026
8ae9c85
docs(trust): note legacy soc2 flag in isConfigured signal
Marfuen May 28, 2026
183f0ae
feat(trust): add overview nudge host types
Marfuen May 28, 2026
c5591e7
feat(trust): add offboarding nudge for overview host
Marfuen May 28, 2026
88e3914
feat(trust): add trust portal setup nudge
Marfuen May 28, 2026
390c263
fix(trust): use Button render prop and direct Alert slots in setup nudge
Marfuen May 28, 2026
cebc5fb
feat(trust): single-slot overview nudge host with tests
Marfuen May 28, 2026
a7be233
refactor(trust): honest effect deps and broader host test coverage
Marfuen May 28, 2026
3d90962
feat(trust): mount overview nudge host, migrate offboarding banner
Marfuen May 28, 2026
46664b1
feat(trust): add getting-started block to trust page
Marfuen May 28, 2026
6738a21
test(trust): cover getting-started steps, document Alert grid placement
Marfuen May 28, 2026
ab836e7
feat(trust): stacked expandable nudge view so waiting notifications a…
Marfuen May 28, 2026
4d52a40
feat(trust): wrap stacked nudges in a NudgeCenter tray with integrate…
Marfuen May 28, 2026
2ab8c21
feat(trust): render collapsed nudges as a peeking card stack
Marfuen May 28, 2026
1f50972
fix(trust): make nudge stack peek render (isolate stacking ctx, near-…
Marfuen May 28, 2026
39941e3
feat(trust): layered card stack — blue peeks, shadows, centered count…
Marfuen May 28, 2026
8965dd6
feat(trust): overlay the expand chip on the stack's bottom edge
Marfuen May 28, 2026
3625792
feat(trust): align nudges to page width, neutral floating expand chip
Marfuen May 28, 2026
463aafd
refactor(trust): use DS Alert variants + semantic tokens, drop hardco…
Marfuen May 28, 2026
4801b7e
fix(trust): use outline variant for offboarding View details button
Marfuen May 28, 2026
a1e44ce
fix(trust): amber (warning-tinted) peek cards so the stack is visible
Marfuen May 28, 2026
c418f56
style(trust): use warning variant for trust setup nudge to match the …
Marfuen May 28, 2026
0be1782
style(trust): label the stack toggle '2 notices'
Marfuen May 28, 2026
dcc543e
style(trust): use foreground text in nudge alerts for contrast
Marfuen May 28, 2026
ac08c69
style(trust): foreground text on View details button for contrast
Marfuen May 28, 2026
265e4b7
style(trust): make View details a primary button to match Set it up
Marfuen May 28, 2026
6940e87
style(trust): add right-arrow iconRight to nudge action buttons
Marfuen May 28, 2026
066ba33
test(trust): mock ArrowRight icon in nudge host tests
Marfuen May 28, 2026
9ded963
feat(trust): bundle framework updates banner into the overview nudge …
Marfuen May 29, 2026
d524801
fix(trust): make stacked peek cards opaque so they read as solid
Marfuen May 29, 2026
23d7a84
style(trust): order framework updates nudge last (priority 30)
Marfuen May 29, 2026
1431887
style(trust): guide to enabling frameworks for progress; published po…
Marfuen May 29, 2026
25ba356
Merge branch 'main' into mariano/sale-63-trust-center-adoption
Marfuen May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/api/src/trust-portal/is-trust-portal-configured.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { isTrustPortalConfigured } from './is-trust-portal-configured';

const DEFAULTS = {
domain: null,
contactEmail: null,
overviewContent: null,
favicon: null,
faqs: null,
frameworkFlags: [false, false, false],
documentCount: 0,
resourceCount: 0,
customLinkCount: 0,
};

describe('isTrustPortalConfigured', () => {
it('returns false for a fresh portal on all defaults', () => {
expect(isTrustPortalConfigured(DEFAULTS)).toBe(false);
});

it('returns false when faqs is an empty array', () => {
expect(isTrustPortalConfigured({ ...DEFAULTS, faqs: [] })).toBe(false);
});

it.each([
['domain', { domain: 'trust.acme.com' }],
['contactEmail', { contactEmail: 'security@acme.com' }],
['overviewContent', { overviewContent: 'We are secure.' }],
['favicon', { favicon: 'org/favicon.png' }],
['faqs', { faqs: [{ question: 'q', answer: 'a', order: 0 }] }],
['a framework flag', { frameworkFlags: [false, true, false] }],
['a document', { documentCount: 1 }],
['a compliance resource (certificate)', { resourceCount: 1 }],
['a custom link', { customLinkCount: 1 }],
])('returns true when %s is set', (_label, override) => {
expect(isTrustPortalConfigured({ ...DEFAULTS, ...override })).toBe(true);
});

it('ignores non-array faqs values', () => {
expect(isTrustPortalConfigured({ ...DEFAULTS, faqs: 'not-an-array' })).toBe(false);
});

it('returns false when frameworkFlags is an empty array', () => {
expect(isTrustPortalConfigured({ ...DEFAULTS, frameworkFlags: [] })).toBe(false);
});
});
42 changes: 42 additions & 0 deletions apps/api/src/trust-portal/is-trust-portal-configured.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export interface TrustPortalConfiguredInput {
domain?: string | null;
contactEmail?: string | null;
overviewContent?: string | null;
favicon?: string | null;
/** Organization.trustPortalFaqs — Json?, expected to be an array when set. */
faqs?: unknown;
/**
* Raw Trust framework "enabled" boolean columns (soc2, soc2type1, soc2type2,
* soc3, iso27001, iso42001, nen7510, gdpr, hipaa, pci_dss, iso9001, pipeda,
* ccpa). Order is irrelevant — any `true` counts as configured. The caller is
* responsible for passing all of them; a dropped column silently weakens the
* signal. Distinct from `resourceCount` (uploaded certificate files).
*/
frameworkFlags: boolean[];
documentCount: number;
resourceCount: number;
customLinkCount: number;
}

/**
* A Trust Portal is "configured" once the org has done anything beyond the
* shared-domain defaults. Used to decide whether to nudge the customer to set
* it up. Computed from RAW values (the settings endpoint substitutes a Context
* Hub default for overviewContent — do not pass the substituted value here).
*/
export function isTrustPortalConfigured(input: TrustPortalConfiguredInput): boolean {
const hasFaqs = Array.isArray(input.faqs) && input.faqs.length > 0;
const hasFramework = input.frameworkFlags.some(Boolean);

return Boolean(
input.domain ||
input.contactEmail ||
input.overviewContent ||
input.favicon ||
hasFaqs ||
hasFramework ||
input.documentCount > 0 ||
input.resourceCount > 0 ||
input.customLinkCount > 0,
);
}
35 changes: 35 additions & 0 deletions apps/api/src/trust-portal/trust-portal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
TrustDocumentUrlResponseDto,
UploadTrustDocumentDto,
} from './dto/trust-document.dto';
import { isTrustPortalConfigured } from './is-trust-portal-configured';

interface VercelDomainVerification {
type: string;
Expand Down Expand Up @@ -1533,8 +1534,42 @@ export class TrustPortalService {
defaultOverviewContent = missionContext?.answer ?? null;
}

const [trustDocumentCount, trustResourceCount, trustCustomLinkCount] =
await Promise.all([
db.trustDocument.count({ where: { organizationId } }),
db.trustResource.count({ where: { organizationId } }),
db.trustCustomLink.count({ where: { organizationId } }),
]);

const isConfigured = isTrustPortalConfigured({
domain: trust.domain,
contactEmail: trust.contactEmail,
overviewContent: trust.overviewContent, // raw column, not the Context fallback
favicon: trust.favicon,
faqs: org.trustPortalFaqs,
frameworkFlags: [
trust.soc2, // legacy column; folded into soc2type2 in the response but still a "configured" signal
trust.soc2type1,
trust.soc2type2,
trust.soc3,
trust.iso27001,
trust.iso42001,
trust.nen7510,
trust.gdpr,
trust.hipaa,
trust.pci_dss,
trust.iso9001,
trust.pipeda,
trust.ccpa,
],
documentCount: trustDocumentCount,
resourceCount: trustResourceCount,
customLinkCount: trustCustomLinkCount,
});

return {
enabled: trust.status === 'published',
isConfigured,
friendlyUrl: trust.friendlyUrl,
domain: trust.domain ?? '',
domainVerified: trust.domainVerified ?? false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import { ChevronUp, Upgrade } from '@trycompai/design-system/icons';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';

export function FrameworkUpdatesBanner() {
/**
* The framework-updates card, with NO outer layout wrapper — the host (e.g. the
* Overview nudge stack) owns width/spacing. Returns null when there are no
* updates, so it's safe to mount unconditionally.
*/
export function FrameworkUpdatesCard() {
const { data: statuses } = useFrameworkUpdateStatuses();
const { hasPermission } = usePermissions();
const router = useRouter();
Expand All @@ -29,7 +34,6 @@ export function FrameworkUpdatesBanner() {
const count = statuses.length;

return (
<div className="mx-auto w-full max-w-[1200px] pb-8">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between rounded-t-lg bg-secondary px-4 py-3">
Expand Down Expand Up @@ -88,6 +92,5 @@ export function FrameworkUpdatesBanner() {
</CollapsibleContent>
</div>
</Collapsible>
</div>
);
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -71,7 +70,6 @@ export const Overview = ({

return (
<div className="flex flex-col gap-6">
<OffboardingBanner />
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<ComplianceOverview
organizationId={organizationId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { useFrameworkUpdateStatuses } from '@/hooks/use-framework-update-statuses';
import { FrameworkUpdatesCard } from '../components/FrameworkUpdatesCard';
import type { NudgeState } from './types';

export function useFrameworkUpdatesNudge(): NudgeState {
const { data: statuses, error } = useFrameworkUpdateStatuses();

return {
id: 'framework-updates',
// Last: after offboarding (10) and trust-portal-setup (20) — least urgent.
priority: 30,
// The card has no dismiss control; keep false so the id stays out of the
// host's persistableIds (no spurious localStorage read for an undismissable nudge).
persistDismissal: false,
// Only "not ready" while the fetch is genuinely in flight (mirrors offboarding).
ready: statuses !== undefined || error !== undefined,
// Eligible only when the fetch succeeded and there's at least one update.
eligible: !error && Array.isArray(statuses) && statuses.length > 0,
// The card keeps its own look and has no dismiss affordance, so ignore onDismiss.
render: () => <FrameworkUpdatesCard />,
};
}
92 changes: 92 additions & 0 deletions apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className={`flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1 text-xs font-medium text-foreground shadow-lg transition-colors hover:bg-accent ${positionClass}`}
>
{expanded ? (
<>
<ChevronUp size={14} />
Show less
</>
) : (
<>
<ChevronDown size={14} />
{count} notices
</>
)}
</button>
);

if (expanded) {
return (
<div className="flex flex-col items-center gap-2">
<div className="flex w-full flex-col gap-2">{children}</div>
{toggle('')}
</div>
);
}

// 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 (
<div style={{ paddingBottom: Math.max(peekLayers * 12, 16) + 12 }}>
{/* `isolate` keeps the stack's own stacking context so the peek layers
sit just behind the top card (not behind an ancestor background). */}
<div className="relative isolate">
{Array.from({ length: peekLayers }).map((_, i) => {
const depth = i + 1;
return (
<div
key={depth}
aria-hidden
className="pointer-events-none absolute top-0 h-full rounded-lg border shadow-md"
style={{
// Each card behind is a little narrower than the one above it.
left: depth * 8,
right: depth * 8,
transform: `translateY(${depth * 12}px)`,
zIndex: MAX_PEEK_LAYERS - depth + 1,
// Opaque amber (warning mixed into the solid card color) so the
// stacked peeks read as solid cards, not translucent slivers.
backgroundColor: 'color-mix(in oklab, var(--warning) 25%, var(--card))',
borderColor: 'color-mix(in oklab, var(--warning) 45%, var(--card))',
}}
/>
);
})}
<div className="relative z-10 rounded-lg shadow-lg">{children}</div>
{toggle(
'absolute bottom-0 left-1/2 z-20 -translate-x-1/2 translate-y-[65%]',
)}
</div>
</div>
);
}
Loading
Loading