From 978db1701b7e6cf4a9c880dfa1f51be24db88aed Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Thu, 15 Oct 2026 21:29:05 +0800 Subject: [PATCH] feat(cloud): add plan comparison and subscription management entry --- .../workspace-settings/CloudPlansDialog.tsx | 487 ++++++++++++++++++ .../workspace-settings/WorkspacePlanBadge.tsx | 28 + .../WorkspaceSettingsPanel.tsx | 49 +- .../workspace-settings/WorkspaceSwitcher.tsx | 5 +- .../cloud-plan-platforms.ts | 201 ++++++++ .../workspace-settings/cloud-plans.ts | 159 ++++++ web/src/components/ui/tooltip.tsx | 12 +- web/src/i18n/locales/en-US.ts | 69 +++ web/src/i18n/locales/es-ES.ts | 68 +++ web/src/i18n/locales/ja-JP.ts | 66 +++ web/src/i18n/locales/ru-RU.ts | 68 +++ web/src/i18n/locales/th-TH.ts | 66 +++ web/src/i18n/locales/vi-VN.ts | 67 +++ web/src/i18n/locales/zh-Hans.ts | 66 +++ web/src/i18n/locales/zh-Hant.ts | 66 +++ web/tests/unit/cloud-plans.test.mjs | 181 +++++++ 16 files changed, 1636 insertions(+), 22 deletions(-) create mode 100644 web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx create mode 100644 web/src/app/home/components/workspace-settings/WorkspacePlanBadge.tsx create mode 100644 web/src/app/home/components/workspace-settings/cloud-plan-platforms.ts create mode 100644 web/src/app/home/components/workspace-settings/cloud-plans.ts create mode 100644 web/tests/unit/cloud-plans.test.mjs diff --git a/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx b/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx new file mode 100644 index 000000000..2841f9b42 --- /dev/null +++ b/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx @@ -0,0 +1,487 @@ +import { useEffect, useState, type ReactNode, type ComponentType } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ArrowRight, + Check, + Info, + Layers, + Loader2, + RefreshCw, +} from 'lucide-react'; +import * as RemixIcons from '@remixicon/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import type { CurrentWorkspace } from '@/app/infra/entities/workspace'; +import { + cloudPlanCheckoutURL, + cloudPlanPlatformIconURL, + cloudSubscriptionManagementURL, + cloudPlanPrice, + cloudPlanCurrencyForLanguage, + fetchPublicCloudPlans, + localizedPlanText, + type CloudPlanBillingCycle, + type PublicCloudPlan, +} from './cloud-plans'; +import { supportedPlanPlatforms } from './cloud-plan-platforms'; + +function PlanIcon({ name }: { name?: string }) { + const key = name + ?.replace(/^ri-/, '') + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + const Icon = + name && /^ri-[a-z0-9-]+-(line|fill)$/.test(name) + ? ( + RemixIcons as unknown as Record< + string, + ComponentType<{ className?: string }> + > + )[`Ri${key}`] + : undefined; + return Icon ? ( + + ) : ( + + ); +} + +function Help({ label, children }: { label: string; children: ReactNode }) { + return ( + + + + + + {children} + + + ); +} + +function Benefits({ + plan, + spaceURL, +}: { + plan: PublicCloudPlan; + spaceURL: string; +}) { + const { t, i18n } = useTranslation(); + const text = (values: Parameters[0]) => + localizedPlanText(values, i18n.language); + const config = plan.display_config; + const platforms = supportedPlanPlatforms(plan.disabled_adapters); + const quotaLabel = text(config?.quota_summary) || t('cloudPlans.quotas'); + const legacyFeatures: Record = { + 'Multi-user workspace': 'multiUserWorkspace', + 'Remote E2B sandbox configuration': 'remoteE2BSandbox', + 'Community support': 'communitySupport', + 'Everything in Free': 'everythingInFree', + 'One always-on global sandbox': 'alwaysOnSandbox', + 'Higher workspace limits': 'higherWorkspaceLimits', + }; + const items = + config?.items ?? + (plan.features ?? []).map((feature) => ({ + label: { + en_US: legacyFeatures[feature] + ? t(`cloudPlans.features.${legacyFeatures[feature]}`) + : feature, + }, + icon: 'ri-check-line', + tooltip: {}, + })); + const rows: Record = { + quota_summary: ( + <> + + {quotaLabel} + + {text(config?.tooltips?.quota_summary) && ( +

