From 5ce1a2b00cabd95742a8837fcef95b5280085f53 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 14 Feb 2026 18:35:10 +0800 Subject: [PATCH 1/3] fix(ecosystem): improve miniapp capsule info UX and error-state clarity --- .../miniapp-capsule-info-sheet.test.tsx | 89 +++++++ .../ecosystem/miniapp-capsule-info-sheet.tsx | 250 ++++++++++++++++++ src/components/ecosystem/miniapp-window.tsx | 24 +- src/components/security/pattern-lock.test.tsx | 29 +- src/components/security/pattern-lock.tsx | 19 +- src/i18n/locales/ar/ecosystem.json | 21 +- src/i18n/locales/en-US/ecosystem.json | 23 ++ src/i18n/locales/en/ecosystem.json | 21 +- src/i18n/locales/zh-CN/ecosystem.json | 21 +- src/i18n/locales/zh-TW/ecosystem.json | 21 +- src/test/i18n-mock.tsx | 4 +- 11 files changed, 507 insertions(+), 15 deletions(-) create mode 100644 src/components/ecosystem/miniapp-capsule-info-sheet.test.tsx create mode 100644 src/components/ecosystem/miniapp-capsule-info-sheet.tsx diff --git a/src/components/ecosystem/miniapp-capsule-info-sheet.test.tsx b/src/components/ecosystem/miniapp-capsule-info-sheet.test.tsx new file mode 100644 index 000000000..9a0f3ded6 --- /dev/null +++ b/src/components/ecosystem/miniapp-capsule-info-sheet.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TestI18nProvider } from '@/test/i18n-mock'; +import { MiniappCapsuleInfoSheet } from './miniapp-capsule-info-sheet'; + +describe('MiniappCapsuleInfoSheet', () => { + it('shows info state when only query params differ', () => { + render( + + + , + ); + + expect(screen.getByText('RWA Hub')).toBeInTheDocument(); + expect(screen.getByText('com.bioforest.rwa-hub')).toBeInTheDocument(); + expect(screen.getByText('1.3.0')).toBeInTheDocument(); + expect(screen.getByText('BioForest')).toBeInTheDocument(); + expect(screen.getByText('Official')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-capsule-current-url')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-capsule-url-adjusted-info')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-capsule-entry-url')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-capsule-source-url')).toBeInTheDocument(); + }); + + it('hides entry url warning when entry url equals runtime url', () => { + render( + + + , + ); + + expect(screen.getByTestId('miniapp-capsule-current-url')).toBeInTheDocument(); + expect(screen.queryByTestId('miniapp-capsule-url-adjusted-info')).not.toBeInTheDocument(); + expect(screen.queryByTestId('miniapp-capsule-url-mismatch-warning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('miniapp-capsule-entry-url')).not.toBeInTheDocument(); + }); + + it('shows warning state when origin/path differs', () => { + render( + + + , + ); + + expect(screen.getByTestId('miniapp-capsule-current-url')).toBeInTheDocument(); + expect(screen.queryByTestId('miniapp-capsule-url-adjusted-info')).not.toBeInTheDocument(); + expect(screen.getByTestId('miniapp-capsule-url-mismatch-warning')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-capsule-entry-url')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ecosystem/miniapp-capsule-info-sheet.tsx b/src/components/ecosystem/miniapp-capsule-info-sheet.tsx new file mode 100644 index 000000000..6db3f7466 --- /dev/null +++ b/src/components/ecosystem/miniapp-capsule-info-sheet.tsx @@ -0,0 +1,250 @@ +import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; +import { IconAlertTriangle, IconCheck, IconCopy, IconInfoCircle, IconExternalLink } from '@tabler/icons-react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { clipboardService } from '@/services/clipboard'; + +interface MiniappCapsuleInfoSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + appName: string; + appId: string; + version: string; + author?: string; + sourceName?: string; + runtime: 'iframe' | 'wujie'; + entryUrl: string; + currentUrl: string; + sourceUrl?: string; + strictUrl?: boolean; +} + +export function MiniappCapsuleInfoSheet({ + open, + onOpenChange, + appName, + appId, + version, + author, + sourceName, + runtime, + entryUrl, + currentUrl, + sourceUrl, + strictUrl, +}: MiniappCapsuleInfoSheetProps) { + const { t } = useTranslation('ecosystem'); + const normalizedEntryUrl = entryUrl.trim(); + const normalizedCurrentUrl = currentUrl.trim(); + const urlMismatch = resolveUrlMismatch(normalizedEntryUrl, normalizedCurrentUrl, strictUrl ?? false); + + return ( + + + + {t('capsule.infoTitle')} + + +
+
+ + + + + + + +
+ + + + {urlMismatch.type === 'query-only' && ( +
+
+ +
+
+ {t('capsule.infoUrlQueryAdjustedTitle')} +
+

+ {t('capsule.infoUrlQueryAdjustedHint')} +

+
+
+ + +
+ )} + + {urlMismatch.type === 'warning' && ( +
+
+ +
+
+ {t('capsule.infoUrlMismatchTitle')} +
+

+ {t('capsule.infoUrlMismatchHint')} +

+
+
+ + +
+ )} + + {sourceUrl && ( + + )} +
+
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function UrlCard({ + label, + url, + emptyText, + testId, +}: { + label: string; + url: string; + emptyText: string; + testId: string; +}) { + if (!url) { + return ( +
+
{label}
+
{emptyText}
+
+ ); + } + + return ( +
+
{label}
+ +
+ ); +} + +function UrlLine({ url }: { url: string }) { + const { t } = useTranslation(['common']); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1600); + return () => window.clearTimeout(timer); + }, [copied]); + + const handleCopy = async () => { + try { + await clipboardService.write({ text: url }); + setCopied(true); + } catch { + setCopied(false); + } + }; + + return ( +
+

+ {url} +

+ + + + +
+ ); +} + +type UrlMismatchType = 'none' | 'query-only' | 'warning'; + +function resolveUrlMismatch(entry: string, current: string, strictUrl: boolean): { type: UrlMismatchType } { + if (!entry || !current || entry === current) { + return { type: 'none' }; + } + + if (strictUrl) { + return { type: 'warning' }; + } + + try { + const entryUrl = new URL(entry); + const currentUrl = new URL(current); + const sameOrigin = entryUrl.origin === currentUrl.origin; + const samePath = normalizePath(entryUrl.pathname) === normalizePath(currentUrl.pathname); + const sameHash = entryUrl.hash === currentUrl.hash; + + if (sameOrigin && samePath && sameHash && entryUrl.search !== currentUrl.search) { + return { type: 'query-only' }; + } + } catch { + return { type: 'warning' }; + } + + return { type: 'warning' }; +} + +function normalizePath(pathname: string): string { + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1); + } + return pathname; +} diff --git a/src/components/ecosystem/miniapp-window.tsx b/src/components/ecosystem/miniapp-window.tsx index 79ce17e98..e615a02fc 100644 --- a/src/components/ecosystem/miniapp-window.tsx +++ b/src/components/ecosystem/miniapp-window.tsx @@ -32,6 +32,7 @@ import { import { getMiniappMotionPresets, type MiniappMotionPresets } from '@/services/miniapp-runtime/visual-config'; import { MiniappSplashScreen } from './miniapp-splash-screen'; import { MiniappCapsule } from './miniapp-capsule'; +import { MiniappCapsuleInfoSheet } from './miniapp-capsule-info-sheet'; import { MiniappIcon } from './miniapp-icon'; import { flowToCapsule, @@ -124,6 +125,7 @@ function MiniappWindowPortal({ const windowRef = useRef(null); const iframeContainerRef = useRef(null); const [presentApp, setPresentApp] = useState(null); + const [isCapsuleInfoOpen, setIsCapsuleInfoOpen] = useState(false); const exitScheduledRef = useRef(false); const exitingTransitionIdRef = useRef(null); @@ -175,6 +177,10 @@ function MiniappWindowPortal({ didPresent(appId, presentation.transitionId); }, [appId, presentApp, presentation.state, presentation.transitionKind, presentation.transitionId]); + useEffect(() => { + setIsCapsuleInfoOpen(false); + }, [appId]); + const lastFlowRef = useRef('closed'); if (presentApp?.flow) { lastFlowRef.current = presentApp.flow; @@ -204,6 +210,7 @@ function MiniappWindowPortal({ themeColor: presentApp?.manifest.themeColorFrom ?? 280, }; }, [presentApp?.appId, presentApp?.manifest.name, presentApp?.manifest.icon, presentApp?.manifest.themeColorFrom]); + const currentUrl = presentApp?.containerHandle?.getIframe()?.src ?? presentApp?.manifest.url ?? ''; useEffect(() => { if (!portalHost) return; @@ -353,12 +360,27 @@ function MiniappWindowPortal({ visible={true} theme={presentApp?.ctx.capsuleTheme ?? 'auto'} onAction={() => { - // TODO: 显示更多操作菜单 + setIsCapsuleInfoOpen(true); }} onClose={handleClose} /> + + )} diff --git a/src/components/security/pattern-lock.test.tsx b/src/components/security/pattern-lock.test.tsx index 4fad723fb..17f45a96d 100644 --- a/src/components/security/pattern-lock.test.tsx +++ b/src/components/security/pattern-lock.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { ReactElement } from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { PatternLock, patternToString, stringToPattern, isValidPattern } from './pattern-lock'; import { TestI18nProvider, testI18n } from '@/test/i18n-mock'; @@ -54,6 +54,33 @@ describe('PatternLock', () => { expect(screen.getByText(testI18n.t('security:patternLock.error'))).toBeInTheDocument(); }); + it('hides pattern error text immediately after leaving error state', async () => { + const errorText = testI18n.t('security:patternLock.error'); + const clearText = testI18n.t('security:patternLock.clear'); + + const view = renderWithI18n(); + expect(screen.queryByText(errorText)).not.toBeInTheDocument(); + + view.rerender( + + + , + ); + expect(screen.getByText(errorText)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText(clearText)).not.toBeInTheDocument(); + }); + + view.rerender( + + + , + ); + + expect(screen.queryByText(errorText)).not.toBeInTheDocument(); + }); + it('shows success state', () => { renderWithI18n(); expect(screen.getByText(testI18n.t('security:patternLock.success'))).toBeInTheDocument(); diff --git a/src/components/security/pattern-lock.tsx b/src/components/security/pattern-lock.tsx index d86985d4b..ab4c6346e 100644 --- a/src/components/security/pattern-lock.tsx +++ b/src/components/security/pattern-lock.tsx @@ -130,18 +130,21 @@ export function PatternLock({ // 处理外部 error 状态变化:开始淡出动画 useEffect(() => { - // 检测 error 从 false 变为 true + // 进入错误:启动动画 if (error && !prevErrorRef.current) { - // 保存当前节点状态用于动画显示(外部可能同时清空 value) - const nodesToAnimate = selectedNodesRef.current.length > 0 - ? [...selectedNodesRef.current] + const nodesToAnimate = selectedNodesRef.current.length > 0 + ? [...selectedNodesRef.current] : [...selectedNodes]; - startErrorAnimation(nodesToAnimate); } - + + // 退出错误:立即取消动画,避免与流程错误文案并存 + if (!error && prevErrorRef.current) { + cancelErrorAnimation(); + } + prevErrorRef.current = error; - }, [error, selectedNodes, startErrorAnimation]); + }, [cancelErrorAnimation, error, selectedNodes, startErrorAnimation]); // 计算节点位置 const nodePositions = useMemo(() => { @@ -497,7 +500,7 @@ export function PatternLock({ {/* 状态提示 - 固定高度避免布局抖动 */}
- {error || isErrorAnimating ? ( + {error ? (

{errorText ?? t('patternLock.error')}

diff --git a/src/i18n/locales/ar/ecosystem.json b/src/i18n/locales/ar/ecosystem.json index da26aa060..347a20049 100644 --- a/src/i18n/locales/ar/ecosystem.json +++ b/src/i18n/locales/ar/ecosystem.json @@ -111,7 +111,26 @@ }, "capsule": { "more": "المزيد من الخيارات", - "close": "إغلاق التطبيق" + "close": "إغلاق التطبيق", + "infoTitle": "تفاصيل التطبيق", + "infoApp": "التطبيق", + "infoAppId": "معرّف التطبيق", + "infoVersion": "الإصدار", + "infoAuthor": "المطور", + "infoSourceName": "المصدر", + "infoSourceUnknown": "مصدر غير معروف", + "infoRuntime": "بيئة التشغيل", + "infoStrictUrl": "رابط URL صارم", + "infoStrictEnabled": "مفعّل", + "infoStrictDisabled": "غير مفعّل", + "infoEntryUrl": "رابط الدخول المُعدّ", + "infoCurrentUrl": "الرابط الحالي", + "infoUrlQueryAdjustedTitle": "تمت إضافة معاملات إلى رابط التشغيل", + "infoUrlQueryAdjustedHint": "تم تغيير معاملات الاستعلام فقط (للإصدار/التخزين المؤقت)، وهذا غالبًا سلوك متوقع.", + "infoUrlMismatchTitle": "رابط الدخول يختلف عن رابط التشغيل", + "infoUrlMismatchHint": "قد يكون السبب حقن معاملات أو إعادة توجيه أو اختلاف في التخزين المؤقت. يرجى التحقق من توافق التطبيق.", + "infoSourceUrl": "رابط المصدر", + "infoUrlUnavailable": "الرابط غير متاح" }, "detail": { "notFound": "التطبيق غير موجود", diff --git a/src/i18n/locales/en-US/ecosystem.json b/src/i18n/locales/en-US/ecosystem.json index 70148cca4..07a86eac9 100644 --- a/src/i18n/locales/en-US/ecosystem.json +++ b/src/i18n/locales/en-US/ecosystem.json @@ -34,6 +34,29 @@ "description": "Write text to your clipboard" } }, + "capsule": { + "more": "More Options", + "close": "Close App", + "infoTitle": "App Details", + "infoApp": "App", + "infoAppId": "App ID", + "infoVersion": "Version", + "infoAuthor": "Developer", + "infoSourceName": "Source", + "infoSourceUnknown": "Unknown source", + "infoRuntime": "Runtime", + "infoStrictUrl": "Strict URL", + "infoStrictEnabled": "Enabled", + "infoStrictDisabled": "Disabled", + "infoEntryUrl": "Configured Entry URL", + "infoCurrentUrl": "Current URL", + "infoUrlQueryAdjustedTitle": "Runtime URL has extra query parameters", + "infoUrlQueryAdjustedHint": "Only query parameters changed (for version/cache control); this is usually expected.", + "infoUrlMismatchTitle": "Entry URL differs from runtime URL", + "infoUrlMismatchHint": "This may come from query injection, redirects, or cache differences. Please verify app compatibility.", + "infoSourceUrl": "Source URL", + "infoUrlUnavailable": "URL unavailable" + }, "detail": { "permissions": "App Permissions" } diff --git a/src/i18n/locales/en/ecosystem.json b/src/i18n/locales/en/ecosystem.json index 0e702d690..09cada004 100644 --- a/src/i18n/locales/en/ecosystem.json +++ b/src/i18n/locales/en/ecosystem.json @@ -111,7 +111,26 @@ }, "capsule": { "more": "More Options", - "close": "Close App" + "close": "Close App", + "infoTitle": "App Details", + "infoApp": "App", + "infoAppId": "App ID", + "infoVersion": "Version", + "infoAuthor": "Developer", + "infoSourceName": "Source", + "infoSourceUnknown": "Unknown source", + "infoRuntime": "Runtime", + "infoStrictUrl": "Strict URL", + "infoStrictEnabled": "Enabled", + "infoStrictDisabled": "Disabled", + "infoEntryUrl": "Configured Entry URL", + "infoCurrentUrl": "Current URL", + "infoUrlQueryAdjustedTitle": "Runtime URL has extra query parameters", + "infoUrlQueryAdjustedHint": "Only query parameters changed (for version/cache control); this is usually expected.", + "infoUrlMismatchTitle": "Entry URL differs from runtime URL", + "infoUrlMismatchHint": "This may come from query injection, redirects, or cache differences. Please verify app compatibility.", + "infoSourceUrl": "Source URL", + "infoUrlUnavailable": "URL unavailable" }, "detail": { "notFound": "App not found", diff --git a/src/i18n/locales/zh-CN/ecosystem.json b/src/i18n/locales/zh-CN/ecosystem.json index 21adb5cef..db56d867c 100644 --- a/src/i18n/locales/zh-CN/ecosystem.json +++ b/src/i18n/locales/zh-CN/ecosystem.json @@ -111,7 +111,26 @@ }, "capsule": { "more": "更多操作", - "close": "关闭应用" + "close": "关闭应用", + "infoTitle": "应用详情", + "infoApp": "应用", + "infoAppId": "应用 ID", + "infoVersion": "版本", + "infoAuthor": "开发者", + "infoSourceName": "来源", + "infoSourceUnknown": "未知来源", + "infoRuntime": "运行时", + "infoStrictUrl": "严格 URL", + "infoStrictEnabled": "已启用", + "infoStrictDisabled": "未启用", + "infoEntryUrl": "配置入口 URL", + "infoCurrentUrl": "当前 URL", + "infoUrlQueryAdjustedTitle": "运行 URL 已附加参数", + "infoUrlQueryAdjustedHint": "仅附加查询参数(如版本/缓存参数),通常属于正常行为。", + "infoUrlMismatchTitle": "入口 URL 与运行 URL 不一致", + "infoUrlMismatchHint": "可能存在参数注入、重定向或缓存差异,请确认该应用可兼容该运行 URL。", + "infoSourceUrl": "订阅源 URL", + "infoUrlUnavailable": "暂无可用 URL" }, "detail": { "notFound": "应用不存在", diff --git a/src/i18n/locales/zh-TW/ecosystem.json b/src/i18n/locales/zh-TW/ecosystem.json index e1df80389..7a088faff 100644 --- a/src/i18n/locales/zh-TW/ecosystem.json +++ b/src/i18n/locales/zh-TW/ecosystem.json @@ -111,7 +111,26 @@ }, "capsule": { "more": "更多操作", - "close": "關閉應用" + "close": "關閉應用", + "infoTitle": "應用詳情", + "infoApp": "應用", + "infoAppId": "應用 ID", + "infoVersion": "版本", + "infoAuthor": "開發者", + "infoSourceName": "來源", + "infoSourceUnknown": "未知來源", + "infoRuntime": "運行時", + "infoStrictUrl": "嚴格 URL", + "infoStrictEnabled": "已啟用", + "infoStrictDisabled": "未啟用", + "infoEntryUrl": "配置入口 URL", + "infoCurrentUrl": "當前 URL", + "infoUrlQueryAdjustedTitle": "運行 URL 已附加參數", + "infoUrlQueryAdjustedHint": "僅附加查詢參數(如版本/快取參數),通常屬於正常行為。", + "infoUrlMismatchTitle": "入口 URL 與運行 URL 不一致", + "infoUrlMismatchHint": "可能存在參數注入、重導向或快取差異,請確認該應用可相容該運行 URL。", + "infoSourceUrl": "訂閱源 URL", + "infoUrlUnavailable": "暫無可用 URL" }, "detail": { "notFound": "應用不存在", diff --git a/src/test/i18n-mock.tsx b/src/test/i18n-mock.tsx index 7ff249910..79a9852be 100644 --- a/src/test/i18n-mock.tsx +++ b/src/test/i18n-mock.tsx @@ -9,6 +9,7 @@ import zhCNTransaction from '@/i18n/locales/zh-CN/transaction.json' import zhCNNotification from '@/i18n/locales/zh-CN/notification.json' import zhCNWallet from '@/i18n/locales/zh-CN/wallet.json' import zhCNSecurity from '@/i18n/locales/zh-CN/security.json' +import zhCNEcosystem from '@/i18n/locales/zh-CN/ecosystem.json' // 创建测试用的 i18n 实例 const testI18n = i18n.createInstance() @@ -16,7 +17,7 @@ const testI18n = i18n.createInstance() testI18n.use(initReactI18next).init({ lng: 'zh-CN', fallbackLng: 'zh-CN', - ns: ['translation', 'authorize', 'common', 'settings', 'onboarding', 'transaction', 'notification', 'wallet', 'security'], + ns: ['translation', 'authorize', 'common', 'settings', 'onboarding', 'transaction', 'notification', 'wallet', 'security', 'ecosystem'], defaultNS: 'translation', resources: { 'zh-CN': { @@ -124,6 +125,7 @@ testI18n.use(initReactI18next).init({ notification: zhCNNotification, wallet: zhCNWallet, security: zhCNSecurity, + ecosystem: zhCNEcosystem, }, }, interpolation: { From e23b20b330e3819722c6018f9917d40dabefdbb5 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 14 Feb 2026 18:37:35 +0800 Subject: [PATCH 2/3] fix(i18n): use explicit common namespace keys in capsule url actions --- src/components/ecosystem/miniapp-capsule-info-sheet.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ecosystem/miniapp-capsule-info-sheet.tsx b/src/components/ecosystem/miniapp-capsule-info-sheet.tsx index 6db3f7466..6943acdde 100644 --- a/src/components/ecosystem/miniapp-capsule-info-sheet.tsx +++ b/src/components/ecosystem/miniapp-capsule-info-sheet.tsx @@ -170,7 +170,7 @@ function UrlCard({ } function UrlLine({ url }: { url: string }) { - const { t } = useTranslation(['common']); + const { t } = useTranslation(); const [copied, setCopied] = useState(false); useEffect(() => { @@ -197,7 +197,7 @@ function UrlLine({ url }: { url: string }) { type="button" onClick={handleCopy} className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors" - aria-label={copied ? t('copiedToClipboard') : t('copy')} + aria-label={copied ? t('common:copiedToClipboard') : t('common:copy')} > {copied ? : } @@ -206,7 +206,7 @@ function UrlLine({ url }: { url: string }) { target="_blank" rel="noreferrer noopener" className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors" - aria-label={t('open')} + aria-label={t('common:open')} > From f0fbea4599dd72433d9788dc0ac25cbd0dc65b79 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 14 Feb 2026 18:39:34 +0800 Subject: [PATCH 3/3] fix(i18n): use existing open key for capsule url action --- src/components/ecosystem/miniapp-capsule-info-sheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ecosystem/miniapp-capsule-info-sheet.tsx b/src/components/ecosystem/miniapp-capsule-info-sheet.tsx index 6943acdde..00f0bd1ae 100644 --- a/src/components/ecosystem/miniapp-capsule-info-sheet.tsx +++ b/src/components/ecosystem/miniapp-capsule-info-sheet.tsx @@ -206,7 +206,7 @@ function UrlLine({ url }: { url: string }) { target="_blank" rel="noreferrer noopener" className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors" - aria-label={t('common:open')} + aria-label={t('common:ecosystem.menu.open')} >