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