{text(config?.tooltips?.quota_summary)}

+ )} +
    + {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 ( +
  • +
    + {label} + + {limit < 0 + ? t('cloudPlans.unlimited') + : limit.toLocaleString()} + +
    + {text(config?.quotas?.[key]?.tooltip) && ( +

    + {text(config?.quotas?.[key]?.tooltip)} +

    + )} +
  • + ); + })} +
+
+ + ), + retention: ( + <> + + + {t('cloudPlans.retention', { + count: plan.chat_history_retention_days, + })} + + + {text(config?.tooltips?.retention) || t('cloudPlans.retentionHelp')} + + + ), + monthly_credits: ( + <> + + + {t('cloudPlans.credits', { count: plan.monthly_bonus_credits })} + + + {text(config?.tooltips?.monthly_credits) || + t('cloudPlans.creditsHelp')} + + + ), + supported_platforms: ( + <> + + {t('cloudPlans.platforms')} + + { + if (event.pointerType === 'touch') event.preventDefault(); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + + + + {text(config?.tooltips?.supported_platforms) && ( +

+ {text(config?.tooltips?.supported_platforms)} +

+ )} +
+ {platforms.map((platform) => ( + + + {t( + `cloudPlans.platformNames.${platform.name.replace('LINE', 'line').replace('openclaw-weixin', 'openclaw_weixin')}`, + { defaultValue: platform.label }, + )} + + ))} +
+ {platforms.length === 0 &&

{t('common.none')}

} +
+
+ + ), + }; + items.forEach((item, index) => { + const label = text(item.label); + rows[`item:${index}`] = ( + <> + + {label} + {text(item.tooltip) && {text(item.tooltip)}} + + ); + }); + const order = [ + ...new Set([ + ...(config?.order ?? []), + 'quota_summary', + 'supported_platforms', + 'retention', + 'monthly_credits', + ...items.map((_, index) => `item:${index}`), + ]), + ].filter((key) => key in rows); + return ( +
    + {order.map((key) => ( +
  • + {rows[key]} +
  • + ))} +
+ ); +} + +export default function CloudPlansDialog({ + open, + onOpenChange, + workspace, + spaceURL, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + workspace: CurrentWorkspace; + spaceURL: string; +}) { + const { t, i18n } = useTranslation(); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [attempt, setAttempt] = useState(0); + const [cycle, setCycle] = useState('annual'); + const currency = cloudPlanCurrencyForLanguage(i18n.language); + const canManage = ['owner', 'admin'].includes(workspace.membership.role); + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + let active = true; + const timeout = window.setTimeout(() => controller.abort(), 15000); + setLoading(true); + setError(false); + fetchPublicCloudPlans(spaceURL, controller.signal) + .then((value) => { + if (active) setPlans(value); + }) + .catch(() => { + if (active) setError(true); + }) + .finally(() => { + window.clearTimeout(timeout); + if (active) setLoading(false); + }); + return () => { + active = false; + controller.abort(); + window.clearTimeout(timeout); + }; + }, [open, spaceURL, attempt]); + const formatPrice = (price: number) => + new Intl.NumberFormat(i18n.language.replace('_', '-'), { + style: 'currency', + currency, + maximumFractionDigits: currency === 'JPY' ? 0 : 2, + }).format(price); + return ( + + + + {t('cloudPlans.title')} + + {t('cloudPlans.description', { + workspace: workspace.workspace.name, + })} + + +
+
+ {(['monthly', 'annual'] as const).map((value) => ( + + ))} +
+
+
+ {loading ? ( +
+ + {t('cloudPlans.loading')} +
+ ) : error ? ( +
+

{t('cloudPlans.error')}

+ +
+ ) : !plans.length ? ( +

+ {t('cloudPlans.empty')} +

+ ) : ( + +
2 && 'lg:grid-cols-3', + )} + > + {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 ( +
+
+

+ + {name} +

+ {current ? ( + + {t('cloudPlans.current')} + + ) : plan.highlight ? ( + {t('cloudPlans.recommended')} + ) : null} +
+

+ {localizedPlanText(plan.description, i18n.language)} +

+

+ {price === 0 + ? t('cloudPlans.free') + : formatPrice(price)} + {price > 0 && ( + + / {t(`cloudPlans.${cycle}`)} + + )} +

+

+ {cycle === 'annual' && price > 0 + ? t('cloudPlans.annualNote', { + price: formatPrice(price / 12), + }) + : ''} +

+
+ +
+ {downgradeBlocked ? null : !canManage ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ )} +
+

+ {t('cloudPlans.handoff')} +

+
+
+ ); +} diff --git a/web/src/app/home/components/workspace-settings/WorkspacePlanBadge.tsx b/web/src/app/home/components/workspace-settings/WorkspacePlanBadge.tsx new file mode 100644 index 000000000..b5eb7471b --- /dev/null +++ b/web/src/app/home/components/workspace-settings/WorkspacePlanBadge.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; + +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export default function WorkspacePlanBadge({ + planName, +}: { + planName?: string | null; +}) { + const { t } = useTranslation(); + const isPaidPlan = + Boolean(planName?.trim()) && planName?.trim().toLowerCase() !== 'free'; + + return ( + + {planName || t('workspace.planUnavailable')} + + ); +} diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx index eed014858..9e614c5d0 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx @@ -1,7 +1,14 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { Copy, - ExternalLink, + ArrowRight, Loader2, Trash2, UserPlus, @@ -39,6 +46,9 @@ import { PanelBody, PanelToolbar, } from '@/app/home/components/settings-dialog/panel-layout'; +import WorkspacePlanBadge from './WorkspacePlanBadge'; + +const CloudPlansDialog = lazy(() => import('./CloudPlansDialog')); interface WorkspaceSettingsPanelProps { active: boolean; @@ -66,6 +76,7 @@ export default function WorkspaceSettingsPanel({ active, }: WorkspaceSettingsPanelProps) { const { t } = useTranslation(); + const [plansOpen, setPlansOpen] = useState(false); const [workspaceInfo, setWorkspaceInfo] = useState( null, ); @@ -93,9 +104,6 @@ export default function WorkspaceSettingsPanel({ const canInvite = permissions.has('member.invite'); const canUpdateMembers = permissions.has('member.update_role'); const canRemoveMembers = permissions.has('member.remove'); - const cloudPortalURL = workspaceInfo - ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan` - : ''; const loadWorkspace = useCallback(async () => { setLoading(true); @@ -254,19 +262,15 @@ export default function WorkspaceSettingsPanel({

{t('workspace.currentPlan')}

-

- {workspaceInfo.plan_name || t('workspace.planUnavailable')} -

+ - )} @@ -490,6 +494,17 @@ export default function WorkspaceSettingsPanel({ )} + {isCloudProjection && workspaceInfo && plansOpen && ( + + + + )} ); } diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx index 9614650fd..5c5ca2b81 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx @@ -15,6 +15,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; +import WorkspacePlanBadge from './WorkspacePlanBadge'; export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings'; @@ -70,9 +71,7 @@ export default function WorkspaceSwitcher({ {entry.workspace.name} {entry.workspace.source === 'cloud_projection' && ( - - {entry.plan_name || t('workspace.planUnavailable')} - + )} {selected && (