de0f037bf4
P0 阻塞: - Pro 加载不再阻塞整个看板:只显示精简顶栏 + 加载状态,不再遮盖整个页面 - Scan 失败加重试按钮:所有用户(含免费)均可手动重试 - Detail Panel 同步超时后显示提示:"同步时间较长,当前展示的数据可能不完整" P1 体验: - 登录页增加"忘记密码"链接:调用 Supabase 密码重置流程 - 登录失败/邮箱未验证增加明确提示:"如刚注册,请先点击验证链接" - 公告横幅增加 ✕ 关闭按钮:点击后永久隐藏 P2 改善: - entitlement-required 页面重设计:品牌化、增加返回首页和登录按钮 - 订阅帮助页中英双语:所有 FAQ 和 UI 文案支持中英文切换 - 新增产品审查文档 docs/product-review-jun-2026.md Tested: npx tsc --noEmit
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import clsx from "clsx";
|
||
import dynamic from "next/dynamic";
|
||
import { useRouter } from "next/navigation";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||
import { useI18n } from "@/hooks/useI18n";
|
||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||
import { trackAppEvent } from "@/lib/app-analytics";
|
||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
||
import { getCityProfileStats } from "@/lib/dashboard-utils";
|
||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
|
||
|
||
const DetailMiniTemperatureChart = dynamic(
|
||
() =>
|
||
import("@/components/dashboard/DetailMiniTemperatureChart").then(
|
||
(module) => module.DetailMiniTemperatureChart,
|
||
),
|
||
{
|
||
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
|
||
ssr: false,
|
||
},
|
||
);
|
||
|
||
export function DetailPanel({
|
||
variant = "overlay",
|
||
}: {
|
||
variant?: "overlay" | "rail";
|
||
} = {}) {
|
||
const store = useDashboardStore();
|
||
const { locale, t } = useI18n();
|
||
const router = useRouter();
|
||
const isRail = variant === "rail";
|
||
const detail = store.selectedDetail;
|
||
const selectedCityItem = useMemo(
|
||
() =>
|
||
store.selectedCity
|
||
? store.cities.find((city) => city.name === store.selectedCity) || null
|
||
: null,
|
||
[store.cities, store.selectedCity],
|
||
);
|
||
const selectedSummary = useMemo(
|
||
() =>
|
||
store.selectedCity
|
||
? store.citySummariesByName[store.selectedCity] || null
|
||
: null,
|
||
[store.citySummariesByName, store.selectedCity],
|
||
);
|
||
const isPro = store.proAccess.subscriptionActive;
|
||
const isAuthenticated = store.proAccess.authenticated;
|
||
const panelRef = useRef<HTMLElement | null>(null);
|
||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||
const isOverlayOpen =
|
||
Boolean(store.futureModalDate) || store.historyState.isOpen;
|
||
const isVisible = isRail
|
||
? Boolean(store.selectedCity) && !isOverlayOpen
|
||
: store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
||
const hasBasicPanelContent = Boolean(
|
||
detail || selectedSummary || selectedCityItem,
|
||
);
|
||
const panelDisplayName =
|
||
detail?.display_name ||
|
||
selectedSummary?.display_name ||
|
||
selectedCityItem?.display_name ||
|
||
store.selectedCity ||
|
||
"...";
|
||
const panelRiskLevel =
|
||
detail?.risk?.level ||
|
||
selectedSummary?.risk?.level ||
|
||
selectedCityItem?.risk_level ||
|
||
"low";
|
||
const profileStats = useMemo(
|
||
() => (detail ? getCityProfileStats(detail, locale) : []),
|
||
[detail, locale],
|
||
);
|
||
const officialLinks = useMemo(
|
||
() => (detail ? getOfficialSourceLinks(detail) : []),
|
||
[detail],
|
||
);
|
||
const marketUrl = useMemo(
|
||
() => getTodayPolymarketUrl(detail, locale),
|
||
[detail, locale],
|
||
);
|
||
const basicSettlementLabel = normalizeObservationSourceLabel(
|
||
selectedSummary?.current?.settlement_source_label ||
|
||
selectedCityItem?.settlement_source_label ||
|
||
selectedCityItem?.settlement_source,
|
||
locale === "en-US" ? "Settlement source pending" : "结算口径待确认",
|
||
);
|
||
const basicAirportLabel =
|
||
selectedCityItem?.airport ||
|
||
selectedSummary?.icao ||
|
||
(locale === "en-US" ? "Airport pending" : "机场待确认");
|
||
const heroSettlementLabel = normalizeObservationSourceLabel(
|
||
detail?.current?.settlement_source_label,
|
||
basicSettlementLabel,
|
||
);
|
||
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
|
||
const isSparsePanelDetail = Boolean(
|
||
detail &&
|
||
(detail.detail_depth !== "full" ||
|
||
(detail.forecast?.daily?.length ?? 0) <= 1),
|
||
);
|
||
const isPanelSyncing = store.loadingState.cityDetail;
|
||
const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false);
|
||
const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut;
|
||
const isShowingCachedDetailDuringSync = false;
|
||
|
||
const blurActiveElement = () => {
|
||
if (typeof document === "undefined") return;
|
||
const active = document.activeElement;
|
||
if (active instanceof HTMLElement) {
|
||
active.blur();
|
||
}
|
||
};
|
||
|
||
const handleFeatureAccess = (feature: "today" | "history") => {
|
||
blurActiveElement();
|
||
|
||
if (!isPro) {
|
||
trackAppEvent("paywall_feature_clicked", {
|
||
entry: "detail_panel",
|
||
feature,
|
||
city: store.selectedCity,
|
||
user_state: isAuthenticated ? "logged_in" : "guest",
|
||
});
|
||
}
|
||
|
||
if (isPro) {
|
||
if (feature === "today") {
|
||
void store.openTodayModal();
|
||
return;
|
||
}
|
||
void store.openHistory();
|
||
return;
|
||
}
|
||
|
||
if (isAuthenticated) {
|
||
router.push("/account");
|
||
return;
|
||
}
|
||
|
||
if (feature === "today") {
|
||
void store.openTodayModal();
|
||
return;
|
||
}
|
||
void store.openHistory();
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isPanelSyncing || !store.selectedCity) {
|
||
setPanelSyncTimedOut(false);
|
||
return;
|
||
}
|
||
|
||
setPanelSyncTimedOut(false);
|
||
const timeoutId = window.setTimeout(() => {
|
||
setPanelSyncTimedOut(true);
|
||
}, 12_000);
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [isPanelSyncing, store.selectedCity]);
|
||
|
||
useEffect(() => {
|
||
const panel = panelRef.current;
|
||
if (!panel) return;
|
||
|
||
if (!isVisible) {
|
||
panel.setAttribute("inert", "");
|
||
if (
|
||
typeof document !== "undefined" &&
|
||
panel.contains(document.activeElement)
|
||
) {
|
||
const active = document.activeElement;
|
||
if (active instanceof HTMLElement) {
|
||
active.blur();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
panel.removeAttribute("inert");
|
||
}, [isVisible]);
|
||
|
||
useEffect(() => {
|
||
if (!isVisible || !detail) {
|
||
setHeavyContentReady(false);
|
||
return;
|
||
}
|
||
|
||
let canceled = false;
|
||
let timeoutId: number | null = null;
|
||
let idleId: number | null = null;
|
||
const win = typeof window !== "undefined" ? (window as any) : null;
|
||
|
||
const markReady = () => {
|
||
if (!canceled) {
|
||
setHeavyContentReady(true);
|
||
}
|
||
};
|
||
|
||
if (win && typeof win.requestIdleCallback === "function") {
|
||
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
|
||
} else if (typeof window !== "undefined") {
|
||
timeoutId = window.setTimeout(markReady, 80);
|
||
} else {
|
||
setHeavyContentReady(true);
|
||
}
|
||
|
||
return () => {
|
||
canceled = true;
|
||
if (
|
||
win &&
|
||
idleId != null &&
|
||
typeof win.cancelIdleCallback === "function"
|
||
) {
|
||
win.cancelIdleCallback(idleId);
|
||
}
|
||
if (timeoutId != null && typeof window !== "undefined") {
|
||
window.clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
}, [detail, isVisible]);
|
||
|
||
return (
|
||
<aside
|
||
ref={panelRef}
|
||
className={clsx(
|
||
"detail-panel",
|
||
isRail && "scan-city-detail-rail",
|
||
(isVisible || isRail) && "visible",
|
||
)}
|
||
>
|
||
<div className="panel-header">
|
||
{!isRail ? (
|
||
<button
|
||
type="button"
|
||
className="panel-close"
|
||
aria-label={t("detail.closeAria")}
|
||
onClick={() => {
|
||
blurActiveElement();
|
||
store.closePanel();
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
) : null}
|
||
<div className="panel-title-area">
|
||
<div className="panel-title-stack">
|
||
<div className="panel-overline">
|
||
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
|
||
<span className="panel-overline-sep">•</span>
|
||
<span>{panelDisplayName.toUpperCase()}</span>
|
||
</div>
|
||
<h2>{panelDisplayName}</h2>
|
||
</div>
|
||
{showPanelSyncing && (
|
||
<div
|
||
className="panel-loading-hint"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<span className="panel-loading-spinner" aria-hidden="true" />
|
||
<span>
|
||
{locale === "en-US"
|
||
? isSparsePanelDetail
|
||
? `Completing ${panelDisplayName} cards...`
|
||
: `Syncing ${panelDisplayName}...`
|
||
: isSparsePanelDetail
|
||
? `正在补齐 ${panelDisplayName} 卡片...`
|
||
: `正在同步 ${panelDisplayName}...`}
|
||
</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)}
|
||
</span>
|
||
<span className="panel-meta-chip panel-meta-chip-strong">
|
||
{heroSettlementLabel}
|
||
</span>
|
||
<span className="panel-meta-chip panel-meta-chip-muted">
|
||
{heroAirportLabel}
|
||
</span>
|
||
</div>
|
||
<div className="panel-actions">
|
||
{marketUrl ? (
|
||
<a
|
||
className="panel-action-button panel-action-button-ghost"
|
||
href={marketUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={
|
||
locale === "en-US"
|
||
? "Open today's Polymarket market"
|
||
: "打开今日 Polymarket 题目页"
|
||
}
|
||
>
|
||
{locale === "en-US" ? "Market" : "市场页"}
|
||
</a>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className={clsx(
|
||
"panel-action-button",
|
||
"panel-action-button-secondary",
|
||
!isPro && "pro-locked",
|
||
)}
|
||
title={
|
||
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
||
}
|
||
onClick={() => handleFeatureAccess("history")}
|
||
disabled={!store.selectedCity}
|
||
>
|
||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={clsx(
|
||
"panel-body",
|
||
isShowingCachedDetailDuringSync && "is-syncing",
|
||
)}
|
||
>
|
||
{isShowingCachedDetailDuringSync ? (
|
||
<div className="panel-sync-blocker" role="status" aria-live="polite">
|
||
<span className="panel-loading-spinner" aria-hidden="true" />
|
||
<span>
|
||
{locale === "en-US"
|
||
? `Refreshing ${panelDisplayName}. Current cards are paused until the latest data arrives.`
|
||
: `正在刷新 ${panelDisplayName},最新数据返回前暂停展示旧卡片。`}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className={clsx(
|
||
isShowingCachedDetailDuringSync && "panel-content-stale",
|
||
)}
|
||
>
|
||
{!hasBasicPanelContent ? (
|
||
<section className="detail-summary-shell detail-empty-state">
|
||
<div className="detail-section-head">
|
||
<div>
|
||
<div className="detail-section-kicker">
|
||
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
|
||
</div>
|
||
<h3>
|
||
{locale === "en-US"
|
||
? "Pick a city to start."
|
||
: "先选择一个城市。"}
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||
{showPanelSyncing
|
||
? t("detail.loading")
|
||
: t("detail.emptyHint")}
|
||
</div>
|
||
</section>
|
||
) : !detail ? (
|
||
<div className="detail-mini-meta" role="status" aria-live="polite">
|
||
{showPanelSyncing
|
||
? locale === "en-US"
|
||
? "Loading city cards..."
|
||
: "正在加载城市卡片..."
|
||
: locale === "en-US"
|
||
? "City cards will appear here."
|
||
: "城市卡片会显示在这里。"}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<section className="detail-structured-section">
|
||
<div className="detail-section-head">
|
||
<div>
|
||
<div className="detail-section-kicker">
|
||
{locale === "en-US" ? "Profile" : "城市画像"}
|
||
</div>
|
||
<h3>{t("detail.profile")}</h3>
|
||
</div>
|
||
</div>
|
||
<div className="detail-grid">
|
||
{profileStats.map((item) => (
|
||
<article key={item.label} className="detail-card">
|
||
<span className="detail-label">{item.label}</span>
|
||
<span className="detail-value">{item.value}</span>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{officialLinks.length > 0 ? (
|
||
<section className="detail-structured-section">
|
||
<div className="detail-section-head">
|
||
<div>
|
||
<div className="detail-section-kicker">
|
||
{locale === "en-US" ? "Primary references" : "官方参考"}
|
||
</div>
|
||
<h3>
|
||
{locale === "en-US"
|
||
? "Settlement and observation references"
|
||
: "结算与观测参考来源"}
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
<p className="detail-source-note">
|
||
{locale === "en-US"
|
||
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
||
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
|
||
</p>
|
||
<div className="detail-source-list">
|
||
{officialLinks.map((link) => (
|
||
<a
|
||
key={`${link.label}-${link.href}`}
|
||
className="detail-source-link"
|
||
href={link.href}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
<span className="detail-source-kind">
|
||
{link.kind.toUpperCase()}
|
||
</span>
|
||
<span className="detail-source-label">
|
||
{link.label}
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="detail-structured-section rounded-2xl">
|
||
<div className="detail-section-head">
|
||
<div>
|
||
<div className="detail-section-kicker">
|
||
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
|
||
</div>
|
||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||
</div>
|
||
</div>
|
||
{heavyContentReady ? (
|
||
<DetailMiniTemperatureChart detail={detail} />
|
||
) : (
|
||
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
||
)}
|
||
</section>
|
||
|
||
{heavyContentReady ? <ForecastTable /> : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|