产品体验修复: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:
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
@@ -21,37 +23,82 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
<section
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
maxWidth: 480,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
background: "rgba(9, 18, 36, 0.88)",
|
||||
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
|
||||
Entitlement Required
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
需要登录方可访问此页面
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
|
||||
<a
|
||||
本页面需要登录权限。请登录后重试。
|
||||
<br />
|
||||
Sign in required to access this page.
|
||||
</p>
|
||||
<div style={{ marginTop: 20, display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{
|
||||
color: "#8fc5ff",
|
||||
display: "inline-flex",
|
||||
minHeight: 36,
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
/auth/login
|
||||
</a>{" "}
|
||||
to sign in first.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Legacy mode still supports <code>?access_token=<your-token></code>.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Requested path: <code>{nextPath}</code>
|
||||
去登录 / Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
background: "rgba(68, 92, 140, 0.2)",
|
||||
color: "#d6e2ff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
返回首页 / Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontSize: 12,
|
||||
color: "#7891b5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
传统令牌模式仍支持 <code style={{ background: "rgba(255,255,255,0.06)", padding: "2px 6px", borderRadius: 4 }}>?access_token=<your-token></code>
|
||||
<br />
|
||||
<span style={{ marginTop: 4, display: "inline-block" }}>
|
||||
请求路径 / Requested path: <code>{nextPath}</code>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
@@ -8,11 +10,7 @@ import {
|
||||
MessageSquare,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | 订阅说明",
|
||||
description: "PolyWeather Pro 订阅、积分抵扣与支付方式说明。",
|
||||
};
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
@@ -21,24 +19,50 @@ const TELEGRAM_GROUP_URL = String(
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: "Pro 包含哪些功能?",
|
||||
a: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
q_en: "What features does Pro include?",
|
||||
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
|
||||
a_en: "Unlocks: intraday METAR rule analysis (including peak window), historical reconciliation + future-date analysis, and cross-platform smart weather push.",
|
||||
},
|
||||
{
|
||||
q: "当前订阅价格是多少?",
|
||||
a: "目前仅提供月付:5 USDC / 30 天。",
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
q_en: "What is the current subscription price?",
|
||||
a_zh: "目前仅提供月付:5 USDC / 30 天。",
|
||||
a_en: "Monthly plan only: 5 USDC / 30 days.",
|
||||
},
|
||||
{
|
||||
q: "积分如何抵扣?",
|
||||
a: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
|
||||
q_zh: "积分如何抵扣?",
|
||||
q_en: "How do points work for discounts?",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
|
||||
a_en: "500 points minimum — every 500 points = 1 USDC off, up to 3 USDC discount per payment.",
|
||||
},
|
||||
{
|
||||
q: "支持哪些钱包和支付方式?",
|
||||
a: "支持 EVM 浏览器钱包(MetaMask / OKX / Rabby / Bitget 等)及 WalletConnect 扫码钱包(Trust Wallet / Binance Web3 Wallet / TokenPocket 等)。",
|
||||
q_zh: "支持哪些钱包和支付方式?",
|
||||
q_en: "Which wallets and payment methods are supported?",
|
||||
a_zh: "支持 EVM 浏览器钱包(MetaMask / OKX / Rabby / Bitget 等)及 WalletConnect 扫码钱包(Trust Wallet / Binance Web3 Wallet / TokenPocket 等)。",
|
||||
a_en: "EVM browser wallets (MetaMask, OKX, Rabby, Bitget, etc.) and WalletConnect-compatible wallets (Trust Wallet, Binance Web3 Wallet, TokenPocket, etc.).",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SubscriptionHelpPage() {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const copy = useMemo(() => ({
|
||||
back: isEn ? "Back to Account" : "返回账户中心",
|
||||
title: isEn ? "PolyWeather Pro Subscription Guide" : "PolyWeather Pro 订阅说明",
|
||||
description: isEn
|
||||
? "Complete subscription rules and payment guide."
|
||||
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
|
||||
priceLabel: isEn ? "Price" : "订阅价格",
|
||||
priceText: "5 USDC / 30 " + (isEn ? "Days" : "天"),
|
||||
discountLabel: isEn ? "Points Discount" : "积分抵扣",
|
||||
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U",
|
||||
communityLabel: isEn ? "Community Points" : "社群积分",
|
||||
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分",
|
||||
faqTitle: isEn ? "FAQ" : "常见问题",
|
||||
}), [isEn]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#070d1d] px-4 py-10 text-slate-100">
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
@@ -47,62 +71,62 @@ export default function SubscriptionHelpPage() {
|
||||
className="mb-5 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-300 transition hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
返回账户中心
|
||||
{copy.back}
|
||||
</Link>
|
||||
|
||||
<section className="rounded-3xl border border-blue-400/20 bg-gradient-to-b from-[#162541] to-[#0e1730] p-6 md:p-8">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<ShieldCheck className="text-cyan-300" size={22} />
|
||||
<h1 className="text-2xl font-bold md:text-3xl">PolyWeather Pro 订阅说明</h1>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{copy.title}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 md:text-base">
|
||||
这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。
|
||||
</p>
|
||||
<p className="text-sm text-slate-300 md:text-base">{copy.description}</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-cyan-300">
|
||||
<CreditCard size={16} />
|
||||
<span className="text-sm font-semibold">订阅价格</span>
|
||||
<span className="text-sm font-semibold">{copy.priceLabel}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">5 USDC / 30 天</p>
|
||||
<p className="text-xl font-bold">{copy.priceText}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-emerald-300">
|
||||
<Coins size={16} />
|
||||
<span className="text-sm font-semibold">积分抵扣</span>
|
||||
<span className="text-sm font-semibold">{copy.discountLabel}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">最多抵 3U</p>
|
||||
<p className="text-xl font-bold">{copy.discountText}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-violet-300">
|
||||
<MessageSquare size={16} />
|
||||
<span className="text-sm font-semibold">社群积分</span>
|
||||
<span className="text-sm font-semibold">{copy.communityLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
|
||||
>
|
||||
加入社群即可赚取积分
|
||||
{copy.communityLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-3xl border border-white/10 bg-[#0f162a]/80 p-6 md:p-8">
|
||||
<h2 className="mb-4 text-lg font-bold">常见问题</h2>
|
||||
<h2 className="mb-4 text-lg font-bold">{copy.faqTitle}</h2>
|
||||
<div className="space-y-4">
|
||||
{FAQ_ITEMS.map((item) => (
|
||||
<article
|
||||
key={item.q}
|
||||
key={item.q_en}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
|
||||
>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-blue-300">
|
||||
<CheckCircle2 size={14} />
|
||||
{item.q}
|
||||
{isEn ? item.q_en : item.q_zh}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-slate-300">{item.a}</p>
|
||||
<p className="text-sm leading-6 text-slate-300">
|
||||
{isEn ? item.a_en : item.a_zh}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 today’s 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),
|
||||
|
||||
Reference in New Issue
Block a user