mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Portal: wire remaining hardcoded strings to i18n (#6953)
# Description of Changes - Audited every portal page/component for hardcoded UI strings not routed through `t()` - Wired the remaining ones to i18n (~80 new `portal.*` keys in `en-US/translation.toml`): - Infrastructure status/label maps (deploy, api-key, cert, key-mode, attestation, audit, model, region, environment) + API-key permissions - Procurement "Key documents" modal, editor-admin deploy targets, users seats label, pipeline output-folder placeholder - Follows the existing house pattern: label maps store i18n keys, resolved via `t(MAP[value])` at the render site - Documents CSV export now reuses the on-screen column keys, and fixes a latent bug where the exported status leaked the raw key instead of the translated label - No UI-copy change: en-US values are identical to the previously hardcoded strings; other locales fall back to en-US as before --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -6958,6 +6958,12 @@ sectionsAriaLabel = "Infrastructure sections"
|
||||
subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
|
||||
title = "Infrastructure"
|
||||
|
||||
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
|
||||
[portal.infrastructure.apiKeyPermission]
|
||||
admin = "Admin"
|
||||
read = "Read"
|
||||
write = "Write"
|
||||
|
||||
[portal.infrastructure.apiKeys]
|
||||
createKey = "Create key"
|
||||
heading = "API keys"
|
||||
@@ -6978,6 +6984,11 @@ usageToday = "Usage today"
|
||||
description = "Create a scoped key to start calling the Stirling API."
|
||||
title = "No API keys yet"
|
||||
|
||||
[portal.infrastructure.attestationLabel]
|
||||
attested = "Attested"
|
||||
inScope = "In scope"
|
||||
notApplicable = "N/A"
|
||||
|
||||
[portal.infrastructure.audit]
|
||||
filterAriaLabel = "Filter audit events by category"
|
||||
heading = "Audit logs"
|
||||
@@ -7034,6 +7045,24 @@ elevation = "Elevation"
|
||||
processing = "Processing"
|
||||
totalEvents = "Total events · 24h"
|
||||
|
||||
[portal.infrastructure.auditCatLabel]
|
||||
auth = "Auth"
|
||||
config = "Config"
|
||||
elevation = "Elevation"
|
||||
processing = "Processing"
|
||||
security = "Security"
|
||||
|
||||
[portal.infrastructure.auditStatusLabel]
|
||||
danger = "Error"
|
||||
info = "Info"
|
||||
success = "Success"
|
||||
warning = "Warning"
|
||||
|
||||
[portal.infrastructure.certLabel]
|
||||
certified = "Certified"
|
||||
inProgress = "In progress"
|
||||
notStarted = "Not started"
|
||||
|
||||
[portal.infrastructure.createKey]
|
||||
cancel = "Cancel"
|
||||
createKey = "Create key"
|
||||
@@ -7050,6 +7079,12 @@ subtitleCreated = "Copy this secret now — it won't be shown again."
|
||||
title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
|
||||
[portal.infrastructure.deployLabel]
|
||||
live = "Live"
|
||||
queued = "Queued"
|
||||
rolledBack = "Rolled back"
|
||||
rolling = "Rolling out"
|
||||
|
||||
[portal.infrastructure.deployments]
|
||||
msValue = "{{value}} ms"
|
||||
throughputValue = "{{value}}/min"
|
||||
@@ -7085,6 +7120,16 @@ subheading = "Live health for every deployed Stirling region — latency, load,
|
||||
description = "Deployed regions appear here once your workspace is provisioned."
|
||||
title = "No regions deployed"
|
||||
|
||||
[portal.infrastructure.keyLabel]
|
||||
active = "Active"
|
||||
revoked = "Revoked"
|
||||
rotateSoon = "Rotate soon"
|
||||
|
||||
[portal.infrastructure.modelLabel]
|
||||
active = "Active"
|
||||
degraded = "Degraded"
|
||||
disabled = "Disabled"
|
||||
|
||||
[portal.infrastructure.models]
|
||||
heading = "Models"
|
||||
msValue = "{{value}} ms"
|
||||
@@ -7112,6 +7157,10 @@ status = "Status"
|
||||
type = "Type"
|
||||
version = "Version"
|
||||
|
||||
[portal.infrastructure.models.cost]
|
||||
perCall = "{{price}}/call"
|
||||
perThousand = "{{price}}/1k"
|
||||
|
||||
[portal.infrastructure.models.metrics]
|
||||
activeModels = "Active models"
|
||||
avgLatency = "Avg latency"
|
||||
@@ -7135,6 +7184,17 @@ modelForAria = "Model for {{operation}}"
|
||||
operation = "Operation"
|
||||
routedTo = "Routed to"
|
||||
|
||||
[portal.infrastructure.modelTypeLabel]
|
||||
classification = "Classification"
|
||||
extraction = "Extraction"
|
||||
llm = "LLM"
|
||||
ocr = "OCR"
|
||||
|
||||
[portal.infrastructure.regionLabel]
|
||||
degraded = "Degraded"
|
||||
down = "Down"
|
||||
healthy = "Healthy"
|
||||
|
||||
[portal.infrastructure.security.access.byok]
|
||||
description = "Supply a key from your own KMS. Stirling encrypts with it but can still read."
|
||||
label = "Bring your own key (BYOK)"
|
||||
@@ -7809,6 +7869,56 @@ blurb = "Evaluate Stirling against your documents and workflows."
|
||||
gatingAction = "Build your quote"
|
||||
label = "Trial"
|
||||
|
||||
[portal.procurement.keyDocs]
|
||||
oneTimeFee = " · one-time {{amount}}"
|
||||
subtitle = "Everything for each stage of your rollout, in one place."
|
||||
title = "Key documents"
|
||||
|
||||
[portal.procurement.keyDocs.docs.baa]
|
||||
name = "Business Associate Agreement"
|
||||
sub = "HIPAA · available on request"
|
||||
|
||||
[portal.procurement.keyDocs.docs.bankTransfer]
|
||||
name = "Bank transfer instructions"
|
||||
sub = "Wire details for your AP team"
|
||||
|
||||
[portal.procurement.keyDocs.docs.coi]
|
||||
name = "Certificate of Insurance"
|
||||
sub = "Cyber + E&O · current policy"
|
||||
|
||||
[portal.procurement.keyDocs.docs.formalQuote]
|
||||
name = "Formal quote"
|
||||
sub = "Built to your volume, term, and service level"
|
||||
|
||||
[portal.procurement.keyDocs.docs.msa]
|
||||
name = "Master Services Agreement"
|
||||
sub = "One signature - MSA, order form, EULA, and DPA combined"
|
||||
|
||||
[portal.procurement.keyDocs.docs.purchaseOrder]
|
||||
name = "Purchase order"
|
||||
sub = "Issuing a PO? Upload it and we invoice against it"
|
||||
|
||||
[portal.procurement.keyDocs.docs.securityReview]
|
||||
name = "Custom security review"
|
||||
sub = "We complete your questionnaire and join your review call"
|
||||
|
||||
[portal.procurement.keyDocs.docs.soc2]
|
||||
name = "SOC 2 Type II report"
|
||||
sub = "Audited · NDA-gated"
|
||||
|
||||
[portal.procurement.keyDocs.docs.w9]
|
||||
name = "IRS Form W-9"
|
||||
sub = "Stirling PDF Inc."
|
||||
|
||||
[portal.procurement.keyDocs.groups]
|
||||
evaluation = "Supporting your evaluation"
|
||||
yourDeal = "Your deal"
|
||||
|
||||
[portal.procurement.keyDocs.status]
|
||||
action = "Action needed"
|
||||
available = "Download"
|
||||
request = "Request"
|
||||
|
||||
[portal.procurement.license]
|
||||
copied = "Copied"
|
||||
copy = "Copy key"
|
||||
@@ -8114,6 +8224,10 @@ summary = "Owns a team — manages its members' resources and shared configs."
|
||||
2 = "Portal access via the default policy"
|
||||
3 = "Everything Member can do"
|
||||
|
||||
[portal.users.seats]
|
||||
limited = "{{used}} / {{limit}}"
|
||||
unlimited = "{{used}} · Unlimited"
|
||||
|
||||
[portal.welcome]
|
||||
ariaLabel = "Welcome to Stirling PDF"
|
||||
badge = "Open-source"
|
||||
|
||||
@@ -111,10 +111,10 @@ export function RecentActivity() {
|
||||
<div className="portal-activity__subject">{event.target}</div>
|
||||
<div className="portal-activity__detail-row">
|
||||
<span className="portal-activity__detail">
|
||||
{AUDIT_CAT_LABEL[event.category]} · {event.actor}
|
||||
{t(AUDIT_CAT_LABEL[event.category])} · {event.actor}
|
||||
</span>
|
||||
<StatusBadge tone={AUDIT_TONE[event.status]} size="sm">
|
||||
{AUDIT_STATUS_LABEL[event.status]}
|
||||
{t(AUDIT_STATUS_LABEL[event.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,9 @@ export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
|
||||
<StatTile
|
||||
label={t("portal.documents.overview.action")}
|
||||
value={
|
||||
doc.product === "Editor" || !doc.action ? "Editor" : doc.action
|
||||
doc.product === "Editor" || !doc.action
|
||||
? t("portal.documents.table.editorAction")
|
||||
: doc.action
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EditorInstance,
|
||||
} from "@portal/api/editorDeploy";
|
||||
|
||||
// Deployment target names are product/brand terms, not localised.
|
||||
const TARGET_LABEL: Record<EditorInstance["target"], string> = {
|
||||
cloud: "Cloud",
|
||||
docker: "Docker",
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
</span>
|
||||
<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" : "")}
|
||||
@@ -72,7 +72,10 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.permissions.map((p) => (
|
||||
<Chip key={p} size="sm">
|
||||
{p}
|
||||
{t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
</Chip>
|
||||
))}
|
||||
</dd>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AuditTab() {
|
||||
render: (e) => (
|
||||
<div className="portal-infra__event">
|
||||
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
|
||||
{AUDIT_CAT_LABEL[e.category]}
|
||||
{t(AUDIT_CAT_LABEL[e.category])}
|
||||
</StatusBadge>
|
||||
<span>{e.action}</span>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ export function AuditTab() {
|
||||
header: t("portal.infrastructure.audit.columns.status"),
|
||||
render: (e) => (
|
||||
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
|
||||
{AUDIT_STATUS_LABEL[e.status]}
|
||||
{t(AUDIT_STATUS_LABEL[e.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -126,7 +126,10 @@ export function CreateKeyModal({
|
||||
{PERMISSION_OPTS.map((p) => (
|
||||
<Checkbox
|
||||
key={p}
|
||||
label={p}
|
||||
label={t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
checked={perms.includes(p)}
|
||||
onChange={() => togglePerm(p)}
|
||||
/>
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
DEPLOY_LABEL,
|
||||
DEPLOY_TONE,
|
||||
pct,
|
||||
REGION_LABEL,
|
||||
REGION_TONE,
|
||||
titleCase,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
export function DeploymentsTab() {
|
||||
@@ -79,7 +79,7 @@ export function DeploymentsTab() {
|
||||
size="sm"
|
||||
pulse={r.status === "healthy"}
|
||||
>
|
||||
{titleCase(r.status)}
|
||||
{t(REGION_LABEL[r.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
@@ -164,7 +164,7 @@ export function DeploymentsTab() {
|
||||
header: t("portal.infrastructure.deployments.deployColumns.status"),
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
|
||||
{DEPLOY_LABEL[d.status]}
|
||||
{t(DEPLOY_LABEL[d.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ModelsTab() {
|
||||
header: t("portal.infrastructure.models.columns.type"),
|
||||
render: (m) => (
|
||||
<Chip accent={MODEL_TYPE_TONE[m.type]} size="sm">
|
||||
{MODEL_TYPE_LABEL[m.type]}
|
||||
{t(MODEL_TYPE_LABEL[m.type])}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
@@ -70,7 +70,7 @@ export function ModelsTab() {
|
||||
size="sm"
|
||||
pulse={m.status === "active"}
|
||||
>
|
||||
{MODEL_LABEL[m.status]}
|
||||
{t(MODEL_LABEL[m.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
@@ -101,7 +101,7 @@ export function ModelsTab() {
|
||||
align: "right",
|
||||
render: (m) => (
|
||||
<span className="portal-infra__mono">
|
||||
{modelCost(m.cost, m.costUnit)}
|
||||
{modelCost(t, m.cost, m.costUnit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -259,7 +259,7 @@ export function SecurityTab() {
|
||||
<div className="portal-infra__cert-head">
|
||||
<span className="portal-infra__cell-strong">{c.name}</span>
|
||||
<StatusBadge tone={CERT_TONE[c.status]} size="sm">
|
||||
{CERT_LABEL[c.status]}
|
||||
{t(CERT_LABEL[c.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="portal-infra__cert-detail">{c.detail}</p>
|
||||
@@ -283,7 +283,7 @@ export function SecurityTab() {
|
||||
<div className="portal-infra__cert-head">
|
||||
<span className="portal-infra__cell-strong">{a.name}</span>
|
||||
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
|
||||
{ATTESTATION_LABEL[a.status]}
|
||||
{t(ATTESTATION_LABEL[a.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<Chip size="sm">{a.framework}</Chip>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ChipAccent, StatusTone } from "@app/ui";
|
||||
import type {
|
||||
ApiKeyStatus,
|
||||
@@ -30,6 +31,13 @@ export const REGION_TONE: Record<RegionStatus, StatusTone> = {
|
||||
down: "danger",
|
||||
};
|
||||
|
||||
// Label maps hold i18n keys, resolved via t(MAP[value]) at the render sites.
|
||||
export const REGION_LABEL: Record<RegionStatus, string> = {
|
||||
healthy: "portal.infrastructure.regionLabel.healthy",
|
||||
degraded: "portal.infrastructure.regionLabel.degraded",
|
||||
down: "portal.infrastructure.regionLabel.down",
|
||||
};
|
||||
|
||||
export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
|
||||
live: "success",
|
||||
rolling: "info",
|
||||
@@ -38,10 +46,10 @@ export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
|
||||
live: "Live",
|
||||
rolling: "Rolling out",
|
||||
"rolled-back": "Rolled back",
|
||||
queued: "Queued",
|
||||
live: "portal.infrastructure.deployLabel.live",
|
||||
rolling: "portal.infrastructure.deployLabel.rolling",
|
||||
"rolled-back": "portal.infrastructure.deployLabel.rolledBack",
|
||||
queued: "portal.infrastructure.deployLabel.queued",
|
||||
};
|
||||
|
||||
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
|
||||
@@ -51,9 +59,9 @@ export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
|
||||
active: "Active",
|
||||
revoked: "Revoked",
|
||||
"rotate-soon": "Rotate soon",
|
||||
active: "portal.infrastructure.keyLabel.active",
|
||||
revoked: "portal.infrastructure.keyLabel.revoked",
|
||||
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
|
||||
};
|
||||
|
||||
export const CERT_TONE: Record<CertStatus, StatusTone> = {
|
||||
@@ -63,11 +71,12 @@ export const CERT_TONE: Record<CertStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const CERT_LABEL: Record<CertStatus, string> = {
|
||||
certified: "Certified",
|
||||
"in-progress": "In progress",
|
||||
"not-started": "Not started",
|
||||
certified: "portal.infrastructure.certLabel.certified",
|
||||
"in-progress": "portal.infrastructure.certLabel.inProgress",
|
||||
"not-started": "portal.infrastructure.certLabel.notStarted",
|
||||
};
|
||||
|
||||
// Brand/acronym key-management modes are not localised.
|
||||
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
|
||||
managed: "Stirling-managed",
|
||||
byok: "BYOK",
|
||||
@@ -82,9 +91,9 @@ export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
|
||||
};
|
||||
|
||||
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
|
||||
attested: "Attested",
|
||||
"in-scope": "In scope",
|
||||
"not-applicable": "N/A",
|
||||
attested: "portal.infrastructure.attestationLabel.attested",
|
||||
"in-scope": "portal.infrastructure.attestationLabel.inScope",
|
||||
"not-applicable": "portal.infrastructure.attestationLabel.notApplicable",
|
||||
};
|
||||
|
||||
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
|
||||
@@ -102,18 +111,18 @@ export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
|
||||
|
||||
// Human labels for the status badge: danger/warning are tones, not outcomes (read "Error").
|
||||
export const AUDIT_STATUS_LABEL: Record<AuditStatus, string> = {
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
danger: "Error",
|
||||
info: "Info",
|
||||
success: "portal.infrastructure.auditStatusLabel.success",
|
||||
warning: "portal.infrastructure.auditStatusLabel.warning",
|
||||
danger: "portal.infrastructure.auditStatusLabel.danger",
|
||||
info: "portal.infrastructure.auditStatusLabel.info",
|
||||
};
|
||||
|
||||
export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
|
||||
auth: "Auth",
|
||||
config: "Config",
|
||||
elevation: "Elevation",
|
||||
processing: "Processing",
|
||||
security: "Security",
|
||||
auth: "portal.infrastructure.auditCatLabel.auth",
|
||||
config: "portal.infrastructure.auditCatLabel.config",
|
||||
elevation: "portal.infrastructure.auditCatLabel.elevation",
|
||||
processing: "portal.infrastructure.auditCatLabel.processing",
|
||||
security: "portal.infrastructure.auditCatLabel.security",
|
||||
};
|
||||
|
||||
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
|
||||
@@ -131,16 +140,16 @@ export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const MODEL_LABEL: Record<ModelStatus, string> = {
|
||||
active: "Active",
|
||||
degraded: "Degraded",
|
||||
disabled: "Disabled",
|
||||
active: "portal.infrastructure.modelLabel.active",
|
||||
degraded: "portal.infrastructure.modelLabel.degraded",
|
||||
disabled: "portal.infrastructure.modelLabel.disabled",
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
|
||||
extraction: "Extraction",
|
||||
classification: "Classification",
|
||||
ocr: "OCR",
|
||||
llm: "LLM",
|
||||
extraction: "portal.infrastructure.modelTypeLabel.extraction",
|
||||
classification: "portal.infrastructure.modelTypeLabel.classification",
|
||||
ocr: "portal.infrastructure.modelTypeLabel.ocr",
|
||||
llm: "portal.infrastructure.modelTypeLabel.llm",
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
|
||||
@@ -150,6 +159,7 @@ export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
|
||||
llm: "warning",
|
||||
};
|
||||
|
||||
// Provider names are proper nouns, not localised.
|
||||
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
|
||||
stirling: "Stirling",
|
||||
openai: "OpenAI",
|
||||
@@ -158,8 +168,14 @@ export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
|
||||
};
|
||||
|
||||
/** Render a model's cost with the unit it's billed against. */
|
||||
export function modelCost(cost: number, unit: ModelCostUnit): string {
|
||||
if (cost === 0) return "Included";
|
||||
export function modelCost(
|
||||
t: TFunction,
|
||||
cost: number,
|
||||
unit: ModelCostUnit,
|
||||
): string {
|
||||
if (cost === 0) return t("portal.infrastructure.models.metrics.included");
|
||||
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
|
||||
return unit === "per-call" ? `${price}/call` : `${price}/1k`;
|
||||
return unit === "per-call"
|
||||
? t("portal.infrastructure.models.cost.perCall", { price })
|
||||
: t("portal.infrastructure.models.cost.perThousand", { price });
|
||||
}
|
||||
|
||||
@@ -77,70 +77,76 @@ function SideModal({
|
||||
// ── Key documents ────────────────────────────────────────────────────────────
|
||||
type DocStatus = "available" | "action" | "request";
|
||||
interface DocRow {
|
||||
name: string;
|
||||
sub: string;
|
||||
nameKey: string;
|
||||
subKey: string;
|
||||
status: DocStatus;
|
||||
fee?: number;
|
||||
}
|
||||
const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
|
||||
// Static demo catalogue for the pilot; the copy lives in the locale under
|
||||
// portal.procurement.keyDocs and is resolved via t() at render time.
|
||||
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
|
||||
{
|
||||
group: "Your deal",
|
||||
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
|
||||
docs: [
|
||||
{
|
||||
name: "Formal quote",
|
||||
sub: "Built to your volume, term, and service level",
|
||||
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Master Services Agreement",
|
||||
sub: "One signature — MSA, order form, EULA, and DPA combined",
|
||||
nameKey: "portal.procurement.keyDocs.docs.msa.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.msa.sub",
|
||||
status: "action",
|
||||
},
|
||||
{
|
||||
name: "Bank transfer instructions",
|
||||
sub: "Wire details for your AP team",
|
||||
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Purchase order",
|
||||
sub: "Issuing a PO? Upload it and we invoice against it",
|
||||
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
|
||||
status: "request",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Supporting your evaluation",
|
||||
groupKey: "portal.procurement.keyDocs.groups.evaluation",
|
||||
docs: [
|
||||
{
|
||||
name: "SOC 2 Type II report",
|
||||
sub: "Audited · NDA-gated",
|
||||
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Custom security review",
|
||||
sub: "We complete your questionnaire and join your review call",
|
||||
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
|
||||
status: "request",
|
||||
fee: 5000,
|
||||
},
|
||||
{
|
||||
name: "Business Associate Agreement",
|
||||
sub: "HIPAA · available on request",
|
||||
nameKey: "portal.procurement.keyDocs.docs.baa.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.baa.sub",
|
||||
status: "request",
|
||||
fee: 2500,
|
||||
},
|
||||
{ name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
|
||||
{
|
||||
name: "Certificate of Insurance",
|
||||
sub: "Cyber + E&O · current policy",
|
||||
nameKey: "portal.procurement.keyDocs.docs.w9.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.w9.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.coi.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.coi.sub",
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const STATUS_LABEL: Record<DocStatus, string> = {
|
||||
available: "Download",
|
||||
action: "Action needed",
|
||||
request: "Request",
|
||||
available: "portal.procurement.keyDocs.status.available",
|
||||
action: "portal.procurement.keyDocs.status.action",
|
||||
request: "portal.procurement.keyDocs.status.request",
|
||||
};
|
||||
|
||||
export function KeyDocumentsModal({
|
||||
@@ -150,31 +156,36 @@ export function KeyDocumentsModal({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Key documents"
|
||||
subtitle="Everything for each stage of your rollout, in one place."
|
||||
title={t("portal.procurement.keyDocs.title")}
|
||||
subtitle={t("portal.procurement.keyDocs.subtitle")}
|
||||
>
|
||||
{STAGE_DOCS.map((g) => (
|
||||
<div key={g.group} className="portal-docs__group">
|
||||
<div className="portal-docs__group-title">{g.group}</div>
|
||||
<div key={g.groupKey} className="portal-docs__group">
|
||||
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
|
||||
<ul className="portal-docs__list">
|
||||
{g.docs.map((d) => (
|
||||
<li key={d.name} className="portal-docs__row">
|
||||
<li key={d.nameKey} className="portal-docs__row">
|
||||
<div className="portal-docs__row-text">
|
||||
<span className="portal-docs__row-name">{d.name}</span>
|
||||
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
|
||||
<span className="portal-docs__row-sub">
|
||||
{d.sub}
|
||||
{d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""}
|
||||
{t(d.subKey)}
|
||||
{d.fee
|
||||
? t("portal.procurement.keyDocs.oneTimeFee", {
|
||||
amount: `$${d.fee.toLocaleString()}`,
|
||||
})
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="portal-docs__row-action"
|
||||
data-status={d.status}
|
||||
>
|
||||
{STATUS_LABEL[d.status]}
|
||||
{t(STATUS_LABEL[d.status])}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
|
||||
@@ -58,6 +59,7 @@ export function ProcurementModal({
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +88,7 @@ export function ProcurementModal({
|
||||
type="button"
|
||||
className="portal-procmodal__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AvatarTone, ChipAccent } from "@app/ui";
|
||||
import type { Member, RoleId } from "@portal/api/users";
|
||||
import { ROLE_TONE } from "@portal/api/users";
|
||||
@@ -23,8 +24,14 @@ export function chipAccentForRole(role: RoleId): ChipAccent {
|
||||
}
|
||||
|
||||
/** Seats used / limit as display copy; null limit → "Unlimited". */
|
||||
export function seatsLabel(used: number, limit: number | null): string {
|
||||
return limit === null ? `${used} · Unlimited` : `${used} / ${limit}`;
|
||||
export function seatsLabel(
|
||||
t: TFunction,
|
||||
used: number,
|
||||
limit: number | null,
|
||||
): string {
|
||||
return limit === null
|
||||
? t("portal.users.seats.unlimited", { used })
|
||||
: t("portal.users.seats.limited", { used, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
@@ -30,20 +31,23 @@ function csvCell(value: string): string {
|
||||
}
|
||||
|
||||
/** Flatten the visible columns to CSV, matching the on-screen table. */
|
||||
function toCsv(docs: ReviewDocument[]): string {
|
||||
function toCsv(docs: ReviewDocument[], t: TFunction): string {
|
||||
const header = [
|
||||
"Document",
|
||||
"Product",
|
||||
"Pipeline / Action",
|
||||
"User",
|
||||
"Status",
|
||||
"Time",
|
||||
t("portal.documents.table.columns.document"),
|
||||
t("portal.documents.table.columns.product"),
|
||||
t("portal.documents.table.columns.action"),
|
||||
t("portal.documents.table.columns.user"),
|
||||
t("portal.documents.table.columns.status"),
|
||||
t("portal.documents.table.columns.time"),
|
||||
];
|
||||
const lines = [header.map(csvCell).join(",")];
|
||||
for (const d of docs) {
|
||||
const action = d.product === "Editor" || !d.action ? "Editor" : d.action;
|
||||
const action =
|
||||
d.product === "Editor" || !d.action
|
||||
? t("portal.documents.table.editorAction")
|
||||
: d.action;
|
||||
const status =
|
||||
DOCUMENT_STATUS_LABEL[d.status] +
|
||||
t(DOCUMENT_STATUS_LABEL[d.status]) +
|
||||
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : "");
|
||||
lines.push(
|
||||
[d.name, d.product, action, d.user || "", status, d.time]
|
||||
@@ -66,7 +70,7 @@ export function Documents() {
|
||||
const documents = state.data?.documents ?? [];
|
||||
|
||||
function exportCsv() {
|
||||
const blob = new Blob([toCsv(documents)], {
|
||||
const blob = new Blob([toCsv(documents, t)], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
Reference in New Issue
Block a user