Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/entitlement-dialog-i18n-and-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@object-ui/app-shell": patch
"@object-ui/i18n": patch
---

Localize the environment entitlement dialog and read cloud's nested error envelope.

The free-plan "Development environments are a paid feature" prompt was built from
English string literals in `entitlements.ts` — including the lowercase `your free
plan` sentence users reported (cloud#959). Both spec builders now take a translator
and resolve `environment.entitlement.*`; all ten locale packs carry the strings.
`entitlements.ts` stays dependency-free: `t` is passed in, not imported, and
defaults to the English copy with local `{{token}}` interpolation.

The dialog now renders the Console's own copy rather than the server's prose — a
control plane upgrades independently and only localizes these messages from
cloud#959 on, so preferring the server string left the reactive path English
against every older deployment.

Also fixes the reactive dialog not firing at all: cloud#948 moved coded errors into
a nested envelope (`{ success, error: { code, … } }`), and
`entitlementDialogFromError` read `code` off the top level — returning `null` for
every entitlement 403, so the upgrade dialog degraded to a generic red error toast.
Both shapes are read now.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
AlertDialogCancel,
Button,
} from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import type { EntitlementCta, EntitlementDialogSpec } from './entitlements';

export interface EntitlementDialogState {
Expand Down Expand Up @@ -78,6 +79,9 @@ interface Props {

export function EnvironmentEntitlementDialog({ state, apiBase, onOpenChange }: Props) {
const spec = state.spec;
// Title / body / CTA labels arrive already localized on the spec; the dismiss
// button is the dialog's own chrome, so it resolves its label here.
const { t } = useObjectTranslation();
return (
<AlertDialog open={state.open} onOpenChange={(open) => { if (!open) onOpenChange(false); }}>
<AlertDialogContent data-testid="environment-entitlement-dialog">
Expand All @@ -86,7 +90,7 @@ export function EnvironmentEntitlementDialog({ state, apiBase, onOpenChange }: P
<AlertDialogDescription>{spec?.message}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
<AlertDialogCancel>{t('common.close', { defaultValue: 'Close' })}</AlertDialogCancel>
{spec?.secondaryCta && (
<CtaButton cta={spec.secondaryCta} apiBase={apiBase} primary={false} onNavigate={() => onOpenChange(false)} />
)}
Expand Down
4 changes: 2 additions & 2 deletions packages/app-shell/src/environment/EnvironmentListToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
// onUpgrade sets parent state.
useEffect(() => {
if (autoRunCreate && ctaKind === 'upgrade_for_development' && entitlements) {
onUpgrade(upgradeDialogSpec(entitlements));
onUpgrade(upgradeDialogSpec(entitlements, t));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRunCreate, ctaKind]);
Expand All @@ -114,7 +114,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
)}
<Button
size="sm"
onClick={() => onUpgrade(upgradeDialogSpec(entitlements!))}
onClick={() => onUpgrade(upgradeDialogSpec(entitlements!, t))}
className="shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9"
data-testid="environment-add-upgrade"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,74 @@ describe('entitlementDialogFromError', () => {
expect(spec).not.toBeNull();
expect(spec!.code).toBe('DEV_ENV_PLAN_LOCKED');
expect(spec!.title).toBe('Development environments are a paid feature');
expect(spec!.message).toContain('paid feature'); // server message preserved
expect(spec!.message).toContain('Your free plan includes one production environment');
expect(spec!.cta).toEqual({ label: 'Upgrade plan', url: '/settings/billing' });
});

it('names a paid plan in the plan-locked copy', () => {
const spec = entitlementDialogFromError({ code: 'DEV_ENV_PLAN_LOCKED', plan: 'team' });
expect(spec!.message).toContain('Your team plan includes');
});

it('maps DEV_ENV_LIMIT to an upgrade dialog (limit-reached title)', () => {
const spec = entitlementDialogFromError({ code: 'DEV_ENV_LIMIT', upgrade_url: '/u' });
expect(spec!.title).toBe('Development environment limit reached');
expect(spec!.cta!.url).toBe('/u');
expect(spec!.message).toContain('Capacity scales with AI seats');
});

it('quotes the seat-pool usage when the server reports counts', () => {
const spec = entitlementDialogFromError({ code: 'DEV_ENV_LIMIT', current: 3, limit: 3 });
expect(spec!.message).toContain('using 3 of 3 development environments');
});

// cloud#948 nested every coded error under `error: { … }`. Reading `code` off
// the top level made this mapper return null against an up-to-date control
// plane, degrading the friendly dialog to a generic red toast.
it('reads the nested cloud#948 error envelope', () => {
const spec = entitlementDialogFromError({
success: false,
error: {
code: 'DEV_ENV_PLAN_LOCKED',
message: 'Development environments are a paid feature. …',
httpStatus: 403,
plan: 'free',
upgrade_url: '/settings/billing',
},
});
expect(spec).not.toBeNull();
expect(spec!.code).toBe('DEV_ENV_PLAN_LOCKED');
expect(spec!.cta).toEqual({ label: 'Upgrade plan', url: '/settings/billing' });
});

it('still reads the legacy flat body (older control planes)', () => {
const spec = entitlementDialogFromError({
success: false,
error: 'Development environments are a paid feature. …',
code: 'DEV_ENV_PLAN_LOCKED',
upgrade_url: '/legacy',
});
expect(spec!.cta!.url).toBe('/legacy');
});

// cloud#959 — the dialog is a paid-conversion surface, so its copy comes from
// the Console's own locale bundle rather than the (possibly older, possibly
// English-only) control plane.
it('renders localized copy when a translator is supplied, ignoring server prose', () => {
const zh = (key: string, o?: any) =>
({
'environment.entitlement.planLockedTitle': '开发环境是付费功能',
'environment.entitlement.planLockedBody': `${o?.plan}包含一个生产环境。升级后即可添加开发环境。`,
'environment.entitlement.freePlan': '免费版',
'environment.entitlement.upgradeCta': '升级套餐',
})[key] ?? key;
const spec = entitlementDialogFromError(
{ error: { code: 'DEV_ENV_PLAN_LOCKED', message: 'Development environments are a paid feature.' } },
zh,
);
expect(spec!.title).toBe('开发环境是付费功能');
expect(spec!.message).toBe('免费版包含一个生产环境。升级后即可添加开发环境。');
expect(spec!.cta!.label).toBe('升级套餐');
});

it('maps PRODUCTION_ENV_LIMIT to a contact-sales dialog (no upgrade CTA)', () => {
Expand Down Expand Up @@ -97,4 +157,32 @@ describe('upgradeDialogSpec', () => {
expect(spec.cta).toEqual({ label: 'Upgrade plan', url: '/settings/billing' });
expect(spec.message).toContain('free plan');
});

// The lowercase-`your` sentence users reported (cloud#959) came from this
// builder, not from the control plane.
it('opens the sentence with a capital letter', () => {
const spec = upgradeDialogSpec(base({ plan: 'free' }));
expect(spec.message.startsWith('Your ')).toBe(true);
});

it('reads identically to the reactive DEV_ENV_PLAN_LOCKED dialog', () => {
const proactive = upgradeDialogSpec(base({ plan: 'free', upgradeUrl: '/settings/billing' }));
const reactive = entitlementDialogFromError({
error: { code: 'DEV_ENV_PLAN_LOCKED', plan: 'free', upgrade_url: '/settings/billing' },
});
expect(reactive).toEqual(proactive);
});

it('localizes through the supplied translator', () => {
const zh = (key: string, o?: any) =>
({
'environment.entitlement.planLockedTitle': '开发环境是付费功能',
'environment.entitlement.planLockedBody': `${o?.plan}包含一个生产环境。`,
'environment.entitlement.freePlan': '免费版',
'environment.entitlement.upgradeCta': '升级套餐',
})[key] ?? key;
const spec = upgradeDialogSpec(base({ plan: 'free' }), zh);
expect(spec.title).toBe('开发环境是付费功能');
expect(spec.message).toBe('免费版包含一个生产环境。');
});
});
154 changes: 121 additions & 33 deletions packages/app-shell/src/environment/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,45 +48,122 @@ export interface EntitlementDialogSpec {
export const DEFAULT_UPGRADE_URL = '/settings/billing';

/**
* Map a cloud env-create 403 body
* (`{ error, code, upgrade_url, contact_url, plan, current, limit }`) to a
* dialog spec. Returns `null` for any non-entitlement error so the caller falls
* back to its normal error handling (a red toast). This is the safety net: it
* fires regardless of whether the up-front state-aware presentation was right.
* Minimal translator — structurally `useObjectTranslation()`'s `t`, passed IN
* rather than imported so this module stays dependency-free and the specs stay
* unit-testable without an i18n provider. Omit it and the English defaults are
* used (with `{{token}}` interpolation applied locally).
*/
export function entitlementDialogFromError(body: any): EntitlementDialogSpec | null {
const code = body?.code;
export type EntitlementTranslate = (key: string, options?: Record<string, any>) => string;

/** English default + `{{token}}` interpolation, for callers with no `t`. */
function fallbackTranslate(key: string, options?: Record<string, any>): string {
const raw = typeof options?.defaultValue === 'string' ? options.defaultValue : key;
return raw.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(options?.[name] ?? ''));
}

/**
* The plan phrase interpolated into the plan-locked copy — "free plan" /
* "team plan" in English, 「免费版」/「team 版」in Chinese.
*/
function planPhrase(t: EntitlementTranslate, plan: unknown): string {
return typeof plan === 'string' && plan && plan !== 'free'
? t('environment.entitlement.namedPlan', { plan, defaultValue: '{{plan}} plan' })
: t('environment.entitlement.freePlan', { defaultValue: 'free plan' });
}

/**
* Read the coded-error fields off a cloud error body, tolerating BOTH shapes:
*
* • nested (cloud#948 and later) — `{ success, error: { code, message, … } }`
* • flat (older control planes) — `{ success, error: '…', code, … }`
*
* The nested envelope moved `code` a level down, which made this mapper return
* `null` for every entitlement 403 — so the friendly upgrade dialog silently
* degraded to a generic red toast against an up-to-date control plane. Control
* planes upgrade independently of the Console, so both shapes stay supported.
*/
function entitlementErrorFields(body: any): any {
const nested = body?.error;
return nested && typeof nested === 'object' ? nested : body;
}

/**
* Map a cloud env-create 403 body to a dialog spec. Returns `null` for any
* non-entitlement error so the caller falls back to its normal error handling
* (a red toast). This is the safety net: it fires regardless of whether the
* up-front state-aware presentation was right.
*
* The copy is the CONSOLE's, not the server's: a control plane may be older
* than the Console (or newer), and its prose is only localized from cloud#959
* on. Rendering our own localized strings — from the same code + counts the
* server reports — keeps this dialog in the user's language either way, and
* keeps it identical to the proactive prompt in {@link upgradeDialogSpec}.
*/
export function entitlementDialogFromError(body: any, t: EntitlementTranslate = fallbackTranslate): EntitlementDialogSpec | null {
const fields = entitlementErrorFields(body);
const code = fields?.code;
if (!isEntitlementErrorCode(code)) return null;
const serverMessage =
typeof body?.error === 'string' && body.error ? body.error
: typeof body?.message === 'string' ? body.message : '';
const upgradeUrl =
typeof body?.upgrade_url === 'string' && body.upgrade_url ? body.upgrade_url : DEFAULT_UPGRADE_URL;
const contactUrl = typeof body?.contact_url === 'string' && body.contact_url ? body.contact_url : '';
typeof fields?.upgrade_url === 'string' && fields.upgrade_url ? fields.upgrade_url : DEFAULT_UPGRADE_URL;
const contactUrl = typeof fields?.contact_url === 'string' && fields.contact_url ? fields.contact_url : '';

if (code === 'PRODUCTION_ENV_LIMIT') {
return {
code,
title: 'You already have your production environment',
message:
serverMessage ||
'Each organization includes exactly one production environment. Create a separate organization for another, or contact us about an Enterprise arrangement.',
cta: contactUrl ? { label: 'Contact sales', url: contactUrl } : undefined,
title: t('environment.entitlement.productionLimitTitle', {
defaultValue: 'You already have your production environment',
}),
message: t('environment.entitlement.productionLimitBody', {
defaultValue:
'Each organization includes exactly one production environment. Create a separate organization for another, or contact us about an Enterprise arrangement.',
}),
cta: contactUrl
? { label: t('environment.entitlement.contactSalesCta', { defaultValue: 'Contact sales' }), url: contactUrl }
: undefined,
};
}

const upgradeCta = {
label: t('environment.entitlement.upgradeCta', { defaultValue: 'Upgrade plan' }),
url: upgradeUrl,
};

if (code === 'DEV_ENV_PLAN_LOCKED') {
return {
code,
title: t('environment.entitlement.planLockedTitle', {
defaultValue: 'Development environments are a paid feature',
}),
message: t('environment.entitlement.planLockedBody', {
plan: planPhrase(t, fields?.plan),
defaultValue:
'Your {{plan}} includes one production environment. Upgrade to add development environments — build in dev, then publish to production.',
}),
cta: upgradeCta,
};
}
// DEV_ENV_PLAN_LOCKED / DEV_ENV_LIMIT — both resolve via an upgrade CTA.

// DEV_ENV_LIMIT — a paid org that exhausted its seat-scaled pool. Quote the
// usage when the server reported it; the counts carry the same information
// the server's own prose did.
const used = Number(fields?.current);
const limit = Number(fields?.limit);
const hasCounts = Number.isFinite(used) && Number.isFinite(limit);
return {
code,
title:
code === 'DEV_ENV_PLAN_LOCKED'
? 'Development environments are a paid feature'
: 'Development environment limit reached',
message:
serverMessage ||
(code === 'DEV_ENV_PLAN_LOCKED'
? 'Your free plan includes one production environment. Upgrade to add development environments — build in dev, then publish to production.'
: 'Capacity scales with AI seats. Add an AI seat, or archive an unused development environment to free one up.'),
cta: { label: 'Upgrade plan', url: upgradeUrl },
title: t('environment.entitlement.limitTitle', { defaultValue: 'Development environment limit reached' }),
message: hasCounts
? t('environment.entitlement.limitBodyWithCount', {
used,
limit,
defaultValue:
'You are using {{used}} of {{limit}} development environments. Capacity scales with AI seats — add an AI seat, or archive an unused development environment to free one up.',
})
: t('environment.entitlement.limitBody', {
defaultValue:
'Capacity scales with AI seats. Add an AI seat, or archive an unused development environment to free one up.',
}),
cta: upgradeCta,
};
}

Expand Down Expand Up @@ -136,12 +213,23 @@ export function decideEnvironmentCta(state: EnvironmentEntitlementsState): Envir
* "Add environment" but development envs aren't in its plan. Mirrors the copy
* of the reactive DEV_ENV_PLAN_LOCKED error so both paths read identically.
*/
export function upgradeDialogSpec(state: EnvironmentEntitlementsState): EntitlementDialogSpec {
const planLabel = state.plan && state.plan !== 'free' ? `your ${state.plan} plan` : 'your free plan';
export function upgradeDialogSpec(
state: EnvironmentEntitlementsState,
t: EntitlementTranslate = fallbackTranslate,
): EntitlementDialogSpec {
return {
code: 'DEV_ENV_PLAN_LOCKED',
title: 'Development environments are a paid feature',
message: `${planLabel} includes one production environment. Upgrade to add development environments — build in dev, then publish to production.`,
cta: { label: 'Upgrade plan', url: state.upgradeUrl || DEFAULT_UPGRADE_URL },
title: t('environment.entitlement.planLockedTitle', {
defaultValue: 'Development environments are a paid feature',
}),
message: t('environment.entitlement.planLockedBody', {
plan: planPhrase(t, state.plan),
defaultValue:
'Your {{plan}} includes one production environment. Upgrade to add development environments — build in dev, then publish to production.',
}),
cta: {
label: t('environment.entitlement.upgradeCta', { defaultValue: 'Upgrade plan' }),
url: state.upgradeUrl || DEFAULT_UPGRADE_URL,
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ vi.mock('@object-ui/i18n', () => ({
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
}),
// The entitlement dialog localizes its own copy — stand in with the English
// defaults (+ `{{token}}` interpolation) the real `t` would resolve to.
useObjectTranslation: () => ({
t: (key: string, options?: any) =>
String(options?.defaultValue ?? key).replace(
/\{\{(\w+)\}\}/g,
(_m: string, name: string) => String(options?.[name] ?? ''),
),
}),
}));

// The client modal transport is stubbed for the same reason — importing it for
Expand Down
Loading
Loading