Add trust center updates list and detail pages
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é <emile@probo.com>
This commit is contained in:
@@ -27,10 +27,6 @@
|
|||||||
"documents": {
|
"documents": {
|
||||||
"title": "Documents"
|
"title": "Documents"
|
||||||
},
|
},
|
||||||
"updates": {
|
|
||||||
"title": "Updates",
|
|
||||||
"subscribe": "Subscribe to updates"
|
|
||||||
},
|
|
||||||
"requests": {
|
"requests": {
|
||||||
"title": "Data Requests",
|
"title": "Data Requests",
|
||||||
"newRequest": "New Request"
|
"newRequest": "New Request"
|
||||||
|
|||||||
@@ -27,10 +27,6 @@
|
|||||||
"documents": {
|
"documents": {
|
||||||
"title": "Documents"
|
"title": "Documents"
|
||||||
},
|
},
|
||||||
"updates": {
|
|
||||||
"title": "Mises à jour",
|
|
||||||
"subscribe": "S'abonner aux mises à jour"
|
|
||||||
},
|
|
||||||
"requests": {
|
"requests": {
|
||||||
"title": "Demandes de données",
|
"title": "Demandes de données",
|
||||||
"newRequest": "Nouvelle demande"
|
"newRequest": "Nouvelle demande"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function ComplianceArticleItemSkeleton() {
|
|||||||
<div className={slots.iconPlaceholder()} />
|
<div className={slots.iconPlaceholder()} />
|
||||||
<div className={slots.content()}>
|
<div className={slots.content()}>
|
||||||
<TextSkeleton size={2} className="w-48" />
|
<TextSkeleton size={2} className="w-48" />
|
||||||
<TextSkeleton size={1} className="w-16" />
|
|
||||||
</div>
|
</div>
|
||||||
<TextSkeleton size={1} className="w-20 shrink-0" />
|
<TextSkeleton size={1} className="w-20 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
|
|
||||||
// Compliance article list row (Figma "Compliance Article Item"): a leading
|
// Compliance article list row (Figma "Compliance Article Item"): a leading
|
||||||
// icon, a title (+ optional eyebrow), and right-aligned meta. Designed to sit
|
// 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.
|
// shared by the row and its skeleton.
|
||||||
export const complianceArticleItem = tv({
|
export const complianceArticleItem = tv({
|
||||||
slots: {
|
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",
|
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",
|
content: "flex min-w-0 flex-1 flex-col gap-1",
|
||||||
meta: "shrink-0",
|
meta: "shrink-0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { NewspaperIcon } from "@phosphor-icons/react";
|
import { NewspaperIcon } from "@phosphor-icons/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { ComplianceArticleItem } from "#/components/ComplianceArticleItem/ComplianceArticleItem";
|
import { ComplianceArticleItem } from "#/components/ComplianceArticleItem/ComplianceArticleItem";
|
||||||
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
|
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
|
||||||
@@ -23,6 +24,7 @@ import type { MailingListUpdateListItem_update$key } from "./__generated__/Maili
|
|||||||
|
|
||||||
const mailingListUpdateListItemFragment = graphql`
|
const mailingListUpdateListItemFragment = graphql`
|
||||||
fragment MailingListUpdateListItem_update on MailingListUpdate {
|
fragment MailingListUpdateListItem_update on MailingListUpdate {
|
||||||
|
id
|
||||||
title
|
title
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
@@ -32,17 +34,19 @@ interface MailingListUpdateListItemProps {
|
|||||||
updateKey: MailingListUpdateListItem_update$key;
|
updateKey: MailingListUpdateListItem_update$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single "Recent updates" row. The schema has no category, so we show the
|
// A single mailing-list update row, linking to its detail page. The schema has
|
||||||
// title and relative date with a single generic icon.
|
// no category, so we show the title and relative date with a generic icon.
|
||||||
export function MailingListUpdateListItem({ updateKey }: MailingListUpdateListItemProps) {
|
export function MailingListUpdateListItem({ updateKey }: MailingListUpdateListItemProps) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const update = useFragment(mailingListUpdateListItemFragment, updateKey);
|
const update = useFragment(mailingListUpdateListItemFragment, updateKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComplianceArticleItem
|
<Link to={`/updates/${update.id}`} className="block transition-colors hover:bg-sand-a2">
|
||||||
icon={<NewspaperIcon weight="light" />}
|
<ComplianceArticleItem
|
||||||
title={update.title}
|
icon={<NewspaperIcon weight="light" />}
|
||||||
meta={formatRelativeTime(update.updatedAt, i18n.language)}
|
title={update.title}
|
||||||
/>
|
meta={formatRelativeTime(update.updatedAt, i18n.language)}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,10 +17,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
import { HomeSection } from "#/components/HomeSection/HomeSection";
|
import { HomeSection } from "#/components/HomeSection/HomeSection";
|
||||||
|
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
||||||
import { dotPatternStyle } from "#/components/MediaTile/variants";
|
import { dotPatternStyle } from "#/components/MediaTile/variants";
|
||||||
|
|
||||||
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
|
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
|
||||||
import { MailingListUpdateListItem } from "./MailingListUpdateListItem";
|
|
||||||
|
|
||||||
const recentUpdatesSectionFragment = graphql`
|
const recentUpdatesSectionFragment = graphql`
|
||||||
fragment RecentUpdatesSection_trustCenter on TrustCenter {
|
fragment RecentUpdatesSection_trustCenter on TrustCenter {
|
||||||
@@ -62,7 +62,7 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
|
|||||||
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
||||||
<div className="relative">
|
<div className="relative divide-y divide-sand-a2">
|
||||||
{updates.map(update => (
|
{updates.map(update => (
|
||||||
<MailingListUpdateListItem key={update.id} updateKey={update} />
|
<MailingListUpdateListItem key={update.id} updateKey={update} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function RecentUpdatesSectionSkeleton() {
|
|||||||
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
||||||
<div className="relative">
|
<div className="relative divide-y divide-sand-a2">
|
||||||
{Array.from({ length: 5 }, (_, index) => (
|
{Array.from({ length: 5 }, (_, index) => (
|
||||||
<ComplianceArticleItemSkeleton key={index} />
|
<ComplianceArticleItemSkeleton key={index} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
26
apps/compliance-portal/src/lib/datetime/formatDate.ts
Normal file
26
apps/compliance-portal/src/lib/datetime/formatDate.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<UpdateDetailPageQuery>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
||||||
|
const { t, i18n } = useTranslation("updates");
|
||||||
|
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
||||||
|
|
||||||
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
|
throw new Error("Update not found");
|
||||||
|
}
|
||||||
|
const update = data.node;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderBand>
|
||||||
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
|
<Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||||
|
{t("backToUpdates")}
|
||||||
|
</Link>
|
||||||
|
<UpdatesSubscribeButton />
|
||||||
|
</div>
|
||||||
|
</HeaderBand>
|
||||||
|
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||||
|
<article className="flex w-full max-w-2xl flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NewspaperIcon weight="light" className="size-4 text-gold-9" />
|
||||||
|
<Text size={1} color="gold">
|
||||||
|
{formatDate(update.updatedAt, i18n.language)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Heading level={1} size={7} weight="medium" highContrast>
|
||||||
|
{update.title}
|
||||||
|
</Heading>
|
||||||
|
<Text size={3} className="block whitespace-pre-wrap">
|
||||||
|
{update.body}
|
||||||
|
</Text>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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>(updateDetailPageQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateId) {
|
||||||
|
loadQuery({ updateId });
|
||||||
|
}
|
||||||
|
}, [loadQuery, updateId]);
|
||||||
|
|
||||||
|
if (!queryRef) {
|
||||||
|
return <UpdateDetailPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UpdateDetailPage queryRef={queryRef} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<HeaderBand>
|
||||||
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
|
<ButtonSkeleton size={2} />
|
||||||
|
<ButtonSkeleton size={2} />
|
||||||
|
</div>
|
||||||
|
</HeaderBand>
|
||||||
|
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||||
|
<div className="flex w-full max-w-2xl flex-col gap-4" aria-hidden>
|
||||||
|
<TextSkeleton size={1} className="w-28" />
|
||||||
|
<HeadingSkeleton size={7} className="w-96" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{BODY_PLACEHOLDERS.map(placeholder => (
|
||||||
|
<TextSkeleton key={placeholder} size={3} className="w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/compliance-portal/src/pages/updates/UpdatesPage.tsx
Normal file
122
apps/compliance-portal/src/pages/updates/UpdatesPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<UpdatesPageQuery>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
||||||
|
const { t } = useTranslation("updates");
|
||||||
|
const root = usePreloadedQuery<UpdatesPageQuery>(updatesPageQuery, queryRef);
|
||||||
|
const [data, refetch] = useRefetchableFragment<UpdatesPageRefetchQuery, UpdatesPage_query$key>(
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title={t("title")} actions={isEmpty ? undefined : <UpdatesSubscribeButton />} />
|
||||||
|
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||||
|
<div className="w-full max-w-5xl">
|
||||||
|
{isEmpty
|
||||||
|
? <UpdatesEmpty />
|
||||||
|
: (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div
|
||||||
|
aria-busy={isPending}
|
||||||
|
className={`overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150 ${isPending ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-sand-a2">
|
||||||
|
{nodes.map(node => (
|
||||||
|
<MailingListUpdateListItem key={node.id} updateKey={node} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
hasPrevious={pageInfo.hasPreviousPage}
|
||||||
|
hasNext={pageInfo.hasNextPage}
|
||||||
|
previousLabel={t("pagination.previous")}
|
||||||
|
nextLabel={t("pagination.next")}
|
||||||
|
onPrevious={goPrevious}
|
||||||
|
onNext={goNext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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>(updatesPageQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuery({ first: UPDATES_PAGE_SIZE });
|
||||||
|
}, [loadQuery]);
|
||||||
|
|
||||||
|
if (!queryRef) {
|
||||||
|
return <UpdatesPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UpdatesPage queryRef={queryRef} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<HeaderBand>
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<HeadingSkeleton size={7} className="w-40" />
|
||||||
|
</div>
|
||||||
|
</HeaderBand>
|
||||||
|
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||||
|
<div className="flex w-full max-w-5xl flex-col gap-8">
|
||||||
|
<div className="overflow-hidden rounded-5 border border-sand-3 bg-sand-1" aria-hidden>
|
||||||
|
<div className="divide-y divide-sand-a2">
|
||||||
|
{ROW_PLACEHOLDERS.map(placeholder => (
|
||||||
|
<ComplianceArticleItemSkeleton key={placeholder} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PaginationSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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 (
|
||||||
|
<EmptyState
|
||||||
|
icon={<NewspaperIcon weight="light" />}
|
||||||
|
title={t("empty.title")}
|
||||||
|
description={t("empty.description")}
|
||||||
|
action={<UpdatesSubscribeButton />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,18 +16,14 @@ import { BellIcon } from "@phosphor-icons/react";
|
|||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { useTranslation } from "react-i18next";
|
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 (
|
return (
|
||||||
<PageHeader
|
<Button variant="soft" color="neutral" highContrast iconStart={<BellIcon />}>
|
||||||
title={t("updates.title")}
|
{t("subscribe")}
|
||||||
actions={(
|
</Button>
|
||||||
<Button variant="soft" color="neutral" highContrast iconStart={<BellIcon />}>
|
|
||||||
{t("updates.subscribe")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
13
apps/compliance-portal/src/pages/updates/_locales/en-US.json
Normal file
13
apps/compliance-portal/src/pages/updates/_locales/en-US.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/compliance-portal/src/pages/updates/_locales/fr-FR.json
Normal file
13
apps/compliance-portal/src/pages/updates/_locales/fr-FR.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/compliance-portal/src/pages/updates/routes.ts
Normal file
32
apps/compliance-portal/src/pages/updates/routes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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[];
|
||||||
@@ -20,6 +20,7 @@ import { getPathPrefix } from "#/lib/http/pathPrefix";
|
|||||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||||
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
||||||
|
import { updateRoutes } from "#/pages/updates/routes";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -37,10 +38,7 @@ const routes = [
|
|||||||
Component: lazy(() => import("#/pages/DocumentsPage")),
|
Component: lazy(() => import("#/pages/DocumentsPage")),
|
||||||
},
|
},
|
||||||
...subprocessorRoutes,
|
...subprocessorRoutes,
|
||||||
{
|
...updateRoutes,
|
||||||
path: "updates",
|
|
||||||
Component: lazy(() => import("#/pages/UpdatesPage")),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "requests",
|
path: "requests",
|
||||||
Component: lazy(() => import("#/pages/RequestsPage")),
|
Component: lazy(() => import("#/pages/RequestsPage")),
|
||||||
|
|||||||
81
packages/ui/src/v2/Pagination/Pagination.tsx
Normal file
81
packages/ui/src/v2/Pagination/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<ComponentProps<"nav">, "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 (
|
||||||
|
<nav className={root({ className })} {...rest}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={2}
|
||||||
|
iconStart={<CaretLeftIcon />}
|
||||||
|
aria-label={previousLabel}
|
||||||
|
className={hasPrevious ? undefined : "invisible"}
|
||||||
|
onClick={onPrevious}
|
||||||
|
/>
|
||||||
|
{label != null && (
|
||||||
|
<Text size={2} color="faint" className={labelSlot()}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={2}
|
||||||
|
iconStart={<CaretRightIcon />}
|
||||||
|
aria-label={nextLabel}
|
||||||
|
className={hasNext ? undefined : "invisible"}
|
||||||
|
onClick={onNext}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
packages/ui/src/v2/Pagination/PaginationSkeleton.tsx
Normal file
27
packages/ui/src/v2/Pagination/PaginationSkeleton.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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 (
|
||||||
|
<div className={root()} aria-hidden>
|
||||||
|
<div className={buttonPlaceholder()} />
|
||||||
|
<div className={buttonPlaceholder()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/ui/src/v2/Pagination/variants.ts
Normal file
26
packages/ui/src/v2/Pagination/variants.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"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/authn"
|
||||||
"go.probo.inc/probo/pkg/server/api/compliancepage"
|
"go.probo.inc/probo/pkg/server/api/compliancepage"
|
||||||
"go.probo.inc/probo/pkg/server/api/trust/v1/schema"
|
"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
|
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:
|
default:
|
||||||
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user