-
-
- {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" : "积分抵扣"}
+
+
-
+ {/* 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