Files
Stirling-PDF/frontend/portal/src/components/NotificationsDropdown.tsx
T
Reece Browne dffc292888 I18n on portal (#6761)
## Overview

Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.

## What's included

### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.

### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.

###  CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.

## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.

## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
2026-06-22 12:57:12 +00:00

134 lines
4.4 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
import { BellIcon } from "@portal/components/icons";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchNotifications,
markAllNotificationsRead,
type Notification,
type NotificationCategory,
} from "@portal/api/notifications";
import "@portal/components/NotificationsDropdown.css";
const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
pipeline: "var(--color-blue)",
deploy: "var(--color-green)",
billing: "var(--color-amber)",
audit: "var(--color-purple)",
agent: "var(--color-cat-insurance)",
doc: "var(--color-cat-healthcare)",
};
export function NotificationsDropdown() {
const { t } = useTranslation();
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
const { data: items } = state;
const { isLoading } = useSectionFlags(state);
// Optimistically clear on "mark all read"; revert if the request fails.
const [cleared, setCleared] = useState(false);
const visible = cleared ? [] : (items ?? []);
async function onMarkAllRead() {
setCleared(true);
try {
await markAllNotificationsRead();
} catch {
setCleared(false);
}
}
const isEmpty = !isLoading && visible.length === 0;
const hasUnread = visible.length > 0;
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<button
type="button"
className="portal-header__icon-btn portal-header__icon-btn--badge"
aria-label={
hasUnread
? t("notifications.ariaLabel.unread", {
count: visible.length,
})
: t("notifications.ariaLabel.none")
}
>
<BellIcon size={16} />
{hasUnread && (
<span className="portal-header__bell-dot" aria-hidden />
)}
</button>
</Dropdown.Trigger>
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
<div className="portal-notif__header">
<span className="portal-notif__title">
{t("notifications.title")}
</span>
{hasUnread ? (
<span className="portal-notif__count">
{t("notifications.count.new", { count: visible.length })}
</span>
) : isLoading ? (
<span className="portal-notif__count portal-notif__count--quiet">
{t("notifications.count.loading")}
</span>
) : (
<span className="portal-notif__count portal-notif__count--quiet">
{t("notifications.count.allRead")}
</span>
)}
</div>
{isLoading && (
<div className="portal-notif__loading">
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title={t("notifications.empty.title")}
description={t("notifications.empty.description")}
/>
)}
{!isLoading && !isEmpty && (
<ul className="portal-notif__list">
{visible.map((item) => (
<li key={item.id} className="portal-notif__item">
<span
className="portal-notif__dot"
style={{ background: CATEGORY_COLOUR[item.category] }}
aria-hidden
/>
<div className="portal-notif__body">
<div className="portal-notif__item-title">{item.title}</div>
<div className="portal-notif__desc">{item.description}</div>
<div className="portal-notif__time">{item.time}</div>
</div>
</li>
))}
</ul>
)}
<div className="portal-notif__footer">
<button
type="button"
className="portal-notif__action"
onClick={onMarkAllRead}
disabled={!hasUnread}
>
{t("notifications.markAllRead")}
</button>
<button type="button" className="portal-notif__action">
{t("notifications.viewAll")}
</button>
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}