From 886cb36f985d2d70e16ca121a061b68b8e04e3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 9 Jul 2026 18:02:16 -0400 Subject: [PATCH] Refine updates pages styling and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the cursor Prev/Next pagination hook into a reusable useCursorPagination in lib/relay, taking the page size as a parameter, and keep the updates page size (25) as a feature constant. Move the list card surface and its loading-dim state into tv variants behind an UpdatesList component, and lift the detail article layout and its gold metadata styling into shared variants, so the pages carry only placement classes. Skeletons reuse the same variants. Relocate the generic pager labels to the app-root namespace and expose Intl.DateTimeFormat options on the formatDate helper. Signed-off-by: Émile Ré --- .../compliance-portal/src/_locales/en-US.json | 4 ++ .../compliance-portal/src/_locales/fr-FR.json | 4 ++ .../src/lib/datetime/formatDate.ts | 26 ++++++---- .../relay/useCursorPagination.ts} | 39 ++++++++------- .../src/pages/updates/UpdateDetailPage.tsx | 15 +++--- .../updates/UpdateDetailPageSkeleton.tsx | 16 ++++--- .../src/pages/updates/UpdatesPage.tsx | 30 ++++++------ .../src/pages/updates/UpdatesPageLoader.tsx | 2 +- .../src/pages/updates/UpdatesPageSkeleton.tsx | 14 ++++-- .../pages/updates/_components/UpdatesList.tsx | 35 ++++++++++++++ .../src/pages/updates/_components/variants.ts | 48 +++++++++++++++++++ .../src/pages/updates/_lib/constants.ts | 16 +++++++ .../src/pages/updates/_locales/en-US.json | 4 -- .../src/pages/updates/_locales/fr-FR.json | 4 -- 14 files changed, 188 insertions(+), 69 deletions(-) rename apps/compliance-portal/src/{pages/updates/_lib/useUpdatesPagination.ts => lib/relay/useCursorPagination.ts} (55%) create mode 100644 apps/compliance-portal/src/pages/updates/_components/UpdatesList.tsx create mode 100644 apps/compliance-portal/src/pages/updates/_components/variants.ts create mode 100644 apps/compliance-portal/src/pages/updates/_lib/constants.ts diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 274c364bc..aa9d0baea 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -38,5 +38,9 @@ }, "footer": { "poweredBy": "Powered by" + }, + "pagination": { + "previous": "Previous page", + "next": "Next page" } } diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 3d3529613..8d043eeab 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -38,5 +38,9 @@ }, "footer": { "poweredBy": "Propulsé par" + }, + "pagination": { + "previous": "Page précédente", + "next": "Page suivante" } } diff --git a/apps/compliance-portal/src/lib/datetime/formatDate.ts b/apps/compliance-portal/src/lib/datetime/formatDate.ts index 8b89ade9c..a568851a9 100644 --- a/apps/compliance-portal/src/lib/datetime/formatDate.ts +++ b/apps/compliance-portal/src/lib/datetime/formatDate.ts @@ -12,15 +12,23 @@ // 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 { +// Long-date defaults, e.g. "August 6, 2026". Callers can override or extend any +// of these through the `options` argument. +const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "long", + day: "numeric", +}; + +// Locale-aware date formatting via Intl.DateTimeFormat. The locale must be the +// active i18next language so the output follows the UI. `options` are merged +// over the long-date defaults, exposing the full Intl.DateTimeFormat surface. +export function formatDate( + date: Date | string | number, + locale: string, + options?: Intl.DateTimeFormatOptions, +): string { const target = date instanceof Date ? date : new Date(date); - return new Intl.DateTimeFormat(locale, { - year: "numeric", - month: "long", - day: "numeric", - }).format(target); + return new Intl.DateTimeFormat(locale, { ...DEFAULT_OPTIONS, ...options }).format(target); } diff --git a/apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts b/apps/compliance-portal/src/lib/relay/useCursorPagination.ts similarity index 55% rename from apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts rename to apps/compliance-portal/src/lib/relay/useCursorPagination.ts index 43da9bf2f..2775be24e 100644 --- a/apps/compliance-portal/src/pages/updates/_lib/useUpdatesPagination.ts +++ b/apps/compliance-portal/src/lib/relay/useCursorPagination.ts @@ -14,33 +14,36 @@ 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 { +// The `pageInfo` shape a Relay connection exposes for bidirectional cursor +// pagination. Structurally compatible with generated connection page info. +export interface CursorPageInfo { hasPreviousPage: boolean; hasNextPage: boolean; startCursor: string | null | undefined; endCursor: string | null | undefined; } -export interface UpdatesPaginationVariables { +// The connection pagination arguments passed to a refetch. +export interface CursorPaginationVariables { first?: number | null; after?: string | null; last?: number | null; before?: string | null; } -type RefetchUpdates = (variables: UpdatesPaginationVariables) => void; +type CursorRefetch = (variables: CursorPaginationVariables) => 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) { +// Prev/Next pagination for a Relay cursor connection. Drives the refetch inside +// a transition so the current page stays mounted (dimmed) while the next one +// loads; Prev/Next availability comes from the server `pageInfo`, so it stays +// correct however the page is reached. There is 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. +export function useCursorPagination( + refetch: CursorRefetch, + pageInfo: CursorPageInfo, + pageSize: number, +) { const [isPending, startTransition] = useTransition(); const goNext = useCallback(() => { @@ -48,18 +51,18 @@ export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesP return; } startTransition(() => { - refetch({ first: UPDATES_PAGE_SIZE, after: pageInfo.endCursor, last: null, before: null }); + refetch({ first: pageSize, after: pageInfo.endCursor, last: null, before: null }); }); - }, [refetch, pageInfo.hasNextPage, pageInfo.endCursor]); + }, [refetch, pageSize, 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({ first: null, after: null, last: pageSize, before: pageInfo.startCursor }); }); - }, [refetch, pageInfo.hasPreviousPage, pageInfo.startCursor]); + }, [refetch, pageSize, pageInfo.hasPreviousPage, pageInfo.startCursor]); return { isPending, goPrevious, goNext }; } diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx index 6f79b5671..3af553226 100644 --- a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx @@ -25,6 +25,7 @@ import { formatDate } from "#/lib/datetime/formatDate"; import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql"; import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton"; +import { updateArticle } from "./_components/variants"; export const updateDetailPageQuery = graphql` query UpdateDetailPageQuery($updateId: ID!) { @@ -52,20 +53,22 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) { } const update = data.node; + const { toolbar, content, article, meta, metaIcon, body } = updateArticle(); + return ( <> -
+
}> {t("backToUpdates")}
-
-
-
- +
+
+
+ {formatDate(update.updatedAt, i18n.language)} @@ -73,7 +76,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) { {update.title} - + {update.body}
diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx index 7ae530a4f..b8c59b8df 100644 --- a/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPageSkeleton.tsx @@ -18,24 +18,28 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton"; import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; -const BODY_PLACEHOLDERS = ["a", "b", "c", "d", "e"]; +import { updateArticle } from "./_components/variants"; + +const BODY_LINE_COUNT = 5; export function UpdateDetailPageSkeleton() { + const { toolbar, content, article } = updateArticle(); + return ( <> -
+
-
-
+
+
- {BODY_PLACEHOLDERS.map(placeholder => ( - + {Array.from({ length: BODY_LINE_COUNT }, (_, index) => ( + ))}
diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx index 37da9a6f2..69b8bd4d8 100644 --- a/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx @@ -20,14 +20,16 @@ import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay" import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem"; import { PageHeader } from "#/components/PageHeader/PageHeader"; +import type { CursorPaginationVariables } from "#/lib/relay/useCursorPagination"; +import { useCursorPagination } from "#/lib/relay/useCursorPagination"; 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 { UpdatesList } from "./_components/UpdatesList"; import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton"; -import type { UpdatesPaginationVariables } from "./_lib/useUpdatesPagination"; -import { useUpdatesPagination } from "./_lib/useUpdatesPagination"; +import { UPDATES_PAGE_SIZE } from "./_lib/constants"; export const updatesPageQuery = graphql` query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) { @@ -69,19 +71,20 @@ interface UpdatesPageProps { export function UpdatesPage({ queryRef }: UpdatesPageProps) { const { t } = useTranslation("updates"); + const { t: tCommon } = useTranslation(); const root = usePreloadedQuery(updatesPageQuery, queryRef); const [data, refetch] = useRefetchableFragment( updatesPageFragment, root, ); - const refetchUpdates = useCallback((variables: UpdatesPaginationVariables) => { + const refetchUpdates = useCallback((variables: CursorPaginationVariables) => { refetch(variables, { fetchPolicy: "store-or-network" }); }, [refetch]); const { updates } = data.currentTrustCenter; const { pageInfo } = updates; - const { isPending, goPrevious, goNext } = useUpdatesPagination(refetchUpdates, pageInfo); + const { isPending, goPrevious, goNext } = useCursorPagination(refetchUpdates, pageInfo, UPDATES_PAGE_SIZE); const nodes = updates.edges.map(edge => edge.node); const isEmpty = nodes.length === 0; @@ -95,21 +98,16 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) { ? : (
-
-
- {nodes.map(node => ( - - ))} -
-
+ + {nodes.map(node => ( + + ))} + diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx index 3f9ff0c02..17b0a4ca2 100644 --- a/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdatesPageLoader.tsx @@ -16,7 +16,7 @@ import { useEffect } from "react"; import { useQueryLoader } from "react-relay"; import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql"; -import { UPDATES_PAGE_SIZE } from "./_lib/useUpdatesPagination"; +import { UPDATES_PAGE_SIZE } from "./_lib/constants"; import { UpdatesPage, updatesPageQuery } from "./UpdatesPage"; import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton"; diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx index bdcda7ccf..7e98df5cc 100644 --- a/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx @@ -18,9 +18,13 @@ 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"]; +import { updatesList } from "./_components/variants"; + +const ROW_COUNT = 10; export function UpdatesPageSkeleton() { + const { card, rows } = updatesList(); + return ( <> @@ -30,10 +34,10 @@ export function UpdatesPageSkeleton() {
-
-
- {ROW_PLACEHOLDERS.map(placeholder => ( - +
+
+ {Array.from({ length: ROW_COUNT }, (_, index) => ( + ))}
diff --git a/apps/compliance-portal/src/pages/updates/_components/UpdatesList.tsx b/apps/compliance-portal/src/pages/updates/_components/UpdatesList.tsx new file mode 100644 index 000000000..93e6acd34 --- /dev/null +++ b/apps/compliance-portal/src/pages/updates/_components/UpdatesList.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 type { ReactNode } from "react"; + +import { updatesList } from "./variants"; + +interface UpdatesListProps { + // Dims the list while a page change is loading. + busy?: boolean; + // The rendered update rows. + children: ReactNode; +} + +// White card surface holding divider-separated update rows. +export function UpdatesList({ busy = false, children }: UpdatesListProps) { + const { card, rows } = updatesList(); + + return ( +
+
{children}
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/updates/_components/variants.ts b/apps/compliance-portal/src/pages/updates/_components/variants.ts new file mode 100644 index 000000000..f4127cbe8 --- /dev/null +++ b/apps/compliance-portal/src/pages/updates/_components/variants.ts @@ -0,0 +1,48 @@ +// 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"; + +// Updates list surface: a white card holding divider-separated rows. The card +// dims while a page change is loading. Slots are shared by the list and its +// skeleton so the loading placeholder matches the real layout. +export const updatesList = tv({ + slots: { + card: "overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150", + rows: "divide-y divide-sand-a2", + }, + variants: { + busy: { + true: { card: "opacity-60" }, + false: {}, + }, + }, + defaultVariants: { + busy: false, + }, +}); + +// Update detail article layout: the toolbar row (back link + subscribe), the +// centered content column, and the article body with its gold metadata row. +// Slots are shared by the detail page and its skeleton. +export const updateArticle = tv({ + slots: { + toolbar: "flex w-full items-center justify-between gap-4", + content: "flex w-full flex-col items-center px-8 py-8", + article: "flex w-full max-w-2xl flex-col gap-4", + meta: "flex items-center gap-1.5", + metaIcon: "size-4 text-gold-9", + body: "block whitespace-pre-wrap", + }, +}); diff --git a/apps/compliance-portal/src/pages/updates/_lib/constants.ts b/apps/compliance-portal/src/pages/updates/_lib/constants.ts new file mode 100644 index 000000000..c8be0aaff --- /dev/null +++ b/apps/compliance-portal/src/pages/updates/_lib/constants.ts @@ -0,0 +1,16 @@ +// 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. + +// Page size for the cursor-paginated updates list. +export const UPDATES_PAGE_SIZE = 25; diff --git a/apps/compliance-portal/src/pages/updates/_locales/en-US.json b/apps/compliance-portal/src/pages/updates/_locales/en-US.json index 7ce5a829e..1f000e702 100644 --- a/apps/compliance-portal/src/pages/updates/_locales/en-US.json +++ b/apps/compliance-portal/src/pages/updates/_locales/en-US.json @@ -5,9 +5,5 @@ "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 index 822b89216..4e12b08b0 100644 --- a/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json +++ b/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json @@ -5,9 +5,5 @@ "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" } }