mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Fix missing and broken translations in Processor (#7016)
# Description of Changes <img width="385" height="92" alt="image" src="https://github.com/user-attachments/assets/7f4b1921-72e8-4c18-a4de-4b9736a5a2f1" /> Started from trying to fix this, but became a larger piece of work to find missing/broken translations in the Processor and fix as many as I could.
This commit is contained in:
@@ -6240,6 +6240,10 @@ muted = "muted"
|
||||
nameLabel = "Name"
|
||||
namePlaceholder = "e.g. Compliance escalation"
|
||||
|
||||
[portal.agentBuilder.status]
|
||||
draft = "Draft"
|
||||
published = "Published"
|
||||
|
||||
[portal.agentBuilder.tabs]
|
||||
evals = "Evals"
|
||||
scenarios = "Scenarios"
|
||||
@@ -6463,7 +6467,7 @@ install = "Install"
|
||||
usage = "Usage"
|
||||
|
||||
[portal.catalogue.detail.locked]
|
||||
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
|
||||
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
|
||||
title = "Not available on your plan"
|
||||
|
||||
[portal.catalogue.detail.preview]
|
||||
@@ -7415,6 +7419,7 @@ manual = "Manual"
|
||||
schedule = "Scheduled"
|
||||
|
||||
[portal.policies]
|
||||
defaultName = "{{category}} Policy"
|
||||
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
|
||||
title = "Policies"
|
||||
|
||||
@@ -8125,6 +8130,11 @@ chooseType = "Choose type"
|
||||
configure = "Configure"
|
||||
review = "Review & connect"
|
||||
|
||||
[portal.tier]
|
||||
enterprise = "Enterprise plan"
|
||||
free = "Editor plan"
|
||||
pro = "Processor plan"
|
||||
|
||||
[portal.usage]
|
||||
managePayment = "Manage Payment"
|
||||
subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console."
|
||||
@@ -10139,6 +10149,19 @@ resetPw = "Reset password"
|
||||
suspend = "Suspend"
|
||||
unlock = "Unlock account"
|
||||
|
||||
[users.activity]
|
||||
daysAgo = "{{count}}d ago"
|
||||
hoursAgo = "{{count}}h ago"
|
||||
justNow = "Just now"
|
||||
minutesAgo = "{{count}}m ago"
|
||||
monthsAgo_one = "{{count}} month ago"
|
||||
monthsAgo_other = "{{count}} months ago"
|
||||
never = "Never"
|
||||
weeksAgo_one = "{{count}} week ago"
|
||||
weeksAgo_other = "{{count}} weeks ago"
|
||||
yearsAgo_one = "{{count}} year ago"
|
||||
yearsAgo_other = "{{count}} years ago"
|
||||
|
||||
[users.cap]
|
||||
addProcessor = "+ Processor"
|
||||
approver = "Approves policy"
|
||||
@@ -10209,7 +10232,9 @@ by = "Invited by {{who}}"
|
||||
cancel = "Cancel"
|
||||
count = "{{count}} pending"
|
||||
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
|
||||
expires = "Expires"
|
||||
expiresInDays_one = "Expires in {{count}} day"
|
||||
expiresInDays_other = "Expires in {{count}} days"
|
||||
expiresToday = "Expires today"
|
||||
title = "Pending invitations"
|
||||
|
||||
[users.loadError]
|
||||
|
||||
@@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
|
||||
published: "portal.agentBuilder.status.published",
|
||||
draft: "portal.agentBuilder.status.draft",
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
|
||||
* palette in restricted mode so the deny list is picked from a known set
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* approach the editor uses for its own catalogue view.
|
||||
*/
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
@@ -556,17 +557,30 @@ const DEFAULT_RETRY_DELAY = 5;
|
||||
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
|
||||
type CatalogueWireBody = WirePolicy & { categoryId: string };
|
||||
|
||||
/**
|
||||
* The persisted policy name derived from its category, e.g. "Security Policy".
|
||||
* `category.label` is an i18n key, so translate it before building the name;
|
||||
* otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources
|
||||
* "Used by" pill).
|
||||
*/
|
||||
function policyDisplayName(entry: CatalogueEntry, t: TFunction): string {
|
||||
return t("portal.policies.defaultName", {
|
||||
category: t(entry.category.label),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a wire policy from a setup wizard result. */
|
||||
export function buildWireFromSetup(
|
||||
entry: CatalogueEntry,
|
||||
result: PolicySetupResult,
|
||||
t: TFunction,
|
||||
enabled = true,
|
||||
): CatalogueWireBody {
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: entry.policy?.state.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
name: policyDisplayName(entry, t),
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: result.sources,
|
||||
@@ -589,13 +603,14 @@ export function buildWireFromState(
|
||||
entry: CatalogueEntry,
|
||||
policy: DecoratedPolicy,
|
||||
enabled: boolean,
|
||||
t: TFunction,
|
||||
): CatalogueWireBody {
|
||||
const s = policy.state;
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: s.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
name: policyDisplayName(entry, t),
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: s.sources,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// The bare i18next singleton (the same instance @app/i18n initializes at
|
||||
// startup), imported directly so this data module doesn't pull i18n's
|
||||
// init side effects into unit tests that mock react-i18next.
|
||||
import i18n from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId {
|
||||
|
||||
/** A member's last-seen time as plain language; "Never" when no session is tracked. */
|
||||
function relativeTime(value: number | string | undefined): string {
|
||||
if (value === undefined || value === null) return "Never";
|
||||
if (value === undefined || value === null)
|
||||
return i18n.t("users.activity.never");
|
||||
const ts = typeof value === "string" ? Date.parse(value) : value;
|
||||
if (!Number.isFinite(ts) || ts <= 0) return "Never";
|
||||
if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never");
|
||||
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return i18n.t("users.activity.justNow");
|
||||
if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 7) return i18n.t("users.activity.daysAgo", { count: days });
|
||||
const weeks = Math.round(days / 7);
|
||||
if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
|
||||
if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`;
|
||||
if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months });
|
||||
const years = Math.round(days / 365);
|
||||
return years <= 1 ? "1 year ago" : `${years} years ago`;
|
||||
return i18n.t("users.activity.yearsAgo", { count: years });
|
||||
}
|
||||
|
||||
/** 0 / huge sentinel license values mean "no seat limit". */
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ProcessingStatusStrip() {
|
||||
style={{ background: TIER_INFO[tier].dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
{TIER_INFO[tier].label}
|
||||
{t(TIER_INFO[tier].labelKey)}
|
||||
</span>
|
||||
<span className="portal-statusstrip__sep" aria-hidden>
|
||||
·
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
@@ -52,7 +56,7 @@ export function AgentBuilderPanel({
|
||||
</div>
|
||||
<div className="portal-agents__builder-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
|
||||
{agent.status}
|
||||
{t(AGENT_STATUS_LABEL[agent.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__builder-version">
|
||||
{agent.version}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
@@ -40,7 +44,7 @@ export function AgentSelector({
|
||||
</span>
|
||||
<span className="portal-agents__selector-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
|
||||
{a.status}
|
||||
{t(AGENT_STATUS_LABEL[a.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__selector-version">{a.version}</code>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface VersionsPanelProps {
|
||||
@@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
{v.version}
|
||||
</code>
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
|
||||
{v.status}
|
||||
{t(AGENT_STATUS_LABEL[v.status])}
|
||||
</StatusBadge>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
} from "@app/ui";
|
||||
import {
|
||||
type SdkComponent,
|
||||
BILLING_UNIT_LABEL,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import { TIER_INFO } from "@portal/contexts/TierContext";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
@@ -113,7 +115,7 @@ export function ComponentDetailModal({
|
||||
title={t("portal.catalogue.detail.locked.title")}
|
||||
description={t("portal.catalogue.detail.locked.description", {
|
||||
name: component.name,
|
||||
tier: component.minTier,
|
||||
plan: t(TIER_INFO[component.minTier].labelKey),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
@@ -205,7 +207,7 @@ export function ComponentDetailModal({
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.billedOn")}
|
||||
value={component.pricing.unit}
|
||||
value={t(BILLING_UNIT_LABEL[component.pricing.unit])}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.freeQuota")}
|
||||
@@ -220,7 +222,7 @@ export function ComponentDetailModal({
|
||||
</div>
|
||||
<p className="portal-components__pricing-note">
|
||||
{t("portal.catalogue.detail.pricing.note", {
|
||||
unit: component.pricing.unit,
|
||||
unit: t(BILLING_UNIT_LABEL[component.pricing.unit]),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
rightSection={
|
||||
<span className="portal-infra__key-head-right">
|
||||
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
|
||||
{KEY_LABEL[apiKey.status]}
|
||||
{t(KEY_LABEL[apiKey.status])}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className={"portal-infra__chevron" + (open ? " is-open" : "")}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DocumentLedger({
|
||||
className="portal-proc__stage-label"
|
||||
data-current={cur || undefined}
|
||||
>
|
||||
{group.label}
|
||||
{t(group.label)}
|
||||
</span>
|
||||
{blurb && (
|
||||
<span className="portal-proc__stage-hint">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Avatar, Button } from "@app/ui";
|
||||
import type { PendingInvitation } from "@portal/api/users";
|
||||
import "@portal/views/Users.css";
|
||||
@@ -11,13 +12,15 @@ interface PendingInvitationsProps {
|
||||
|
||||
/** Human "Expires in 3 days" from an ISO expiry; empty when absent, unparseable,
|
||||
* or already past (the adapter filters expired invites, so no "expired" state). */
|
||||
function expiryLabel(iso: string | undefined, expiresWord: string): string {
|
||||
function expiryLabel(iso: string | undefined, t: TFunction): string {
|
||||
if (!iso) return "";
|
||||
const ts = Date.parse(iso);
|
||||
if (!Number.isFinite(ts) || ts <= Date.now()) return "";
|
||||
const days = Math.round((ts - Date.now()) / 86400000);
|
||||
if (days === 0) return `${expiresWord} today`;
|
||||
return `${expiresWord} in ${days === 1 ? "1 day" : `${days} days`}`;
|
||||
if (days === 0) return t("users.invites.expiresToday", "Expires today");
|
||||
return t("users.invites.expiresInDays", "Expires in {{count}} days", {
|
||||
count: days,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +33,6 @@ export function PendingInvitations({
|
||||
onCancel,
|
||||
}: PendingInvitationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const expiresWord = t("users.invites.expires", "Expires");
|
||||
return (
|
||||
<section className="portal-users__group">
|
||||
<header className="portal-users__group-head">
|
||||
@@ -50,7 +52,7 @@ export function PendingInvitations({
|
||||
</span>
|
||||
</header>
|
||||
{invitations.map((inv) => {
|
||||
const expires = expiryLabel(inv.expiresAt, expiresWord);
|
||||
const expires = expiryLabel(inv.expiresAt, t);
|
||||
return (
|
||||
<div className="portal-users__row" key={inv.id}>
|
||||
<div className="portal-users__row-main">
|
||||
|
||||
@@ -10,16 +10,20 @@ import { usePlanTier } from "@portal/contexts/usePlanTier";
|
||||
export type Tier = "free" | "pro" | "enterprise";
|
||||
|
||||
export interface TierInfo {
|
||||
label: string;
|
||||
/** i18n key for the plan label; resolve with `t()` at the call site. */
|
||||
labelKey: string;
|
||||
dotColor: string;
|
||||
}
|
||||
|
||||
export const TIER_INFO: Record<Tier, TierInfo> = {
|
||||
// Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free
|
||||
// manual-tools tier is "Editor plan"; the metered tier is "Processor plan".
|
||||
free: { label: "Editor plan", dotColor: "var(--color-text-4)" },
|
||||
pro: { label: "Processor plan", dotColor: "var(--color-blue)" },
|
||||
enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" },
|
||||
free: { labelKey: "portal.tier.free", dotColor: "var(--color-text-4)" },
|
||||
pro: { labelKey: "portal.tier.pro", dotColor: "var(--color-blue)" },
|
||||
enterprise: {
|
||||
labelKey: "portal.tier.enterprise",
|
||||
dotColor: "var(--color-purple)",
|
||||
},
|
||||
};
|
||||
|
||||
interface TierContextValue {
|
||||
|
||||
@@ -51,7 +51,7 @@ const ENTERPRISE_DEAL: Deal = {
|
||||
const ENTERPRISE_LEDGER: LedgerGroup[] = [
|
||||
{
|
||||
stage: "trial",
|
||||
label: "Trial",
|
||||
label: "portal.procurement.journeySteps.trial.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-trial-quickstart",
|
||||
@@ -71,7 +71,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
|
||||
},
|
||||
{
|
||||
stage: "quote",
|
||||
label: "Quote",
|
||||
label: "portal.procurement.journeySteps.quote.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-quote-formal",
|
||||
@@ -84,7 +84,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
|
||||
},
|
||||
{
|
||||
stage: "security",
|
||||
label: "Agreement",
|
||||
label: "portal.procurement.journeySteps.agreement.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-agreement-enterprise",
|
||||
@@ -97,7 +97,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
|
||||
},
|
||||
{
|
||||
stage: "procurement",
|
||||
label: "Payment",
|
||||
label: "portal.procurement.journeySteps.payment.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-pay-online",
|
||||
@@ -124,7 +124,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
|
||||
},
|
||||
{
|
||||
stage: "active",
|
||||
label: "Implementation",
|
||||
label: "portal.procurement.journeySteps.implementation.label",
|
||||
docs: [
|
||||
{
|
||||
id: "doc-active-playbook",
|
||||
|
||||
@@ -67,7 +67,7 @@ export function Policies() {
|
||||
) {
|
||||
setPageError(null);
|
||||
try {
|
||||
await savePolicy(buildWireFromSetup(entry, result));
|
||||
await savePolicy(buildWireFromSetup(entry, result, t));
|
||||
setWizard(null);
|
||||
setDetail(null);
|
||||
refetch();
|
||||
@@ -97,7 +97,7 @@ export function Policies() {
|
||||
if (!entry || !policy?.state.backendId) return;
|
||||
const enabled = policy.state.status === "paused";
|
||||
void runLifecycle(() =>
|
||||
savePolicy(buildWireFromState(entry, policy, enabled)),
|
||||
savePolicy(buildWireFromState(entry, policy, enabled, t)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user