fix(cloud): retire beta banner and use production subscription links

This commit is contained in:
RockChinQ
2026-10-15 22:50:24 +08:00
parent 978db1701b
commit aabcaedf15
14 changed files with 21 additions and 228 deletions
+1 -7
View File
@@ -1,15 +1,9 @@
import { Outlet } from 'react-router-dom';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { BetaBanner } from '@/components/BetaBanner';
// Top-level route layout: drives the dynamic document title from the active
// route and renders the matched child route via <Outlet />.
export default function RootLayout() {
useDocumentTitle();
return (
<>
<BetaBanner />
<Outlet />
</>
);
return <Outlet />;
}
@@ -104,7 +104,7 @@ export function cloudPlanCheckoutURL(
): string {
const url = spaceURL(base, '/cloud');
url.search = new URLSearchParams({
environment: 'beta',
environment: 'new',
step: 'plan',
workspace,
plan,
@@ -121,7 +121,7 @@ export function cloudSubscriptionManagementURL(
): string {
const url = spaceURL(base, '/cloud');
url.search = new URLSearchParams({
environment: 'beta',
environment: 'new',
step: 'subscription',
workspace,
}).toString();
+2 -8
View File
@@ -1059,10 +1059,7 @@ export default function WizardPage() {
if (isLoading) {
return (
<div
style={{ top: 'var(--beta-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex items-center justify-center"
>
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<LoadingSpinner text={t('wizard.loading')} />
</div>
);
@@ -1076,10 +1073,7 @@ export default function WizardPage() {
];
return (
<div
style={{ top: 'var(--beta-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex flex-col"
>
<div className="fixed inset-0 z-50 bg-background flex flex-col">
{/* Top bar: Skip button */}
<div className="shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 border-b">
<div className="flex items-center gap-2">
-136
View File
@@ -1,136 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AlertCircle, X } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { backendClient } from '@/app/infra/http';
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
const CLOUD_BETA_RETRY_LIMIT = RETRY_DELAYS_MS.length;
export function BetaBanner() {
const { t } = useTranslation();
const bannerRef = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const [dismissed, setDismissed] = useState(false);
const visible = show && !dismissed;
// Publish the banner height so the fixed app shell (sidebar + inset) starts
// below the notice instead of painting over it.
useEffect(() => {
const root = document.documentElement;
if (!visible) {
root.style.removeProperty('--beta-banner-height');
return;
}
const element = bannerRef.current;
if (!element) {
return;
}
const sync = () => {
root.style.setProperty(
'--beta-banner-height',
`${element.getBoundingClientRect().height}px`,
);
};
sync();
const observer = new ResizeObserver(sync);
observer.observe(element);
return () => {
observer.disconnect();
root.style.removeProperty('--beta-banner-height');
};
}, [visible]);
useEffect(() => {
let cancelled = false;
let retryTimer: number | undefined;
const checkBetaStatus = async (attempt = 0) => {
try {
const info = await backendClient.getSystemInfo();
if (cancelled) {
return;
}
setShow(info.deployment_mode === 'cloud' && info.beta === true);
} catch {
if (cancelled) {
return;
}
// A single failed probe (cold start, restarting backend, flaky
// network) must not hide the banner for the rest of the session, so
// keep the last known state and retry with backoff.
if (attempt < CLOUD_BETA_RETRY_LIMIT) {
retryTimer = window.setTimeout(
() => void checkBetaStatus(attempt + 1),
RETRY_DELAYS_MS[attempt],
);
}
}
};
void checkBetaStatus();
const recheck = () => {
if (!cancelled) {
void checkBetaStatus();
}
};
window.addEventListener('focus', recheck);
return () => {
cancelled = true;
clearTimeout(retryTimer);
window.removeEventListener('focus', recheck);
};
}, []);
if (!visible) {
return null;
}
// The cloud product page opens the dedicated (paid v1) environment tab;
// space.langbot.app resolves the locale itself, so no path prefix applies.
const cloudUrl = 'https://space.langbot.app/cloud?environment=dedicated';
const ossUrl = 'https://github.com/langbot-app/LangBot';
return (
<Alert
ref={bannerRef}
className="relative z-30 rounded-none border-x-0 border-t-0 bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900/50 py-2"
>
<AlertCircle className="text-amber-600 dark:text-amber-500" />
<AlertDescription className="text-sm text-amber-900 dark:text-amber-100">
<div className="flex w-full items-start gap-3">
<span className="flex-1">
{t('beta_banner.message')}{' '}
<a
href={cloudUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
{t('beta_banner.cloud_link')}
</a>{' '}
{t('beta_banner.or')}{' '}
<a
href={ossUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
{t('beta_banner.oss_link')}
</a>
{t('beta_banner.period')}
</span>
<button
onClick={() => setDismissed(true)}
className="shrink-0 text-amber-600 dark:text-amber-500 hover:text-amber-800 dark:hover:text-amber-300 p-1 -mt-1 -mr-2"
aria-label={t('beta_banner.dismiss')}
>
<X className="h-4 w-4" />
</button>
</div>
</AlertDescription>
</Alert>
);
}
+3 -5
View File
@@ -203,9 +203,7 @@ function SidebarProvider({
{
'--sidebar-width': `${sidebarWidth}px`,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
// Global notices (e.g. the beta banner) publish their height so
// the shell starts below them instead of underneath.
height: 'calc(100svh - var(--beta-banner-height, 0px))',
height: '100svh',
...style,
} as React.CSSProperties
}
@@ -302,8 +300,8 @@ function Sidebar({
<div
data-slot="sidebar-container"
style={{
top: 'var(--beta-banner-height, 0px)',
height: 'calc(100svh - var(--beta-banner-height, 0px))',
top: 0,
height: '100svh',
}}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
-9
View File
@@ -3696,14 +3696,5 @@ const enUS = {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',
},
beta_banner: {
message:
'This environment is in Beta testing. Service stability is not guaranteed. Consider using',
cloud_link: 'LangBot Cloud (dedicated environment)',
or: 'or',
oss_link: 'self-hosted open-source version',
period: '.',
dismiss: 'Dismiss',
},
};
export default enUS;
-9
View File
@@ -3764,14 +3764,5 @@ const esES = {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
beta_banner: {
message:
'Este entorno está en fase beta. La estabilidad del servicio no está garantizada. Considera usar',
cloud_link: 'LangBot Cloud (entorno dedicado)',
or: 'o',
oss_link: 'la versión de código abierto autoalojada',
period: '.',
dismiss: 'Cerrar',
},
};
export default esES;
-8
View File
@@ -3705,13 +3705,5 @@ const jaJP = {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
},
beta_banner: {
message: 'この環境はベータテスト中です。サービスの安定性は保証されません。',
cloud_link: 'LangBot Cloud(専用環境)',
or: 'または',
oss_link: 'セルフホスト版',
period: 'をご検討ください。',
dismiss: '閉じる',
},
};
export default jaJP;
-9
View File
@@ -3722,14 +3722,5 @@ const ruRU = {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
beta_banner: {
message:
'Эта среда находится в стадии бета-тестирования. Стабильность сервиса не гарантируется. Рекомендуем использовать',
cloud_link: 'LangBot Cloud (выделенная среда)',
or: 'или',
oss_link: 'версию с открытым исходным кодом на своём сервере',
period: '.',
dismiss: 'Закрыть',
},
};
export default ruRU;
-9
View File
@@ -3624,14 +3624,5 @@ const thTH = {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
beta_banner: {
message:
'สภาพแวดล้อมนี้อยู่ระหว่างการทดสอบเบต้า ไม่รับประกันความเสถียรของบริการ โปรดพิจารณาใช้',
cloud_link: 'LangBot Cloud (สภาพแวดล้อมเฉพาะ)',
or: 'หรือ',
oss_link: 'เวอร์ชันโอเพนซอร์สที่โฮสต์เอง',
period: '',
dismiss: 'ปิด',
},
};
export default thTH;
-9
View File
@@ -3676,14 +3676,5 @@ const viVN = {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
beta_banner: {
message:
'Môi trường này đang trong giai đoạn thử nghiệm beta. Độ ổn định của dịch vụ không được đảm bảo. Hãy cân nhắc sử dụng',
cloud_link: 'LangBot Cloud (môi trường riêng)',
or: 'hoặc',
oss_link: 'phiên bản mã nguồn mở tự lưu trữ',
period: '.',
dismiss: 'Đóng',
},
};
export default viVN;
-8
View File
@@ -3487,13 +3487,5 @@ const zhHans = {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',
},
beta_banner: {
message: '当前环境正在进行 Beta 测试,服务稳定性不受保证,建议优先选择',
cloud_link: '独立环境版 LangBot Cloud',
or: '或',
oss_link: '自部署开源版本',
period: '。',
dismiss: '关闭横幅',
},
};
export default zhHans;
-8
View File
@@ -3488,13 +3488,5 @@ const zhHant = {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
beta_banner: {
message: '此環境正處於 Beta 測試階段,服務穩定性尚未保證。建議使用',
cloud_link: 'LangBot Cloud(獨立環境)',
or: '或',
oss_link: '自行部署的開源版本',
period: '。',
dismiss: '關閉',
},
};
export default zhHant;
+13 -1
View File
@@ -22,6 +22,7 @@ new Function(
)(m, m.exports);
const {
cloudPlanCheckoutURL,
cloudSubscriptionManagementURL,
cloudPlanPrice,
localizedPlanText,
fetchPublicCloudPlans,
@@ -40,7 +41,7 @@ test('checkout links preserve the workspace and selection, never source credenti
assert.equal(url.pathname, '/prefix/cloud');
assert.equal(url.hash, '');
assert.deepEqual(Object.fromEntries(url.searchParams), {
environment: 'beta',
environment: 'new',
step: 'plan',
workspace: 'workspace-1',
plan: 'pro & team',
@@ -179,3 +180,14 @@ test('plan currency follows the current UI language', () => {
])
assert.equal(m.exports.cloudPlanCurrencyForLanguage(locale), 'USD');
});
test('subscription management links use the production environment', () => {
const url = new URL(
cloudSubscriptionManagementURL('https://space.example', 'w'),
);
assert.deepEqual(Object.fromEntries(url.searchParams), {
environment: 'new',
step: 'subscription',
workspace: 'w',
});
});