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) }