fix(portal/i18n): add inline default values to account-link + billing t() calls (#6842)
## Problem The account-link / billing / Usage strings migrated to i18next in #6738 call `t("key")` with **no inline default**. When no i18next instance is initialized — which is the case in **Storybook** (the preview doesn't load the portal i18n config) — or whenever a key is missing, react-i18next renders the **raw key** (e.g. `billing.walletMeter.title`) instead of English. That's why the billing stories regressed to showing keys. ## Fix Add the English string as the `t()` default value, matching the **existing portal convention** (`AuthGate`, `Header`, `Sidebar`) and the editor: - plain → `t("key", "English")` - interpolation → `t("key", "English {{var}}", { var })` - plural → `t("key", "{{count}} …", { count })` Dynamic keys resolved via data fields carry a sibling `*Default` string passed as the default: - `LINK_INFO` badge labels → `labelDefault` (`t(info.labelKey, info.labelDefault)`) - `PdfsProcessedCard` segment legend → `labelDefault` / `descDefault` Defaults were sourced **verbatim from the merged `en-US/translation.toml`**, so the TOML stays the source of truth — the inline default only fills in when the catalogue isn't loaded or lacks the key. ## Scope All strings added in #6738: 5 account-link + 12 billing components + the Usage view (157 static call sites + the `LINK_INFO` / segment dynamic ones). No new keys; no copy changes. ## Verification - `tsc -p portal/tsconfig.json` → 0 - `eslint --max-warnings=0` (changed files) → 0 - `prettier --check` → clean - portal `vitest` → **62/62 pass** No behaviour change when i18n is initialized; Storybook and any missing-key fallback now render English.
This commit is contained in:
@@ -56,7 +56,12 @@ export function AccountLinkPanel() {
|
||||
<div className="portal-link portal-link--in-settings">
|
||||
<header className="portal-link__header">
|
||||
<div>
|
||||
<p className="portal-link__page-sub">{t("accountLink.panel.sub")}</p>
|
||||
<p className="portal-link__page-sub">
|
||||
{t(
|
||||
"accountLink.panel.sub",
|
||||
"Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={
|
||||
@@ -68,7 +73,7 @@ export function AccountLinkPanel() {
|
||||
}
|
||||
size="md"
|
||||
>
|
||||
{t(LINK_INFO[linkState].labelKey)}
|
||||
{t(LINK_INFO[linkState].labelKey, LINK_INFO[linkState].labelDefault)}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
@@ -78,10 +83,13 @@ export function AccountLinkPanel() {
|
||||
<section className="portal-link__instances">
|
||||
<div className="portal-link__section-head">
|
||||
<h2 className="portal-link__section-title">
|
||||
{t("accountLink.panel.instancesTitle")}
|
||||
{t("accountLink.panel.instancesTitle", "Linked instances")}
|
||||
</h2>
|
||||
<p className="portal-link__section-sub">
|
||||
{t("accountLink.panel.instancesSub")}
|
||||
{t(
|
||||
"accountLink.panel.instancesSub",
|
||||
"Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{instancesState.loading ? (
|
||||
@@ -93,12 +101,21 @@ export function AccountLinkPanel() {
|
||||
) : instancesState.error ? (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("accountLink.panel.loadError.title")}
|
||||
title={t(
|
||||
"accountLink.panel.loadError.title",
|
||||
"Couldn't load linked instances",
|
||||
)}
|
||||
>
|
||||
{instancesState.error instanceof HttpError &&
|
||||
instancesState.error.status === 403
|
||||
? t("accountLink.panel.loadError.forbidden")
|
||||
: t("accountLink.panel.loadError.generic")}
|
||||
? t(
|
||||
"accountLink.panel.loadError.forbidden",
|
||||
"Only the team owner can view the org's linked instances.",
|
||||
)
|
||||
: t(
|
||||
"accountLink.panel.loadError.generic",
|
||||
"Couldn't load the team's linked instances. Try again in a moment.",
|
||||
)}
|
||||
</Banner>
|
||||
) : (
|
||||
<LinkedInstancesTable
|
||||
@@ -109,7 +126,13 @@ export function AccountLinkPanel() {
|
||||
)}
|
||||
|
||||
{revokeError && (
|
||||
<Banner tone="danger" title={t("accountLink.panel.revokeError")}>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
"accountLink.panel.revokeError",
|
||||
"Couldn't revoke instance",
|
||||
)}
|
||||
>
|
||||
{revokeError}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -24,30 +24,44 @@ export function LinkAccountCard({ link }: Props) {
|
||||
<div className="portal-link__card-head">
|
||||
<div>
|
||||
<span className="portal-link__eyebrow">
|
||||
{t("accountLink.card.eyebrow")}
|
||||
{t("accountLink.card.eyebrow", "Account link")}
|
||||
</span>
|
||||
<h2 className="portal-link__title">{t("accountLink.card.title")}</h2>
|
||||
<h2 className="portal-link__title">
|
||||
{t(
|
||||
"accountLink.card.title",
|
||||
"Link this org to its Stirling account",
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<StatusBadge tone={linked ? "success" : "neutral"} size="sm">
|
||||
{linked
|
||||
? t("accountLink.card.linked")
|
||||
: t("accountLink.card.notLinked")}
|
||||
? t("accountLink.card.linked", "Linked")
|
||||
: t("accountLink.card.notLinked", "Not linked")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{!link.loginConfigured && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("accountLink.card.loginNotConfigured.title")}
|
||||
title={t(
|
||||
"accountLink.card.loginNotConfigured.title",
|
||||
"SaaS login not configured",
|
||||
)}
|
||||
>
|
||||
{t("accountLink.card.loginNotConfigured.before")}{" "}
|
||||
{t("accountLink.card.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
{t("accountLink.card.loginNotConfigured.after")}
|
||||
{t(
|
||||
"accountLink.card.loginNotConfigured.after",
|
||||
"to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{link.error && (
|
||||
<Banner tone="danger" title={t("accountLink.card.error.title")}>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("accountLink.card.error.title", "Couldn't link")}
|
||||
>
|
||||
{link.error}
|
||||
</Banner>
|
||||
)}
|
||||
@@ -56,9 +70,17 @@ export function LinkAccountCard({ link }: Props) {
|
||||
<div className="portal-link__actions">
|
||||
<span className="portal-link__muted">
|
||||
{link.status?.name
|
||||
? t("accountLink.card.linkedAs", { name: link.status.name })
|
||||
: t("accountLink.card.linkedGeneric")}{" "}
|
||||
{t("accountLink.card.billingNote")}
|
||||
? t("accountLink.card.linkedAs", "Linked as {{name}}.", {
|
||||
name: link.status.name,
|
||||
})
|
||||
: t(
|
||||
"accountLink.card.linkedGeneric",
|
||||
"This instance is linked.",
|
||||
)}{" "}
|
||||
{t(
|
||||
"accountLink.card.billingNote",
|
||||
"Unattended processing bills against your org wallet.",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -66,13 +88,13 @@ export function LinkAccountCard({ link }: Props) {
|
||||
loading={linking}
|
||||
onClick={link.unlink}
|
||||
>
|
||||
{t("accountLink.card.unlink")}
|
||||
{t("accountLink.card.unlink", "Unlink")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-link__actions">
|
||||
<Button loading={linking} onClick={() => openLinkModal()}>
|
||||
{t("accountLink.card.linkButton")}
|
||||
{t("accountLink.card.linkButton", "Link your Stirling account")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -66,13 +66,19 @@ export function LinkAccountModal({
|
||||
width="md"
|
||||
title={
|
||||
reauth
|
||||
? t("accountLink.modal.reauthTitle")
|
||||
: t("accountLink.modal.linkTitle")
|
||||
? t("accountLink.modal.reauthTitle", "Sign in again")
|
||||
: t("accountLink.modal.linkTitle", "Link your Stirling account")
|
||||
}
|
||||
subtitle={
|
||||
reauth
|
||||
? t("accountLink.modal.reauthSubtitle")
|
||||
: t("accountLink.modal.linkSubtitle")
|
||||
? t(
|
||||
"accountLink.modal.reauthSubtitle",
|
||||
"Your session expired — sign back in to your Stirling account. Your instance stays linked.",
|
||||
)
|
||||
: t(
|
||||
"accountLink.modal.linkSubtitle",
|
||||
"Sign in to the account this server should bill against.",
|
||||
)
|
||||
}
|
||||
>
|
||||
{isSaasSupabaseConfigured ? (
|
||||
@@ -81,13 +87,19 @@ export function LinkAccountModal({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("accountLink.modal.loginNotConfigured.title")}
|
||||
title={t(
|
||||
"accountLink.modal.loginNotConfigured.title",
|
||||
"SaaS login not configured",
|
||||
)}
|
||||
>
|
||||
{t("accountLink.modal.loginNotConfigured.before")}{" "}
|
||||
{t("accountLink.modal.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
{t("accountLink.modal.loginNotConfigured.and")}{" "}
|
||||
{t("accountLink.modal.loginNotConfigured.and", "and")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
|
||||
{t("accountLink.modal.loginNotConfigured.after")}
|
||||
{t(
|
||||
"accountLink.modal.loginNotConfigured.after",
|
||||
"to enable in-app linking against the hosted Stirling account.",
|
||||
)}
|
||||
</Banner>
|
||||
{import.meta.env.DEV && (
|
||||
<Button
|
||||
@@ -97,7 +109,7 @@ export function LinkAccountModal({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("accountLink.modal.simulateSignIn")}
|
||||
{t("accountLink.modal.simulateSignIn", "Simulate sign-in (dev)")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,18 @@ export function LinkGate({ children, feature }: Props) {
|
||||
tone="info"
|
||||
title={
|
||||
feature
|
||||
? t("accountLink.gate.titleFeature", { feature })
|
||||
: t("accountLink.gate.title")
|
||||
? t("accountLink.gate.titleFeature", "Link to unlock {{feature}}", {
|
||||
feature,
|
||||
})
|
||||
: t("accountLink.gate.title", "Link to unlock")
|
||||
}
|
||||
description={t("accountLink.gate.description")}
|
||||
description={t(
|
||||
"accountLink.gate.description",
|
||||
"Link this org's Stirling account to use billable features.",
|
||||
)}
|
||||
action={
|
||||
<Button size="sm" onClick={() => openLinkModal()}>
|
||||
{t("accountLink.gate.action")}
|
||||
{t("accountLink.gate.action", "Link account")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -19,15 +19,20 @@ interface Props {
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null, t: TFunction): string {
|
||||
if (!iso) return t("accountLink.instances.time.never");
|
||||
if (!iso) return t("accountLink.instances.time.never", "never");
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.round(diffMs / 60_000);
|
||||
if (mins < 1) return t("accountLink.instances.time.justNow");
|
||||
if (mins < 1) return t("accountLink.instances.time.justNow", "just now");
|
||||
if (mins < 60)
|
||||
return t("accountLink.instances.time.minutesAgo", { count: mins });
|
||||
return t("accountLink.instances.time.minutesAgo", "{{count}}m ago", {
|
||||
count: mins,
|
||||
});
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return t("accountLink.instances.time.hoursAgo", { count: hrs });
|
||||
return t("accountLink.instances.time.daysAgo", {
|
||||
if (hrs < 24)
|
||||
return t("accountLink.instances.time.hoursAgo", "{{count}}h ago", {
|
||||
count: hrs,
|
||||
});
|
||||
return t("accountLink.instances.time.daysAgo", "{{count}}d ago", {
|
||||
count: Math.round(hrs / 24),
|
||||
});
|
||||
}
|
||||
@@ -42,11 +47,11 @@ export function LinkedInstancesTable({
|
||||
const cols: TableColumn<LinkedInstanceRow>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: t("accountLink.instances.columns.instance"),
|
||||
header: t("accountLink.instances.columns.instance", "Instance"),
|
||||
render: (i) => (
|
||||
<div className="portal-link__cell-stack">
|
||||
<span className="portal-link__cell-strong">
|
||||
{i.name ?? t("accountLink.instances.unnamed")}
|
||||
{i.name ?? t("accountLink.instances.unnamed", "Unnamed instance")}
|
||||
</span>
|
||||
<code className="portal-link__device-id">{i.deviceId}</code>
|
||||
</div>
|
||||
@@ -54,21 +59,21 @@ export function LinkedInstancesTable({
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("accountLink.instances.columns.status"),
|
||||
header: t("accountLink.instances.columns.status", "Status"),
|
||||
render: (i) =>
|
||||
i.revoked ? (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{t("accountLink.instances.revoked")}
|
||||
{t("accountLink.instances.revoked", "Revoked")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success" size="sm" pulse>
|
||||
{t("accountLink.instances.active")}
|
||||
{t("accountLink.instances.active", "Active")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastSeen",
|
||||
header: t("accountLink.instances.columns.lastSeen"),
|
||||
header: t("accountLink.instances.columns.lastSeen", "Last seen"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.lastSeenAt, t)}
|
||||
@@ -77,7 +82,7 @@ export function LinkedInstancesTable({
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
header: t("accountLink.instances.columns.linked"),
|
||||
header: t("accountLink.instances.columns.linked", "Linked"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.createdAt, t)}
|
||||
@@ -97,7 +102,7 @@ export function LinkedInstancesTable({
|
||||
loading={revokingId === i.instanceId}
|
||||
onClick={() => onRevoke(i)}
|
||||
>
|
||||
{t("accountLink.instances.revoke")}
|
||||
{t("accountLink.instances.revoke", "Revoke")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
@@ -108,8 +113,11 @@ export function LinkedInstancesTable({
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("accountLink.instances.empty.title")}
|
||||
description={t("accountLink.instances.empty.description")}
|
||||
title={t("accountLink.instances.empty.title", "No linked instances")}
|
||||
description={t(
|
||||
"accountLink.instances.empty.description",
|
||||
"Link this org's account, then register your self-hosted instances to see them here.",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
|
||||
@@ -15,20 +15,23 @@ export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
const body = (
|
||||
<>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.enterpriseUpsell.eyebrow")}
|
||||
{t("billing.enterpriseUpsell.eyebrow", "Volume discount · 1M+ PDFs")}
|
||||
</span>
|
||||
<div className="portal-billing__enterprise-head">
|
||||
<div>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.enterpriseUpsell.title")}
|
||||
{t("billing.enterpriseUpsell.title", "Stirling Enterprise")}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.enterpriseUpsell.description")}
|
||||
{t(
|
||||
"billing.enterpriseUpsell.description",
|
||||
"Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Destination wired when the enterprise/sales URL is confirmed. */}
|
||||
<Button variant="gradient" size="sm" disabled>
|
||||
{t("billing.enterpriseUpsell.cta")}
|
||||
{t("billing.enterpriseUpsell.cta", "Build your Enterprise quote")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -36,30 +36,39 @@ export function FreePdfEditorsCard() {
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.freeEditors.title")}{" "}
|
||||
{t("billing.freeEditors.title", "Free PDF Editors")}{" "}
|
||||
<StatusBadge tone="warning" size="sm" showDot={false}>
|
||||
{t("billing.freeEditors.previewBadge")}
|
||||
{t("billing.freeEditors.previewBadge", "Preview · sample data")}
|
||||
</StatusBadge>
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.freeEditors.subtitle")}
|
||||
{t(
|
||||
"billing.freeEditors.subtitle",
|
||||
"Deploy anywhere, for your whole team.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<MetricStrip className="portal-billing__fleet-metrics">
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.editorsDeployed")}
|
||||
label={t("billing.freeEditors.editorsDeployed", "Editors deployed")}
|
||||
value={SAMPLE.editorsDeployed}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.activeThisMonth")}
|
||||
label={t(
|
||||
"billing.freeEditors.activeThisMonth",
|
||||
"Active this month",
|
||||
)}
|
||||
value={SAMPLE.activeThisMonth}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.pdfsEdited")}
|
||||
label={t("billing.freeEditors.pdfsEdited", "PDFs edited")}
|
||||
value={SAMPLE.pdfsEdited}
|
||||
/>
|
||||
<MetricCard label={t("billing.freeEditors.cost")} value="$0" />
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.cost", "Cost")}
|
||||
value="$0"
|
||||
/>
|
||||
</MetricStrip>
|
||||
{/* Opens the Users tab with its invite-member modal (via the ?invite param). */}
|
||||
<Button
|
||||
@@ -68,7 +77,7 @@ export function FreePdfEditorsCard() {
|
||||
leadingIcon={<PersonAddIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={() => navigate("/users?invite=1")}
|
||||
>
|
||||
{t("billing.freeEditors.inviteTeammates")}
|
||||
{t("billing.freeEditors.inviteTeammates", "Invite teammates")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -35,7 +35,12 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
|
||||
function openCheckout() {
|
||||
if (wallet.teamId == null) {
|
||||
setMissingTeam(t("billing.freePlan.noTeamResolved"));
|
||||
setMissingTeam(
|
||||
t(
|
||||
"billing.freePlan.noTeamResolved",
|
||||
"No team is resolved on your wallet yet — refresh and try again.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMissingTeam(null);
|
||||
@@ -48,7 +53,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
onClick={openCheckout}
|
||||
disabled={wallet.teamId == null}
|
||||
>
|
||||
{t("billing.freePlan.switchOnProcessor")}
|
||||
{t("billing.freePlan.switchOnProcessor", "Switch on the Processor →")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
@@ -57,20 +62,20 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
{/* Current plan */}
|
||||
<div className="portal-billing__current-plan">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.freePlan.currentPlan")}
|
||||
{t("billing.freePlan.currentPlan", "Current plan")}
|
||||
</span>
|
||||
<div className="portal-billing__current-plan-row">
|
||||
<h2 className="portal-billing__current-plan-name">
|
||||
{t("billing.freePlan.planName")}
|
||||
{t("billing.freePlan.planName", "Editor")}
|
||||
</h2>
|
||||
<StatusBadge tone="success" size="sm" showDot={false}>
|
||||
{t("billing.freePlan.freeForever")}
|
||||
{t("billing.freePlan.freeForever", "Free forever")}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{t("billing.freePlan.ssoIncluded")}
|
||||
{t("billing.freePlan.ssoIncluded", "SSO included")}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="purple" size="sm" showDot={false}>
|
||||
{t("billing.freePlan.unlimitedUsers")}
|
||||
{t("billing.freePlan.unlimitedUsers", "Unlimited users")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,13 +86,22 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
<WalletMeter wallet={wallet} action={switchOnAction} />
|
||||
|
||||
{missingTeam && (
|
||||
<Banner tone="warning" title={t("billing.freePlan.checkoutErrorTitle")}>
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t(
|
||||
"billing.freePlan.checkoutErrorTitle",
|
||||
"Couldn't start checkout",
|
||||
)}
|
||||
>
|
||||
{missingTeam}
|
||||
</Banner>
|
||||
)}
|
||||
{!isLeader && (
|
||||
<p className="portal-billing__plan-readonly">
|
||||
{t("billing.freePlan.ownerOnly")}
|
||||
{t(
|
||||
"billing.freePlan.ownerOnly",
|
||||
"Only the team owner can switch on the Processor plan.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -83,13 +83,13 @@ export function InvoicesList() {
|
||||
const columns: TableColumn<Invoice>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: t("billing.invoices.columnDate"),
|
||||
header: t("billing.invoices.columnDate", "Date"),
|
||||
render: (inv) =>
|
||||
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—",
|
||||
},
|
||||
{
|
||||
key: "pdfs",
|
||||
header: t("billing.invoices.columnPdfsProcessed"),
|
||||
header: t("billing.invoices.columnPdfsProcessed", "PDFs processed"),
|
||||
align: "right",
|
||||
// Billed units on the invoice's metered line item; "—" when the
|
||||
// line-item table isn't synced into the Stripe mirror.
|
||||
@@ -98,7 +98,7 @@ export function InvoicesList() {
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: t("billing.invoices.columnAmount"),
|
||||
header: t("billing.invoices.columnAmount", "Amount"),
|
||||
align: "right",
|
||||
render: (inv) =>
|
||||
inv.totalMinor == null
|
||||
@@ -107,7 +107,7 @@ export function InvoicesList() {
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("billing.invoices.columnStatus"),
|
||||
header: t("billing.invoices.columnStatus", "Status"),
|
||||
render: (inv) => (
|
||||
<StatusBadge tone={statusTone(inv.status)} size="sm">
|
||||
{inv.status}
|
||||
@@ -116,10 +116,11 @@ export function InvoicesList() {
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: t("billing.invoices.columnDescription"),
|
||||
header: t("billing.invoices.columnDescription", "Description"),
|
||||
render: (inv) => (
|
||||
<span className="portal-billing__invoice-desc">
|
||||
{inv.description ?? t("billing.invoices.descriptionFallback")}
|
||||
{inv.description ??
|
||||
t("billing.invoices.descriptionFallback", "Invoice")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -135,11 +136,15 @@ export function InvoicesList() {
|
||||
href={inv.hostedInvoiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("billing.invoices.viewAriaLabel", {
|
||||
number: inv.number ?? inv.id,
|
||||
})}
|
||||
aria-label={t(
|
||||
"billing.invoices.viewAriaLabel",
|
||||
"View invoice {{number}} in Stripe",
|
||||
{
|
||||
number: inv.number ?? inv.id,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t("billing.invoices.viewLink")}
|
||||
{t("billing.invoices.viewLink", "View ↗")}
|
||||
</a>
|
||||
)}
|
||||
{inv.invoicePdf && (
|
||||
@@ -148,11 +153,15 @@ export function InvoicesList() {
|
||||
href={inv.invoicePdf}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("billing.invoices.downloadAriaLabel", {
|
||||
number: inv.number ?? inv.id,
|
||||
})}
|
||||
aria-label={t(
|
||||
"billing.invoices.downloadAriaLabel",
|
||||
"Download invoice {{number}} as PDF",
|
||||
{
|
||||
number: inv.number ?? inv.id,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t("billing.invoices.pdfLink")}
|
||||
{t("billing.invoices.pdfLink", "PDF ↓")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,7 +172,7 @@ export function InvoicesList() {
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.invoices.title")}
|
||||
{t("billing.invoices.title", "Invoice history")}
|
||||
</h3>
|
||||
|
||||
{invoices === null && !error && (
|
||||
@@ -176,15 +185,22 @@ export function InvoicesList() {
|
||||
|
||||
{error && (
|
||||
<p className="portal-billing__error" role="alert">
|
||||
{t("billing.invoices.loadError", { error })}
|
||||
{t(
|
||||
"billing.invoices.loadError",
|
||||
"Couldn't load invoices: {{error}}",
|
||||
{ error },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invoices !== null && invoices.length === 0 && !error && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("billing.invoices.emptyTitle")}
|
||||
description={t("billing.invoices.emptyDescription")}
|
||||
title={t("billing.invoices.emptyTitle", "No invoices yet")}
|
||||
description={t(
|
||||
"billing.invoices.emptyDescription",
|
||||
"Once your team subscribes and the first cycle closes, your invoices appear here.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -204,14 +220,28 @@ export function InvoicesList() {
|
||||
onClick={() => setShowAll((v) => !v)}
|
||||
>
|
||||
{showAll
|
||||
? t("billing.invoices.showFewer", { count: DEFAULT_VISIBLE })
|
||||
? t(
|
||||
"billing.invoices.showFewer",
|
||||
"Show fewer (top {{count}})",
|
||||
{ count: DEFAULT_VISIBLE },
|
||||
)
|
||||
: atFetchLimit
|
||||
? t("billing.invoices.showMostRecent", { count: total })
|
||||
: t("billing.invoices.showAll", { count: total })}
|
||||
? t(
|
||||
"billing.invoices.showMostRecent",
|
||||
"Show {{count}} most recent",
|
||||
{ count: total },
|
||||
)
|
||||
: t("billing.invoices.showAll", "Show all {{count}}", {
|
||||
count: total,
|
||||
})}
|
||||
</Button>
|
||||
{showAll && atFetchLimit && (
|
||||
<span className="portal-billing__invoice-note">
|
||||
{t("billing.invoices.fetchLimitNote", { count: FETCH_LIMIT })}
|
||||
{t(
|
||||
"billing.invoices.fetchLimitNote",
|
||||
"Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal.",
|
||||
{ count: FETCH_LIMIT },
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,11 +14,14 @@ export function LinkAccountPrompt() {
|
||||
<Card padding="loose">
|
||||
<EmptyState
|
||||
size="default"
|
||||
title={t("billing.linkPrompt.title")}
|
||||
description={t("billing.linkPrompt.description")}
|
||||
title={t("billing.linkPrompt.title", "Link your Stirling account")}
|
||||
description={t(
|
||||
"billing.linkPrompt.description",
|
||||
"Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use.",
|
||||
)}
|
||||
actions={
|
||||
<Button variant="gradient" onClick={() => openLinkModal()}>
|
||||
{t("billing.linkPrompt.cta")}
|
||||
{t("billing.linkPrompt.cta", "Link Stirling account")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -46,33 +46,45 @@ export function PaymentMethodCard({ onManage, managing }: Props) {
|
||||
<div className="portal-billing__subscription-head">
|
||||
<div>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.paymentMethod.eyebrow")}
|
||||
{t("billing.paymentMethod.eyebrow", "Payment method")}
|
||||
</span>
|
||||
{hasCard ? (
|
||||
<>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.paymentMethod.cardEnding", {
|
||||
brand: titleCase(
|
||||
pm.brand ?? t("billing.paymentMethod.cardFallback"),
|
||||
),
|
||||
last4: pm.last4,
|
||||
})}
|
||||
{t(
|
||||
"billing.paymentMethod.cardEnding",
|
||||
"{{brand}} ending {{last4}}",
|
||||
{
|
||||
brand: titleCase(
|
||||
pm.brand ??
|
||||
t("billing.paymentMethod.cardFallback", "Card"),
|
||||
),
|
||||
last4: pm.last4,
|
||||
},
|
||||
)}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{pm.expMonth != null && pm.expYear != null
|
||||
? t("billing.paymentMethod.expiresBilledMonthly", {
|
||||
expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`,
|
||||
})
|
||||
: t("billing.paymentMethod.billedMonthly")}
|
||||
? t(
|
||||
"billing.paymentMethod.expiresBilledMonthly",
|
||||
"Expires {{expiry}} · billed monthly",
|
||||
{
|
||||
expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`,
|
||||
},
|
||||
)
|
||||
: t("billing.paymentMethod.billedMonthly", "Billed monthly")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.paymentMethod.managedTitle")}
|
||||
{t("billing.paymentMethod.managedTitle", "Managed in Stripe")}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.paymentMethod.managedSub")}
|
||||
{t(
|
||||
"billing.paymentMethod.managedSub",
|
||||
"Your card and billing details are kept securely in Stripe's customer portal.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -83,7 +95,7 @@ export function PaymentMethodCard({ onManage, managing }: Props) {
|
||||
loading={managing}
|
||||
onClick={onManage}
|
||||
>
|
||||
{t("billing.paymentMethod.update")}
|
||||
{t("billing.paymentMethod.update", "Update")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -12,25 +12,33 @@ import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
|
||||
const SEGMENTS: ReadonlyArray<{
|
||||
key: keyof WalletCategoryBreakdown;
|
||||
labelKey: string;
|
||||
labelDefault: string;
|
||||
descKey: string;
|
||||
descDefault: string;
|
||||
cls: string;
|
||||
}> = [
|
||||
{
|
||||
key: "api",
|
||||
labelKey: "billing.pdfsProcessed.segmentApiLabel",
|
||||
labelDefault: "API",
|
||||
descKey: "billing.pdfsProcessed.segmentApiDesc",
|
||||
descDefault: "Direct API requests",
|
||||
cls: "blue",
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
labelKey: "billing.pdfsProcessed.segmentAgentsLabel",
|
||||
labelDefault: "Agents",
|
||||
descKey: "billing.pdfsProcessed.segmentAgentsDesc",
|
||||
descDefault: "AI agent actions",
|
||||
cls: "purple",
|
||||
},
|
||||
{
|
||||
key: "automation",
|
||||
labelKey: "billing.pdfsProcessed.segmentAutomationLabel",
|
||||
labelDefault: "Automation",
|
||||
descKey: "billing.pdfsProcessed.segmentAutomationDesc",
|
||||
descDefault: "Automations & pipelines",
|
||||
cls: "teal",
|
||||
},
|
||||
];
|
||||
@@ -43,14 +51,14 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.pdfsProcessed.eyebrow")}
|
||||
{t("billing.pdfsProcessed.eyebrow", "PDFs processed this period")}
|
||||
</span>
|
||||
<div className="portal-billing__bignum-row">
|
||||
<span className="portal-billing__bignum">
|
||||
{wallet.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="portal-billing__bignum-unit">
|
||||
{t("billing.pdfsProcessed.unit")}
|
||||
{t("billing.pdfsProcessed.unit", "metered PDFs")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +67,10 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
<div
|
||||
className="portal-billing__segbar"
|
||||
role="img"
|
||||
aria-label={t("billing.pdfsProcessed.segbarAriaLabel")}
|
||||
aria-label={t(
|
||||
"billing.pdfsProcessed.segbarAriaLabel",
|
||||
"Metered PDFs split by category",
|
||||
)}
|
||||
>
|
||||
{SEGMENTS.map((s) =>
|
||||
b[s.key] > 0 ? (
|
||||
@@ -79,16 +90,20 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="portal-billing__seglegend-label">
|
||||
{t(s.labelKey)}
|
||||
{t(s.labelKey, s.labelDefault)}
|
||||
</span>
|
||||
<span className="portal-billing__seglegend-val">
|
||||
{t("billing.pdfsProcessed.legendValue", {
|
||||
count: b[s.key],
|
||||
formatted: b[s.key].toLocaleString(),
|
||||
})}
|
||||
{t(
|
||||
"billing.pdfsProcessed.legendValue",
|
||||
"{{formatted}} PDFs",
|
||||
{
|
||||
count: b[s.key],
|
||||
formatted: b[s.key].toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<span className="portal-billing__seglegend-desc">
|
||||
{t(s.descKey)}
|
||||
{t(s.descKey, s.descDefault)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -96,7 +111,10 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
</>
|
||||
) : (
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.pdfsProcessed.emptyPeriod")}
|
||||
{t(
|
||||
"billing.pdfsProcessed.emptyPeriod",
|
||||
"No metered processing yet this period.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -127,10 +127,10 @@ export function SpendLimitCard({
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.spendLimit.eyebrow")}
|
||||
{t("billing.spendLimit.eyebrow", "Spend limit")}
|
||||
</span>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.spendLimit.editTitle")}
|
||||
{t("billing.spendLimit.editTitle", "Set your monthly ceiling")}
|
||||
</h3>
|
||||
|
||||
<SharedSpendCapControl
|
||||
@@ -138,7 +138,10 @@ export function SpendLimitCard({
|
||||
onChange={setDraftCap}
|
||||
pricePerDocMinor={wallet.pricePerDocMinor}
|
||||
currency={wallet.currency}
|
||||
note={t("billing.spendLimit.capControlNote")}
|
||||
note={t(
|
||||
"billing.spendLimit.capControlNote",
|
||||
"Changes apply immediately — raise or lower the ceiling any time.",
|
||||
)}
|
||||
/>
|
||||
|
||||
{proj && draftCap !== proj.suggestedMajor && (
|
||||
@@ -147,19 +150,31 @@ export function SpendLimitCard({
|
||||
className="portal-billing__suggested"
|
||||
onClick={() => setDraftCap(proj.suggestedMajor)}
|
||||
>
|
||||
{t("billing.spendLimit.useSuggested", {
|
||||
amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
|
||||
})}
|
||||
{t(
|
||||
"billing.spendLimit.useSuggested",
|
||||
"Use suggested · {{amount}} / month",
|
||||
{
|
||||
amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
|
||||
},
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="portal-billing__guardrail">
|
||||
<strong>{t("billing.spendLimit.guardrailLabel")}</strong>{" "}
|
||||
{t("billing.spendLimit.guardrailBody")}
|
||||
<strong>
|
||||
{t("billing.spendLimit.guardrailLabel", "Your guardrail:")}
|
||||
</strong>{" "}
|
||||
{t(
|
||||
"billing.spendLimit.guardrailBody",
|
||||
"a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost.",
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Banner tone="danger" title={t("billing.spendLimit.saveError")}>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("billing.spendLimit.saveError", "Couldn't save limit")}
|
||||
>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
@@ -170,10 +185,10 @@ export function SpendLimitCard({
|
||||
size="sm"
|
||||
onClick={() => onAdjustingChange(false)}
|
||||
>
|
||||
{t("billing.spendLimit.cancel")}
|
||||
{t("billing.spendLimit.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="gradient" size="sm" loading={saving} onClick={save}>
|
||||
{t("billing.spendLimit.save")}
|
||||
{t("billing.spendLimit.save", "Save limit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -194,10 +209,13 @@ export function SpendLimitCard({
|
||||
<div className="portal-billing__subscription-head">
|
||||
<div>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.spendLimit.eyebrow")}
|
||||
{t("billing.spendLimit.eyebrow", "Spend limit")}
|
||||
</span>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.spendLimit.displaySub")}
|
||||
{t(
|
||||
"billing.spendLimit.displaySub",
|
||||
"You're only billed for what you process automatically — never past the ceiling.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{isLeader && (
|
||||
@@ -206,7 +224,7 @@ export function SpendLimitCard({
|
||||
size="sm"
|
||||
onClick={() => onAdjustingChange(true)}
|
||||
>
|
||||
{t("billing.spendLimit.adjustLimit")}
|
||||
{t("billing.spendLimit.adjustLimit", "Adjust limit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,15 +239,21 @@ export function SpendLimitCard({
|
||||
capSuffix={
|
||||
capActive
|
||||
? docEstimate != null
|
||||
? t("billing.spendLimit.capSuffixWithDocs", {
|
||||
documents: docEstimate.toLocaleString(),
|
||||
})
|
||||
: t("billing.spendLimit.capSuffix")
|
||||
: t("billing.spendLimit.noCap")
|
||||
? t(
|
||||
"billing.spendLimit.capSuffixWithDocs",
|
||||
"/ month · ≈ {{documents}} documents",
|
||||
{
|
||||
documents: docEstimate.toLocaleString(),
|
||||
},
|
||||
)
|
||||
: t("billing.spendLimit.capSuffix", "/ month")
|
||||
: t("billing.spendLimit.noCap", "no cap")
|
||||
}
|
||||
statusLabel={
|
||||
capActive
|
||||
? t("billing.spendLimit.pctUsed", { pct: Math.round(pct) })
|
||||
? t("billing.spendLimit.pctUsed", "{{pct}}% used", {
|
||||
pct: Math.round(pct),
|
||||
})
|
||||
: null
|
||||
}
|
||||
showBar={capActive}
|
||||
@@ -237,21 +261,29 @@ export function SpendLimitCard({
|
||||
capActive ? (
|
||||
<>
|
||||
<span>
|
||||
{t("billing.spendLimit.usedThisMonth", {
|
||||
amount: spentLabel,
|
||||
})}
|
||||
{t(
|
||||
"billing.spendLimit.usedThisMonth",
|
||||
"{{amount}} used this month",
|
||||
{
|
||||
amount: spentLabel,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t("billing.spendLimit.remaining", {
|
||||
{t("billing.spendLimit.remaining", "{{amount}} remaining", {
|
||||
amount: formatMinor(remainingMinor, wallet.currency),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{t("billing.spendLimit.thisPeriodUncapped", {
|
||||
amount: spentLabel,
|
||||
})}
|
||||
{t(
|
||||
"billing.spendLimit.thisPeriodUncapped",
|
||||
"{{amount}} this period · uncapped",
|
||||
{
|
||||
amount: spentLabel,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -260,18 +292,24 @@ export function SpendLimitCard({
|
||||
|
||||
{proj && (
|
||||
<p className="portal-billing__projection">
|
||||
<strong>{t("billing.spendLimit.projection.label")}</strong>{" "}
|
||||
{t("billing.spendLimit.projection.body", {
|
||||
count: proj.daysToCap,
|
||||
rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
monthEnd: formatMoneyMajor(
|
||||
Math.round(proj.projectedEndMajor),
|
||||
wallet.currency,
|
||||
),
|
||||
suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
|
||||
})}
|
||||
<strong>
|
||||
{t("billing.spendLimit.projection.label", "Projected to exceed.")}
|
||||
</strong>{" "}
|
||||
{t(
|
||||
"billing.spendLimit.projection.body",
|
||||
"At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}.",
|
||||
{
|
||||
count: proj.daysToCap,
|
||||
rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
monthEnd: formatMoneyMajor(
|
||||
Math.round(proj.projectedEndMajor),
|
||||
wallet.currency,
|
||||
),
|
||||
suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {
|
||||
return (
|
||||
<Card padding="loose" className="portal-billing__spend-this-month">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.spendThisMonth.eyebrow")}
|
||||
{t("billing.spendThisMonth.eyebrow", "Spend this month")}
|
||||
</span>
|
||||
<div className="portal-billing__bignum-row">
|
||||
<span className="portal-billing__bignum">
|
||||
@@ -28,15 +28,23 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {
|
||||
</div>
|
||||
<p className="portal-billing__section-sub">
|
||||
{rateLabel
|
||||
? t("billing.spendThisMonth.processedWithRate", {
|
||||
count: wallet.billableUsed,
|
||||
formattedCount: wallet.billableUsed.toLocaleString(),
|
||||
rate: rateLabel,
|
||||
})
|
||||
: t("billing.spendThisMonth.processed", {
|
||||
count: wallet.billableUsed,
|
||||
formattedCount: wallet.billableUsed.toLocaleString(),
|
||||
})}
|
||||
? t(
|
||||
"billing.spendThisMonth.processedWithRate",
|
||||
"{{formattedCount}} PDFs processed, at {{rate}} each.",
|
||||
{
|
||||
count: wallet.billableUsed,
|
||||
formattedCount: wallet.billableUsed.toLocaleString(),
|
||||
rate: rateLabel,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"billing.spendThisMonth.processed",
|
||||
"{{formattedCount}} PDFs processed.",
|
||||
{
|
||||
count: wallet.billableUsed,
|
||||
formattedCount: wallet.billableUsed.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="portal-billing__spend-foot">
|
||||
|
||||
@@ -89,7 +89,12 @@ export function StripeCheckoutModal({
|
||||
return;
|
||||
}
|
||||
if (!session.clientSecret) {
|
||||
setError(t("billing.checkout.noClientSecret"));
|
||||
setError(
|
||||
t(
|
||||
"billing.checkout.noClientSecret",
|
||||
"Edge function returned no client_secret.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setClientSecret(session.clientSecret);
|
||||
@@ -115,21 +120,33 @@ export function StripeCheckoutModal({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={t("billing.checkout.title")}
|
||||
subtitle={t("billing.checkout.subtitle")}
|
||||
title={t("billing.checkout.title", "Turn on the Processor plan")}
|
||||
subtitle={t(
|
||||
"billing.checkout.subtitle",
|
||||
"Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.",
|
||||
)}
|
||||
>
|
||||
{!publishableKey && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("billing.checkout.notConfigured.title")}
|
||||
title={t(
|
||||
"billing.checkout.notConfigured.title",
|
||||
"Stripe not configured",
|
||||
)}
|
||||
>
|
||||
{t("billing.checkout.notConfigured.bodyBefore")}{" "}
|
||||
{t("billing.checkout.notConfigured.bodyBefore", "Set")}{" "}
|
||||
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
|
||||
{t("billing.checkout.notConfigured.bodyAfter")}
|
||||
{t(
|
||||
"billing.checkout.notConfigured.bodyAfter",
|
||||
"in the portal env to enable in-app checkout.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
{publishableKey && error && (
|
||||
<Banner tone="danger" title={t("billing.checkout.error.title")}>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("billing.checkout.error.title", "Couldn't start checkout")}
|
||||
>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -56,22 +56,35 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
|
||||
tone={state === "DEGRADED" ? "danger" : "warning"}
|
||||
title={
|
||||
state === "DEGRADED"
|
||||
? t("billing.subscribedPlan.capWarn.reachedTitle")
|
||||
: t("billing.subscribedPlan.capWarn.approachingTitle", {
|
||||
pct: Math.round(pct),
|
||||
})
|
||||
? t(
|
||||
"billing.subscribedPlan.capWarn.reachedTitle",
|
||||
"Monthly spend limit reached",
|
||||
)
|
||||
: t(
|
||||
"billing.subscribedPlan.capWarn.approachingTitle",
|
||||
"You're at {{pct}}% of your monthly spend limit",
|
||||
{
|
||||
pct: Math.round(pct),
|
||||
},
|
||||
)
|
||||
}
|
||||
action={
|
||||
isLeader ? (
|
||||
<Button size="sm" onClick={raiseLimit}>
|
||||
{t("billing.subscribedPlan.capWarn.raiseLimit")}
|
||||
{t("billing.subscribedPlan.capWarn.raiseLimit", "Raise limit")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{state === "DEGRADED"
|
||||
? t("billing.subscribedPlan.capWarn.reachedBody")
|
||||
: t("billing.subscribedPlan.capWarn.approachingBody")}
|
||||
? t(
|
||||
"billing.subscribedPlan.capWarn.reachedBody",
|
||||
"Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working.",
|
||||
)
|
||||
: t(
|
||||
"billing.subscribedPlan.capWarn.approachingBody",
|
||||
"Raise it now so automated processing never pauses.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -96,7 +109,10 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
|
||||
{portal.error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("billing.subscribedPlan.portalError.title")}
|
||||
title={t(
|
||||
"billing.subscribedPlan.portalError.title",
|
||||
"Couldn't open Stripe portal",
|
||||
)}
|
||||
>
|
||||
{portal.error}
|
||||
</Banner>
|
||||
|
||||
@@ -26,12 +26,16 @@ export function WalletMeter({ wallet, action }: Props) {
|
||||
: null;
|
||||
const title =
|
||||
rate != null
|
||||
? t("billing.walletMeter.titleWithRate", {
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
rate: formatMinor(rate, wallet.currency),
|
||||
})
|
||||
: t("billing.walletMeter.title", {
|
||||
? t(
|
||||
"billing.walletMeter.titleWithRate",
|
||||
"Process {{allowance}} PDFs free, then {{rate}}/PDF",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
rate: formatMinor(rate, wallet.currency),
|
||||
},
|
||||
)
|
||||
: t("billing.walletMeter.title", "Process {{allowance}} PDFs free", {
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
});
|
||||
@@ -41,11 +45,14 @@ export function WalletMeter({ wallet, action }: Props) {
|
||||
<div className="portal-billing__subscription-head">
|
||||
<div>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.walletMeter.eyebrow")}
|
||||
{t("billing.walletMeter.eyebrow", "Processor trial")}
|
||||
</span>
|
||||
<h2 className="portal-billing__meter-title">{title}</h2>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.walletMeter.sub")}
|
||||
{t(
|
||||
"billing.walletMeter.sub",
|
||||
"Use the PDF Editor for free. Pay to process PDFs automatically.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{action}
|
||||
@@ -55,14 +62,22 @@ export function WalletMeter({ wallet, action }: Props) {
|
||||
state={state}
|
||||
pct={pct}
|
||||
figure={wallet.billableUsed.toLocaleString()}
|
||||
capSuffix={t("billing.walletMeter.capSuffix", {
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
})}
|
||||
statusLabel={t("billing.walletMeter.statusLabel", {
|
||||
count: wallet.freeRemaining,
|
||||
remaining: wallet.freeRemaining.toLocaleString(),
|
||||
})}
|
||||
capSuffix={t(
|
||||
"billing.walletMeter.capSuffix",
|
||||
"of {{allowance}} free PDFs used",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
statusLabel={t(
|
||||
"billing.walletMeter.statusLabel",
|
||||
"{{remaining}} left",
|
||||
{
|
||||
count: wallet.freeRemaining,
|
||||
remaining: wallet.freeRemaining.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -28,15 +28,26 @@ export type LinkState = "unlinked" | "linked-free" | "linked-subscribed";
|
||||
export interface LinkInfo {
|
||||
/** i18n key for the badge label; resolve with `t()` at the call site. */
|
||||
labelKey: string;
|
||||
/** English fallback for {@link labelKey}, passed as the t() default value. */
|
||||
labelDefault: string;
|
||||
/** Whether billable features are unlocked (any linked state). */
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
export const LINK_INFO: Record<LinkState, LinkInfo> = {
|
||||
unlinked: { labelKey: "accountLink.state.unlinked", unlocked: false },
|
||||
"linked-free": { labelKey: "accountLink.state.free", unlocked: true },
|
||||
unlinked: {
|
||||
labelKey: "accountLink.state.unlinked",
|
||||
labelDefault: "Not linked",
|
||||
unlocked: false,
|
||||
},
|
||||
"linked-free": {
|
||||
labelKey: "accountLink.state.free",
|
||||
labelDefault: "Editor plan",
|
||||
unlocked: true,
|
||||
},
|
||||
"linked-subscribed": {
|
||||
labelKey: "accountLink.state.subscribed",
|
||||
labelDefault: "Processor plan",
|
||||
unlocked: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,10 +89,14 @@ export function Usage() {
|
||||
setError(e.message);
|
||||
} else if (e instanceof HttpError) {
|
||||
setError(
|
||||
t("usage.error.walletUnavailable", {
|
||||
status: e.status,
|
||||
statusText: e.statusText,
|
||||
}),
|
||||
t(
|
||||
"usage.error.walletUnavailable",
|
||||
"Wallet unavailable: {{status}} {{statusText}}",
|
||||
{
|
||||
status: e.status,
|
||||
statusText: e.statusText,
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -140,8 +144,15 @@ export function Usage() {
|
||||
<header className="portal-usage__header">
|
||||
<div className="portal-usage__header-inner">
|
||||
<div>
|
||||
<h1 className="portal-usage__title">{t("usage.title")}</h1>
|
||||
<p className="portal-usage__subtitle">{t("usage.subtitle")}</p>
|
||||
<h1 className="portal-usage__title">
|
||||
{t("usage.title", "Usage & billing")}
|
||||
</h1>
|
||||
<p className="portal-usage__subtitle">
|
||||
{t(
|
||||
"usage.subtitle",
|
||||
"Consumption, invoices, and plan management for every PDF Stirling has billed, in one console.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{wallet?.status === "subscribed" && (
|
||||
<Button
|
||||
@@ -150,7 +161,7 @@ export function Usage() {
|
||||
loading={portal.opening}
|
||||
onClick={portal.open}
|
||||
>
|
||||
{t("usage.managePayment")}
|
||||
{t("usage.managePayment", "Manage Payment")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -167,33 +178,51 @@ export function Usage() {
|
||||
)}
|
||||
|
||||
{isLinked && finalizing && (
|
||||
<Banner tone="info" title={t("usage.finalizing.title")}>
|
||||
{t("usage.finalizing.body")}
|
||||
<Banner
|
||||
tone="info"
|
||||
title={t("usage.finalizing.title", "Finalizing your subscription…")}
|
||||
>
|
||||
{t(
|
||||
"usage.finalizing.body",
|
||||
"It can take a few seconds for your subscription to activate. This page updates automatically.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && needsReauth && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t("usage.sessionExpired.title")}
|
||||
title={t("usage.sessionExpired.title", "Session expired")}
|
||||
action={
|
||||
<Button size="sm" onClick={() => openLinkModal("reauth")}>
|
||||
{t("usage.sessionExpired.action")}
|
||||
{t("usage.sessionExpired.action", "Sign in again")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{t("usage.sessionExpired.body")}
|
||||
{t(
|
||||
"usage.sessionExpired.body",
|
||||
"Your Stirling account session has expired. Sign in again to view billing — your instance stays linked.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && error && (
|
||||
<Banner tone="danger" title={t("usage.error.loadWallet")}>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("usage.error.loadWallet", "Couldn't load wallet")}
|
||||
>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && portal.error && (
|
||||
<Banner tone="danger" title={t("usage.error.openStripePortal")}>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
"usage.error.openStripePortal",
|
||||
"Couldn't open Stripe portal",
|
||||
)}
|
||||
>
|
||||
{portal.error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user