From a238f78768198d001d34a54a83f9c31af6d93f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 9 Jul 2026 17:45:13 -0400 Subject: [PATCH] Add trust center updates list and detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the public Updates pages in the compliance portal: a cursor-paginated list of sent mailing-list updates and a detail view for a single update, replacing the previous stub page. Add a MailingListUpdate case to the trust API node resolver, guarded so only SENT updates belonging to the current trust center's mailing list are exposed, so the detail page can load an update by URL. Add a Prev/Next Pagination primitive to the v2 UI kit. Page numbers are omitted because cursor pagination cannot derive an ordinal page index; each arrow only shows when its page exists while keeping its slot reserved so a visible arrow never shifts position. Relocate the shared MailingListUpdateListItem to its own component folder and wrap each row in a link to the detail page, so both the home recent-updates section and the list navigate to detail. Signed-off-by: Émile Ré --- .../compliance-portal/src/_locales/en-US.json | 4 - .../compliance-portal/src/_locales/fr-FR.json | 4 - .../ComplianceArticleItemSkeleton.tsx | 1 - .../ComplianceArticleItem/variants.ts | 5 +- .../MailingListUpdateListItem.tsx | 18 ++- .../RecentUpdates/RecentUpdatesSection.tsx | 4 +- .../RecentUpdatesSectionSkeleton.tsx | 2 +- .../src/lib/datetime/formatDate.ts | 26 ++++ .../src/pages/updates/UpdateDetailPage.tsx | 83 ++++++++++++ .../pages/updates/UpdateDetailPageLoader.tsx | 38 ++++++ .../updates/UpdateDetailPageSkeleton.tsx | 45 +++++++ .../src/pages/updates/UpdatesPage.tsx | 122 ++++++++++++++++++ .../src/pages/updates/UpdatesPageLoader.tsx | 35 +++++ .../src/pages/updates/UpdatesPageSkeleton.tsx | 45 +++++++ .../updates/_components/UpdatesEmpty.tsx | 35 +++++ .../_components/UpdatesSubscribeButton.tsx} | 18 +-- .../updates/_lib/useUpdatesPagination.ts | 65 ++++++++++ .../src/pages/updates/_locales/en-US.json | 13 ++ .../src/pages/updates/_locales/fr-FR.json | 13 ++ .../src/pages/updates/routes.ts | 32 +++++ apps/compliance-portal/src/routes.tsx | 6 +- packages/ui/src/v2/Pagination/Pagination.tsx | 81 ++++++++++++ .../src/v2/Pagination/PaginationSkeleton.tsx | 27 ++++ packages/ui/src/v2/Pagination/variants.ts | 26 ++++ pkg/server/api/trust/v1/base_resolvers.go | 30 +++++ 25 files changed, 742 insertions(+), 36 deletions(-) rename apps/compliance-portal/src/components/{RecentUpdates => MailingListUpdateListItem}/MailingListUpdateListItem.tsx (77%) create mode 100644 apps/compliance-portal/src/lib/datetime/formatDate.ts create mode 100644 apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx create mode 100644 apps/compliance-portal/src/pages/updates/UpdateDetailPageLoader.tsx create mode 100644 apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx create mode 100644 apps/compliance-portal/src/pages/updates/UpdatesPage.tsx create mode 100644 apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx create mode 100644 apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx create mode 100644 apps/compliance-portal/src/pages/updates/_components/UpdatesEmpty.tsx rename apps/compliance-portal/src/pages/{UpdatesPage.tsx => updates/_components/UpdatesSubscribeButton.tsx} (72%) create mode 100644 apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts create mode 100644 apps/compliance-portal/src/pages/updates/_locales/en-US.json create mode 100644 apps/compliance-portal/src/pages/updates/_locales/fr-FR.json create mode 100644 apps/compliance-portal/src/pages/updates/routes.ts create mode 100644 packages/ui/src/v2/Pagination/Pagination.tsx create mode 100644 packages/ui/src/v2/Pagination/PaginationSkeleton.tsx create mode 100644 packages/ui/src/v2/Pagination/variants.ts diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 03a5e7663..274c364bc 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -27,10 +27,6 @@ "documents": { "title": "Documents" }, - "updates": { - "title": "Updates", - "subscribe": "Subscribe to updates" - }, "requests": { "title": "Data Requests", "newRequest": "New Request" diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 4612b2f66..3d3529613 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -27,10 +27,6 @@ "documents": { "title": "Documents" }, - "updates": { - "title": "Mises à jour", - "subscribe": "S'abonner aux mises à jour" - }, "requests": { "title": "Demandes de données", "newRequest": "Nouvelle demande" diff --git a/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx b/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx index b13e37146..4d9aead5d 100644 --- a/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx +++ b/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx @@ -26,7 +26,6 @@ export function ComplianceArticleItemSkeleton() {
-
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
-
+
{updates.map(update => ( ))} diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx index f1bcdb5f4..6290fb347 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx @@ -30,7 +30,7 @@ export function RecentUpdatesSectionSkeleton() {
-
+
{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 ( + <> + +
+ }> + {t("backToUpdates")} + + +
+
+
+
+
+ + + {formatDate(update.updatedAt, i18n.language)} + +
+ + {update.title} + + + {update.body} + +
+
+ + ); +} 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 ( + <> + +
+ + +
+
+
+
+ + +
+ {BODY_PLACEHOLDERS.map(placeholder => ( + + ))} +
+
+
+ + ); +} 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 ( + <> + +
+ +
+
+
+
+
+
+ {ROW_PLACEHOLDERS.map(placeholder => ( + + ))} +
+
+ +
+
+ + ); +} 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")} - - )} - /> + ); } 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) }