mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
feat(cloud): show purchased plan revision alongside the latest revision
- Expose the workspace's purchased plan revision from the entitlement snapshot. - Stack the purchased revision card in front of the latest revision in the plan comparison dialog; both cards share the same layout, and the purchased card's copy and prices come from the Space revision endpoint while benefits stay on the entitlement snapshot. - Add a cancel-first tooltip for subscribing to the newer revision. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ececcb8749
commit
16cfb6f8bd
@@ -0,0 +1,37 @@
|
||||
## Cloud plan comparison
|
||||
|
||||
`EntitlementSnapshot.plan_revision` and `monthly_bonus_credits` are optional
|
||||
signed display metadata. The Space adapter opts into these fields with
|
||||
`plan_metadata=2`. `/api/v1/workspaces/current` exposes them as `current_plan`,
|
||||
alongside the snapshot's effective limits and feature switches. The existing
|
||||
Workspace permission check and entitlement resolver remain authoritative.
|
||||
Admission and quotas do not use plan names or display metadata.
|
||||
|
||||
The comparison dialog shows the purchased revision even when the catalog has
|
||||
no matching plan. It compares resource limits, retention, credits, platform
|
||||
restrictions and feature switches; identical benefits use one column even if
|
||||
the revision changed. Missing signed metadata is an explicit unavailable state.
|
||||
Purchase links for existing paid plans go to Space subscription management,
|
||||
where permissions, cancellation and revision eligibility are checked again.
|
||||
|
||||
This adds display information to an existing authenticated response, not an
|
||||
agent operation. There is no new MCP operation or billing mutation in Core.
|
||||
Roll out the matching Core snapshot model before the updated Space adapter.
|
||||
|
||||
Verification:
|
||||
|
||||
```sh
|
||||
uv run pytest tests/unit_tests/cloud/test_entitlements.py tests/integration/api/test_workspaces.py -q
|
||||
cd web
|
||||
node --test tests/unit/cloud-plans.test.mjs tests/unit/plan-revisions.test.mjs
|
||||
pnpm build
|
||||
node tests/revisions/verify.mjs
|
||||
```
|
||||
|
||||
The browser runner expects sibling `space` and `core` checkouts and uses isolated
|
||||
fixtures and mocked catalog responses. It never authenticates to real Space or
|
||||
initiates a real payment, and shuts down its own servers after the tests.
|
||||
|
||||
Purchased benefits are a card in the existing horizontally scrollable plan card area, immediately before the matching latest plan. There is no separate revision comparison panel, and historical prices are not inferred from the latest catalog.
|
||||
|
||||
Customer presentation uses subscription dates and “Latest quotas for this plan”, never revision numbers. Unrelated plans and current latest subscriptions have no comparison markers. Core reuses its normal Benefits card content without diff rows or highlighting. Internal entitlement feature keys are not rendered as product benefits. The optional signed subscription_started_at is the current subscription period start, returned only for a matching entitlement revision; it is not snapshot issuance time. Requests with plan_metadata=1 retain their previous response shape.
|
||||
@@ -160,6 +160,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
|
||||
plan_name: str | None = None
|
||||
limitation = None
|
||||
current_plan = None
|
||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||
if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None:
|
||||
entitlement = await resolver.resolve(
|
||||
@@ -167,6 +168,15 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
minimum_revision=request_context.entitlement_revision,
|
||||
)
|
||||
plan_name = entitlement.plan_name
|
||||
if entitlement.plan_revision is not None and entitlement.monthly_bonus_credits is not None:
|
||||
current_plan = {
|
||||
'name': entitlement.plan_name,
|
||||
'subscription_started_at': entitlement.subscription_started_at,
|
||||
'revision': entitlement.plan_revision,
|
||||
'monthly_bonus_credits': entitlement.monthly_bonus_credits,
|
||||
'limits': entitlement.limits,
|
||||
'entitlement_features': entitlement.features,
|
||||
}
|
||||
from ...service.workspace_limits import LIMIT_KEYS
|
||||
|
||||
limitation = {key: entitlement.limit(value) for key, value in LIMIT_KEYS.items()}
|
||||
@@ -188,6 +198,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
'current_plan': current_plan,
|
||||
'limitation': limitation,
|
||||
}
|
||||
)
|
||||
@@ -205,6 +216,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
'current_plan': current_plan,
|
||||
'limitation': limitation,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,6 +55,10 @@ class EntitlementSnapshot(pydantic.BaseModel):
|
||||
# must continue to use generic ``features`` and ``limits`` exclusively.
|
||||
plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128)
|
||||
|
||||
subscription_started_at: int | None = pydantic.Field(default=None, ge=0)
|
||||
plan_revision: int | None = pydantic.Field(default=None, ge=1)
|
||||
monthly_bonus_credits: int | None = pydantic.Field(default=None, ge=0)
|
||||
|
||||
@pydantic.field_validator('features')
|
||||
@classmethod
|
||||
def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]:
|
||||
|
||||
@@ -824,6 +824,9 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in
|
||||
not_before=1,
|
||||
expires_at=4102444800,
|
||||
plan_name='free',
|
||||
plan_revision=3,
|
||||
monthly_bonus_credits=12,
|
||||
subscription_started_at=1720000000,
|
||||
limits={'bots.max': 2, 'pipelines.max': 3, 'plugins.max': 4, 'knowledge_bases.max': 5},
|
||||
)
|
||||
|
||||
@@ -845,6 +848,10 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in
|
||||
assert current['placement_generation'] == 12
|
||||
assert current['plan_name'] == 'free'
|
||||
assert current['limitation']['max_bots'] == 2
|
||||
assert current['current_plan']['subscription_started_at'] == 1720000000
|
||||
assert current['current_plan']['revision'] == 3
|
||||
assert current['current_plan']['monthly_bonus_credits'] == 12
|
||||
assert current['current_plan']['limits']['bots.max'] == 2
|
||||
|
||||
create_workspace = await client.post(
|
||||
'/api/v1/workspaces',
|
||||
|
||||
@@ -32,12 +32,15 @@ import {
|
||||
cloudSubscriptionManagementURL,
|
||||
cloudPlanPrice,
|
||||
cloudPlanCurrencyForLanguage,
|
||||
fetchCloudPlanRevision,
|
||||
fetchPublicCloudPlans,
|
||||
localizedPlanText,
|
||||
type CloudPlanBillingCycle,
|
||||
type PublicCloudPlan,
|
||||
} from './cloud-plans';
|
||||
import { revisionRows } from './plan-revisions';
|
||||
import { supportedPlanPlatforms } from './cloud-plan-platforms';
|
||||
import PlanRevisionStack from './PlanRevisionStack';
|
||||
|
||||
function PlanIcon({ name }: { name?: string }) {
|
||||
const key = name
|
||||
@@ -68,9 +71,9 @@ function Help({ label, children }: { label: string; children: ReactNode }) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
className="flex h-5 shrink-0 items-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 whitespace-pre-line">
|
||||
@@ -84,7 +87,15 @@ function Benefits({
|
||||
plan,
|
||||
spaceURL,
|
||||
}: {
|
||||
plan: PublicCloudPlan;
|
||||
plan: Pick<
|
||||
PublicCloudPlan,
|
||||
| 'limits'
|
||||
| 'features'
|
||||
| 'disabled_adapters'
|
||||
| 'display_config'
|
||||
| 'chat_history_retention_days'
|
||||
| 'monthly_bonus_credits'
|
||||
>;
|
||||
spaceURL: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -116,37 +127,39 @@ function Benefits({
|
||||
quota_summary: (
|
||||
<>
|
||||
<Layers className="size-4 shrink-0 text-blue-500" />
|
||||
<span className="min-w-0 flex-1">{quotaLabel}</span>
|
||||
<span className="min-w-0 break-words">{quotaLabel}</span>
|
||||
<Help label={quotaLabel}>
|
||||
{text(config?.tooltips?.quota_summary) && (
|
||||
<p className="mb-2">{text(config?.tooltips?.quota_summary)}</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{Object.entries(plan.limits).map(([key, limit]) => {
|
||||
const resource = key.replace(/\.max$/, '');
|
||||
const label =
|
||||
text(config?.quotas?.[key]?.label) ||
|
||||
t(`cloudPlans.limits.${resource}`, {
|
||||
defaultValue: resource.replace(/_/g, ' '),
|
||||
});
|
||||
return (
|
||||
<li key={key}>
|
||||
<div className="flex justify-between gap-5">
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{limit < 0
|
||||
? t('cloudPlans.unlimited')
|
||||
: limit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{text(config?.quotas?.[key]?.tooltip) && (
|
||||
<p className="mt-1 opacity-75">
|
||||
{text(config?.quotas?.[key]?.tooltip)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{Object.entries(plan.limits)
|
||||
.filter(([key]) => key !== 'chat_history.retention_days')
|
||||
.map(([key, limit]) => {
|
||||
const resource = key.replace(/\.max$/, '');
|
||||
const label =
|
||||
text(config?.quotas?.[key]?.label) ||
|
||||
t(`cloudPlans.limits.${resource}`, {
|
||||
defaultValue: resource.replace(/_/g, ' '),
|
||||
});
|
||||
return (
|
||||
<li key={key}>
|
||||
<div className="flex justify-between gap-5">
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{limit < 0
|
||||
? t('cloudPlans.unlimited')
|
||||
: limit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{text(config?.quotas?.[key]?.tooltip) && (
|
||||
<p className="mt-1 opacity-75">
|
||||
{text(config?.quotas?.[key]?.tooltip)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Help>
|
||||
</>
|
||||
@@ -154,7 +167,7 @@ function Benefits({
|
||||
retention: (
|
||||
<>
|
||||
<PlanIcon name="ri-history-line" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="min-w-0 break-words">
|
||||
{t('cloudPlans.retention', {
|
||||
count: plan.chat_history_retention_days,
|
||||
})}
|
||||
@@ -167,7 +180,7 @@ function Benefits({
|
||||
monthly_credits: (
|
||||
<>
|
||||
<PlanIcon name="ri-coin-line" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="min-w-0 break-words">
|
||||
{t('cloudPlans.credits', { count: plan.monthly_bonus_credits })}
|
||||
</span>
|
||||
<Help label={t('cloudPlans.creditsLabel')}>
|
||||
@@ -179,7 +192,7 @@ function Benefits({
|
||||
supported_platforms: (
|
||||
<>
|
||||
<PlanIcon name="ri-apps-2-line" />
|
||||
<span className="min-w-0 flex-1">{t('cloudPlans.platforms')}</span>
|
||||
<span className="min-w-0 break-words">{t('cloudPlans.platforms')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
@@ -234,7 +247,7 @@ function Benefits({
|
||||
rows[`item:${index}`] = (
|
||||
<>
|
||||
<PlanIcon name={item.icon} />
|
||||
<span className="min-w-0 flex-1 whitespace-pre-line">{label}</span>
|
||||
<span className="min-w-0 whitespace-pre-line break-words">{label}</span>
|
||||
{text(item.tooltip) && <Help label={label}>{text(item.tooltip)}</Help>}
|
||||
</>
|
||||
);
|
||||
@@ -303,6 +316,94 @@ export default function CloudPlansDialog({
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [open, spaceURL, attempt]);
|
||||
const owned = workspace.current_plan;
|
||||
const latest = plans.find((plan) => plan.name === owned?.name);
|
||||
// Catalog copy of the purchased revision, so its card renders exactly like
|
||||
// the latest one. Benefits still come from the entitlement snapshot below.
|
||||
const [ownedRevision, setOwnedRevision] = useState<PublicCloudPlan | null>(
|
||||
null,
|
||||
);
|
||||
const ownedName = owned?.name;
|
||||
const ownedRevisionNumber = owned?.revision;
|
||||
const needsOwnedRevision =
|
||||
!!latest && !!ownedName && latest.revision !== ownedRevisionNumber;
|
||||
useEffect(() => {
|
||||
setOwnedRevision(null);
|
||||
if (!open || !needsOwnedRevision || !ownedName || !ownedRevisionNumber)
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
fetchCloudPlanRevision(
|
||||
spaceURL,
|
||||
ownedName,
|
||||
ownedRevisionNumber,
|
||||
controller.signal,
|
||||
)
|
||||
.then(setOwnedRevision)
|
||||
// Fall back to the snapshot-only card when the revision is unavailable.
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [open, spaceURL, needsOwnedRevision, ownedName, ownedRevisionNumber]);
|
||||
const rows = owned ? revisionRows(owned, latest) : [];
|
||||
const distinct =
|
||||
!!owned &&
|
||||
(!latest ||
|
||||
(owned.revision !== latest.revision && rows.some((row) => row.changed)));
|
||||
const entries: (PublicCloudPlan | 'owned')[] = [...plans];
|
||||
if (distinct) {
|
||||
const index = entries.findIndex(
|
||||
(plan) => plan !== 'owned' && plan.name === owned.name,
|
||||
);
|
||||
entries.splice(index < 0 ? 0 : index, 0, 'owned');
|
||||
}
|
||||
// Copy, prices and display items come from the purchased revision itself;
|
||||
// limits, platforms, retention and credits come only from the workspace
|
||||
// entitlement snapshot, never from the latest revision.
|
||||
const ownedCatalog =
|
||||
ownedRevision?.revision === owned?.revision ? ownedRevision : null;
|
||||
const ownedPlan: PublicCloudPlan | null =
|
||||
distinct && owned
|
||||
? {
|
||||
uuid: `owned-${owned.name}-${owned.revision}`,
|
||||
name: owned.name,
|
||||
revision: owned.revision,
|
||||
display_name:
|
||||
ownedCatalog?.display_name ?? latest?.display_name ?? {},
|
||||
description: ownedCatalog?.description ?? {},
|
||||
price_cny: ownedCatalog?.price_cny ?? '0',
|
||||
price_usd: ownedCatalog?.price_usd ?? '0',
|
||||
price_jpy: ownedCatalog?.price_jpy ?? '0',
|
||||
limits: owned.limits,
|
||||
features: ownedCatalog?.features ?? [],
|
||||
is_active: true,
|
||||
sort_order: latest?.sort_order ?? 0,
|
||||
highlight: ownedCatalog?.highlight ?? false,
|
||||
chat_history_retention_days:
|
||||
owned.limits['chat_history.retention_days'] ?? 30,
|
||||
monthly_bonus_credits: owned.monthly_bonus_credits,
|
||||
disabled_adapters: Object.entries(owned.entitlement_features)
|
||||
.filter(([key, enabled]) => key.startsWith('platform.') && !enabled)
|
||||
.map(([key]) => key.slice(9)),
|
||||
display_config: ownedCatalog?.display_config
|
||||
? ownedCatalog.display_config
|
||||
: latest?.display_config
|
||||
? {
|
||||
icon: latest.display_config.icon,
|
||||
order: latest.display_config.order,
|
||||
tooltips: latest.display_config.tooltips,
|
||||
quotas: latest.display_config.quotas,
|
||||
items: [],
|
||||
}
|
||||
: { items: [] },
|
||||
}
|
||||
: null;
|
||||
const subscribedOn = owned?.subscription_started_at
|
||||
? t('planRevision.subscribedOn', {
|
||||
interpolation: { escapeValue: false },
|
||||
date: new Date(owned.subscription_started_at * 1000).toLocaleDateString(
|
||||
i18n.language.replace('_', '-'),
|
||||
),
|
||||
})
|
||||
: null;
|
||||
const formatPrice = (price: number) =>
|
||||
new Intl.NumberFormat(i18n.language.replace('_', '-'), {
|
||||
style: 'currency',
|
||||
@@ -344,6 +445,15 @@ export default function CloudPlansDialog({
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
{!loading && (
|
||||
<>
|
||||
{!workspace.current_plan && (
|
||||
<p className="mb-4 text-sm" role="status">
|
||||
{t('planRevision.unknown')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-48 items-center justify-center"
|
||||
@@ -363,7 +473,7 @@ export default function CloudPlansDialog({
|
||||
{t('cloudPlans.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : !plans.length ? (
|
||||
) : !entries.length ? (
|
||||
<p className="py-10 text-center text-muted-foreground">
|
||||
{t('cloudPlans.empty')}
|
||||
</p>
|
||||
@@ -371,120 +481,197 @@ export default function CloudPlansDialog({
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 p-1 sm:grid-cols-2',
|
||||
plans.length > 2 && 'lg:grid-cols-3',
|
||||
'flex gap-4 overflow-x-auto p-1 [&>*]:w-72 [&>*]:shrink-0 [&>*]:grow',
|
||||
)}
|
||||
>
|
||||
{plans.map((plan) => {
|
||||
const current = workspace.plan_name === plan.name;
|
||||
const downgradeBlocked =
|
||||
Boolean(
|
||||
workspace.plan_name && workspace.plan_name !== 'free',
|
||||
) && plan.name === 'free';
|
||||
const price = cloudPlanPrice(plan, cycle, currency);
|
||||
const name =
|
||||
localizedPlanText(plan.display_name, i18n.language) ||
|
||||
plan.name.toUpperCase();
|
||||
return (
|
||||
<article
|
||||
key={plan.uuid}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col rounded-xl border p-5',
|
||||
current && 'border-blue-500/50 bg-blue-500/5',
|
||||
)}
|
||||
aria-label={name}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<PlanIcon name={plan.display_config?.icon} />
|
||||
{name}
|
||||
</h3>
|
||||
{current ? (
|
||||
<Badge variant="secondary">
|
||||
{t('cloudPlans.current')}
|
||||
</Badge>
|
||||
) : plan.highlight ? (
|
||||
<Badge>{t('cloudPlans.recommended')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{localizedPlanText(plan.description, i18n.language)}
|
||||
</p>
|
||||
<p className="mt-5 text-2xl font-semibold">
|
||||
{price === 0
|
||||
? t('cloudPlans.free')
|
||||
: formatPrice(price)}
|
||||
{price > 0 && (
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
/ {t(`cloudPlans.${cycle}`)}
|
||||
</span>
|
||||
{entries.map((plan) => {
|
||||
const renderCard = (
|
||||
plan: PublicCloudPlan,
|
||||
ownedCard = false,
|
||||
) => {
|
||||
const samePlan = workspace.plan_name === plan.name;
|
||||
const current =
|
||||
ownedCard || (samePlan && !distinct && !!owned);
|
||||
// Both stacked revisions share one card style; only the
|
||||
// badge text tells them apart.
|
||||
const stacked = distinct && plan.name === owned?.name;
|
||||
const downgradeBlocked =
|
||||
Boolean(
|
||||
workspace.plan_name && workspace.plan_name !== 'free',
|
||||
) && plan.name === 'free';
|
||||
const price = cloudPlanPrice(plan, cycle, currency);
|
||||
const name =
|
||||
localizedPlanText(plan.display_name, i18n.language) ||
|
||||
plan.name.toUpperCase();
|
||||
return (
|
||||
<article
|
||||
key={plan.uuid}
|
||||
data-owned-plan={ownedCard || undefined}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col rounded-xl border p-5',
|
||||
current &&
|
||||
!stacked &&
|
||||
'border-blue-500/50 bg-blue-500/5',
|
||||
)}
|
||||
</p>
|
||||
<p className="mb-5 mt-1 min-h-4 text-xs text-muted-foreground">
|
||||
{cycle === 'annual' && price > 0
|
||||
? t('cloudPlans.annualNote', {
|
||||
price: formatPrice(price / 12),
|
||||
})
|
||||
: ''}
|
||||
</p>
|
||||
<div className="flex-1">
|
||||
<Benefits plan={plan} spaceURL={spaceURL} />
|
||||
</div>
|
||||
{plan.subscription_disabled && (
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
{t('cloudPlans.subscriptionUnavailableHint')}
|
||||
</p>
|
||||
)}
|
||||
{plan.subscription_disabled && !current ? (
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
{t('cloudPlans.subscriptionUnavailable')}
|
||||
</Button>
|
||||
) : downgradeBlocked ? null : !canManage ? (
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
{t('cloudPlans.ownerOnly')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full"
|
||||
variant={plan.highlight ? 'default' : 'outline'}
|
||||
>
|
||||
<a
|
||||
href={
|
||||
current
|
||||
? cloudSubscriptionManagementURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
)
|
||||
: cloudPlanCheckoutURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
plan.name,
|
||||
cycle,
|
||||
currency,
|
||||
)
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(
|
||||
current
|
||||
? 'cloudPlans.manageSubscription'
|
||||
: 'cloudPlans.choose',
|
||||
aria-label={name}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<PlanIcon name={plan.display_config?.icon} />
|
||||
{name}
|
||||
</h3>
|
||||
{current ? (
|
||||
<Badge variant={stacked ? 'default' : 'secondary'}>
|
||||
{t('cloudPlans.current')}
|
||||
</Badge>
|
||||
) : plan.highlight ? (
|
||||
<Badge>{t('cloudPlans.recommended')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{localizedPlanText(plan.description, i18n.language)}
|
||||
{distinct &&
|
||||
!ownedCard &&
|
||||
plan.name === owned?.name && (
|
||||
<span className="block" data-latest-quota>
|
||||
{t('planRevision.latestQuota')}
|
||||
</span>
|
||||
)}
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</article>
|
||||
{ownedCard && subscribedOn && (
|
||||
<span className="block" data-subscribed-on>
|
||||
{subscribedOn}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{ownedCard && !ownedCatalog ? (
|
||||
<div className="mb-5" />
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-5 text-2xl font-semibold">
|
||||
{price === 0
|
||||
? t('cloudPlans.free')
|
||||
: formatPrice(price)}
|
||||
{price > 0 && (
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
/ {t(`cloudPlans.${cycle}`)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mb-5 mt-1 min-h-4 text-xs text-muted-foreground">
|
||||
{cycle === 'annual' && price > 0
|
||||
? t('cloudPlans.annualNote', {
|
||||
price: formatPrice(price / 12),
|
||||
})
|
||||
: ''}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Benefits plan={plan} spaceURL={spaceURL} />
|
||||
</div>
|
||||
{plan.subscription_disabled && (
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
{t('cloudPlans.subscriptionUnavailableHint')}
|
||||
</p>
|
||||
)}
|
||||
{plan.subscription_disabled && !current ? (
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
{t('cloudPlans.subscriptionUnavailable')}
|
||||
</Button>
|
||||
) : downgradeBlocked ? null : !canManage ? (
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
{t('cloudPlans.ownerOnly')}
|
||||
</Button>
|
||||
) : distinct &&
|
||||
!ownedCard &&
|
||||
plan.name === owned?.name ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full"
|
||||
variant={plan.highlight ? 'default' : 'outline'}
|
||||
>
|
||||
<a
|
||||
href={cloudSubscriptionManagementURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('cloudPlans.manageSubscription')}
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('planRevision.cancelToSubscribe')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full"
|
||||
variant={plan.highlight ? 'default' : 'outline'}
|
||||
>
|
||||
<a
|
||||
href={
|
||||
workspace.plan_name &&
|
||||
workspace.plan_name !== 'free'
|
||||
? cloudSubscriptionManagementURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
)
|
||||
: cloudPlanCheckoutURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
plan.name,
|
||||
cycle,
|
||||
currency,
|
||||
)
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(
|
||||
workspace.plan_name &&
|
||||
workspace.plan_name !== 'free'
|
||||
? 'cloudPlans.manageSubscription'
|
||||
: 'cloudPlans.choose',
|
||||
)}
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{ownedCard && !latest && (
|
||||
<p className="mt-4 text-sm">
|
||||
{t('planRevision.unavailable')}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
if (plan === 'owned')
|
||||
return ownedPlan && !latest
|
||||
? renderCard(ownedPlan, true)
|
||||
: null;
|
||||
return ownedPlan && latest === plan ? (
|
||||
<PlanRevisionStack
|
||||
key={plan.uuid}
|
||||
current={renderCard(ownedPlan, true)}
|
||||
latest={renderCard(plan)}
|
||||
/>
|
||||
) : (
|
||||
renderCard(plan)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Layer = 'current' | 'latest';
|
||||
|
||||
/**
|
||||
* Stacks the purchased revision in front of the plan's latest revision. The
|
||||
* toggle only changes which card is shown; it never changes the subscription.
|
||||
* Layers stay opaque; the back card is dimmed with an overlay instead of
|
||||
* opacity so the two cards never show through each other while swapping.
|
||||
*/
|
||||
export default function PlanRevisionStack({
|
||||
current,
|
||||
latest,
|
||||
}: {
|
||||
current: ReactNode;
|
||||
latest: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [front, setFront] = useState<Layer>('current');
|
||||
const back: Layer = front === 'current' ? 'latest' : 'current';
|
||||
const label = t(
|
||||
back === 'latest' ? 'planRevision.showLatest' : 'planRevision.showCurrent',
|
||||
);
|
||||
const layer = (key: Layer, node: ReactNode) => {
|
||||
const isFront = front === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-revision-layer={key}
|
||||
data-front={isFront || undefined}
|
||||
aria-hidden={!isFront || undefined}
|
||||
inert={!isFront}
|
||||
className={cn(
|
||||
'relative mr-8 flex min-w-0 origin-right rounded-xl bg-background transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:bg-background after:transition-opacity after:duration-500 [grid-area:stack] motion-reduce:transition-none [&>article]:flex-1',
|
||||
isFront
|
||||
? 'z-10 after:opacity-0'
|
||||
: 'pointer-events-none z-0 translate-x-8 scale-[0.95] after:opacity-50 [&>article]:shadow-none',
|
||||
)}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className='relative grid min-w-0 [grid-template-areas:"stack"]'
|
||||
data-plan-revision-stack
|
||||
data-front-layer={front}
|
||||
>
|
||||
{layer('current', current)}
|
||||
{layer('latest', latest)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-revision-toggle
|
||||
onClick={(event) => {
|
||||
setFront(back);
|
||||
// Pointer clicks should not leave the strip highlighted.
|
||||
if (event.detail > 0) event.currentTarget.blur();
|
||||
}}
|
||||
className="group absolute inset-y-2 right-0 z-20 flex w-8 flex-col items-center justify-center gap-2 rounded-r-xl text-muted-foreground outline-none transition-colors hover:bg-primary/5 hover:text-primary focus-visible:bg-primary/10 focus-visible:text-primary"
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-4 shrink-0 transition-transform group-hover:-translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium [writing-mode:vertical-rl]"
|
||||
aria-hidden
|
||||
>
|
||||
{t(
|
||||
back === 'latest'
|
||||
? 'planRevision.latestTab'
|
||||
: 'planRevision.currentTab',
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type CloudPlanBillingCycle = 'monthly' | 'annual';
|
||||
export type CloudPlanCurrency = 'CNY' | 'USD' | 'JPY';
|
||||
export type PlanLocalizedText = Partial<Record<string, string>>;
|
||||
export interface PublicCloudPlan {
|
||||
entitlement_features?: Record<string, boolean>;
|
||||
uuid: string;
|
||||
name: string;
|
||||
revision: number;
|
||||
@@ -158,3 +159,25 @@ export async function fetchPublicCloudPlans(
|
||||
}
|
||||
return plans.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}
|
||||
|
||||
// Catalog metadata (copy, prices, display items) of one historical revision.
|
||||
export async function fetchCloudPlanRevision(
|
||||
base: string,
|
||||
name: string,
|
||||
revision: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicCloudPlan> {
|
||||
const response = await fetch(
|
||||
spaceURL(
|
||||
base,
|
||||
`/api/v1/cloud/v2/plans/${encodeURIComponent(name)}/revisions/${revision}`,
|
||||
),
|
||||
{ credentials: 'omit', referrerPolicy: 'no-referrer', signal },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not load plan revision');
|
||||
const result = await response.json();
|
||||
const plan = result?.data as PublicCloudPlan | undefined;
|
||||
if (result?.code !== 0 || !plan?.name || plan.name !== name)
|
||||
throw new Error('Invalid plan revision');
|
||||
return plan;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Only trusted subscription data may populate the current side of a comparison. */
|
||||
export interface PlanRevisionBenefits {
|
||||
name: string;
|
||||
revision: number;
|
||||
limits: Record<string, number>;
|
||||
entitlement_features?: Record<string, boolean>;
|
||||
disabled_adapters?: string[];
|
||||
chat_history_retention_days?: number;
|
||||
monthly_bonus_credits?: number;
|
||||
}
|
||||
|
||||
export function benefitValues(plan: PlanRevisionBenefits) {
|
||||
const values: Record<string, number | boolean> = {};
|
||||
for (const [key, value] of Object.entries(plan.limits))
|
||||
values[`limit:${key}`] = value;
|
||||
values['limit:chat_history.retention_days'] =
|
||||
plan.limits['chat_history.retention_days'] ??
|
||||
plan.chat_history_retention_days ??
|
||||
30;
|
||||
values.monthly_credits = plan.monthly_bonus_credits ?? 0;
|
||||
for (const [key, value] of Object.entries(plan.entitlement_features ?? {}))
|
||||
values[`feature:${key}`] = value;
|
||||
for (const adapter of plan.disabled_adapters ?? [])
|
||||
values[`feature:platform.${adapter}`] = false;
|
||||
return values;
|
||||
}
|
||||
|
||||
export function revisionRows(
|
||||
current: PlanRevisionBenefits,
|
||||
latest?: PlanRevisionBenefits,
|
||||
) {
|
||||
const left = benefitValues(current);
|
||||
const right = latest ? benefitValues(latest) : {};
|
||||
return [...new Set([...Object.keys(left), ...Object.keys(right)])]
|
||||
.sort()
|
||||
.map((key) => {
|
||||
// Platform adapters are allowed unless explicitly disabled. Other feature
|
||||
// switches must be explicitly granted, matching Core's admission semantics.
|
||||
const fallback = key.startsWith('feature:platform.')
|
||||
? true
|
||||
: key.startsWith('feature:')
|
||||
? false
|
||||
: undefined;
|
||||
const before = left[key] ?? fallback;
|
||||
const after = right[key] ?? fallback;
|
||||
return { key, before, after, changed: !!latest && before !== after };
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,14 @@ export interface WorkspaceMembership {
|
||||
}
|
||||
|
||||
export interface CurrentWorkspace {
|
||||
current_plan?: {
|
||||
subscription_started_at?: number | null;
|
||||
name: string;
|
||||
revision: number;
|
||||
limits: Record<string, number>;
|
||||
entitlement_features: Record<string, boolean>;
|
||||
monthly_bonus_credits: number;
|
||||
} | null;
|
||||
workspace: Workspace;
|
||||
membership: WorkspaceMembership;
|
||||
permissions: string[];
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
import slackSetup from './slack-setup/en-US';
|
||||
import pipelineMigration from './pipeline-migration/en-US';
|
||||
const enUS = {
|
||||
planRevision: {
|
||||
subscribedOn: 'Subscribed on {{date}}',
|
||||
latestQuota: 'Latest quotas for this plan',
|
||||
showLatest: 'Show latest quotas for this plan',
|
||||
showCurrent: 'Show your current subscription',
|
||||
latestTab: 'Latest',
|
||||
currentTab: 'Current',
|
||||
cancelToSubscribe:
|
||||
'Cancel your subscription to subscribe to the latest quota next period',
|
||||
|
||||
current: 'Current subscription: {{plan}} · revision {{revision}}',
|
||||
owned: 'Purchased revision {{revision}}',
|
||||
latest: 'Latest revision {{revision}}',
|
||||
changed:
|
||||
'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
|
||||
same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
|
||||
unavailable:
|
||||
'The latest plan is unavailable. Your purchased benefits remain in effect.',
|
||||
benefit: 'Benefit',
|
||||
change: 'Changed',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
monthlyCredits: 'Monthly bonus credits',
|
||||
retention: 'Chat history retention (days)',
|
||||
unknown:
|
||||
'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
|
||||
handoff:
|
||||
'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
|
||||
cancelFirst:
|
||||
'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
|
||||
canceled:
|
||||
'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
|
||||
prepaid:
|
||||
'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
|
||||
renew: 'Renew current subscription',
|
||||
buying: 'Purchasing revision {{revision}}.',
|
||||
fields: {
|
||||
bots_max: 'Bots',
|
||||
members_max: 'Members',
|
||||
pipelines_max: 'Processors',
|
||||
plugins_max: 'Extensions',
|
||||
knowledge_bases_max: 'Knowledge bases',
|
||||
managed_sandbox_sessions_max: 'Sandbox sessions',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: 'Not available to subscribe',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
import pipelineMigration from './pipeline-migration/es-ES';
|
||||
const esES = {
|
||||
planRevision: {
|
||||
subscribedOn: 'Fecha de suscripción: {{date}}',
|
||||
latestQuota: 'Cuotas más recientes de este plan',
|
||||
showLatest: 'Ver las cuotas más recientes de este plan',
|
||||
showCurrent: 'Ver tu suscripción actual',
|
||||
latestTab: 'Más reciente',
|
||||
currentTab: 'Actual',
|
||||
cancelToSubscribe:
|
||||
'Cancela tu suscripción para acceder a las últimas cuotas el próximo período',
|
||||
|
||||
current: 'Current subscription: {{plan}} · revision {{revision}}',
|
||||
owned: 'Purchased revision {{revision}}',
|
||||
latest: 'Latest revision {{revision}}',
|
||||
changed:
|
||||
'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
|
||||
same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
|
||||
unavailable:
|
||||
'The latest plan is unavailable. Your purchased benefits remain in effect.',
|
||||
benefit: 'Benefit',
|
||||
change: 'Changed',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
monthlyCredits: 'Monthly bonus credits',
|
||||
retention: 'Chat history retention (days)',
|
||||
unknown:
|
||||
'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
|
||||
handoff:
|
||||
'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
|
||||
cancelFirst:
|
||||
'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
|
||||
canceled:
|
||||
'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
|
||||
prepaid:
|
||||
'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
|
||||
renew: 'Renovar la suscripción actual',
|
||||
buying: 'Purchasing revision {{revision}}.',
|
||||
fields: {
|
||||
bots_max: 'Bots',
|
||||
members_max: 'Members',
|
||||
pipelines_max: 'Processors',
|
||||
plugins_max: 'Extensions',
|
||||
knowledge_bases_max: 'Knowledge bases',
|
||||
managed_sandbox_sessions_max: 'Sandbox sessions',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: 'Suscripción no disponible',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
import slackSetup from './slack-setup/ja-JP';
|
||||
import pipelineMigration from './pipeline-migration/ja-JP';
|
||||
const jaJP = {
|
||||
planRevision: {
|
||||
subscribedOn: '契約日:{{date}}',
|
||||
latestQuota: 'このプランの最新の利用枠',
|
||||
showLatest: 'このプランの最新クォータを表示',
|
||||
showCurrent: '現在のサブスクリプションを表示',
|
||||
latestTab: '最新',
|
||||
currentTab: '現在',
|
||||
cancelToSubscribe:
|
||||
'現在のサブスクリプションをキャンセルすると、次期から最新のクォータに登録できます',
|
||||
|
||||
current: '現在の契約:{{plan}} · revision {{revision}}',
|
||||
owned: '購入済み revision {{revision}}',
|
||||
latest: '最新 revision {{revision}}',
|
||||
changed:
|
||||
'購入済みの特典と最新バージョンに違いがあります。変更箇所を強調表示しています。',
|
||||
same: '特典は最新 revision {{revision}} と同じです。購入済みバージョンは変更されません。',
|
||||
unavailable:
|
||||
'最新プランは現在利用できません。購入済みの特典は引き続き有効です。',
|
||||
benefit: '特典',
|
||||
change: '変更あり',
|
||||
enabled: '有効',
|
||||
disabled: '無効',
|
||||
monthlyCredits: '毎月のボーナスクレジット',
|
||||
retention: 'チャット履歴の保存日数',
|
||||
unknown:
|
||||
'現在の契約情報を取得できません。再読み込みするか Space で契約を管理してください。最新カタログの特典は購入済み特典ではありません。',
|
||||
handoff:
|
||||
'Space で契約を管理してください。新バージョンの購入前に自動更新を解除してください。前払い契約は期限前に更新しなければ自動終了します。',
|
||||
cancelFirst:
|
||||
'まず契約管理で自動更新を解除してください。支払済みの特典は期間終了まで保持され、返金はありません。解除の確認後、最新バージョンを購入でき、決済成功時に新しい特典が有効になります。',
|
||||
canceled:
|
||||
'更新は解除済みです。支払済みの特典は期間終了まで保持され、返金はありません。新しい購入の特典は決済成功時に有効になります。',
|
||||
prepaid:
|
||||
'この前払い契約は期間終了時に自動終了します。期限前は現在の内容で更新でき、期限後は各プランの最新の利用枠でのみ契約できます。',
|
||||
renew: '現在の契約を更新',
|
||||
buying: 'revision {{revision}} を購入します。',
|
||||
fields: {
|
||||
bots_max: 'ボット',
|
||||
members_max: 'メンバー',
|
||||
pipelines_max: 'プロセッサー',
|
||||
plugins_max: '拡張機能',
|
||||
knowledge_bases_max: 'ナレッジベース',
|
||||
managed_sandbox_sessions_max: 'サンドボックスセッション',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: '現在お申し込みできません',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
import pipelineMigration from './pipeline-migration/ru-RU';
|
||||
const ruRU = {
|
||||
planRevision: {
|
||||
subscribedOn: 'Дата подписки: {{date}}',
|
||||
latestQuota: 'Актуальные квоты этого плана',
|
||||
showLatest: 'Показать актуальные квоты этого тарифа',
|
||||
showCurrent: 'Показать текущую подписку',
|
||||
latestTab: 'Новые',
|
||||
currentTab: 'Текущая',
|
||||
cancelToSubscribe:
|
||||
'Отмените подписку, чтобы получить доступ к новым квотам со следующего периода',
|
||||
|
||||
current: 'Current subscription: {{plan}} · revision {{revision}}',
|
||||
owned: 'Purchased revision {{revision}}',
|
||||
latest: 'Latest revision {{revision}}',
|
||||
changed:
|
||||
'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
|
||||
same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
|
||||
unavailable:
|
||||
'The latest plan is unavailable. Your purchased benefits remain in effect.',
|
||||
benefit: 'Benefit',
|
||||
change: 'Changed',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
monthlyCredits: 'Monthly bonus credits',
|
||||
retention: 'Chat history retention (days)',
|
||||
unknown:
|
||||
'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
|
||||
handoff:
|
||||
'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
|
||||
cancelFirst:
|
||||
'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
|
||||
canceled:
|
||||
'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
|
||||
prepaid:
|
||||
'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
|
||||
renew: 'Продлить текущую подписку',
|
||||
buying: 'Purchasing revision {{revision}}.',
|
||||
fields: {
|
||||
bots_max: 'Bots',
|
||||
members_max: 'Members',
|
||||
pipelines_max: 'Processors',
|
||||
plugins_max: 'Extensions',
|
||||
knowledge_bases_max: 'Knowledge bases',
|
||||
managed_sandbox_sessions_max: 'Sandbox sessions',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: 'Подписка недоступна',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
import pipelineMigration from './pipeline-migration/th-TH';
|
||||
const thTH = {
|
||||
planRevision: {
|
||||
subscribedOn: 'วันที่สมัคร: {{date}}',
|
||||
latestQuota: 'โควตาล่าสุดของแผนนี้',
|
||||
showLatest: 'ดูโควตาล่าสุดของแผนนี้',
|
||||
showCurrent: 'ดูการสมัครสมาชิกปัจจุบัน',
|
||||
latestTab: 'ล่าสุด',
|
||||
currentTab: 'ปัจจุบัน',
|
||||
cancelToSubscribe: 'ยกเลิกการสมัครสมาชิกเพื่อรับโควต้าล่าสุดในรอบถัดไป',
|
||||
|
||||
current: 'Current subscription: {{plan}} · revision {{revision}}',
|
||||
owned: 'Purchased revision {{revision}}',
|
||||
latest: 'Latest revision {{revision}}',
|
||||
changed:
|
||||
'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
|
||||
same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
|
||||
unavailable:
|
||||
'The latest plan is unavailable. Your purchased benefits remain in effect.',
|
||||
benefit: 'Benefit',
|
||||
change: 'Changed',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
monthlyCredits: 'Monthly bonus credits',
|
||||
retention: 'Chat history retention (days)',
|
||||
unknown:
|
||||
'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
|
||||
handoff:
|
||||
'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
|
||||
cancelFirst:
|
||||
'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
|
||||
canceled:
|
||||
'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
|
||||
prepaid:
|
||||
'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
|
||||
renew: 'ต่ออายุการสมัครปัจจุบัน',
|
||||
buying: 'Purchasing revision {{revision}}.',
|
||||
fields: {
|
||||
bots_max: 'Bots',
|
||||
members_max: 'Members',
|
||||
pipelines_max: 'Processors',
|
||||
plugins_max: 'Extensions',
|
||||
knowledge_bases_max: 'Knowledge bases',
|
||||
managed_sandbox_sessions_max: 'Sandbox sessions',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: 'ยังไม่เปิดรับการสมัคร',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
import pipelineMigration from './pipeline-migration/vi-VN';
|
||||
const viVN = {
|
||||
planRevision: {
|
||||
subscribedOn: 'Ngày đăng ký: {{date}}',
|
||||
latestQuota: 'Hạn mức mới nhất của gói này',
|
||||
showLatest: 'Xem hạn mức mới nhất của gói này',
|
||||
showCurrent: 'Xem gói đăng ký hiện tại',
|
||||
latestTab: 'Mới nhất',
|
||||
currentTab: 'Hiện tại',
|
||||
cancelToSubscribe:
|
||||
'Hủy gói hiện tại để đăng ký hạn mức mới nhất vào kỳ tiếp theo',
|
||||
|
||||
current: 'Current subscription: {{plan}} · revision {{revision}}',
|
||||
owned: 'Purchased revision {{revision}}',
|
||||
latest: 'Latest revision {{revision}}',
|
||||
changed:
|
||||
'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
|
||||
same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
|
||||
unavailable:
|
||||
'The latest plan is unavailable. Your purchased benefits remain in effect.',
|
||||
benefit: 'Benefit',
|
||||
change: 'Changed',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
monthlyCredits: 'Monthly bonus credits',
|
||||
retention: 'Chat history retention (days)',
|
||||
unknown:
|
||||
'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
|
||||
handoff:
|
||||
'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
|
||||
cancelFirst:
|
||||
'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
|
||||
canceled:
|
||||
'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
|
||||
prepaid:
|
||||
'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
|
||||
renew: 'Gia hạn gói đăng ký hiện tại',
|
||||
buying: 'Purchasing revision {{revision}}.',
|
||||
fields: {
|
||||
bots_max: 'Bots',
|
||||
members_max: 'Members',
|
||||
pipelines_max: 'Processors',
|
||||
plugins_max: 'Extensions',
|
||||
knowledge_bases_max: 'Knowledge bases',
|
||||
managed_sandbox_sessions_max: 'Sandbox sessions',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: 'Chưa thể đăng ký',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
import slackSetup from './slack-setup/zh-Hans';
|
||||
import pipelineMigration from './pipeline-migration/zh-Hans';
|
||||
const zhHans = {
|
||||
planRevision: {
|
||||
subscribedOn: '订阅日期:{{date}}',
|
||||
latestQuota: '该计划最新配额',
|
||||
showLatest: '查看该计划最新配额',
|
||||
showCurrent: '查看当前订阅',
|
||||
latestTab: '最新配额',
|
||||
currentTab: '当前订阅',
|
||||
cancelToSubscribe: '取消本期订阅,下一期即可订阅最新配额',
|
||||
|
||||
current: '当前订阅:{{plan}} · revision {{revision}}',
|
||||
owned: '已购买 revision {{revision}}',
|
||||
latest: '最新 revision {{revision}}',
|
||||
changed: '已购买权益与最新版本存在差异,高亮行标示变动。',
|
||||
same: '权益与最新 revision {{revision}} 相同,已购买版本保持不变。',
|
||||
unavailable: '最新计划暂不可用,已购买权益继续有效。',
|
||||
benefit: '权益',
|
||||
change: '有变化',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
monthlyCredits: '每月赠送额度',
|
||||
retention: '聊天记录保留天数',
|
||||
unknown:
|
||||
'当前订阅详情不可用。请刷新或前往 Space 管理订阅;最新目录权益不代表已购买权益。',
|
||||
handoff:
|
||||
'请在 Space 管理订阅。自动续费需先取消后才能购买新版本;预付订阅到期自动结束,除非到期前手动续订。',
|
||||
cancelFirst:
|
||||
'请先在订阅管理中取消自动续费。取消后保留本期已付权益,不退款。服务端确认取消后可重新购买最新版本,付款成功后新权益生效。',
|
||||
canceled:
|
||||
'续费已取消。本期已付权益保留至到期,不退款;重新购买的权益在付款成功后生效。',
|
||||
prepaid:
|
||||
'本预付订阅到期自动结束。到期前可续订当前方案;到期后只能按各计划的最新配额订阅。',
|
||||
renew: '续订当前订阅',
|
||||
buying: '正在购买 revision {{revision}}。',
|
||||
fields: {
|
||||
bots_max: '机器人',
|
||||
members_max: '成员',
|
||||
pipelines_max: '处理器',
|
||||
plugins_max: '扩展',
|
||||
knowledge_bases_max: '知识库',
|
||||
managed_sandbox_sessions_max: '沙箱会话',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: '暂不可订阅',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
import slackSetup from './slack-setup/zh-Hant';
|
||||
import pipelineMigration from './pipeline-migration/zh-Hant';
|
||||
const zhHant = {
|
||||
planRevision: {
|
||||
subscribedOn: '訂閱日期:{{date}}',
|
||||
latestQuota: '該方案最新配額',
|
||||
showLatest: '查看該計畫最新配額',
|
||||
showCurrent: '查看目前訂閱',
|
||||
latestTab: '最新配額',
|
||||
currentTab: '目前訂閱',
|
||||
cancelToSubscribe: '取消本期訂閱,下一期即可訂閱最新配額',
|
||||
|
||||
current: '当前订阅:{{plan}} · revision {{revision}}',
|
||||
owned: '已购买 revision {{revision}}',
|
||||
latest: '最新 revision {{revision}}',
|
||||
changed: '已购买权益与最新版本存在差异,高亮行标示变动。',
|
||||
same: '权益与最新 revision {{revision}} 相同,已购买版本保持不变。',
|
||||
unavailable: '最新计划暂不可用,已购买权益继续有效。',
|
||||
benefit: '权益',
|
||||
change: '有变化',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
monthlyCredits: '每月赠送额度',
|
||||
retention: '聊天记录保留天数',
|
||||
unknown:
|
||||
'当前订阅详情不可用。请刷新或前往 Space 管理订阅;最新目录权益不代表已购买权益。',
|
||||
handoff:
|
||||
'请在 Space 管理订阅。自动续费需先取消后才能购买新版本;预付订阅到期自动结束,除非到期前手动续订。',
|
||||
cancelFirst:
|
||||
'请先在订阅管理中取消自动续费。取消后保留本期已付权益,不退款。服务端确认取消后可重新购买最新版本,付款成功后新权益生效。',
|
||||
canceled:
|
||||
'续费已取消。本期已付权益保留至到期,不退款;重新购买的权益在付款成功后生效。',
|
||||
prepaid:
|
||||
'本預付訂閱到期自動結束。到期前可續訂目前方案;到期後只能按各方案的最新配額訂閱。',
|
||||
renew: '續訂目前訂閱',
|
||||
buying: '正在购买 revision {{revision}}。',
|
||||
fields: {
|
||||
bots_max: '机器人',
|
||||
members_max: '成员',
|
||||
pipelines_max: '处理器',
|
||||
plugins_max: '扩展',
|
||||
knowledge_bases_max: '知识库',
|
||||
managed_sandbox_sessions_max: '沙箱会话',
|
||||
},
|
||||
},
|
||||
cloudPlans: {
|
||||
subscriptionUnavailable: '暫不可訂閱',
|
||||
subscriptionUnavailableHint:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<html><head><meta charset="utf-8" /></head><body><div id="root"></div><script type="module" src="./preview.tsx"></script></body></html>
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createInstance } from 'i18next';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import enUS from '../../src/i18n/locales/en-US';
|
||||
import CloudPlansDialog from '../../src/app/home/components/workspace-settings/CloudPlansDialog';
|
||||
import '../../src/app/global.css';
|
||||
const mode = new URLSearchParams(location.search).get('mode') || 'changed';
|
||||
const current = {
|
||||
name: 'pro',
|
||||
subscription_started_at: 1790812800,
|
||||
revision: 1,
|
||||
limits: {
|
||||
'bots.max': 2,
|
||||
'members.max': 3,
|
||||
'chat_history.retention_days': 30,
|
||||
},
|
||||
entitlement_features: { mcp_stdio: false, 'platform.telegram': false },
|
||||
monthly_bonus_credits: 10,
|
||||
};
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: 'en-US',
|
||||
resources: { 'en-US': { translation: enUS } },
|
||||
});
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CloudPlansDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
workspace={
|
||||
{
|
||||
workspace: { uuid: 'fixture', name: 'Review workspace' },
|
||||
membership: { role: mode === 'member' ? 'viewer' : 'owner' },
|
||||
plan_name: 'pro',
|
||||
current_plan: mode === 'unknown' ? null : current,
|
||||
} as any
|
||||
}
|
||||
spaceURL="https://space.example.test"
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import {createServer} from 'vite';
|
||||
import {chromium} from '@playwright/test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import path from 'node:path';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
const workdir=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../../..');
|
||||
const evidence=path.join(workdir,'evidence');
|
||||
await mkdir(evidence,{recursive:true});
|
||||
const servers=[];let browser;
|
||||
const results=[];
|
||||
const latest=(mode)=>({uuid:'latest',name:'pro',revision:mode==='same'?1:2,display_name:{en_US:'Pro'},description:{en_US:'Workspace plan'},price_cny:'70',price_usd:'10',price_jpy:'1500',features:[],limits: mode==='equal'||mode==='same'?{'bots.max':2,'members.max':3}:{'bots.max':8,'members.max':5},entitlement_features:{mcp_stdio:!['same','equal'].includes(mode)},disabled_adapters:['same','equal'].includes(mode)?['telegram']:[],chat_history_retention_days:['same','equal'].includes(mode)?30:90,monthly_bonus_credits:['same','equal'].includes(mode)?10:20,is_active:true,subscription_disabled:mode==='paused',sort_order:1,highlight:false});
|
||||
try {
|
||||
for(const [repo,port] of [['space',4185],['core',4186]]){
|
||||
const server=await createServer({configFile:path.join(workdir,repo,'web/tests/revisions/vite.config.mjs')});await server.listen();servers.push(server);
|
||||
}
|
||||
browser=await chromium.launch({headless:true});
|
||||
for(const repo of ['space','core']){
|
||||
const modes=repo==='space'?['changed','same','equal','paused','removed','member','stripe','cancel-failure']:['changed','same','equal','paused','removed','member','unknown','error'];
|
||||
for(const mode of modes){
|
||||
const page=await browser.newPage({viewport:{width:1360,height:1100}});
|
||||
page.setDefaultTimeout(120000);
|
||||
const errors=[];page.on('pageerror',error=>{errors.push(error.message);console.error(error.message);});
|
||||
await page.route('**/*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.hostname==='127.0.0.1')return route.continue();
|
||||
if(url.pathname==='/api/v1/cloud/v2/plans')return route.fulfill({status:mode==='error'?500:200,contentType:'application/json',body:JSON.stringify({code:0,data:mode==='removed'?[]:[latest(mode)]})});
|
||||
return route.abort();
|
||||
});
|
||||
const port=repo==='space'?4185:4186;
|
||||
await page.goto(`http://127.0.0.1:${port}/tests/revisions/index.html?mode=${mode}${mode==='stripe'?'&intent=1':''}`,{waitUntil:'domcontentloaded',timeout:120000});
|
||||
if(repo==='core'&&mode==='error')await page.getByRole('alert').waitFor();
|
||||
else if(mode==='unknown')await page.getByText(/Current subscription details are unavailable/).waitFor();
|
||||
else await page.locator(repo==='space'?'[data-plan-comparison]':'[data-cloud-plans] article').first().waitFor();
|
||||
if(errors.length)throw new Error(`${repo}/${mode}: ${errors.join(';')}`);
|
||||
if(await page.locator('[data-revision-comparison]').count())throw new Error('Standalone comparison remains');
|
||||
if(['same','equal'].includes(mode) && await page.locator('[data-owned-plan]').count())throw new Error('Identical benefits duplicated');
|
||||
const area=page.locator(repo==='space'?'[data-plan-comparison]':'[data-cloud-plans]');
|
||||
if(!['unknown','error'].includes(mode)) {
|
||||
const copy=await area.innerText();
|
||||
if(/&#|revision|mcp stdio|workspace multi user|platform telegram/.test(copy))throw new Error('Internal metadata leaked into plan presentation');
|
||||
if(repo==='core'&&await area.locator('[data-changed], .bg-amber-500\\/10').count())throw new Error('Diff highlighting remains');
|
||||
if(['same','equal'].includes(mode)&&/Subscribed on|Latest quotas for this plan/.test(copy))throw new Error('Unnecessary comparison labels');
|
||||
}
|
||||
if(mode==='changed') {
|
||||
await area.getByText(/Subscribed on/).waitFor();
|
||||
await area.getByText('Latest quotas for this plan',{exact:true}).waitFor();
|
||||
const owned=page.locator('[data-owned-plan]');
|
||||
if(await owned.count()!==1)throw new Error('Missing integrated owned version');
|
||||
const horizontal=await owned.evaluate(el=>{const next=el.nextElementSibling;return !!next && Math.abs(next.getBoundingClientRect().y-el.getBoundingClientRect().y)<5 && next.getBoundingClientRect().x>el.getBoundingClientRect().x;});
|
||||
if(!horizontal)throw new Error('Owned and latest are not adjacent horizontally');
|
||||
}
|
||||
if(repo==='space'&&mode==='stripe'){
|
||||
if(await page.getByRole('dialog').count())throw new Error('Deep link bypassed renewal gate');
|
||||
await page.getByRole('button',{name:'Manage subscription'}).first().click();
|
||||
await page.getByText('Server refresh confirmed cancellation').waitFor();
|
||||
if (!(await page.getByRole('dialog').count())) await page.getByRole('button',{name:/Upgrade to|Subscribe to|Choose/}).last().click();
|
||||
await page.getByRole('dialog').waitFor();
|
||||
}
|
||||
if(repo==='space'&&mode==='cancel-failure'){
|
||||
await page.getByRole('button',{name:'Manage subscription'}).first().click();
|
||||
await page.getByText('Cancellation failed').waitFor();
|
||||
if(await page.getByRole('dialog').count())throw new Error('Failed cancellation opened checkout');
|
||||
}
|
||||
if(mode==='member'&&repo==='core'&&await page.getByRole('link').count())throw new Error('Member purchase link');
|
||||
if(mode==='changed'||mode==='equal'||mode==='member'||mode==='paused')await page.screenshot({path:path.join(evidence,`${repo}-${mode}.png`),fullPage:true});
|
||||
results.push(`${repo}/${mode}: PASS`);console.log(results.at(-1));await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await writeFile(path.join(evidence,'browser-results.txt'),results.join('\n')+'\n');
|
||||
if(browser)await browser.close();for(const server of servers)await server.close();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import tailwind from '@tailwindcss/postcss';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
|
||||
export default {root,resolve:{alias:{'@':path.join(root,'src')}},esbuild:{jsx:'automatic'},css:{postcss:{plugins:[tailwind({base:root})]}},server:{host:'127.0.0.1',port:4186,strictPort:true}};
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const m = { exports: {} };
|
||||
new Function('module', 'exports', ts.transpileModule(readFileSync(new URL('../../src/app/home/components/workspace-settings/plan-revisions.ts', import.meta.url), 'utf8'), {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022}}).outputText)(m, m.exports);
|
||||
const { revisionRows } = m.exports;
|
||||
const current = { name: 'pro', revision: 1, limits: {'bots.max': 2, 'chat_history.retention_days': 30}, monthly_bonus_credits: 10, entitlement_features: {'platform.telegram': false, mcp_stdio: false} };
|
||||
test('same version and revision-only changes do not duplicate comparisons', () => {
|
||||
assert.ok(revisionRows(current, current).every(row => !row.changed));
|
||||
assert.ok(revisionRows(current, {...current, revision: 2}).every(row => !row.changed));
|
||||
});
|
||||
test('compares effective quotas, credits, retention, platforms and feature switches', () => {
|
||||
const latest = {name:'pro', revision:2, limits:{'bots.max':5}, chat_history_retention_days:60, monthly_bonus_credits:20, entitlement_features:{mcp_stdio:true}, disabled_adapters:[]};
|
||||
const changed = revisionRows(current, latest).filter(row => row.changed);
|
||||
assert.deepEqual(changed.map(row => row.key).sort(), ['feature:mcp_stdio','feature:platform.telegram','limit:bots.max','limit:chat_history.retention_days','monthly_credits'].sort());
|
||||
assert.equal(changed.find(row => row.key === 'feature:platform.telegram').after, true);
|
||||
});
|
||||
test('catalog representations normalize to snapshot values', () => {
|
||||
const latest = {name:'pro',revision:2,limits:{'bots.max':2},chat_history_retention_days:30,monthly_bonus_credits:10,disabled_adapters:['telegram']};
|
||||
assert.ok(revisionRows(current, latest).every(row => !row.changed));
|
||||
});
|
||||
test('missing latest keeps current values without invented changes', () => {
|
||||
const rows=revisionRows(current);
|
||||
assert.equal(rows.find(row=>row.key==='limit:bots.max').before,2);
|
||||
assert.ok(rows.every(row=>!row.changed));
|
||||
});
|
||||
Reference in New Issue
Block a user