feat: implement history and future forecast modals, alongside a new Pro feature paywall component.
This commit is contained in:
@@ -550,246 +550,180 @@ export function FutureForecastModal() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal-content large future-modal">
|
{isProLoading ? (
|
||||||
<div className="modal-header">
|
<div
|
||||||
<h2
|
className="modal-content large"
|
||||||
id="future-modal-title"
|
style={{ padding: "40px", textAlign: "center" }}
|
||||||
className="future-modal-title-with-actions"
|
>
|
||||||
>
|
<div style={{ color: "var(--text-muted)" }}>
|
||||||
<span>
|
{t("dashboard.loading")}
|
||||||
{isToday
|
</div>
|
||||||
? t("future.todayTitle", {
|
|
||||||
city: detail.display_name.toUpperCase(),
|
|
||||||
})
|
|
||||||
: t("future.dateTitle", {
|
|
||||||
city: detail.display_name.toUpperCase(),
|
|
||||||
date: dateStr,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"future-refresh-btn",
|
|
||||||
store.loadingState.marketScan && "spinning",
|
|
||||||
)}
|
|
||||||
disabled={!isPro || isProLoading}
|
|
||||||
onClick={() => {
|
|
||||||
if (isToday) {
|
|
||||||
void store.openTodayModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.openFutureModal(dateStr, true);
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
!isPro
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Pro subscription required"
|
|
||||||
: "需要 Pro 订阅"
|
|
||||||
: locale === "en-US"
|
|
||||||
? "Refresh Data"
|
|
||||||
: "刷新数据"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
||||||
<path d="M3 3v5h5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-close"
|
|
||||||
aria-label={
|
|
||||||
isToday ? t("future.closeTodayAria") : t("future.closeDateAria")
|
|
||||||
}
|
|
||||||
onClick={store.closeFutureModal}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : !isPro ? (
|
||||||
<div className="modal-body future-modal-body">
|
<ProFeaturePaywall
|
||||||
{isProLoading ? (
|
feature={isToday ? "today" : "future"}
|
||||||
<div
|
onClose={store.closeFutureModal}
|
||||||
style={{
|
/>
|
||||||
color: "var(--text-muted)",
|
) : (
|
||||||
display: "flex",
|
<div className="modal-content large future-modal">
|
||||||
justifyContent: "center",
|
<div className="modal-header">
|
||||||
padding: "28px 0",
|
<h2
|
||||||
}}
|
id="future-modal-title"
|
||||||
|
className="future-modal-title-with-actions"
|
||||||
>
|
>
|
||||||
{t("dashboard.loading")}
|
<span>
|
||||||
</div>
|
{isToday
|
||||||
) : !isPro ? (
|
? t("future.todayTitle", {
|
||||||
<ProFeaturePaywall feature={isToday ? "today" : "future"} />
|
city: detail.display_name.toUpperCase(),
|
||||||
) : isToday ? (
|
})
|
||||||
<div className="future-v2-layout">
|
: t("future.dateTitle", {
|
||||||
<aside className="future-v2-left">
|
city: detail.display_name.toUpperCase(),
|
||||||
<section className="future-v2-card future-v2-hero-card">
|
date: dateStr,
|
||||||
<h3 className="future-v2-hero-title">
|
})}
|
||||||
{locale === "en-US"
|
</span>
|
||||||
? "Current Conditions"
|
<button
|
||||||
: "实况与气象特征"}
|
className={clsx(
|
||||||
</h3>
|
"future-refresh-btn",
|
||||||
<div className="future-v2-hero-main">
|
store.loadingState.marketScan && "spinning",
|
||||||
<div className="future-v2-hero-temp">{currentTempText}</div>
|
)}
|
||||||
<div className="future-v2-hero-divider" />
|
disabled={!isPro || isProLoading}
|
||||||
<div className="future-v2-hero-weather">
|
onClick={() => {
|
||||||
<span className="future-v2-hero-icon">
|
if (isToday) {
|
||||||
<WeatherIcon
|
void store.openTodayModal(true);
|
||||||
emoji={weatherSummary.weatherIcon}
|
return;
|
||||||
size={42}
|
}
|
||||||
/>
|
store.openFutureModal(dateStr, true);
|
||||||
</span>
|
}}
|
||||||
<span>{weatherSummary.weatherText}</span>
|
title={
|
||||||
</div>
|
!isPro
|
||||||
</div>
|
? locale === "en-US"
|
||||||
<div className="future-v2-hero-obs">
|
? "Pro subscription required"
|
||||||
@{detail.current?.obs_time || "--"}
|
: "需要 Pro 订阅"
|
||||||
</div>
|
: locale === "en-US"
|
||||||
<div className="future-v2-mini-grid">
|
? "Refresh Data"
|
||||||
<div className="future-v2-mini-item">
|
: "刷新数据"
|
||||||
<span>
|
}
|
||||||
{locale === "en-US" ? "High So Far" : "目前最高温"}
|
>
|
||||||
</span>
|
<svg
|
||||||
<strong>
|
width="14"
|
||||||
{topObservedTemp ?? "--"}
|
height="14"
|
||||||
{detail.temp_symbol}
|
viewBox="0 0 24 24"
|
||||||
</strong>
|
fill="none"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
<div className="future-v2-mini-item">
|
strokeWidth="2.5"
|
||||||
<span>{locale === "en-US" ? "Sunrise" : "日出时间"}</span>
|
strokeLinecap="round"
|
||||||
<strong>{detail.forecast?.sunrise || "--"}</strong>
|
strokeLinejoin="round"
|
||||||
</div>
|
>
|
||||||
<div className="future-v2-mini-item">
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
<span>{locale === "en-US" ? "Sunset" : "日落时间"}</span>
|
<path d="M3 3v5h5" />
|
||||||
<strong>{detail.forecast?.sunset || "--"}</strong>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
<div className="future-v2-mini-item">
|
</h2>
|
||||||
<span>
|
<button
|
||||||
{locale === "en-US" ? "Sunshine" : "日照时长"}
|
type="button"
|
||||||
</span>
|
className="modal-close"
|
||||||
<strong>
|
aria-label={
|
||||||
{detail.forecast?.sunshine_hours != null
|
isToday ? t("future.closeTodayAria") : t("future.closeDateAria")
|
||||||
? `${detail.forecast.sunshine_hours}h`
|
}
|
||||||
: "--"}
|
onClick={store.closeFutureModal}
|
||||||
</strong>
|
>
|
||||||
</div>
|
×
|
||||||
</div>
|
</button>
|
||||||
</section>
|
</div>
|
||||||
|
<div className="modal-body future-modal-body">
|
||||||
<section className="future-v2-card">
|
{isToday ? (
|
||||||
<h4 className="future-v2-card-title">
|
<div className="future-v2-layout">
|
||||||
{locale === "en-US" ? "Current Metrics" : "当前指标"}
|
<aside className="future-v2-left">
|
||||||
</h4>
|
<section className="future-v2-card future-v2-hero-card">
|
||||||
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
<h3 className="future-v2-hero-title">
|
||||||
<div className="future-v2-mini-item">
|
{locale === "en-US"
|
||||||
<span>{locale === "en-US" ? "Humidity" : "湿度"}</span>
|
? "Current Conditions"
|
||||||
<strong>{humidityText}</strong>
|
: "实况与气象特征"}
|
||||||
</div>
|
</h3>
|
||||||
<div className="future-v2-mini-item">
|
<div className="future-v2-hero-main">
|
||||||
<span>{locale === "en-US" ? "Dew Point" : "露点"}</span>
|
<div className="future-v2-hero-temp">
|
||||||
<strong>{dewpointText}</strong>
|
{currentTempText}
|
||||||
</div>
|
|
||||||
<div className="future-v2-mini-item">
|
|
||||||
<span>{locale === "en-US" ? "Wind" : "风速"}</span>
|
|
||||||
<strong>{windText}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="future-v2-mini-item">
|
|
||||||
<span>
|
|
||||||
{locale === "en-US" ? "Visibility" : "能见度"}
|
|
||||||
</span>
|
|
||||||
<strong>{visibilityText}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="future-v2-card">
|
|
||||||
<h4 className="future-v2-card-title">
|
|
||||||
{locale === "en-US" ? "Market Alignment" : "市场对照"}
|
|
||||||
</h4>
|
|
||||||
<div className="future-v2-market-v3">
|
|
||||||
{/* Loading Overlay */}
|
|
||||||
{store.loadingState.marketScan && (
|
|
||||||
<div className="market-layer-loading-overlay">
|
|
||||||
<div
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{
|
|
||||||
marginBottom: "8px",
|
|
||||||
width: "24px",
|
|
||||||
height: "24px",
|
|
||||||
borderWidth: "2px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Crunching Polymarket Edges..."
|
|
||||||
: "正在计算市场对手盘..."}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="future-v2-hero-divider" />
|
||||||
|
<div className="future-v2-hero-weather">
|
||||||
{/* Layer 1: Target & Edge */}
|
<span className="future-v2-hero-icon">
|
||||||
<div className="market-layer-target">
|
<WeatherIcon
|
||||||
<div className="market-target-header">
|
emoji={weatherSummary.weatherIcon}
|
||||||
<span>
|
size={42}
|
||||||
{locale === "en-US"
|
/>
|
||||||
? "Target Bucket:"
|
|
||||||
: "结算温度区间:"}
|
|
||||||
</span>
|
</span>
|
||||||
<strong className="market-target-bucket">
|
<span>{weatherSummary.weatherText}</span>
|
||||||
{settlementBucketLabel}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="future-v2-hero-obs">
|
||||||
{/* Layer 3: Context */}
|
@{detail.current?.obs_time || "--"}
|
||||||
<div className="market-layer-context">
|
</div>
|
||||||
<div className="market-sub-title">
|
<div className="future-v2-mini-grid">
|
||||||
👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"}
|
<div className="future-v2-mini-item">
|
||||||
</div>
|
|
||||||
<div className="market-context-row">
|
|
||||||
<span>
|
<span>
|
||||||
{locale === "en-US"
|
{locale === "en-US" ? "High So Far" : "目前最高温"}
|
||||||
? "Top Volume Bucket:"
|
|
||||||
: "市场当前押注最热:"}
|
|
||||||
</span>
|
</span>
|
||||||
<strong>
|
<strong>
|
||||||
{hottestBucketLabel}{" "}
|
{topObservedTemp ?? "--"}
|
||||||
{hottestBucketProb !== "--"
|
{detail.temp_symbol}
|
||||||
? `(${hottestBucketProb})`
|
</strong>
|
||||||
: ""}
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US" ? "Sunrise" : "日出时间"}
|
||||||
|
</span>
|
||||||
|
<strong>{detail.forecast?.sunrise || "--"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US" ? "Sunset" : "日落时间"}
|
||||||
|
</span>
|
||||||
|
<strong>{detail.forecast?.sunset || "--"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US" ? "Sunshine" : "日照时长"}
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{detail.forecast?.sunshine_hours != null
|
||||||
|
? `${detail.forecast.sunshine_hours}h`
|
||||||
|
: "--"}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div className="future-v2-market-signal mt-3">
|
|
||||||
{locale === "en-US" ? "Signal" : "信号"}:{" "}
|
|
||||||
<strong>{marketSignal}</strong>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="future-v2-right">
|
<section className="future-v2-card">
|
||||||
<section className="future-modal-section future-v2-main-chart">
|
<h4 className="future-v2-card-title">
|
||||||
<h3>
|
{locale === "en-US" ? "Current Metrics" : "当前指标"}
|
||||||
{locale === "en-US"
|
</h4>
|
||||||
? "Today's temperature forecast (obs + market)"
|
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
||||||
: "今日气温预测(观测 + 市场)"}
|
<div className="future-v2-mini-item">
|
||||||
</h3>
|
<span>{locale === "en-US" ? "Humidity" : "湿度"}</span>
|
||||||
<DailyTemperatureChart dateStr={dateStr} />
|
<strong>{humidityText}</strong>
|
||||||
</section>
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>{locale === "en-US" ? "Dew Point" : "露点"}</span>
|
||||||
|
<strong>{dewpointText}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>{locale === "en-US" ? "Wind" : "风速"}</span>
|
||||||
|
<strong>{windText}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-mini-item">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US" ? "Visibility" : "能见度"}
|
||||||
|
</span>
|
||||||
|
<strong>{visibilityText}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="future-modal-grid">
|
<section className="future-v2-card">
|
||||||
<section className="future-modal-section">
|
<h4 className="future-v2-card-title">
|
||||||
<h3>{t("future.probability")}</h3>
|
{locale === "en-US" ? "Market Alignment" : "市场对照"}
|
||||||
<div style={{ position: "relative", minHeight: "120px" }}>
|
</h4>
|
||||||
|
<div className="future-v2-market-v3">
|
||||||
{/* Loading Overlay */}
|
{/* Loading Overlay */}
|
||||||
{store.loadingState.marketScan && (
|
{store.loadingState.marketScan && (
|
||||||
<div className="market-layer-loading-overlay">
|
<div className="market-layer-loading-overlay">
|
||||||
@@ -804,16 +738,247 @@ export function FutureForecastModal() {
|
|||||||
/>
|
/>
|
||||||
{locale === "en-US"
|
{locale === "en-US"
|
||||||
? "Crunching Polymarket Edges..."
|
? "Crunching Polymarket Edges..."
|
||||||
: "正在同步市场挂单..."}
|
: "正在计算市场对手盘..."}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ProbabilityDistribution
|
|
||||||
|
{/* Layer 1: Target & Edge */}
|
||||||
|
<div className="market-layer-target">
|
||||||
|
<div className="market-target-header">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US"
|
||||||
|
? "Target Bucket:"
|
||||||
|
: "结算温度区间:"}
|
||||||
|
</span>
|
||||||
|
<strong className="market-target-bucket">
|
||||||
|
{settlementBucketLabel}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layer 3: Context */}
|
||||||
|
<div className="market-layer-context">
|
||||||
|
<div className="market-sub-title">
|
||||||
|
👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"}
|
||||||
|
</div>
|
||||||
|
<div className="market-context-row">
|
||||||
|
<span>
|
||||||
|
{locale === "en-US"
|
||||||
|
? "Top Volume Bucket:"
|
||||||
|
: "市场当前押注最热:"}
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{hottestBucketLabel}{" "}
|
||||||
|
{hottestBucketProb !== "--"
|
||||||
|
? `(${hottestBucketProb})`
|
||||||
|
: ""}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="future-v2-market-signal mt-3">
|
||||||
|
{locale === "en-US" ? "Signal" : "信号"}:{" "}
|
||||||
|
<strong>{marketSignal}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="future-v2-right">
|
||||||
|
<section className="future-modal-section future-v2-main-chart">
|
||||||
|
<h3>
|
||||||
|
{locale === "en-US"
|
||||||
|
? "Today's temperature forecast (obs + market)"
|
||||||
|
: "今日气温预测(观测 + 市场)"}
|
||||||
|
</h3>
|
||||||
|
<DailyTemperatureChart dateStr={dateStr} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="future-modal-grid">
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{t("future.probability")}</h3>
|
||||||
|
<div style={{ position: "relative", minHeight: "120px" }}>
|
||||||
|
{/* Loading Overlay */}
|
||||||
|
{store.loadingState.marketScan && (
|
||||||
|
<div className="market-layer-loading-overlay">
|
||||||
|
<div
|
||||||
|
className="loading-spinner"
|
||||||
|
style={{
|
||||||
|
marginBottom: "8px",
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
borderWidth: "2px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{locale === "en-US"
|
||||||
|
? "Crunching Polymarket Edges..."
|
||||||
|
: "正在同步市场挂单..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ProbabilityDistribution
|
||||||
|
detail={detail}
|
||||||
|
targetDate={dateStr}
|
||||||
|
hideTitle
|
||||||
|
marketScan={marketScan}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{t("future.models")}</h3>
|
||||||
|
<ModelForecast
|
||||||
detail={detail}
|
detail={detail}
|
||||||
targetDate={dateStr}
|
targetDate={dateStr}
|
||||||
hideTitle
|
hideTitle
|
||||||
marketScan={marketScan}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="future-modal-grid">
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{t("future.structureToday")}</h3>
|
||||||
|
<div className="future-front-score">
|
||||||
|
<div className="future-front-bar" style={barStyle}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: "50%",
|
||||||
|
width: "2px",
|
||||||
|
background: "rgba(255, 255, 255, 0.2)",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="future-front-meta">
|
||||||
|
<span className="future-front-pill">
|
||||||
|
{t("future.judgement")}: {view.front.label}
|
||||||
|
</span>
|
||||||
|
<span className="future-front-pill">
|
||||||
|
{t("future.confidence")}:{" "}
|
||||||
|
{t(`confidence.${view.front.confidence}`)}
|
||||||
|
</span>
|
||||||
|
<span className="future-front-pill">
|
||||||
|
{t("future.maxPrecip")}:{" "}
|
||||||
|
{Math.round(view.front.precipMax)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="future-trend-grid">
|
||||||
|
{view.front.metrics.slice(0, 6).map((metric) => (
|
||||||
|
<div key={metric.label} className="future-trend-card">
|
||||||
|
<div className="future-trend-label">
|
||||||
|
{metric.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"future-trend-value",
|
||||||
|
metric.tone === "warm" && "warm",
|
||||||
|
metric.tone === "cold" && "cold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{metric.value}
|
||||||
|
</div>
|
||||||
|
<div className="future-trend-note">
|
||||||
|
{metric.note}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{t("future.ai")}</h3>
|
||||||
|
<div className="future-text-block">
|
||||||
|
{ai.summary ? (
|
||||||
|
<div>{ai.summary}</div>
|
||||||
|
) : (
|
||||||
|
<div>{t("future.noAi")}</div>
|
||||||
|
)}
|
||||||
|
{ai.bullets.length > 0 && (
|
||||||
|
<div style={{ marginTop: "10px" }}>
|
||||||
|
{ai.bullets.slice(0, 3).map((item) => (
|
||||||
|
<div key={item}>{item}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
{nowcastRows.slice(0, 4).map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<strong>{label}: </strong>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{riskLines.length > 0 && (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<strong>
|
||||||
|
{locale === "en-US" ? "Risk" : "风险"}:{" "}
|
||||||
|
</strong>
|
||||||
|
{riskLines[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{climateDrivers.length > 0 && (
|
||||||
|
<div style={{ marginTop: "8px" }}>
|
||||||
|
<strong>
|
||||||
|
{locale === "en-US" ? "Climate" : "气候"}:{" "}
|
||||||
|
</strong>
|
||||||
|
{climateDrivers[0].text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="history-stats">
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("future.targetForecast")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.forecastEntry?.max_temp ?? "--"}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("future.deb")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.deb ?? "--"}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("future.mu")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.mu != null
|
||||||
|
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("future.score")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.front.score > 0 ? "+" : ""}
|
||||||
|
{view.front.score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{t("future.targetTempTrend")}</h3>
|
||||||
|
<DailyTemperatureChart dateStr={dateStr} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="future-modal-grid">
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{t("future.probability")}</h3>
|
||||||
|
<ProbabilityDistribution
|
||||||
|
detail={detail}
|
||||||
|
targetDate={dateStr}
|
||||||
|
hideTitle
|
||||||
|
marketScan={marketScan}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section className="future-modal-section">
|
<section className="future-modal-section">
|
||||||
<h3>{t("future.models")}</h3>
|
<h3>{t("future.models")}</h3>
|
||||||
@@ -824,166 +989,11 @@ export function FutureForecastModal() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<div className="future-modal-grid">
|
)}
|
||||||
<section className="future-modal-section">
|
</div>
|
||||||
<h3>{t("future.structureToday")}</h3>
|
|
||||||
<div className="future-front-score">
|
|
||||||
<div className="future-front-bar" style={barStyle}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: "50%",
|
|
||||||
width: "2px",
|
|
||||||
background: "rgba(255, 255, 255, 0.2)",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="future-front-meta">
|
|
||||||
<span className="future-front-pill">
|
|
||||||
{t("future.judgement")}: {view.front.label}
|
|
||||||
</span>
|
|
||||||
<span className="future-front-pill">
|
|
||||||
{t("future.confidence")}:{" "}
|
|
||||||
{t(`confidence.${view.front.confidence}`)}
|
|
||||||
</span>
|
|
||||||
<span className="future-front-pill">
|
|
||||||
{t("future.maxPrecip")}:{" "}
|
|
||||||
{Math.round(view.front.precipMax)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="future-trend-grid">
|
|
||||||
{view.front.metrics.slice(0, 6).map((metric) => (
|
|
||||||
<div key={metric.label} className="future-trend-card">
|
|
||||||
<div className="future-trend-label">
|
|
||||||
{metric.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"future-trend-value",
|
|
||||||
metric.tone === "warm" && "warm",
|
|
||||||
metric.tone === "cold" && "cold",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{metric.value}
|
|
||||||
</div>
|
|
||||||
<div className="future-trend-note">{metric.note}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="future-modal-section">
|
|
||||||
<h3>{t("future.ai")}</h3>
|
|
||||||
<div className="future-text-block">
|
|
||||||
{ai.summary ? (
|
|
||||||
<div>{ai.summary}</div>
|
|
||||||
) : (
|
|
||||||
<div>{t("future.noAi")}</div>
|
|
||||||
)}
|
|
||||||
{ai.bullets.length > 0 && (
|
|
||||||
<div style={{ marginTop: "10px" }}>
|
|
||||||
{ai.bullets.slice(0, 3).map((item) => (
|
|
||||||
<div key={item}>{item}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ marginTop: "12px" }}>
|
|
||||||
{nowcastRows.slice(0, 4).map(([label, value]) => (
|
|
||||||
<div key={label}>
|
|
||||||
<strong>{label}: </strong>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{riskLines.length > 0 && (
|
|
||||||
<div style={{ marginTop: "12px" }}>
|
|
||||||
<strong>
|
|
||||||
{locale === "en-US" ? "Risk" : "风险"}:{" "}
|
|
||||||
</strong>
|
|
||||||
{riskLines[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{climateDrivers.length > 0 && (
|
|
||||||
<div style={{ marginTop: "8px" }}>
|
|
||||||
<strong>
|
|
||||||
{locale === "en-US" ? "Climate" : "气候"}:{" "}
|
|
||||||
</strong>
|
|
||||||
{climateDrivers[0].text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="history-stats">
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("future.targetForecast")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{view.forecastEntry?.max_temp ?? "--"}
|
|
||||||
{detail.temp_symbol}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("future.deb")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{view.deb ?? "--"}
|
|
||||||
{detail.temp_symbol}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("future.mu")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{view.mu != null
|
|
||||||
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
|
|
||||||
: "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("future.score")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{view.front.score > 0 ? "+" : ""}
|
|
||||||
{view.front.score}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="future-modal-section">
|
|
||||||
<h3>{t("future.targetTempTrend")}</h3>
|
|
||||||
<DailyTemperatureChart dateStr={dateStr} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="future-modal-grid">
|
|
||||||
<section className="future-modal-section">
|
|
||||||
<h3>{t("future.probability")}</h3>
|
|
||||||
<ProbabilityDistribution
|
|
||||||
detail={detail}
|
|
||||||
targetDate={dateStr}
|
|
||||||
hideTitle
|
|
||||||
marketScan={marketScan}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section className="future-modal-section">
|
|
||||||
<h3>{t("future.models")}</h3>
|
|
||||||
<ModelForecast
|
|
||||||
detail={detail}
|
|
||||||
targetDate={dateStr}
|
|
||||||
hideTitle
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,101 +20,101 @@ function HistoryChart() {
|
|||||||
store.selectedCity === "ankara" &&
|
store.selectedCity === "ankara" &&
|
||||||
summary.mgms.some((value) => value != null);
|
summary.mgms.some((value) => value != null);
|
||||||
|
|
||||||
const canvasRef = useChart(
|
const canvasRef = useChart(() => {
|
||||||
() => {
|
const datasets: NonNullable<
|
||||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [
|
ChartConfiguration<"line">["data"]
|
||||||
{
|
>["datasets"] = [
|
||||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
{
|
||||||
borderColor: "#f87171",
|
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||||
borderWidth: 2,
|
borderColor: "#f87171",
|
||||||
data: summary.actuals,
|
borderWidth: 2,
|
||||||
label: locale === "en-US" ? "Observed High" : "实测最高温",
|
data: summary.actuals,
|
||||||
pointBackgroundColor: "#f87171",
|
label: locale === "en-US" ? "Observed High" : "实测最高温",
|
||||||
pointBorderColor: "#fff",
|
pointBackgroundColor: "#f87171",
|
||||||
pointHoverRadius: 7,
|
pointBorderColor: "#fff",
|
||||||
pointRadius: 5,
|
pointHoverRadius: 7,
|
||||||
tension: 0.2,
|
pointRadius: 5,
|
||||||
},
|
tension: 0.2,
|
||||||
{
|
},
|
||||||
backgroundColor: "transparent",
|
{
|
||||||
borderColor: "#34d399",
|
backgroundColor: "transparent",
|
||||||
borderDash: [5, 4],
|
borderColor: "#34d399",
|
||||||
borderWidth: 2,
|
borderDash: [5, 4],
|
||||||
data: summary.debs,
|
borderWidth: 2,
|
||||||
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
|
data: summary.debs,
|
||||||
pointHoverRadius: 6,
|
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
|
||||||
pointRadius: 4,
|
pointHoverRadius: 6,
|
||||||
tension: 0.2,
|
pointRadius: 4,
|
||||||
},
|
tension: 0.2,
|
||||||
];
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (hasMgm) {
|
if (hasMgm) {
|
||||||
datasets.push({
|
datasets.push({
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
borderColor: "#fb923c",
|
borderColor: "#fb923c",
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
data: summary.mgms,
|
data: summary.mgms,
|
||||||
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
|
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 6,
|
||||||
pointRadius: 4,
|
pointRadius: 4,
|
||||||
tension: 0.2,
|
tension: 0.2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
datasets,
|
datasets,
|
||||||
labels: summary.dates,
|
labels: summary.dates,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
interaction: { intersect: false, mode: "index" },
|
interaction: { intersect: false, mode: "index" },
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
labels: {
|
labels: {
|
||||||
boxHeight: 12,
|
boxHeight: 12,
|
||||||
boxWidth: 34,
|
boxWidth: 34,
|
||||||
color: "#94a3b8",
|
color: "#94a3b8",
|
||||||
font: { family: "Inter", size: 14 },
|
font: { family: "Inter", size: 14 },
|
||||||
padding: 18,
|
padding: 18,
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
|
||||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
|
||||||
borderWidth: 1,
|
|
||||||
bodyFont: { family: "Inter", size: 13 },
|
|
||||||
titleFont: { family: "Inter", size: 13, weight: 600 },
|
|
||||||
callbacks: {
|
|
||||||
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responsive: true,
|
tooltip: {
|
||||||
scales: {
|
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||||
x: {
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
grid: { color: "rgba(255,255,255,0.04)" },
|
borderWidth: 1,
|
||||||
ticks: {
|
bodyFont: { family: "Inter", size: 13 },
|
||||||
color: "#64748b",
|
titleFont: { family: "Inter", size: 13, weight: 600 },
|
||||||
font: { family: "Inter", size: 12 },
|
callbacks: {
|
||||||
padding: 8,
|
label: (ctx) =>
|
||||||
},
|
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: { color: "rgba(255,255,255,0.04)" },
|
|
||||||
ticks: {
|
|
||||||
color: "#64748b",
|
|
||||||
font: { family: "Inter", size: 12 },
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
type: "line",
|
responsive: true,
|
||||||
} satisfies ChartConfiguration<"line">;
|
scales: {
|
||||||
},
|
x: {
|
||||||
[hasMgm, summary, locale],
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
);
|
ticks: {
|
||||||
|
color: "#64748b",
|
||||||
|
font: { family: "Inter", size: 12 },
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: {
|
||||||
|
color: "#64748b",
|
||||||
|
font: { family: "Inter", size: 12 },
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
}, [hasMgm, summary, locale]);
|
||||||
|
|
||||||
if (!summary.recentData.length) return null;
|
if (!summary.recentData.length) return null;
|
||||||
|
|
||||||
@@ -150,77 +150,75 @@ export function HistoryModal() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal-content history-modal">
|
{isProLoading ? (
|
||||||
<div className="modal-header">
|
<div
|
||||||
<h2 id="history-modal-title">
|
className="modal-content"
|
||||||
{t("history.title", { city: store.selectedCity?.toUpperCase() || "" })}
|
style={{ padding: "40px", textAlign: "center" }}
|
||||||
</h2>
|
>
|
||||||
<button
|
<div style={{ color: "var(--text-muted)" }}>
|
||||||
type="button"
|
{t("dashboard.loading")}
|
||||||
className="modal-close"
|
</div>
|
||||||
aria-label={t("history.closeAria")}
|
|
||||||
onClick={store.closeHistory}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
) : !isPro ? (
|
||||||
{isProLoading ? (
|
<ProFeaturePaywall feature="history" onClose={store.closeHistory} />
|
||||||
<div
|
) : (
|
||||||
style={{
|
<div className="modal-content history-modal">
|
||||||
color: "var(--text-muted)",
|
<div className="modal-header">
|
||||||
display: "flex",
|
<h2 id="history-modal-title">
|
||||||
justifyContent: "center",
|
{t("history.title", {
|
||||||
padding: "28px 0",
|
city: store.selectedCity?.toUpperCase() || "",
|
||||||
}}
|
})}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close"
|
||||||
|
aria-label={t("history.closeAria")}
|
||||||
|
onClick={store.closeHistory}
|
||||||
>
|
>
|
||||||
{t("dashboard.loading")}
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="history-stats">
|
||||||
|
{isLoading ? (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("history.loading")}
|
||||||
|
</span>
|
||||||
|
) : error ? (
|
||||||
|
<span style={{ color: "var(--accent-red)" }}>
|
||||||
|
{t("history.error")}
|
||||||
|
</span>
|
||||||
|
) : !summary.recentData.length ? (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("history.empty")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("history.hitRate")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("history.mae")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{summary.debMae != null ? `${summary.debMae}°` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{t("history.sample")}</span>
|
||||||
|
<span className="val">
|
||||||
|
{t("history.sampleDays", { count: summary.settledCount })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : !isPro ? (
|
{!isLoading && !error && <HistoryChart />}
|
||||||
<ProFeaturePaywall feature="history" />
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="history-stats">
|
|
||||||
{isLoading ? (
|
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
|
||||||
{t("history.loading")}
|
|
||||||
</span>
|
|
||||||
) : error ? (
|
|
||||||
<span style={{ color: "var(--accent-red)" }}>
|
|
||||||
{t("history.error")}
|
|
||||||
</span>
|
|
||||||
) : !summary.recentData.length ? (
|
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
|
||||||
{t("history.empty")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("history.hitRate")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("history.mae")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{summary.debMae != null ? `${summary.debMae}°` : "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-stat-card">
|
|
||||||
<span className="label">{t("history.sample")}</span>
|
|
||||||
<span className="val">
|
|
||||||
{t("history.sampleDays", { count: summary.settledCount })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isLoading && !error && <HistoryChart />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,112 +8,176 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
type ProFeaturePaywallProps = {
|
type ProFeaturePaywallProps = {
|
||||||
feature: "today" | "history" | "future";
|
feature: "today" | "history" | "future";
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFeatureLabel(
|
export function ProFeaturePaywall({
|
||||||
locale: "zh-CN" | "en-US",
|
feature,
|
||||||
feature: "today" | "history" | "future",
|
onClose,
|
||||||
) {
|
}: ProFeaturePaywallProps) {
|
||||||
if (locale === "en-US") {
|
|
||||||
if (feature === "today") return "Intraday Analysis";
|
|
||||||
if (feature === "history") return "History Reconciliation";
|
|
||||||
return "Future-date Analysis";
|
|
||||||
}
|
|
||||||
if (feature === "today") return "今日日内分析";
|
|
||||||
if (feature === "history") return "历史对账";
|
|
||||||
return "未来日期分析";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProFeaturePaywall({ feature }: ProFeaturePaywallProps) {
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const featureLabel = getFeatureLabel(locale, feature);
|
const { proAccess } = useDashboardStore();
|
||||||
|
const [usePoints, setUsePoints] = useState(true);
|
||||||
|
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
|
|
||||||
|
// Redemption logic: 500 points = $1 discount
|
||||||
|
// Max discount $3 (requires 1500 points)
|
||||||
|
const maxRedeemablePoints = 1500;
|
||||||
|
const pointsAvailable = proAccess.points || 0;
|
||||||
|
const effectivePoints = Math.min(pointsAvailable, maxRedeemablePoints);
|
||||||
|
const discountAmount = usePoints ? Math.floor(effectivePoints / 500) : 0;
|
||||||
|
const originalPrice = 5.0;
|
||||||
|
const finalPrice = originalPrice - discountAmount;
|
||||||
|
const pointsToConsume = discountAmount * 500;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-center justify-center py-10 md:py-16">
|
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10">
|
||||||
<div className="relative w-full max-w-4xl rounded-[2.5rem] border border-white/10 bg-slate-900/60 backdrop-blur-xl p-8 shadow-2xl md:p-12">
|
<div className="relative w-full max-w-xl rounded-[2.5rem] border border-white/10 bg-slate-900/80 p-8 shadow-3xl backdrop-blur-3xl md:p-12">
|
||||||
<div className="absolute left-1/2 top-0 flex h-20 w-20 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-[1.5rem] border-4 border-slate-950 bg-gradient-to-tr from-amber-500 via-orange-500 to-yellow-400 text-white shadow-2xl shadow-orange-500/40">
|
{onClose && (
|
||||||
<Crown size={32} fill="currentColor" />
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-6 top-6 flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-slate-400 transition hover:bg-white/10 hover:text-white z-10"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crown Badge */}
|
||||||
|
<div className="absolute left-1/2 top-0 flex h-20 w-20 -translate-x-1/2 -translate-y-1/2 rotate-[-6deg] items-center justify-center rounded-[1.5rem] border-4 border-slate-950 bg-gradient-to-tr from-amber-400 via-orange-500 to-yellow-500 text-white shadow-2xl shadow-orange-500/30">
|
||||||
|
<Crown size={32} fill="white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<h3 className="text-3xl font-extrabold tracking-tight text-white md:text-4xl">
|
<h3 className="text-3xl font-extrabold tracking-tight text-white md:text-4xl">
|
||||||
{isEn ? "Unlock PolyWeather Pro" : "开启 PolyWeather Pro"}
|
{isEn ? "Unlock PolyWeather Pro" : "开启 PolyWeather Pro"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-300">
|
<p className="mx-auto mt-4 max-w-sm text-base leading-relaxed text-slate-400">
|
||||||
{isEn
|
{isEn
|
||||||
? `This module (${featureLabel}) is available for subscribers only. Upgrade to unlock advanced weather intelligence.`
|
? "Unlock 15-day precision trends, real-time radar, and ad-free experience across all platforms."
|
||||||
: `当前模块(${featureLabel})仅对订阅用户开放。升级后可解锁更完整的气象分析能力。`}
|
: "解锁 15 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 grid grid-cols-1 gap-4 md:grid-cols-2">
|
{/* Pricing Cards */}
|
||||||
{[
|
<div className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{
|
{/* Plan Card */}
|
||||||
icon: Zap,
|
<div className="relative overflow-hidden rounded-3xl border border-blue-500/30 bg-blue-500/5 p-6 transition hover:bg-blue-500/10">
|
||||||
text: isEn
|
<div className="absolute -right-2 -top-1 rotate-12 rounded-lg bg-blue-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase tracking-tighter shadow-lg">
|
||||||
? "Real-time radar and lightning tracking"
|
{isEn ? "Monthly" : "月付套餐"}
|
||||||
: "实时雷达与闪电追踪",
|
</div>
|
||||||
},
|
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">
|
||||||
{
|
PRO PLAN
|
||||||
icon: ShieldCheck,
|
</div>
|
||||||
text: isEn
|
<div className="mt-2 flex items-baseline">
|
||||||
? "15-day high-precision trend analysis"
|
<span className="text-3xl font-black text-white">$5.00</span>
|
||||||
: "15 天高精度趋势分析",
|
<span className="ml-1 text-sm font-medium text-slate-500">
|
||||||
},
|
/ {isEn ? "mo" : "月"}
|
||||||
{
|
|
||||||
icon: BarChart3,
|
|
||||||
text: isEn
|
|
||||||
? "Pro-grade station raw data"
|
|
||||||
: "专业级气象站原始数据",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Sparkles,
|
|
||||||
text: isEn
|
|
||||||
? "Ad-free experience across all surfaces"
|
|
||||||
: "全平台无广告体验",
|
|
||||||
},
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.text}
|
|
||||||
className="flex items-center gap-4 rounded-2xl border border-white/5 bg-white/5 px-5 py-4 transition hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/10 text-cyan-300">
|
|
||||||
<item.icon size={20} />
|
|
||||||
</div>
|
|
||||||
<span className="text-base font-medium text-slate-200">
|
|
||||||
{item.text}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
{/* Points Card */}
|
||||||
|
<div
|
||||||
|
className={`relative rounded-3xl border p-6 transition ${
|
||||||
|
usePoints && pointsAvailable >= 500
|
||||||
|
? "border-indigo-500/30 bg-indigo-500/5"
|
||||||
|
: "border-white/5 bg-white/5 opacity-60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold uppercase tracking-widest ${
|
||||||
|
usePoints && pointsAvailable >= 500
|
||||||
|
? "text-indigo-400"
|
||||||
|
: "text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEn ? "Points Credit" : "积分抵扣"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setUsePoints(!usePoints)}
|
||||||
|
disabled={pointsAvailable < 500}
|
||||||
|
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||||
|
usePoints && pointsAvailable >= 500
|
||||||
|
? "bg-indigo-600"
|
||||||
|
: "bg-slate-700"
|
||||||
|
} ${pointsAvailable < 500 ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
usePoints && pointsAvailable >= 500
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-baseline">
|
||||||
|
<span
|
||||||
|
className={`text-3xl font-black ${usePoints && pointsAvailable >= 500 ? "text-emerald-400" : "text-slate-500"}`}
|
||||||
|
>
|
||||||
|
-${discountAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
|
OFF
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] font-medium text-slate-500">
|
||||||
|
{pointsAvailable < 500
|
||||||
|
? isEn
|
||||||
|
? "Need 500+ points"
|
||||||
|
: `积分不足 (当前 ${pointsAvailable})`
|
||||||
|
: isEn
|
||||||
|
? `Auto-consume ${pointsToConsume} points`
|
||||||
|
: `已自动消耗 ${pointsToConsume} 积分`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10">
|
{/* Total & Action */}
|
||||||
|
<div className="mt-10 space-y-6">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<span className="text-sm font-medium text-slate-400">
|
||||||
|
{isEn ? "Total Due:" : "应付总计:"}
|
||||||
|
</span>
|
||||||
|
<span className="text-3xl font-black text-white">
|
||||||
|
${finalPrice.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account"
|
||||||
className="group flex w-full items-center justify-center rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 px-6 py-5 text-xl font-bold text-white shadow-xl shadow-blue-600/20 transition hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98]"
|
className="group flex w-full items-center justify-center rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 px-6 py-5 text-lg font-bold text-white shadow-2xl shadow-blue-600/30 transition hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
{isEn ? "Upgrade now - $5 / month" : "立即升级 - $5 / 月"}
|
{isEn ? "Connect Wallet & Pay" : "连接钱包并支付"}
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
size={22}
|
size={20}
|
||||||
className="ml-2 transition-transform group-hover:translate-x-1"
|
className="ml-2 transition-transform group-hover:translate-x-1"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 border-t border-white/10 pt-6 text-center text-xs text-slate-400">
|
<div className="flex items-center justify-center gap-6">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="flex items-center gap-1.5 text-[10px] font-medium text-slate-500">
|
||||||
<Lock size={14} className="text-slate-500" />
|
<Lock size={12} className="opacity-50" />
|
||||||
{isEn
|
{isEn ? "Secured Payment" : "安全加密支付"}
|
||||||
? "Payments are secured with AES-256 encryption"
|
</span>
|
||||||
: "所有交易均经过 AES-256 加密保护"}
|
<Link
|
||||||
</span>
|
href="/account"
|
||||||
|
className="text-[10px] font-medium text-slate-500 transition hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{isEn ? "FAQ" : "常见问题 (FAQ)"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function getInitialProAccessState(): ProAccessState {
|
|||||||
loading: true,
|
loading: true,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
subscriptionActive: false,
|
subscriptionActive: false,
|
||||||
|
points: 0,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -235,7 +236,10 @@ export function DashboardStoreProvider({
|
|||||||
? cityDetailsByName[selectedCity] || null
|
? cityDetailsByName[selectedCity] || null
|
||||||
: null;
|
: null;
|
||||||
const selectedMarketDate =
|
const selectedMarketDate =
|
||||||
futureModalDate || selectedForecastDate || selectedDetail?.local_date || null;
|
futureModalDate ||
|
||||||
|
selectedForecastDate ||
|
||||||
|
selectedDetail?.local_date ||
|
||||||
|
null;
|
||||||
const selectedMarketScanKey = selectedCity
|
const selectedMarketScanKey = selectedCity
|
||||||
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
|
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
|
||||||
: null;
|
: null;
|
||||||
@@ -402,11 +406,13 @@ export function DashboardStoreProvider({
|
|||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
subscription_active?: boolean | null;
|
subscription_active?: boolean | null;
|
||||||
|
points?: number;
|
||||||
};
|
};
|
||||||
setProAccess({
|
setProAccess({
|
||||||
loading: false,
|
loading: false,
|
||||||
authenticated: Boolean(payload.authenticated),
|
authenticated: Boolean(payload.authenticated),
|
||||||
subscriptionActive: payload.subscription_active === true,
|
subscriptionActive: payload.subscription_active === true,
|
||||||
|
points: payload.points ?? 0,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -414,6 +420,7 @@ export function DashboardStoreProvider({
|
|||||||
loading: false,
|
loading: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
subscriptionActive: false,
|
subscriptionActive: false,
|
||||||
|
points: 0,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export interface ProAccessState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
subscriptionActive: boolean;
|
subscriptionActive: boolean;
|
||||||
|
points: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ def extract_bearer_token(auth_header: Optional[str]) -> str:
|
|||||||
class SupabaseIdentity:
|
class SupabaseIdentity:
|
||||||
user_id: str
|
user_id: str
|
||||||
email: str
|
email: str
|
||||||
|
points: int = 0
|
||||||
|
|
||||||
|
|
||||||
class SupabaseEntitlementService:
|
class SupabaseEntitlementService:
|
||||||
@@ -120,9 +121,15 @@ class SupabaseEntitlementService:
|
|||||||
user_id = str(data.get("id") or "").strip()
|
user_id = str(data.get("id") or "").strip()
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Extract points from user_metadata
|
||||||
|
metadata = data.get("user_metadata") or {}
|
||||||
|
points = int(metadata.get("points") or metadata.get("total_points") or 0)
|
||||||
|
|
||||||
identity = SupabaseIdentity(
|
identity = SupabaseIdentity(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
email=str(data.get("email") or "").strip(),
|
email=str(data.get("email") or "").strip(),
|
||||||
|
points=points,
|
||||||
)
|
)
|
||||||
with self._identity_cache_lock:
|
with self._identity_cache_lock:
|
||||||
self._identity_cache[access_token] = {
|
self._identity_cache[access_token] = {
|
||||||
@@ -133,6 +140,9 @@ class SupabaseEntitlementService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"supabase auth user check failed: {exc}")
|
logger.warning(f"supabase auth user check failed: {exc}")
|
||||||
return None
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"supabase auth user check failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _query_active_subscription(self, user_id: str) -> bool:
|
def _query_active_subscription(self, user_id: str) -> bool:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ def _bind_optional_supabase_identity(request: Request) -> None:
|
|||||||
return
|
return
|
||||||
request.state.auth_user_id = identity.user_id
|
request.state.auth_user_id = identity.user_id
|
||||||
request.state.auth_email = identity.email
|
request.state.auth_email = identity.email
|
||||||
|
request.state.auth_points = identity.points
|
||||||
|
|
||||||
|
|
||||||
def _assert_entitlement(request: Request) -> None:
|
def _assert_entitlement(request: Request) -> None:
|
||||||
@@ -1093,6 +1094,7 @@ async def auth_me(request: Request):
|
|||||||
"authenticated": bool(user_id),
|
"authenticated": bool(user_id),
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"email": getattr(request.state, "auth_email", None),
|
"email": getattr(request.state, "auth_email", None),
|
||||||
|
"points": getattr(request.state, "auth_points", 0),
|
||||||
"entitlement_mode": (
|
"entitlement_mode": (
|
||||||
"supabase_required"
|
"supabase_required"
|
||||||
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED
|
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED
|
||||||
|
|||||||
Reference in New Issue
Block a user