From a5948a35d4268d7d1bb39fdf72b48071cfd760a6 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 13 Mar 2026 07:56:20 +0800 Subject: [PATCH] feat: implement history and future forecast modals, alongside a new Pro feature paywall component. --- .../dashboard/FutureForecastModal.tsx | 788 +++++++++--------- .../components/dashboard/HistoryModal.tsx | 308 ++++--- .../dashboard/ProFeaturePaywall.tsx | 208 +++-- frontend/hooks/useDashboardStore.tsx | 9 +- frontend/lib/dashboard-types.ts | 1 + src/auth/supabase_entitlement.py | 10 + web/app.py | 2 + 7 files changed, 709 insertions(+), 617 deletions(-) diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 1b948f29..82d856b6 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -550,246 +550,180 @@ export function FutureForecastModal() { } }} > -
-
-

- - {isToday - ? t("future.todayTitle", { - city: detail.display_name.toUpperCase(), - }) - : t("future.dateTitle", { - city: detail.display_name.toUpperCase(), - date: dateStr, - })} - - -

- + {isProLoading ? ( +
+
+ {t("dashboard.loading")} +
- -
- {isProLoading ? ( -
+ ) : ( +
+
+

- {t("dashboard.loading")} -

- ) : !isPro ? ( - - ) : isToday ? ( -
- + +
+
+

+ {locale === "en-US" + ? "Today's temperature forecast (obs + market)" + : "今日气温预测(观测 + 市场)"} +

+ +
+ +
+
+

{t("future.probability")}

+
+ {/* Loading Overlay */} + {store.loadingState.marketScan && ( +
+
+ {locale === "en-US" + ? "Crunching Polymarket Edges..." + : "正在同步市场挂单..."} +
+ )} + +
+
+
+

{t("future.models")}

+ -
+ +
+ +
+
+

{t("future.structureToday")}

+
+
+
+
+
+ + {t("future.judgement")}: {view.front.label} + + + {t("future.confidence")}:{" "} + {t(`confidence.${view.front.confidence}`)} + + + {t("future.maxPrecip")}:{" "} + {Math.round(view.front.precipMax)}% + +
+
+
+ {view.front.metrics.slice(0, 6).map((metric) => ( +
+
+ {metric.label} +
+
+ {metric.value} +
+
+ {metric.note} +
+
+ ))} +
+
+ +
+

{t("future.ai")}

+
+ {ai.summary ? ( +
{ai.summary}
+ ) : ( +
{t("future.noAi")}
+ )} + {ai.bullets.length > 0 && ( +
+ {ai.bullets.slice(0, 3).map((item) => ( +
{item}
+ ))} +
+ )} +
+ {nowcastRows.slice(0, 4).map(([label, value]) => ( +
+ {label}: + {value} +
+ ))} +
+ {riskLines.length > 0 && ( +
+ + {locale === "en-US" ? "Risk" : "风险"}:{" "} + + {riskLines[0]} +
+ )} + {climateDrivers.length > 0 && ( +
+ + {locale === "en-US" ? "Climate" : "气候"}:{" "} + + {climateDrivers[0].text} +
+ )} +
+
+
+ +
+ ) : ( + <> +
+
+ {t("future.targetForecast")} + + {view.forecastEntry?.max_temp ?? "--"} + {detail.temp_symbol} + +
+
+ {t("future.deb")} + + {view.deb ?? "--"} + {detail.temp_symbol} + +
+
+ {t("future.mu")} + + {view.mu != null + ? `${view.mu.toFixed(1)}${detail.temp_symbol}` + : "--"} + +
+
+ {t("future.score")} + + {view.front.score > 0 ? "+" : ""} + {view.front.score} + +
+
+ +
+

{t("future.targetTempTrend")}

+ +
+ +
+
+

{t("future.probability")}

+

{t("future.models")}

@@ -824,166 +989,11 @@ export function FutureForecastModal() { />
- -
-
-

{t("future.structureToday")}

-
-
-
-
-
- - {t("future.judgement")}: {view.front.label} - - - {t("future.confidence")}:{" "} - {t(`confidence.${view.front.confidence}`)} - - - {t("future.maxPrecip")}:{" "} - {Math.round(view.front.precipMax)}% - -
-
-
- {view.front.metrics.slice(0, 6).map((metric) => ( -
-
- {metric.label} -
-
- {metric.value} -
-
{metric.note}
-
- ))} -
-
- -
-

{t("future.ai")}

-
- {ai.summary ? ( -
{ai.summary}
- ) : ( -
{t("future.noAi")}
- )} - {ai.bullets.length > 0 && ( -
- {ai.bullets.slice(0, 3).map((item) => ( -
{item}
- ))} -
- )} -
- {nowcastRows.slice(0, 4).map(([label, value]) => ( -
- {label}: - {value} -
- ))} -
- {riskLines.length > 0 && ( -
- - {locale === "en-US" ? "Risk" : "风险"}:{" "} - - {riskLines[0]} -
- )} - {climateDrivers.length > 0 && ( -
- - {locale === "en-US" ? "Climate" : "气候"}:{" "} - - {climateDrivers[0].text} -
- )} -
-
-
- -
- ) : ( - <> -
-
- {t("future.targetForecast")} - - {view.forecastEntry?.max_temp ?? "--"} - {detail.temp_symbol} - -
-
- {t("future.deb")} - - {view.deb ?? "--"} - {detail.temp_symbol} - -
-
- {t("future.mu")} - - {view.mu != null - ? `${view.mu.toFixed(1)}${detail.temp_symbol}` - : "--"} - -
-
- {t("future.score")} - - {view.front.score > 0 ? "+" : ""} - {view.front.score} - -
-
- -
-

{t("future.targetTempTrend")}

- -
- -
-
-

{t("future.probability")}

- -
-
-

{t("future.models")}

- -
-
- - )} + + )} +
-
+ )} ); } diff --git a/frontend/components/dashboard/HistoryModal.tsx b/frontend/components/dashboard/HistoryModal.tsx index 4e31dcc9..df1f8ae6 100644 --- a/frontend/components/dashboard/HistoryModal.tsx +++ b/frontend/components/dashboard/HistoryModal.tsx @@ -20,101 +20,101 @@ function HistoryChart() { store.selectedCity === "ankara" && summary.mgms.some((value) => value != null); - const canvasRef = useChart( - () => { - const datasets: NonNullable["data"]>["datasets"] = [ - { - backgroundColor: "rgba(248, 113, 113, 0.1)", - borderColor: "#f87171", - borderWidth: 2, - data: summary.actuals, - label: locale === "en-US" ? "Observed High" : "实测最高温", - pointBackgroundColor: "#f87171", - pointBorderColor: "#fff", - pointHoverRadius: 7, - pointRadius: 5, - tension: 0.2, - }, - { - backgroundColor: "transparent", - borderColor: "#34d399", - borderDash: [5, 4], - borderWidth: 2, - data: summary.debs, - label: locale === "en-US" ? "DEB Fusion" : "DEB 融合", - pointHoverRadius: 6, - pointRadius: 4, - tension: 0.2, - }, - ]; + const canvasRef = useChart(() => { + const datasets: NonNullable< + ChartConfiguration<"line">["data"] + >["datasets"] = [ + { + backgroundColor: "rgba(248, 113, 113, 0.1)", + borderColor: "#f87171", + borderWidth: 2, + data: summary.actuals, + label: locale === "en-US" ? "Observed High" : "实测最高温", + pointBackgroundColor: "#f87171", + pointBorderColor: "#fff", + pointHoverRadius: 7, + pointRadius: 5, + tension: 0.2, + }, + { + backgroundColor: "transparent", + borderColor: "#34d399", + borderDash: [5, 4], + borderWidth: 2, + data: summary.debs, + label: locale === "en-US" ? "DEB Fusion" : "DEB 融合", + pointHoverRadius: 6, + pointRadius: 4, + tension: 0.2, + }, + ]; - if (hasMgm) { - datasets.push({ - backgroundColor: "transparent", - borderColor: "#fb923c", - borderWidth: 2, - data: summary.mgms, - label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报", - pointHoverRadius: 6, - pointRadius: 4, - tension: 0.2, - }); - } + if (hasMgm) { + datasets.push({ + backgroundColor: "transparent", + borderColor: "#fb923c", + borderWidth: 2, + data: summary.mgms, + label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报", + pointHoverRadius: 6, + pointRadius: 4, + tension: 0.2, + }); + } - return { - data: { - datasets, - labels: summary.dates, - }, - options: { - interaction: { intersect: false, mode: "index" }, - maintainAspectRatio: false, - plugins: { - legend: { - labels: { - boxHeight: 12, - boxWidth: 34, - color: "#94a3b8", - font: { family: "Inter", size: 14 }, - 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)}°`, - }, + return { + data: { + datasets, + labels: summary.dates, + }, + options: { + interaction: { intersect: false, mode: "index" }, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { + boxHeight: 12, + boxWidth: 34, + color: "#94a3b8", + font: { family: "Inter", size: 14 }, + padding: 18, }, }, - responsive: true, - scales: { - x: { - 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, - }, + 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)}°`, }, }, }, - type: "line", - } satisfies ChartConfiguration<"line">; - }, - [hasMgm, summary, locale], - ); + responsive: true, + scales: { + x: { + 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; @@ -150,77 +150,75 @@ export function HistoryModal() { } }} > -
-
-

- {t("history.title", { city: store.selectedCity?.toUpperCase() || "" })} -

- + {isProLoading ? ( +
+
+ {t("dashboard.loading")} +
-
- {isProLoading ? ( -
+ ) : ( +
+
+

+ {t("history.title", { + city: store.selectedCity?.toUpperCase() || "", + })} +

+ +
+
+
+ {isLoading ? ( + + {t("history.loading")} + + ) : error ? ( + + {t("history.error")} + + ) : !summary.recentData.length ? ( + + {t("history.empty")} + + ) : ( + <> +
+ {t("history.hitRate")} + + {summary.hitRate != null ? `${summary.hitRate}%` : "--"} + +
+
+ {t("history.mae")} + + {summary.debMae != null ? `${summary.debMae}°` : "--"} + +
+
+ {t("history.sample")} + + {t("history.sampleDays", { count: summary.settledCount })} + +
+ + )}
- ) : !isPro ? ( - - ) : ( - <> -
- {isLoading ? ( - - {t("history.loading")} - - ) : error ? ( - - {t("history.error")} - - ) : !summary.recentData.length ? ( - - {t("history.empty")} - - ) : ( - <> -
- {t("history.hitRate")} - - {summary.hitRate != null ? `${summary.hitRate}%` : "--"} - -
-
- {t("history.mae")} - - {summary.debMae != null ? `${summary.debMae}°` : "--"} - -
-
- {t("history.sample")} - - {t("history.sampleDays", { count: summary.settledCount })} - -
- - )} -
- {!isLoading && !error && } - - )} + {!isLoading && !error && } +
-
+ )}
); } diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx index da7692f1..905b10af 100644 --- a/frontend/components/dashboard/ProFeaturePaywall.tsx +++ b/frontend/components/dashboard/ProFeaturePaywall.tsx @@ -8,112 +8,176 @@ import { Lock, ShieldCheck, Sparkles, + X, Zap, } from "lucide-react"; import { useI18n } from "@/hooks/useI18n"; +import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { useState } from "react"; type ProFeaturePaywallProps = { feature: "today" | "history" | "future"; + onClose?: () => void; }; -function getFeatureLabel( - locale: "zh-CN" | "en-US", - feature: "today" | "history" | "future", -) { - 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) { +export function ProFeaturePaywall({ + feature, + onClose, +}: ProFeaturePaywallProps) { const { locale } = useI18n(); - const featureLabel = getFeatureLabel(locale, feature); + const { proAccess } = useDashboardStore(); + const [usePoints, setUsePoints] = useState(true); + 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 ( -
-
-
- +
+
+ {onClose && ( + + )} + + {/* Crown Badge */} +
+

{isEn ? "Unlock PolyWeather Pro" : "开启 PolyWeather Pro"}

-

+

{isEn - ? `This module (${featureLabel}) is available for subscribers only. Upgrade to unlock advanced weather intelligence.` - : `当前模块(${featureLabel})仅对订阅用户开放。升级后可解锁更完整的气象分析能力。`} + ? "Unlock 15-day precision trends, real-time radar, and ad-free experience across all platforms." + : "解锁 15 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。"}

-
- {[ - { - icon: Zap, - text: isEn - ? "Real-time radar and lightning tracking" - : "实时雷达与闪电追踪", - }, - { - icon: ShieldCheck, - text: isEn - ? "15-day high-precision trend analysis" - : "15 天高精度趋势分析", - }, - { - icon: BarChart3, - text: isEn - ? "Pro-grade station raw data" - : "专业级气象站原始数据", - }, - { - icon: Sparkles, - text: isEn - ? "Ad-free experience across all surfaces" - : "全平台无广告体验", - }, - ].map((item) => ( -
-
- -
- - {item.text} + {/* Pricing Cards */} +
+ {/* Plan Card */} +
+
+ {isEn ? "Monthly" : "月付套餐"} +
+
+ PRO PLAN +
+
+ $5.00 + + / {isEn ? "mo" : "月"}
- ))} +
+ + {/* Points Card */} +
= 500 + ? "border-indigo-500/30 bg-indigo-500/5" + : "border-white/5 bg-white/5 opacity-60" + }`} + > +
+ = 500 + ? "text-indigo-400" + : "text-slate-500" + }`} + > + {isEn ? "Points Credit" : "积分抵扣"} + + +
+
+ = 500 ? "text-emerald-400" : "text-slate-500"}`} + > + -${discountAmount.toFixed(2)} + + + OFF + +
+
+ {pointsAvailable < 500 + ? isEn + ? "Need 500+ points" + : `积分不足 (当前 ${pointsAvailable})` + : isEn + ? `Auto-consume ${pointsToConsume} points` + : `已自动消耗 ${pointsToConsume} 积分`} +
+
-
+ {/* Total & Action */} +
+
+ + {isEn ? "Total Due:" : "应付总计:"} + + + ${finalPrice.toFixed(2)} + +
+ - {isEn ? "Upgrade now - $5 / month" : "立即升级 - $5 / 月"} + {isEn ? "Connect Wallet & Pay" : "连接钱包并支付"} -
-
- - - {isEn - ? "Payments are secured with AES-256 encryption" - : "所有交易均经过 AES-256 加密保护"} - +
+ + + {isEn ? "Secured Payment" : "安全加密支付"} + + + {isEn ? "FAQ" : "常见问题 (FAQ)"} + +
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index d57781ea..2d7eefa0 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -75,6 +75,7 @@ function getInitialProAccessState(): ProAccessState { loading: true, authenticated: false, subscriptionActive: false, + points: 0, error: null, }; } @@ -235,7 +236,10 @@ export function DashboardStoreProvider({ ? cityDetailsByName[selectedCity] || null : null; const selectedMarketDate = - futureModalDate || selectedForecastDate || selectedDetail?.local_date || null; + futureModalDate || + selectedForecastDate || + selectedDetail?.local_date || + null; const selectedMarketScanKey = selectedCity ? getMarketScanCacheKey(selectedCity, selectedMarketDate) : null; @@ -402,11 +406,13 @@ export function DashboardStoreProvider({ const payload = (await response.json()) as { authenticated?: boolean; subscription_active?: boolean | null; + points?: number; }; setProAccess({ loading: false, authenticated: Boolean(payload.authenticated), subscriptionActive: payload.subscription_active === true, + points: payload.points ?? 0, error: null, }); } catch (error) { @@ -414,6 +420,7 @@ export function DashboardStoreProvider({ loading: false, authenticated: false, subscriptionActive: false, + points: 0, error: String(error), }); } diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index f10f6a7b..ebea4101 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -318,6 +318,7 @@ export interface ProAccessState { loading: boolean; authenticated: boolean; subscriptionActive: boolean; + points: number; error: string | null; } diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 1f58dcd8..3f385561 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -41,6 +41,7 @@ def extract_bearer_token(auth_header: Optional[str]) -> str: class SupabaseIdentity: user_id: str email: str + points: int = 0 class SupabaseEntitlementService: @@ -120,9 +121,15 @@ class SupabaseEntitlementService: user_id = str(data.get("id") or "").strip() if not user_id: 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( user_id=user_id, email=str(data.get("email") or "").strip(), + points=points, ) with self._identity_cache_lock: self._identity_cache[access_token] = { @@ -133,6 +140,9 @@ class SupabaseEntitlementService: except Exception as exc: logger.warning(f"supabase auth user check failed: {exc}") 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: if not user_id: diff --git a/web/app.py b/web/app.py index ff8baf07..7f4602bb 100644 --- a/web/app.py +++ b/web/app.py @@ -117,6 +117,7 @@ def _bind_optional_supabase_identity(request: Request) -> None: return request.state.auth_user_id = identity.user_id request.state.auth_email = identity.email + request.state.auth_points = identity.points def _assert_entitlement(request: Request) -> None: @@ -1093,6 +1094,7 @@ async def auth_me(request: Request): "authenticated": bool(user_id), "user_id": user_id, "email": getattr(request.state, "auth_email", None), + "points": getattr(request.state, "auth_points", 0), "entitlement_mode": ( "supabase_required" if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED