产品体验修复:P0 阻塞 + P1/P2 改善(产品审查 14 项中修复 10 项)

P0 阻塞:
- Pro 加载不再阻塞整个看板:只显示精简顶栏 + 加载状态,不再遮盖整个页面
- Scan 失败加重试按钮:所有用户(含免费)均可手动重试
- Detail Panel 同步超时后显示提示:"同步时间较长,当前展示的数据可能不完整"

P1 体验:
- 登录页增加"忘记密码"链接:调用 Supabase 密码重置流程
- 登录失败/邮箱未验证增加明确提示:"如刚注册,请先点击验证链接"
- 公告横幅增加 ✕ 关闭按钮:点击后永久隐藏

P2 改善:
- entitlement-required 页面重设计:品牌化、增加返回首页和登录按钮
- 订阅帮助页中英双语:所有 FAQ 和 UI 文案支持中英文切换
- 新增产品审查文档 docs/product-review-jun-2026.md

Tested: npx tsc --noEmit
This commit is contained in:
2569718930@qq.com
2026-05-10 16:13:34 +08:00
parent 9635387beb
commit de0f037bf4
8 changed files with 392 additions and 46 deletions
+57
View File
@@ -34,6 +34,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const [resetSent, setResetSent] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const isEn = locale === "en-US";
@@ -74,8 +75,45 @@ export function LoginClient({ nextPath }: LoginClientProps) {
trialPromo: isEn
? "New users unlock a free 3-day Pro trial after sign-up."
: "新用户注册后可免费体验 3 天 Pro。",
reset: isEn ? "Forgot password?" : "忘记密码?",
resetSent: isEn
? "Reset link sent. Check your inbox."
: "重置链接已发送,请检查收件箱。",
resetPlaceholder: isEn ? "Enter your email to reset" : "输入邮箱以重置密码",
resendVerify: isEn
? "Didn't receive the verification email? Sign up again with the same email to resend."
: "没收到验证邮件?用同一邮箱重新注册即可重发。",
loginFailedHint: isEn
? "If you just signed up, please verify your email first. Check your inbox or spam folder."
: "如果刚注册,请先点击邮箱中的验证链接。检查收件箱或垃圾邮件。",
} as const;
const onResetPassword = async () => {
setErrorText("");
setInfoText("");
if (!email.trim()) {
setErrorText(copy.resetPlaceholder);
return;
}
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim());
if (error) {
setErrorText(error.message);
return;
}
setResetSent(true);
setInfoText(copy.resetSent);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!supabaseReady) return;
const run = async () => {
@@ -270,6 +308,19 @@ export function LoginClient({ nextPath }: LoginClientProps) {
/>
</div>
{isLogin && !resetSent ? (
<div className="mt-2 text-right">
<button
type="button"
onClick={() => void onResetPassword()}
disabled={loading}
className="text-xs text-slate-500 underline-offset-2 transition-all hover:text-blue-400 hover:underline disabled:opacity-50"
>
{copy.reset}
</button>
</div>
) : null}
<button
type="submit"
disabled={loading}
@@ -282,6 +333,12 @@ export function LoginClient({ nextPath }: LoginClientProps) {
{errorText ? <p className="mt-4 text-sm text-rose-300">{errorText}</p> : null}
{infoText ? <p className="mt-4 text-sm text-emerald-300">{infoText}</p> : null}
{errorText && isLogin && errorText.includes("Invalid login") ? (
<p className="mt-1 text-xs text-slate-500">{copy.loginFailedHint}</p>
) : null}
{infoText === copy.signupCheckEmail ? (
<p className="mt-1 text-xs text-slate-500">{copy.resendVerify}</p>
) : null}
<div className="mt-8 text-center">
<p className="text-xs text-slate-500">
@@ -277,6 +277,15 @@ export function DetailPanel({
</span>
</div>
)}
{panelSyncTimedOut && (
<div className="panel-loading-hint timed-out" role="alert">
<span>
{locale === "en-US"
? "Sync is taking longer than expected. Data shown may be incomplete."
: "同步时间较长,当前展示的数据可能不完整。"}
</span>
</div>
)}
<div className="panel-meta">
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
@@ -210,3 +210,24 @@
font-size: 13px;
line-height: 1.7;
}
.root :global(.panel-loading-hint) {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(77, 163, 255, 0.06);
border: 1px solid rgba(77, 163, 255, 0.1);
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 600;
}
.root :global(.panel-loading-hint.timed-out) {
background: rgba(245, 158, 11, 0.06);
border-color: rgba(245, 158, 11, 0.16);
color: var(--color-signal-warning);
font-size: 12px;
}
@@ -353,12 +353,25 @@ function ScanTerminalScreen() {
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<span>
{isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
<div className="scan-topbar-actions">
<span className="scan-topbar-time">{userLocalTime}</span>
</div>
</div>
<div className="scan-loading-state">
<LoadingSignal
title={isEn ? "Preparing decision workspace" : "正在准备决策工作台"}
description={
isEn
? "Checking access, city context and todays tradable weather windows."
? "Checking access, city context and today's tradable weather windows."
: "正在检查权限、城市上下文和今日可交易天气窗口。"
}
/>
@@ -461,6 +474,17 @@ function ScanTerminalScreen() {
<li>{isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}</li>
<li>{isEn ? "Smoother scrolling experience" : "滚动体验优化"}</li>
</ul>
<button
type="button"
className="scan-announcement-dismiss"
aria-label={isEn ? "Dismiss" : "关闭"}
onClick={() => {
localStorage.setItem("polyweather_v156_announcement_seen_at", String(Date.now() + 90 * 24 * 60 * 60 * 1000));
setShowAnnouncement(false);
}}
>
</button>
</section>
) : null}
@@ -534,6 +558,14 @@ function ScanTerminalScreen() {
{isEn ? "Scan failed" : "扫描失败"}
</div>
<div className="scan-empty-copy">{staleReason}</div>
<button
type="button"
className="scan-retry-button"
onClick={() => refreshScanTerminalManually()}
>
<RefreshCw size={13} />
{isEn ? "Retry" : "重试"}
</button>
</div>
) : (
renderMainView()
@@ -148,6 +148,32 @@
padding: 7px 10px;
}
.root :global(.scan-announcement-dismiss) {
position: absolute;
top: 10px;
right: 12px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: rgba(148, 163, 184, 0.1);
color: #94a3b8;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.root :global(.scan-announcement-dismiss:hover) {
background: rgba(148, 163, 184, 0.22);
color: #e2e8f0;
}
.root :global(.scan-upgrade-announcement) {
position: relative;
}
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost),
.root :global(.scan-theme-button),