Blocks

Trust Center

The public security page a B2B buyer asks for — audit dates measured against each report's own refresh cycle, a filterable subprocessor table with DPA links, plain-language practices, uptime, and when a human last reviewed the page.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Activity,
  BadgeCheck,
  Bell,
  DatabaseBackup,
  ExternalLink,
  EyeOff,
  FileText,
  KeyRound,
  Lock,
  MailCheck,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/trust-center.json

Prompt

Build a React + TypeScript + Tailwind "TrustCenter" block (lucide-react icons,
zod, shadcn Button / Badge / Input / Label).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title; asOf (ISO instant);
    reviewedAt (YYYY-MM-DD);
    certifications: { id, label, auditedAt, reportUrl?, auditor?, scope?,
      refreshMonths? }[];
    subprocessors: { name, purpose, region, dpaUrl? }[];
    practices: { id, title, body, icon? }[];
    uptime?: { ninetyDayPct (0-100), statusUrl };
    securityContact?: { label, href }; changeFeedUrl?; errorMessage? }.
- Props = z.infer of that schema plus locale, defaultRefreshMonths (12),
  reviewStaleDays (90), defaultRegion, onRetry?, onSubscribe?(email), className,
  and the remaining <section> props spread on the root; forwardRef the root.
- Days are plain YYYY-MM-DD and are parsed AND formatted in UTC — a day has no
  instant, and rendering it in the reader's zone turns Feb 28 into Feb 27.
- asOf is injected, never read from the clock, so server and client agree.

Behavior
- Four first-class branches. loading = skeleton echoing the real anatomy plus an
  sr-only role=status; empty = "nothing published yet" panel (ready with zero
  certifications, zero subprocessors, zero practices and no uptime falls back to
  it); error = errorMessage or a generic sentence plus a retry that is locked for
  one burst by a ref (a state flag is not visible until the next render) and
  re-arms on a timer cleared on unmount; ready = the page.
- Certification freshness is arithmetic, not a verdict: whole calendar months
  between auditedAt and asOf (month difference, minus one when the day of the
  month has not come round — 2025-06-30 to 2026-06-29 is 11 months, not 12),
  compared with that report's own refreshMonths (SOC 2 Type II 12, an ISO 27001
  certificate cycle 36). At or past it the card says "Refresh due" and names the
  cycle; it never says compliant, certified, or failed. A future-dated or
  unparseable audit date is labelled as such and printed verbatim. Zero whole
  months reads "less than a month ago", never "this month" — 20 Jul to 18 Aug is
  zero months and is not the same calendar month.
- The section header restates only what could be computed: "4 of 5 reports
  inside the stated refresh window" counts readable, non-future dates on both
  sides of the ratio and appends "· 1 date not readable" / "· 1 date after this
  page's as-of date" for the rest, and the whole line is dropped when asOf
  itself is unparseable. An age the page could not derive is never folded into
  the overdue side of a count.
- A certification with no reportUrl renders a sentence — "Report not published
  here. Request a copy from <securityContact>." — never a disabled button.
- Subprocessors: region facets sorted by count then name, with whole-percent
  shares apportioned by largest remainder so the filter chips total exactly 100
  (three of eight is 37.5%; rounding each on its own prints 101). The chips are a
  role=radiogroup with roving tabindex — arrows move and select, Home/End jump,
  Tab leaves; each chip overrides the button base's whitespace-nowrap/shrink-0
  (whitespace-normal + h-auto + shrink) so a host-worded region such as "Asia
  Pacific (Singapore, Tokyo, Sydney)" wraps instead of pushing the block
  sideways. The table is a real <table> with scope=col/row inside an
  overflow-x-auto wrapper; a row with no dpaUrl reads "Not linked". Under it, a
  role=status line states the visible counts ("Showing 3 of 8 processors ·
  region: European Union · DPA linked for 3 of 3") — always true of what is on
  screen, in every filter state.
- "Notify me of changes" takes the shape of what the host wired: onSubscribe →
  an email field validated on submit (loose pattern, noValidate, aria-invalid +
  role=alert message) that reports only what it witnessed — "Change notices
  requested for <address>", never "notices go to <address>", since the handler's
  outcome never comes back — and offers "use a different address"; no handler
  but a changeFeedUrl → that link; neither → one sentence naming the gap. The
  confirmation is a role=status live region mounted in every shape (sr-only, so
  no layout cost, while empty: a region inserted with its text already in it is
  not announced), and focus follows the swap that unmounts the pressed button —
  onto the confirmation on submit, back to the field on reset.
- Uptime is optional and prints truncated at two decimals (99.999 → 99.99%), so
  the page can only understate it; no uptime data draws no tile at all.
- reviewedAt prints with its age in days and gains a badge past reviewStaleDays.

Rendering & styling
- Semantic tokens only: bg-card panels, border / border-dashed for unpublished
  slots, text-muted-foreground for supporting copy, text-primary for links and
  the audit glyph, destructive reserved for "refresh due" and a stale review.
- Container queries (@container/trust) drive the one- to two-column switch, so
  the block adapts to its slot rather than to the viewport; cn() merges
  className; every link is a real href from the contract, absolute ones opening
  in a new tab with an sr-only "(opens in a new tab)".
- The only animation is the skeleton pulse and the retry spinner, both
  motion-reduce:animate-none; focus-visible rings on chips, links and buttons.

Customization levers
- Sections: certifications, practices and subprocessors are sibling <section>s —
  drop one, reorder them, or move the uptime tile out of the header without
  touching the derivations.
- Density: swap the @2xl/trust:grid-cols-2 grids for three columns, or make the
  certification cards a single list on narrow embeds.
- Freshness policy: defaultRefreshMonths for the house rule, per-certification
  refreshMonths for frameworks that differ, reviewStaleDays for the page badge.
- Filter axis: facet on `region` today; the same facet + largest-remainder
  helper works for a `purpose` or data-class column by changing one field name.
- Notification: keep the email capture, or pass no onSubscribe and let a
  changeFeedUrl (RSS, changelog page) carry it.
- Icons: practices map icon keys to lucide glyphs through one record — extend
  the enum and the record together; unknown keys already fall back to the shield.

Concepts

  • Report, don't certify — the framework name prints verbatim and the only derived judgement is months elapsed against the cadence the host itself supplied, so the page can be wrong about a date but never about a legal claim it invented.
  • Freshness in calendar months — audit cycles are stated in months, so the age is counted the way an age is (month difference, minus one until the day of the month comes round), not as days / 30.44, which disagrees by a whole month several times a year.
  • Absence renders as absence — a missing report link becomes the request route, a missing DPA reads "Not linked", missing uptime draws no tile, and an audit date the page could not read drops out of the freshness ratio instead of being counted as overdue; nothing is filled in with a default that would flatter the page.
  • Blocked, explained — every affordance that cannot work says why in a sentence with the next step in it; the one affordance that does act claims only what it witnessed ("change notices requested for…"), then moves focus to that confirmation and announces it through a live region mounted long before it had anything to say.
  • Largest remainder over the facets — the region shares are floored and the leftover points handed to the biggest fractions, so the chips add to exactly 100 instead of the 101 that per-chip rounding prints for three-of-eight splits.
  • Derived counts, restated per filter — "DPA linked for 3 of 3" is computed from the rows currently on screen and announced through role=status, so a filtered view never inherits the unfiltered claim.

On This Page