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:
RockChinQ
2026-10-17 00:25:41 +08:00
co-authored by Claude Opus 5.5
parent ececcb8749
commit 16cfb6f8bd
22 changed files with 1054 additions and 144 deletions
+1
View File
@@ -0,0 +1 @@
<html><head><meta charset="utf-8" /></head><body><div id="root"></div><script type="module" src="./preview.tsx"></script></body></html>
+42
View File
@@ -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>,
);
+72
View File
@@ -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();
}
+5
View File
@@ -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}};