Skip to content

Commit 67b3b07

Browse files
authored
fix(ecosystem): miniapp capsule info UX + pattern error clarity (#450)
* fix(ecosystem): improve miniapp capsule info UX and error-state clarity * fix(i18n): use explicit common namespace keys in capsule url actions * fix(i18n): use existing open key for capsule url action
1 parent b635a78 commit 67b3b07

11 files changed

Lines changed: 507 additions & 15 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { TestI18nProvider } from '@/test/i18n-mock';
4+
import { MiniappCapsuleInfoSheet } from './miniapp-capsule-info-sheet';
5+
6+
describe('MiniappCapsuleInfoSheet', () => {
7+
it('shows info state when only query params differ', () => {
8+
render(
9+
<TestI18nProvider>
10+
<MiniappCapsuleInfoSheet
11+
open={true}
12+
onOpenChange={vi.fn()}
13+
appName="RWA Hub"
14+
appId="com.bioforest.rwa-hub"
15+
version="1.3.0"
16+
author="BioForest"
17+
sourceName="Official"
18+
runtime="iframe"
19+
entryUrl="https://rwahub.example.com/"
20+
currentUrl="https://rwahub.example.com/?__rv=v%3A1.3.0"
21+
sourceUrl="https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json"
22+
strictUrl={false}
23+
/>
24+
</TestI18nProvider>,
25+
);
26+
27+
expect(screen.getByText('RWA Hub')).toBeInTheDocument();
28+
expect(screen.getByText('com.bioforest.rwa-hub')).toBeInTheDocument();
29+
expect(screen.getByText('1.3.0')).toBeInTheDocument();
30+
expect(screen.getByText('BioForest')).toBeInTheDocument();
31+
expect(screen.getByText('Official')).toBeInTheDocument();
32+
expect(screen.getByTestId('miniapp-capsule-current-url')).toBeInTheDocument();
33+
expect(screen.getByTestId('miniapp-capsule-url-adjusted-info')).toBeInTheDocument();
34+
expect(screen.getByTestId('miniapp-capsule-entry-url')).toBeInTheDocument();
35+
expect(screen.getByTestId('miniapp-capsule-source-url')).toBeInTheDocument();
36+
});
37+
38+
it('hides entry url warning when entry url equals runtime url', () => {
39+
render(
40+
<TestI18nProvider>
41+
<MiniappCapsuleInfoSheet
42+
open={true}
43+
onOpenChange={vi.fn()}
44+
appName="RWA Hub"
45+
appId="com.bioforest.rwa-hub"
46+
version="1.3.0"
47+
author="BioForest"
48+
sourceName="Official"
49+
runtime="iframe"
50+
entryUrl="https://rwahub.example.com/"
51+
currentUrl="https://rwahub.example.com/"
52+
sourceUrl="https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json"
53+
strictUrl={false}
54+
/>
55+
</TestI18nProvider>,
56+
);
57+
58+
expect(screen.getByTestId('miniapp-capsule-current-url')).toBeInTheDocument();
59+
expect(screen.queryByTestId('miniapp-capsule-url-adjusted-info')).not.toBeInTheDocument();
60+
expect(screen.queryByTestId('miniapp-capsule-url-mismatch-warning')).not.toBeInTheDocument();
61+
expect(screen.queryByTestId('miniapp-capsule-entry-url')).not.toBeInTheDocument();
62+
});
63+
64+
it('shows warning state when origin/path differs', () => {
65+
render(
66+
<TestI18nProvider>
67+
<MiniappCapsuleInfoSheet
68+
open={true}
69+
onOpenChange={vi.fn()}
70+
appName="RWA Hub"
71+
appId="com.bioforest.rwa-hub"
72+
version="1.3.0"
73+
author="BioForest"
74+
sourceName="Official"
75+
runtime="iframe"
76+
entryUrl="https://rwahub.example.com/"
77+
currentUrl="https://rwahub-alt.example.com/"
78+
sourceUrl="https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json"
79+
strictUrl={false}
80+
/>
81+
</TestI18nProvider>,
82+
);
83+
84+
expect(screen.getByTestId('miniapp-capsule-current-url')).toBeInTheDocument();
85+
expect(screen.queryByTestId('miniapp-capsule-url-adjusted-info')).not.toBeInTheDocument();
86+
expect(screen.getByTestId('miniapp-capsule-url-mismatch-warning')).toBeInTheDocument();
87+
expect(screen.getByTestId('miniapp-capsule-entry-url')).toBeInTheDocument();
88+
});
89+
});
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { useTranslation } from 'react-i18next';
2+
import { useEffect, useState } from 'react';
3+
import { IconAlertTriangle, IconCheck, IconCopy, IconInfoCircle, IconExternalLink } from '@tabler/icons-react';
4+
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
5+
import { clipboardService } from '@/services/clipboard';
6+
7+
interface MiniappCapsuleInfoSheetProps {
8+
open: boolean;
9+
onOpenChange: (open: boolean) => void;
10+
appName: string;
11+
appId: string;
12+
version: string;
13+
author?: string;
14+
sourceName?: string;
15+
runtime: 'iframe' | 'wujie';
16+
entryUrl: string;
17+
currentUrl: string;
18+
sourceUrl?: string;
19+
strictUrl?: boolean;
20+
}
21+
22+
export function MiniappCapsuleInfoSheet({
23+
open,
24+
onOpenChange,
25+
appName,
26+
appId,
27+
version,
28+
author,
29+
sourceName,
30+
runtime,
31+
entryUrl,
32+
currentUrl,
33+
sourceUrl,
34+
strictUrl,
35+
}: MiniappCapsuleInfoSheetProps) {
36+
const { t } = useTranslation('ecosystem');
37+
const normalizedEntryUrl = entryUrl.trim();
38+
const normalizedCurrentUrl = currentUrl.trim();
39+
const urlMismatch = resolveUrlMismatch(normalizedEntryUrl, normalizedCurrentUrl, strictUrl ?? false);
40+
41+
return (
42+
<Sheet open={open} onOpenChange={onOpenChange}>
43+
<SheetContent side="bottom" className="rounded-t-2xl pb-[max(env(safe-area-inset-bottom),1rem)]">
44+
<SheetHeader>
45+
<SheetTitle>{t('capsule.infoTitle')}</SheetTitle>
46+
</SheetHeader>
47+
48+
<div className="space-y-3 px-4">
49+
<div className="bg-muted/50 space-y-2 rounded-xl p-3">
50+
<InfoRow label={t('capsule.infoApp')} value={appName} />
51+
<InfoRow label={t('capsule.infoAppId')} value={appId} />
52+
<InfoRow label={t('capsule.infoVersion')} value={version} />
53+
<InfoRow label={t('capsule.infoAuthor')} value={author || t('detail.unknownDeveloper')} />
54+
<InfoRow label={t('capsule.infoSourceName')} value={sourceName || t('capsule.infoSourceUnknown')} />
55+
<InfoRow label={t('capsule.infoRuntime')} value={runtime} />
56+
<InfoRow
57+
label={t('capsule.infoStrictUrl')}
58+
value={strictUrl ? t('capsule.infoStrictEnabled') : t('capsule.infoStrictDisabled')}
59+
/>
60+
</div>
61+
62+
<UrlCard
63+
label={t('capsule.infoCurrentUrl')}
64+
url={normalizedCurrentUrl}
65+
emptyText={t('capsule.infoUrlUnavailable')}
66+
testId="miniapp-capsule-current-url"
67+
/>
68+
69+
{urlMismatch.type === 'query-only' && (
70+
<div
71+
data-testid="miniapp-capsule-url-adjusted-info"
72+
className="rounded-xl border border-sky-500/30 bg-sky-50 p-3 dark:border-sky-400/30 dark:bg-sky-950/30"
73+
>
74+
<div className="mb-2 flex items-start gap-2">
75+
<IconInfoCircle className="mt-0.5 size-4 shrink-0 text-sky-600 dark:text-sky-300" />
76+
<div>
77+
<div className="text-sm font-medium text-sky-800 dark:text-sky-200">
78+
{t('capsule.infoUrlQueryAdjustedTitle')}
79+
</div>
80+
<p className="text-xs text-sky-700 dark:text-sky-300">
81+
{t('capsule.infoUrlQueryAdjustedHint')}
82+
</p>
83+
</div>
84+
</div>
85+
86+
<UrlCard
87+
label={t('capsule.infoEntryUrl')}
88+
url={normalizedEntryUrl}
89+
emptyText={t('capsule.infoUrlUnavailable')}
90+
testId="miniapp-capsule-entry-url"
91+
/>
92+
</div>
93+
)}
94+
95+
{urlMismatch.type === 'warning' && (
96+
<div
97+
data-testid="miniapp-capsule-url-mismatch-warning"
98+
className="rounded-xl border border-amber-500/30 bg-amber-50 p-3 dark:border-amber-400/30 dark:bg-amber-950/30"
99+
>
100+
<div className="mb-2 flex items-start gap-2">
101+
<IconAlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-300" />
102+
<div>
103+
<div className="text-sm font-medium text-amber-800 dark:text-amber-200">
104+
{t('capsule.infoUrlMismatchTitle')}
105+
</div>
106+
<p className="text-xs text-amber-700 dark:text-amber-300">
107+
{t('capsule.infoUrlMismatchHint')}
108+
</p>
109+
</div>
110+
</div>
111+
112+
<UrlCard
113+
label={t('capsule.infoEntryUrl')}
114+
url={normalizedEntryUrl}
115+
emptyText={t('capsule.infoUrlUnavailable')}
116+
testId="miniapp-capsule-entry-url"
117+
/>
118+
</div>
119+
)}
120+
121+
{sourceUrl && (
122+
<UrlCard
123+
label={t('capsule.infoSourceUrl')}
124+
url={sourceUrl}
125+
emptyText={t('capsule.infoUrlUnavailable')}
126+
testId="miniapp-capsule-source-url"
127+
/>
128+
)}
129+
</div>
130+
</SheetContent>
131+
</Sheet>
132+
);
133+
}
134+
135+
function InfoRow({ label, value }: { label: string; value: string }) {
136+
return (
137+
<div className="flex items-center justify-between gap-3">
138+
<span className="text-muted-foreground text-sm">{label}</span>
139+
<span className="text-sm font-medium">{value}</span>
140+
</div>
141+
);
142+
}
143+
144+
function UrlCard({
145+
label,
146+
url,
147+
emptyText,
148+
testId,
149+
}: {
150+
label: string;
151+
url: string;
152+
emptyText: string;
153+
testId: string;
154+
}) {
155+
if (!url) {
156+
return (
157+
<div className="bg-muted/50 rounded-xl p-3">
158+
<div className="text-muted-foreground mb-1 text-xs">{label}</div>
159+
<div className="text-muted-foreground text-sm">{emptyText}</div>
160+
</div>
161+
);
162+
}
163+
164+
return (
165+
<div className="bg-muted/50 space-y-2 rounded-xl p-3" data-testid={testId}>
166+
<div className="text-muted-foreground text-xs">{label}</div>
167+
<UrlLine url={url} />
168+
</div>
169+
);
170+
}
171+
172+
function UrlLine({ url }: { url: string }) {
173+
const { t } = useTranslation();
174+
const [copied, setCopied] = useState(false);
175+
176+
useEffect(() => {
177+
if (!copied) return;
178+
const timer = window.setTimeout(() => setCopied(false), 1600);
179+
return () => window.clearTimeout(timer);
180+
}, [copied]);
181+
182+
const handleCopy = async () => {
183+
try {
184+
await clipboardService.write({ text: url });
185+
setCopied(true);
186+
} catch {
187+
setCopied(false);
188+
}
189+
};
190+
191+
return (
192+
<div className="flex items-center gap-2">
193+
<p className="min-w-0 flex-1 truncate font-mono text-sm" title={url}>
194+
{url}
195+
</p>
196+
<button
197+
type="button"
198+
onClick={handleCopy}
199+
className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors"
200+
aria-label={copied ? t('common:copiedToClipboard') : t('common:copy')}
201+
>
202+
{copied ? <IconCheck className="size-4 text-emerald-500" /> : <IconCopy className="size-4" />}
203+
</button>
204+
<a
205+
href={url}
206+
target="_blank"
207+
rel="noreferrer noopener"
208+
className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors"
209+
aria-label={t('common:ecosystem.menu.open')}
210+
>
211+
<IconExternalLink className="size-4" />
212+
</a>
213+
</div>
214+
);
215+
}
216+
217+
type UrlMismatchType = 'none' | 'query-only' | 'warning';
218+
219+
function resolveUrlMismatch(entry: string, current: string, strictUrl: boolean): { type: UrlMismatchType } {
220+
if (!entry || !current || entry === current) {
221+
return { type: 'none' };
222+
}
223+
224+
if (strictUrl) {
225+
return { type: 'warning' };
226+
}
227+
228+
try {
229+
const entryUrl = new URL(entry);
230+
const currentUrl = new URL(current);
231+
const sameOrigin = entryUrl.origin === currentUrl.origin;
232+
const samePath = normalizePath(entryUrl.pathname) === normalizePath(currentUrl.pathname);
233+
const sameHash = entryUrl.hash === currentUrl.hash;
234+
235+
if (sameOrigin && samePath && sameHash && entryUrl.search !== currentUrl.search) {
236+
return { type: 'query-only' };
237+
}
238+
} catch {
239+
return { type: 'warning' };
240+
}
241+
242+
return { type: 'warning' };
243+
}
244+
245+
function normalizePath(pathname: string): string {
246+
if (pathname.length > 1 && pathname.endsWith('/')) {
247+
return pathname.slice(0, -1);
248+
}
249+
return pathname;
250+
}

0 commit comments

Comments
 (0)