diff --git a/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts b/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts
index 6ec1befba..fe256c707 100644
--- a/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts
+++ b/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts
@@ -16,11 +16,12 @@ import { tv } from "tailwind-variants/lite";
// Compliance article list row (Figma "Compliance Article Item"): a leading
// icon, a title (+ optional eyebrow), and right-aligned meta. Designed to sit
-// inside a bordered list container; the last row drops its divider. Slots are
+// inside a list container that draws the dividers (e.g. `divide-y`), so rows
+// stay divider-agnostic whether or not each is wrapped in a link. Slots are
// shared by the row and its skeleton.
export const complianceArticleItem = tv({
slots: {
- root: "flex w-full items-center gap-4 border-b border-sand-a2 px-8 py-4 last:border-b-0",
+ root: "flex w-full items-center gap-4 px-8 py-4",
icon: "flex size-6 shrink-0 items-center justify-center text-gold-9 [&_svg]:size-full",
content: "flex min-w-0 flex-1 flex-col gap-1",
meta: "shrink-0",
diff --git a/apps/compliance-portal/src/components/RecentUpdates/MailingListUpdateListItem.tsx b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx
similarity index 77%
rename from apps/compliance-portal/src/components/RecentUpdates/MailingListUpdateListItem.tsx
rename to apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx
index 306fa8bb9..ea86561d6 100644
--- a/apps/compliance-portal/src/components/RecentUpdates/MailingListUpdateListItem.tsx
+++ b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx
@@ -15,6 +15,7 @@
import { NewspaperIcon } from "@phosphor-icons/react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
+import { Link } from "react-router";
import { ComplianceArticleItem } from "#/components/ComplianceArticleItem/ComplianceArticleItem";
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
@@ -23,6 +24,7 @@ import type { MailingListUpdateListItem_update$key } from "./__generated__/Maili
const mailingListUpdateListItemFragment = graphql`
fragment MailingListUpdateListItem_update on MailingListUpdate {
+ id
title
updatedAt
}
@@ -32,17 +34,19 @@ interface MailingListUpdateListItemProps {
updateKey: MailingListUpdateListItem_update$key;
}
-// A single "Recent updates" row. The schema has no category, so we show the
-// title and relative date with a single generic icon.
+// A single mailing-list update row, linking to its detail page. The schema has
+// no category, so we show the title and relative date with a generic icon.
export function MailingListUpdateListItem({ updateKey }: MailingListUpdateListItemProps) {
const { i18n } = useTranslation();
const update = useFragment(mailingListUpdateListItemFragment, updateKey);
return (
- }
- title={update.title}
- meta={formatRelativeTime(update.updatedAt, i18n.language)}
- />
+
+ }
+ title={update.title}
+ meta={formatRelativeTime(update.updatedAt, i18n.language)}
+ />
+
);
}
diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx
index 0c014e431..a2fc29e7d 100644
--- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx
+++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx
@@ -17,10 +17,10 @@ import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { HomeSection } from "#/components/HomeSection/HomeSection";
+import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
import { dotPatternStyle } from "#/components/MediaTile/variants";
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
-import { MailingListUpdateListItem } from "./MailingListUpdateListItem";
const recentUpdatesSectionFragment = graphql`
fragment RecentUpdatesSection_trustCenter on TrustCenter {
@@ -62,7 +62,7 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
{Array.from({ length: 5 }, (_, index) => (
))}
diff --git a/apps/compliance-portal/src/lib/datetime/formatDate.ts b/apps/compliance-portal/src/lib/datetime/formatDate.ts
new file mode 100644
index 000000000..8b89ade9c
--- /dev/null
+++ b/apps/compliance-portal/src/lib/datetime/formatDate.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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.
+
+// Locale-aware long date formatting via Intl.DateTimeFormat, e.g.
+// "August 6, 2026". The locale must be the active i18next language so the output
+// follows the UI.
+export function formatDate(date: Date | string | number, locale: string): string {
+ const target = date instanceof Date ? date : new Date(date);
+
+ return new Intl.DateTimeFormat(locale, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(target);
+}
diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx
new file mode 100644
index 000000000..6f79b5671
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx
@@ -0,0 +1,83 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { CaretLeftIcon, NewspaperIcon } from "@phosphor-icons/react";
+import { Link } from "@probo/ui/src/v2/Button/Link";
+import { Heading } from "@probo/ui/src/v2/typography/Heading";
+import { Text } from "@probo/ui/src/v2/typography/Text";
+import { useTranslation } from "react-i18next";
+import type { PreloadedQuery } from "react-relay";
+import { graphql, usePreloadedQuery } from "react-relay";
+
+import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
+import { formatDate } from "#/lib/datetime/formatDate";
+
+import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
+import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
+
+export const updateDetailPageQuery = graphql`
+ query UpdateDetailPageQuery($updateId: ID!) {
+ node(id: $updateId) {
+ __typename
+ ... on MailingListUpdate {
+ title
+ body
+ updatedAt
+ }
+ }
+ }
+`;
+
+interface UpdateDetailPageProps {
+ queryRef: PreloadedQuery;
+}
+
+export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
+ const { t, i18n } = useTranslation("updates");
+ const data = usePreloadedQuery(updateDetailPageQuery, queryRef);
+
+ if (data.node?.__typename !== "MailingListUpdate") {
+ throw new Error("Update not found");
+ }
+ const update = data.node;
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPageLoader.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPageLoader.tsx
new file mode 100644
index 000000000..82ce98fdb
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPageLoader.tsx
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { useParams } from "react-router";
+
+import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
+import { UpdateDetailPage, updateDetailPageQuery } from "./UpdateDetailPage";
+import { UpdateDetailPageSkeleton } from "./UpdateDetailPageSkeleton";
+
+export default function UpdateDetailPageLoader() {
+ const { updateId } = useParams<{ updateId: string }>();
+ const [queryRef, loadQuery] = useQueryLoader(updateDetailPageQuery);
+
+ useEffect(() => {
+ if (updateId) {
+ loadQuery({ updateId });
+ }
+ }, [loadQuery, updateId]);
+
+ if (!queryRef) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx
new file mode 100644
index 000000000..7ae530a4f
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
+import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
+import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
+
+import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
+
+const BODY_PLACEHOLDERS = ["a", "b", "c", "d", "e"];
+
+export function UpdateDetailPageSkeleton() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx
new file mode 100644
index 000000000..37da9a6f2
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx
@@ -0,0 +1,122 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { Pagination } from "@probo/ui/src/v2/Pagination/Pagination";
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import type { PreloadedQuery } from "react-relay";
+import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay";
+
+import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
+import { PageHeader } from "#/components/PageHeader/PageHeader";
+
+import type { UpdatesPage_query$key } from "./__generated__/UpdatesPage_query.graphql";
+import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
+import type { UpdatesPageRefetchQuery } from "./__generated__/UpdatesPageRefetchQuery.graphql";
+import { UpdatesEmpty } from "./_components/UpdatesEmpty";
+import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
+import type { UpdatesPaginationVariables } from "./_lib/useUpdatesPagination";
+import { useUpdatesPagination } from "./_lib/useUpdatesPagination";
+
+export const updatesPageQuery = graphql`
+ query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) {
+ ...UpdatesPage_query @arguments(first: $first, after: $after, last: $last, before: $before)
+ }
+`;
+
+const updatesPageFragment = graphql`
+ fragment UpdatesPage_query on Query
+ @refetchable(queryName: "UpdatesPageRefetchQuery")
+ @argumentDefinitions(
+ first: { type: "Int" }
+ after: { type: "CursorKey" }
+ last: { type: "Int" }
+ before: { type: "CursorKey" }
+ ) {
+ currentTrustCenter @required(action: THROW) {
+ updates(first: $first, after: $after, last: $last, before: $before) {
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ edges {
+ node {
+ id
+ ...MailingListUpdateListItem_update
+ }
+ }
+ }
+ }
+ }
+`;
+
+interface UpdatesPageProps {
+ queryRef: PreloadedQuery;
+}
+
+export function UpdatesPage({ queryRef }: UpdatesPageProps) {
+ const { t } = useTranslation("updates");
+ const root = usePreloadedQuery(updatesPageQuery, queryRef);
+ const [data, refetch] = useRefetchableFragment(
+ updatesPageFragment,
+ root,
+ );
+
+ const refetchUpdates = useCallback((variables: UpdatesPaginationVariables) => {
+ refetch(variables, { fetchPolicy: "store-or-network" });
+ }, [refetch]);
+
+ const { updates } = data.currentTrustCenter;
+ const { pageInfo } = updates;
+ const { isPending, goPrevious, goNext } = useUpdatesPagination(refetchUpdates, pageInfo);
+
+ const nodes = updates.edges.map(edge => edge.node);
+ const isEmpty = nodes.length === 0;
+
+ return (
+ <>
+ } />
+
+
+ {isEmpty
+ ?
+ : (
+
+
+
+ {nodes.map(node => (
+
+ ))}
+
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx
new file mode 100644
index 000000000..3f9ff0c02
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
+import { UPDATES_PAGE_SIZE } from "./_lib/useUpdatesPagination";
+import { UpdatesPage, updatesPageQuery } from "./UpdatesPage";
+import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton";
+
+export default function UpdatesPageLoader() {
+ const [queryRef, loadQuery] = useQueryLoader(updatesPageQuery);
+
+ useEffect(() => {
+ loadQuery({ first: UPDATES_PAGE_SIZE });
+ }, [loadQuery]);
+
+ if (!queryRef) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx
new file mode 100644
index 000000000..bdcda7ccf
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { PaginationSkeleton } from "@probo/ui/src/v2/Pagination/PaginationSkeleton";
+import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
+
+import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
+import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
+
+const ROW_PLACEHOLDERS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
+
+export function UpdatesPageSkeleton() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/compliance-portal/src/pages/updates/_components/UpdatesEmpty.tsx b/apps/compliance-portal/src/pages/updates/_components/UpdatesEmpty.tsx
new file mode 100644
index 000000000..8e65909a8
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/_components/UpdatesEmpty.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { NewspaperIcon } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+
+import { EmptyState } from "#/components/EmptyState/EmptyState";
+
+import { UpdatesSubscribeButton } from "./UpdatesSubscribeButton";
+
+// Empty state for the updates list: shown when the trust center has published no
+// updates yet, inviting the visitor to subscribe.
+export function UpdatesEmpty() {
+ const { t } = useTranslation("updates");
+
+ return (
+ }
+ title={t("empty.title")}
+ description={t("empty.description")}
+ action={}
+ />
+ );
+}
diff --git a/apps/compliance-portal/src/pages/UpdatesPage.tsx b/apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx
similarity index 72%
rename from apps/compliance-portal/src/pages/UpdatesPage.tsx
rename to apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx
index 711a50d03..f69ef99d4 100644
--- a/apps/compliance-portal/src/pages/UpdatesPage.tsx
+++ b/apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx
@@ -16,18 +16,14 @@ import { BellIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { useTranslation } from "react-i18next";
-import { PageHeader } from "#/components/PageHeader/PageHeader";
+// "Subscribe to updates" call to action shared by the list header and the
+// detail toolbar. Mailing-list subscription wiring is not implemented yet.
+export function UpdatesSubscribeButton() {
+ const { t } = useTranslation("updates");
-export default function UpdatesPage() {
- const { t } = useTranslation();
return (
- }>
- {t("updates.subscribe")}
-
- )}
- />
+ }>
+ {t("subscribe")}
+
);
}
diff --git a/apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts b/apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts
new file mode 100644
index 000000000..43da9bf2f
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts
@@ -0,0 +1,65 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { useCallback, useTransition } from "react";
+
+// Page size for the cursor-paginated updates list. Matches the Figma list frame.
+export const UPDATES_PAGE_SIZE = 10;
+
+export interface UpdatesPageInfo {
+ hasPreviousPage: boolean;
+ hasNextPage: boolean;
+ startCursor: string | null | undefined;
+ endCursor: string | null | undefined;
+}
+
+export interface UpdatesPaginationVariables {
+ first?: number | null;
+ after?: string | null;
+ last?: number | null;
+ before?: string | null;
+}
+
+type RefetchUpdates = (variables: UpdatesPaginationVariables) => void;
+
+// Cursor-based Prev/Next pagination for the updates list. Drives the connection
+// refetch inside a transition so the current page stays mounted (dimmed) while
+// the next one loads. Enabled/disabled state comes from the server `pageInfo`,
+// which stays correct however the page is reached. No page-number counter:
+// cursor pagination encodes a position, not an ordinal, so a reliable page
+// index (deep-linkable or refresh-safe) would need offset + totalCount, which
+// this API does not expose.
+export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesPageInfo) {
+ const [isPending, startTransition] = useTransition();
+
+ const goNext = useCallback(() => {
+ if (!pageInfo.hasNextPage || pageInfo.endCursor == null) {
+ return;
+ }
+ startTransition(() => {
+ refetch({ first: UPDATES_PAGE_SIZE, after: pageInfo.endCursor, last: null, before: null });
+ });
+ }, [refetch, pageInfo.hasNextPage, pageInfo.endCursor]);
+
+ const goPrevious = useCallback(() => {
+ if (!pageInfo.hasPreviousPage || pageInfo.startCursor == null) {
+ return;
+ }
+ startTransition(() => {
+ refetch({ first: null, after: null, last: UPDATES_PAGE_SIZE, before: pageInfo.startCursor });
+ });
+ }, [refetch, pageInfo.hasPreviousPage, pageInfo.startCursor]);
+
+ return { isPending, goPrevious, goNext };
+}
diff --git a/apps/compliance-portal/src/pages/updates/_locales/en-US.json b/apps/compliance-portal/src/pages/updates/_locales/en-US.json
new file mode 100644
index 000000000..7ce5a829e
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/_locales/en-US.json
@@ -0,0 +1,13 @@
+{
+ "title": "Updates",
+ "subscribe": "Subscribe to updates",
+ "backToUpdates": "All updates",
+ "empty": {
+ "title": "No updates yet.",
+ "description": "Subscribe to get notified when new updates are published."
+ },
+ "pagination": {
+ "previous": "Previous page",
+ "next": "Next page"
+ }
+}
diff --git a/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json b/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json
new file mode 100644
index 000000000..822b89216
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json
@@ -0,0 +1,13 @@
+{
+ "title": "Mises à jour",
+ "subscribe": "S'abonner aux mises à jour",
+ "backToUpdates": "Toutes les mises à jour",
+ "empty": {
+ "title": "Aucune mise à jour pour le moment.",
+ "description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour."
+ },
+ "pagination": {
+ "previous": "Page précédente",
+ "next": "Page suivante"
+ }
+}
diff --git a/apps/compliance-portal/src/pages/updates/routes.ts b/apps/compliance-portal/src/pages/updates/routes.ts
new file mode 100644
index 000000000..571ffddcd
--- /dev/null
+++ b/apps/compliance-portal/src/pages/updates/routes.ts
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { lazy } from "@probo/react-lazy";
+import type { AppRoute } from "@probo/routes";
+
+import { UpdateDetailPageSkeleton } from "./UpdateDetailPageSkeleton";
+import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton";
+
+export const updateRoutes = [
+ {
+ path: "updates",
+ Fallback: UpdatesPageSkeleton,
+ Component: lazy(() => import("./UpdatesPageLoader")),
+ },
+ {
+ path: "updates/:updateId",
+ Fallback: UpdateDetailPageSkeleton,
+ Component: lazy(() => import("./UpdateDetailPageLoader")),
+ },
+] satisfies AppRoute[];
diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx
index 75218ca53..80fe7ddd3 100644
--- a/apps/compliance-portal/src/routes.tsx
+++ b/apps/compliance-portal/src/routes.tsx
@@ -20,6 +20,7 @@ import { getPathPrefix } from "#/lib/http/pathPrefix";
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
+import { updateRoutes } from "#/pages/updates/routes";
const routes = [
{
@@ -37,10 +38,7 @@ const routes = [
Component: lazy(() => import("#/pages/DocumentsPage")),
},
...subprocessorRoutes,
- {
- path: "updates",
- Component: lazy(() => import("#/pages/UpdatesPage")),
- },
+ ...updateRoutes,
{
path: "requests",
Component: lazy(() => import("#/pages/RequestsPage")),
diff --git a/packages/ui/src/v2/Pagination/Pagination.tsx b/packages/ui/src/v2/Pagination/Pagination.tsx
new file mode 100644
index 000000000..ea1c1605d
--- /dev/null
+++ b/packages/ui/src/v2/Pagination/Pagination.tsx
@@ -0,0 +1,81 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
+import type { ComponentProps, ReactNode } from "react";
+
+import { Button } from "../Button/Button";
+import { Text } from "../typography/Text";
+
+import { pagination } from "./variants";
+
+export type PaginationProps = Omit, "onChange"> & {
+ hasPrevious: boolean;
+ hasNext: boolean;
+ // Current-position label rendered between the arrows (e.g. "Page 2").
+ label?: ReactNode;
+ // Accessible labels for the arrow controls.
+ previousLabel?: string;
+ nextLabel?: string;
+ onPrevious: () => void;
+ onNext: () => void;
+};
+
+// Prev/Next pager for cursor-paginated lists. Page numbers are intentionally
+// omitted because cursor pagination cannot compute a total page count; an
+// optional current-position label sits between the arrows instead. Each arrow
+// only renders when its page exists, but its slot is always reserved (the
+// missing arrow is kept invisible) so a visible arrow sits in the exact same
+// position whether or not the other is present. Renders nothing when neither
+// page exists.
+export function Pagination(props: PaginationProps) {
+ const {
+ hasPrevious, hasNext, label,
+ previousLabel = "Previous page", nextLabel = "Next page",
+ onPrevious, onNext, className, ...rest
+ } = props;
+ const { root, label: labelSlot } = pagination();
+
+ if (!hasPrevious && !hasNext) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/ui/src/v2/Pagination/PaginationSkeleton.tsx b/packages/ui/src/v2/Pagination/PaginationSkeleton.tsx
new file mode 100644
index 000000000..1bb47bc38
--- /dev/null
+++ b/packages/ui/src/v2/Pagination/PaginationSkeleton.tsx
@@ -0,0 +1,27 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 { pagination } from "./variants";
+
+// Loading placeholder paired with Pagination: two pulse arrow blocks.
+export function PaginationSkeleton() {
+ const { root, buttonPlaceholder } = pagination();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/v2/Pagination/variants.ts b/packages/ui/src/v2/Pagination/variants.ts
new file mode 100644
index 000000000..66c179409
--- /dev/null
+++ b/packages/ui/src/v2/Pagination/variants.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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";
+
+// Prev/Next pager (Figma "Pagination"). A centered row with a previous button,
+// a current-position label, and a next button. Slots are shared by the pager
+// and its skeleton so the loading placeholder matches the real layout.
+export const pagination = tv({
+ slots: {
+ root: "flex items-center justify-center gap-2",
+ label: "min-w-16 text-center",
+ buttonPlaceholder: "size-8 shrink-0 animate-pulse rounded-2 bg-sand-3",
+ },
+});
diff --git a/pkg/server/api/trust/v1/base_resolvers.go b/pkg/server/api/trust/v1/base_resolvers.go
index 61de6d268..73336c625 100644
--- a/pkg/server/api/trust/v1/base_resolvers.go
+++ b/pkg/server/api/trust/v1/base_resolvers.go
@@ -13,6 +13,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
+ "go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/api/trust/v1/schema"
@@ -199,6 +200,35 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewTrustCenterFile(trustCenterFile), nil
+ case coredata.MailingListUpdateEntityType:
+ update, err := r.mailman.GetMailingListUpdate(ctx, id)
+ if err != nil {
+ if errors.Is(err, mailman.ErrMailingListUpdateNotFound) || errors.Is(err, coredata.ErrResourceNotFound) {
+ return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
+ }
+
+ r.logger.ErrorCtx(ctx, "cannot get mailing list update", log.Error(err))
+
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ if update.Status != coredata.MailingListUpdateStatusSent {
+ return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
+ }
+
+ trustCenter, err := trustService.TrustCenters.Get(ctx, scope, compliancePage.ID)
+ if err != nil {
+ r.logger.ErrorCtx(ctx, "cannot get trust center", log.Error(err))
+
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ if trustCenter.MailingListID == nil || *trustCenter.MailingListID != update.MailingListID {
+ return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
+ }
+
+ return types.NewMailingListUpdate(update), nil
+
default:
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
}