feat: implement history and future forecast modals, alongside a new Pro feature paywall component.

This commit is contained in:
2569718930@qq.com
2026-03-13 07:56:20 +08:00
parent 5c3de0dbe0
commit a5948a35d4
7 changed files with 709 additions and 617 deletions
@@ -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>
); );
} }
+153 -155
View File
@@ -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>
+8 -1
View File
@@ -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),
}); });
} }
+1
View File
@@ -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;
} }
+10
View File
@@ -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:
+2
View File
@@ -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