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
@@ -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