feat: Introduce premium map dashboard with dark theme, pro features, and payment processing.

This commit is contained in:
2569718930@qq.com
2026-03-13 08:15:27 +08:00
parent a5948a35d4
commit 0f51780566
9 changed files with 2388 additions and 665 deletions
+4
View File
@@ -67,6 +67,10 @@ POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
# Payment points redemption
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
# Comma-separated allowed plans for checkout UI + backend validation. # Comma-separated allowed plans for checkout UI + backend validation.
# Default is monthly-only launch. # Default is monthly-only launch.
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
+5 -1
View File
@@ -75,8 +75,12 @@ POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
# 支付积分抵扣(500 积分 = 1 USDC,最高抵扣 3 USDC
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
# JSON 示例: # JSON 示例:
# {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}} # {"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
``` ```
File diff suppressed because it is too large Load Diff
@@ -549,6 +549,25 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.root :global(.pro-locked) {
filter: grayscale(0.8) opacity(0.7);
position: relative;
}
.root :global(.pro-locked::after) {
content: "PRO";
position: absolute;
top: -4px;
right: -4px;
background: var(--accent-blue);
color: white;
font-size: 7px;
padding: 1px 3px;
border-radius: 3px;
font-weight: 900;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.root :global(.risk-badge) { .root :global(.risk-badge) {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
+151 -124
View File
@@ -14,103 +14,102 @@ import {
getRiskBadgeLabel, getRiskBadgeLabel,
getTemperatureChartData, getTemperatureChartData,
} from "@/lib/dashboard-utils"; } from "@/lib/dashboard-utils";
import { ChevronRight, Crown } from "lucide-react";
import { useRouter } from "next/navigation";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) { function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const chartData = getTemperatureChartData(detail, locale); const chartData = getTemperatureChartData(detail, locale);
const canvasRef = useChart( const canvasRef = useChart(() => {
() => { if (!chartData) {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return { return {
data: { data: { datasets: [], labels: [] },
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: chartData.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测",
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
pointHoverRadius: 6,
pointRadius: 3.8,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#64748b",
font: { size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
color: "#64748b",
font: { size: 10 },
},
},
},
},
type: "line", type: "line",
} satisfies ChartConfiguration<"line">; } satisfies ChartConfiguration<"line">;
}, }
[chartData, detail.temp_symbol, locale],
); const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: chartData.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测",
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
pointHoverRadius: 6,
pointRadius: 3.8,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#64748b",
font: { size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
color: "#64748b",
font: { size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [chartData, detail.temp_symbol, locale]);
return ( return (
<div className="detail-mini-chart-wrap"> <div className="detail-mini-chart-wrap">
@@ -126,6 +125,7 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
export function DetailPanel() { export function DetailPanel() {
const store = useDashboardStore(); const store = useDashboardStore();
const router = useRouter();
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const detail = store.selectedDetail; const detail = store.selectedDetail;
const isPro = store.proAccess.subscriptionActive; const isPro = store.proAccess.subscriptionActive;
@@ -194,39 +194,40 @@ export function DetailPanel() {
<span className={clsx("risk-badge", detail?.risk?.level || "low")}> <span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level, locale)} {getRiskBadgeLabel(detail?.risk?.level, locale)}
</span> </span>
<span className="local-time"> <div className="relative group">
{detail <button
? `${detail.local_date} ${detail.local_time}` type="button"
: t("detail.waitSelect")} className={clsx("history-btn", !isPro && "pro-locked")}
</span> title={
<button isPro
type="button" ? t("detail.todayAnalysis")
className="history-btn" : `${t("detail.todayAnalysis")} (Pro)`
title={ }
isPro onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{isPro
? t("detail.todayAnalysis") ? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)` : `${t("detail.todayAnalysis")} · Pro`}
} </button>
onClick={() => { <button
blurActiveElement(); type="button"
void store.openTodayModal(); className={clsx("history-btn", !isPro && "pro-locked")}
}} title={
disabled={!detail} isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
> }
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`} onClick={() => {
</button> blurActiveElement();
<button void store.openHistory();
type="button" }}
className="history-btn" disabled={!detail}
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`} >
onClick={() => { {isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
blurActiveElement(); </button>
void store.openHistory(); </div>
}}
disabled={!detail}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -268,7 +269,9 @@ export function DetailPanel() {
</> </>
) : ( ) : (
<div className="detail-scenery-fallback"> <div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">{detail.display_name}</span> <span className="detail-scenery-kicker">
{detail.display_name}
</span>
<strong className="detail-scenery-title"> <strong className="detail-scenery-title">
{t("detail.sceneryTitle")} {t("detail.sceneryTitle")}
</strong> </strong>
@@ -291,9 +294,33 @@ export function DetailPanel() {
</div> </div>
</section> </section>
<section className="detail-section"> <section className="detail-section relative overflow-hidden rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3> <h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} /> <div
className={clsx(
"transition-all duration-500",
!isPro &&
"blur-md opacity-30 select-none pointer-events-none",
)}
>
<DetailMiniTemperatureChart detail={detail} />
</div>
{!isPro && (
<div className="absolute inset-x-0 bottom-0 top-[3rem] z-10 flex flex-col items-center justify-center p-4 bg-gradient-to-t from-[#0b0f1a] via-transparent to-transparent">
<div className="bg-blue-600/90 text-white text-[10px] font-bold px-3 py-1.5 rounded-full shadow-lg shadow-blue-600/40 flex items-center gap-1.5 mb-2">
<Crown size={10} fill="currentColor" /> Pro Feature
</div>
<p className="text-[10px] text-slate-400 text-center mb-3">
</p>
<button
onClick={() => router.push("/account")}
className="text-[10px] font-bold text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
>
<ChevronRight size={10} />
</button>
</div>
)}
</section> </section>
<ForecastTable /> <ForecastTable />
@@ -13,7 +13,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useState } from "react"; import { useMemo, useState } from "react";
type ProFeaturePaywallProps = { type ProFeaturePaywallProps = {
feature: "today" | "history" | "future"; feature: "today" | "history" | "future";
@@ -32,148 +32,143 @@ export function ProFeaturePaywall({
// Redemption logic: 500 points = $1 discount // Redemption logic: 500 points = $1 discount
// Max discount $3 (requires 1500 points) // Max discount $3 (requires 1500 points)
const maxRedeemablePoints = 1500; const PRO_PRICE = 5;
const POINTS_VAL = 500;
const MAX_DISCOUNT = 3;
const pointsAvailable = proAccess.points || 0; const pointsAvailable = proAccess.points || 0;
const effectivePoints = Math.min(pointsAvailable, maxRedeemablePoints);
const discountAmount = usePoints ? Math.floor(effectivePoints / 500) : 0; const billing = useMemo(() => {
const originalPrice = 5.0; const maxRedeemable = MAX_DISCOUNT * POINTS_VAL;
const finalPrice = originalPrice - discountAmount; const actualRedeem = Math.min(pointsAvailable, maxRedeemable);
const pointsToConsume = discountAmount * 500; const discount = Math.floor(actualRedeem / POINTS_VAL);
return {
pointsUsed: discount * POINTS_VAL,
discountAmount: discount,
payAmount: PRO_PRICE - (usePoints ? discount : 0),
};
}, [usePoints, pointsAvailable]);
return ( return (
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10"> <div className="flex w-full flex-col items-center justify-center py-6 md:py-10 z-30 p-4">
<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="w-full max-w-2xl bg-[#161b2a]/90 backdrop-blur-2xl border border-white/10 rounded-[2.5rem] p-8 md:p-12 shadow-[0_0_80px_-10px_rgba(79,70,229,0.3)] text-center relative">
{/* 关闭按钮 */}
{onClose && ( {onClose && (
<button <button
onClick={onClose} 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" className="absolute top-6 right-6 p-2 bg-white/5 hover:bg-white/10 rounded-full text-slate-500 hover:text-white transition-all z-10"
title={isEn ? "Close" : "稍后再说"}
> >
<X size={20} /> <X size={20} />
</button> </button>
)} )}
{/* Crown Badge */} {/* 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"> <div className="absolute -top-10 left-1/2 -translate-x-1/2 w-20 h-20 bg-gradient-to-tr from-yellow-500 to-amber-400 rounded-3xl flex items-center justify-center shadow-2xl rotate-12">
<Crown size={32} fill="white" /> <Crown className="text-white w-10 h-10" fill="currentColor" />
</div> </div>
<div className="mt-4 text-center"> <h2 className="text-3xl font-bold text-white mb-4 mt-4">
<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"} </h2>
</h3> <p className="text-slate-400 mb-10 max-w-md mx-auto">
<p className="mx-auto mt-4 max-w-sm text-base leading-relaxed text-slate-400"> {isEn
{isEn ? "Unlock 15-day precision trends, real-time radar, and ad-free experience across all platforms."
? "Unlock 15-day precision trends, real-time radar, and ad-free experience across all platforms." : "解锁 15 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。"}
: "解锁 15 天高精度趋势分析、实时雷达与闪电追踪。尊享全平台无广告体验。"} </p>
</p>
</div>
{/* Pricing Cards */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-left mb-10">
<div className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2"> {/* 订阅方案卡片 */}
{/* Plan Card */} <div className="p-6 bg-blue-600/10 border-2 border-blue-500/50 rounded-3xl relative">
<div className="relative overflow-hidden rounded-3xl border border-blue-500/30 bg-blue-500/5 p-6 transition hover:bg-blue-500/10"> <div className="absolute -top-3 right-6 bg-blue-500 text-[10px] px-2 py-1 rounded font-bold text-white uppercase tracking-tighter">
<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">
{isEn ? "Monthly" : "月付套餐"} {isEn ? "Monthly" : "月付套餐"}
</div> </div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest"> <p className="text-slate-500 text-[10px] font-bold uppercase tracking-widest mb-1">
PRO PLAN PRO PLAN
</div> </p>
<div className="mt-2 flex items-baseline"> <div className="flex items-baseline gap-1">
<span className="text-3xl font-black text-white">$5.00</span> <span className="text-3xl font-black text-white">$5.00</span>
<span className="ml-1 text-sm font-medium text-slate-500"> <span className="text-slate-500 text-sm">
/ {isEn ? "mo" : ""} / {isEn ? "mo" : ""}
</span> </span>
</div> </div>
</div> </div>
{/* Points Card */} {/* 积分抵扣卡片 */}
<div <div
className={`relative rounded-3xl border p-6 transition ${ className={`p-6 rounded-3xl border transition-all ${usePoints && pointsAvailable >= 500 ? "bg-indigo-600/20 border-indigo-500/50" : "bg-white/5 border-white/10"}`}
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"> <div className="flex items-center justify-between mb-2">
<span <span
className={`text-[10px] font-bold uppercase tracking-widest ${ className={`text-[10px] font-bold uppercase tracking-widest ${usePoints && pointsAvailable >= 500 ? "text-indigo-400" : "text-slate-500"}`}
usePoints && pointsAvailable >= 500
? "text-indigo-400"
: "text-slate-500"
}`}
> >
{isEn ? "Points Credit" : "积分抵扣"} {isEn ? "Points Credit" : "积分抵扣"}
</span> </span>
<button <button
onClick={() => setUsePoints(!usePoints)} onClick={() => setUsePoints(!usePoints)}
disabled={pointsAvailable < 500} disabled={pointsAvailable < 500}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${ className={`w-10 h-5 rounded-full relative transition-all ${usePoints && pointsAvailable >= 500 ? "bg-indigo-500" : "bg-slate-700"} ${pointsAvailable < 500 ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
usePoints && pointsAvailable >= 500
? "bg-indigo-600"
: "bg-slate-700"
} ${pointsAvailable < 500 ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
> >
<div <div
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${ className={`absolute top-1 w-3 h-3 rounded-full bg-white transition-all ${usePoints && pointsAvailable >= 500 ? "right-1" : "left-1"}`}
usePoints && pointsAvailable >= 500
? "translate-x-5"
: "translate-x-0"
}`}
/> />
</button> </button>
</div> </div>
<div className="mt-2 flex items-baseline"> <div className="flex items-baseline gap-1">
<span <span
className={`text-3xl font-black ${usePoints && pointsAvailable >= 500 ? "text-emerald-400" : "text-slate-500"}`} className={`text-3xl font-black ${usePoints && pointsAvailable >= 500 ? "text-emerald-400" : "text-slate-500"}`}
> >
-${discountAmount.toFixed(2)} -${billing.discountAmount.toFixed(2)}
</span> </span>
<span className="ml-1 text-[10px] font-bold text-slate-500 uppercase"> <span className="text-slate-500 text-[10px] font-bold uppercase">
OFF OFF
</span> </span>
</div> </div>
<div className="mt-1 text-[10px] font-medium text-slate-500"> <p className="text-[10px] text-slate-500 mt-1 italic">
{pointsAvailable < 500 {pointsAvailable < 500
? isEn ? isEn
? "Need 500+ points" ? `Need 500+ points (Current: ${pointsAvailable})`
: `积分不足 (当前 ${pointsAvailable})` : `积分不足 (当前 ${pointsAvailable})`
: isEn : usePoints
? `Auto-consume ${pointsToConsume} points` ? isEn
: `已自动消耗 ${pointsToConsume} 积分`} ? `Consumed ${billing.pointsUsed} points`
</div> : `已自动消耗 ${billing.pointsUsed} 积分`
: isEn
? "Up to $3.00 off"
: "开启后最多抵扣 $3.00"}
</p>
</div> </div>
</div> </div>
{/* Total & Action */} <div className="space-y-6">
<div className="mt-10 space-y-6"> <div className="flex items-center justify-between px-2 text-sm">
<div className="flex items-center justify-between px-2"> <span className="text-slate-400">
<span className="text-sm font-medium text-slate-400">
{isEn ? "Total Due:" : "应付总计:"} {isEn ? "Total Due:" : "应付总计:"}
</span> </span>
<span className="text-3xl font-black text-white"> <span className="text-3xl font-black text-white">
${finalPrice.toFixed(2)} ${billing.payAmount.toFixed(2)}
</span> </span>
</div> </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-lg font-bold text-white shadow-2xl shadow-blue-600/30 transition hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98]" className="w-full py-4 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold rounded-2xl shadow-xl shadow-blue-600/30 transition-all flex items-center justify-center gap-2 group active:scale-95 text-lg"
> >
{isEn ? "Connect Wallet & Pay" : "连接钱包并支付"} {isEn ? "Connect Wallet & Pay" : "连接钱包并支付"}
<ArrowRight <ArrowRight
size={20} size={20}
className="ml-2 transition-transform group-hover:translate-x-1" className="group-hover:translate-x-1 transition-transform"
/> />
</Link> </Link>
<div className="flex items-center justify-center gap-6"> <div className="flex justify-center items-center gap-6 text-[10px] font-medium text-slate-500 uppercase tracking-widest">
<span className="flex items-center gap-1.5 text-[10px] font-medium text-slate-500"> <span className="flex items-center gap-1.5">
<Lock size={12} className="opacity-50" /> <Lock size={12} className="opacity-50" />{" "}
{isEn ? "Secured Payment" : "安全加密支付"} {isEn ? "Secured Payment" : "安全加密支付"}
</span> </span>
<Link <Link
href="/account" href="/account"
className="text-[10px] font-medium text-slate-500 transition hover:text-slate-300" className="hover:text-white cursor-pointer transition-colors"
> >
{isEn ? "FAQ" : "常见问题 (FAQ)"} {isEn ? "FAQ" : "常见问题 (FAQ)"}
</Link> </Link>
+300 -19
View File
@@ -6,7 +6,7 @@ import secrets
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation, ROUND_FLOOR
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import requests import requests
@@ -196,6 +196,7 @@ class PaymentIntentRecord:
allowed_wallet: Optional[str] allowed_wallet: Optional[str]
expires_at: str expires_at: str
tx_hash: Optional[str] tx_hash: Optional[str]
metadata: Dict[str, Any]
class PaymentCheckoutError(Exception): class PaymentCheckoutError(Exception):
@@ -254,6 +255,11 @@ class PaymentContractCheckoutService:
self.notify_telegram = _env_bool( self.notify_telegram = _env_bool(
"POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True "POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True
) )
self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True)
self.points_per_usdc = max(1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500))
self.points_max_discount_usdc = max(
0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3)
)
self._w3_lock = threading.Lock() self._w3_lock = threading.Lock()
self._w3: Optional[Web3] = None self._w3: Optional[Web3] = None
self._event_topic = Web3.keccak( self._event_topic = Web3.keccak(
@@ -327,6 +333,245 @@ class PaymentContractCheckoutService:
except Exception: except Exception:
return None return None
def _admin_auth_headers(self) -> Dict[str, str]:
return {
"apikey": self.supabase_service_role_key,
"Authorization": f"Bearer {self.supabase_service_role_key}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def _auth_admin_request(
self,
method: str,
path: str,
*,
payload: Optional[Dict[str, Any]] = None,
allowed_status: Optional[List[int]] = None,
) -> Any:
url = f"{self.supabase_url}/auth/v1{path}"
status_ok = allowed_status or [200]
try:
response = requests.request(
method=method.upper(),
url=url,
json=payload,
headers=self._admin_auth_headers(),
timeout=self.timeout_sec,
)
except Exception as exc:
raise PaymentCheckoutError(503, f"supabase auth request failed: {exc}") from exc
if response.status_code not in status_ok:
detail = response.text[:350] if response.text else response.reason
raise PaymentCheckoutError(
502,
(
f"supabase auth {method.upper()} {path} failed: "
f"{response.status_code} {detail}"
),
)
if not response.content:
return None
try:
return response.json()
except Exception:
return None
def _extract_user_metadata(self, user_payload: Any) -> Dict[str, Any]:
if not isinstance(user_payload, dict):
return {}
if isinstance(user_payload.get("user_metadata"), dict):
return dict(user_payload.get("user_metadata") or {})
user_obj = user_payload.get("user")
if isinstance(user_obj, dict) and isinstance(user_obj.get("user_metadata"), dict):
return dict(user_obj.get("user_metadata") or {})
return {}
def _extract_points_from_metadata(self, metadata: Dict[str, Any]) -> int:
if not isinstance(metadata, dict):
return 0
for key in ("points", "total_points"):
raw = metadata.get(key)
if raw is None:
continue
try:
return max(0, int(raw))
except Exception:
continue
return 0
def _auth_admin_get_user(self, user_id: str) -> Dict[str, Any]:
user_id_text = str(user_id or "").strip()
if not user_id_text:
raise PaymentCheckoutError(400, "user_id required")
data = self._auth_admin_request(
"GET",
f"/admin/users/{user_id_text}",
allowed_status=[200],
)
if isinstance(data, dict):
user_obj = data.get("user")
if isinstance(user_obj, dict):
return user_obj
return data
return {}
def _auth_admin_update_user_metadata(
self,
user_id: str,
metadata: Dict[str, Any],
) -> Dict[str, Any]:
user_id_text = str(user_id or "").strip()
if not user_id_text:
raise PaymentCheckoutError(400, "user_id required")
payload = {"user_metadata": metadata or {}}
data = self._auth_admin_request(
"PUT",
f"/admin/users/{user_id_text}",
payload=payload,
allowed_status=[200],
)
if isinstance(data, dict):
user_obj = data.get("user")
if isinstance(user_obj, dict):
return user_obj
return data
return {}
def _build_points_redemption(
self,
*,
user_id: str,
plan_amount_usdc: Decimal,
use_points: bool,
requested_points_to_consume: Optional[int],
) -> Dict[str, Any]:
base = {
"enabled": bool(self.points_enabled),
"applied": False,
"points_per_usdc": int(self.points_per_usdc),
"max_discount_usdc": int(self.points_max_discount_usdc),
"points_balance_snapshot": 0,
"points_to_consume": 0,
"discount_usdc": "0",
"pay_amount_usdc": plan_amount_usdc,
}
if not self.points_enabled:
return base
if not use_points:
return base
if plan_amount_usdc <= 0:
return base
user_obj = self._auth_admin_get_user(user_id)
metadata = self._extract_user_metadata(user_obj)
balance = self._extract_points_from_metadata(metadata)
base["points_balance_snapshot"] = balance
if balance <= 0:
return base
max_discount_usdc = min(
Decimal(int(self.points_max_discount_usdc)),
plan_amount_usdc,
)
max_points_by_plan = int(
(max_discount_usdc * Decimal(int(self.points_per_usdc))).to_integral_value(
rounding=ROUND_FLOOR
)
)
if max_points_by_plan <= 0:
return base
desired_points = max_points_by_plan
if requested_points_to_consume is not None:
try:
desired_points = max(0, int(requested_points_to_consume))
except Exception:
desired_points = 0
candidate_points = min(balance, max_points_by_plan, desired_points)
if candidate_points <= 0:
return base
normalized_points = (candidate_points // int(self.points_per_usdc)) * int(
self.points_per_usdc
)
if normalized_points <= 0:
return base
discount_units = normalized_points // int(self.points_per_usdc)
discount_usdc = Decimal(discount_units)
pay_amount = plan_amount_usdc - discount_usdc
if pay_amount <= 0:
return base
base["applied"] = True
base["points_to_consume"] = int(normalized_points)
base["discount_usdc"] = _format_decimal(discount_usdc)
base["pay_amount_usdc"] = pay_amount
return base
def _consume_points_for_intent(
self,
user_id: str,
intent: PaymentIntentRecord,
) -> Dict[str, Any]:
result = {
"enabled": bool(self.points_enabled),
"applied": False,
"points_per_usdc": int(self.points_per_usdc),
"points_redeemed": 0,
"points_before": 0,
"points_after": 0,
"discount_usdc": "0",
}
if not self.points_enabled:
return result
metadata = dict(intent.metadata or {})
redemption = metadata.get("points_redemption")
if not isinstance(redemption, dict):
return result
if not bool(redemption.get("applied")):
return result
if bool(redemption.get("consumed")):
result["applied"] = True
result["points_redeemed"] = int(redemption.get("consumed_points") or 0)
result["points_after"] = int(redemption.get("points_after") or 0)
result["discount_usdc"] = str(redemption.get("discount_usdc") or "0")
return result
planned_points = int(redemption.get("points_to_consume") or 0)
if planned_points <= 0:
return result
user_obj = self._auth_admin_get_user(user_id)
user_metadata = self._extract_user_metadata(user_obj)
points_before = self._extract_points_from_metadata(user_metadata)
if points_before <= 0:
return result
redeemable = min(points_before, planned_points)
redeemable = (redeemable // int(self.points_per_usdc)) * int(self.points_per_usdc)
if redeemable <= 0:
return result
points_after = points_before - redeemable
updated_metadata = dict(user_metadata or {})
if "points" in updated_metadata:
updated_metadata["points"] = points_after
if "total_points" in updated_metadata:
updated_metadata["total_points"] = points_after
if "points" not in updated_metadata and "total_points" not in updated_metadata:
updated_metadata["points"] = points_after
updated_metadata["total_points"] = points_after
self._auth_admin_update_user_metadata(user_id, updated_metadata)
discount_usdc = Decimal(redeemable // int(self.points_per_usdc))
result["applied"] = True
result["points_redeemed"] = int(redeemable)
result["points_before"] = int(points_before)
result["points_after"] = int(points_after)
result["discount_usdc"] = _format_decimal(discount_usdc)
return result
def _get_web3(self) -> Web3: def _get_web3(self) -> Web3:
with self._w3_lock: with self._w3_lock:
if self._w3 is None: if self._w3 is None:
@@ -355,6 +600,11 @@ class PaymentContractCheckoutService:
"intent_ttl_sec": self.intent_ttl_sec, "intent_ttl_sec": self.intent_ttl_sec,
"event_name": "OrderPaid", "event_name": "OrderPaid",
"event_topic0": self._event_topic, "event_topic0": self._event_topic,
"points_redemption": {
"enabled": bool(self.points_enabled),
"points_per_usdc": int(self.points_per_usdc),
"max_discount_usdc": int(self.points_max_discount_usdc),
},
"plans": [ "plans": [
{ {
"plan_code": plan_code, "plan_code": plan_code,
@@ -386,6 +636,7 @@ class PaymentContractCheckoutService:
allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None, allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None,
expires_at=str(row.get("expires_at")), expires_at=str(row.get("expires_at")),
tx_hash=str(row.get("tx_hash") or "") or None, tx_hash=str(row.get("tx_hash") or "") or None,
metadata=dict(row.get("metadata") or {}) if isinstance(row.get("metadata"), dict) else {},
) )
def list_wallets(self, user_id: str) -> List[WalletBindingRecord]: def list_wallets(self, user_id: str) -> List[WalletBindingRecord]:
@@ -645,17 +896,17 @@ class PaymentContractCheckoutService:
payment_mode: str = "strict", payment_mode: str = "strict",
allowed_wallet: Optional[str] = None, allowed_wallet: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
use_points: bool = False,
points_to_consume: Optional[int] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
self._ensure_enabled() self._ensure_enabled()
plan = self._select_plan(plan_code) plan = self._select_plan(plan_code)
mode = str(payment_mode or "strict").strip().lower() mode = str(payment_mode or "strict").strip().lower()
if mode not in {"strict", "flex"}: if mode not in {"strict", "flex"}:
raise PaymentCheckoutError(400, "payment_mode must be strict or flex") raise PaymentCheckoutError(400, "payment_mode must be strict or flex")
bound_wallets = self.list_wallets(user_id) bound_wallets = self.list_wallets(user_id)
if not bound_wallets: if not bound_wallets:
raise PaymentCheckoutError(403, "bind wallet first") raise PaymentCheckoutError(403, "bind wallet first")
target_wallet = _normalize_address(allowed_wallet or "") target_wallet = _normalize_address(allowed_wallet or "")
if mode == "strict": if mode == "strict":
if target_wallet: if target_wallet:
@@ -668,8 +919,29 @@ class PaymentContractCheckoutService:
target_wallet = primary.address if primary else bound_wallets[0].address target_wallet = primary.address if primary else bound_wallets[0].address
elif target_wallet: elif target_wallet:
self._require_user_wallet(user_id, target_wallet) self._require_user_wallet(user_id, target_wallet)
plan_amount_usdc = plan["amount_usdc_decimal"]
amount_units = _decimal_to_units(plan["amount_usdc_decimal"], self.token_decimals) redemption = self._build_points_redemption(
user_id=user_id,
plan_amount_usdc=plan_amount_usdc,
use_points=bool(use_points),
requested_points_to_consume=points_to_consume,
)
final_amount_usdc = redemption["pay_amount_usdc"]
amount_units = _decimal_to_units(final_amount_usdc, self.token_decimals)
if amount_units <= 0:
raise PaymentCheckoutError(400, "invalid final payment amount")
combined_metadata = dict(metadata or {})
combined_metadata["amount_before_discount_usdc"] = _format_decimal(plan_amount_usdc)
combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc)
combined_metadata["points_redemption"] = {
"enabled": bool(redemption.get("enabled")),
"applied": bool(redemption.get("applied")),
"points_per_usdc": int(redemption.get("points_per_usdc") or self.points_per_usdc),
"max_discount_usdc": int(redemption.get("max_discount_usdc") or self.points_max_discount_usdc),
"points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0),
"points_to_consume": int(redemption.get("points_to_consume") or 0),
"discount_usdc": str(redemption.get("discount_usdc") or "0"),
}
order_id_hex = "0x" + secrets.token_hex(32) order_id_hex = "0x" + secrets.token_hex(32)
now = _now_utc() now = _now_utc()
expires_at = now + timedelta(seconds=self.intent_ttl_sec) expires_at = now + timedelta(seconds=self.intent_ttl_sec)
@@ -689,7 +961,7 @@ class PaymentContractCheckoutService:
"order_id_hex": order_id_hex, "order_id_hex": order_id_hex,
"status": "created", "status": "created",
"expires_at": _to_iso(expires_at), "expires_at": _to_iso(expires_at),
"metadata": metadata or {}, "metadata": combined_metadata,
"created_at": _to_iso(now), "created_at": _to_iso(now),
"updated_at": _to_iso(now), "updated_at": _to_iso(now),
}, },
@@ -706,9 +978,16 @@ class PaymentContractCheckoutService:
"plan_code": plan["plan_code"], "plan_code": plan["plan_code"],
"plan_id": plan["plan_id"], "plan_id": plan["plan_id"],
"duration_days": plan["duration_days"], "duration_days": plan["duration_days"],
"amount_before_discount_usdc": _format_decimal(plan_amount_usdc),
"amount_after_discount_usdc": _format_decimal(final_amount_usdc),
},
"points_redemption": {
"applied": bool(redemption.get("applied")),
"points_to_consume": int(redemption.get("points_to_consume") or 0),
"discount_usdc": str(redemption.get("discount_usdc") or "0"),
"points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0),
}, },
} }
def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord: def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord:
self._ensure_enabled() self._ensure_enabled()
rows = self._rest( rows = self._rest(
@@ -717,7 +996,7 @@ class PaymentContractCheckoutService:
params={ params={
"select": ( "select": (
"id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address," "id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address,"
"amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash" "amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash,metadata"
), ),
"id": f"eq.{intent_id}", "id": f"eq.{intent_id}",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
@@ -992,24 +1271,20 @@ class PaymentContractCheckoutService:
return {"intent": intent.__dict__, "already_confirmed": True} return {"intent": intent.__dict__, "already_confirmed": True}
if intent.status in {"failed", "cancelled", "expired"}: if intent.status in {"failed", "cancelled", "expired"}:
raise PaymentCheckoutError(409, f"intent status is {intent.status}") raise PaymentCheckoutError(409, f"intent status is {intent.status}")
tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower() tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
if not tx_hash_text: if not tx_hash_text:
raise PaymentCheckoutError(400, "tx_hash required") raise PaymentCheckoutError(400, "tx_hash required")
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
raise PaymentCheckoutError(400, "invalid tx_hash") raise PaymentCheckoutError(400, "invalid tx_hash")
w3 = self._get_web3() w3 = self._get_web3()
if not w3.is_connected(): if not w3.is_connected():
raise PaymentCheckoutError(503, "cannot connect payment rpc") raise PaymentCheckoutError(503, "cannot connect payment rpc")
if int(w3.eth.chain_id) != int(self.chain_id): if int(w3.eth.chain_id) != int(self.chain_id):
raise PaymentCheckoutError(503, "payment rpc chain mismatch") raise PaymentCheckoutError(503, "payment rpc chain mismatch")
try: try:
tx = w3.eth.get_transaction(tx_hash_text) tx = w3.eth.get_transaction(tx_hash_text)
except Exception: except Exception:
raise PaymentCheckoutError(404, "tx not found on chain") raise PaymentCheckoutError(404, "tx not found on chain")
tx_to = _normalize_address(tx.get("to")) tx_to = _normalize_address(tx.get("to"))
tx_from = _normalize_address(tx.get("from")) tx_from = _normalize_address(tx.get("from"))
if tx_to != intent.receiver_address: if tx_to != intent.receiver_address:
@@ -1025,11 +1300,9 @@ class PaymentContractCheckoutService:
) )
else: else:
self._require_user_wallet(user_id, tx_from) self._require_user_wallet(user_id, tx_from)
receipt = self._wait_receipt(tx_hash_text) receipt = self._wait_receipt(tx_hash_text)
if int(receipt.get("status") or 0) != 1: if int(receipt.get("status") or 0) != 1:
raise PaymentCheckoutError(400, "tx reverted") raise PaymentCheckoutError(400, "tx reverted")
block_number = int(receipt.get("blockNumber") or 0) block_number = int(receipt.get("blockNumber") or 0)
latest_block = int(w3.eth.block_number) latest_block = int(w3.eth.block_number)
confirmations = max(0, latest_block - block_number + 1) if block_number else 0 confirmations = max(0, latest_block - block_number + 1) if block_number else 0
@@ -1037,15 +1310,22 @@ class PaymentContractCheckoutService:
raise PaymentCheckoutError( raise PaymentCheckoutError(
409, f"confirmations not enough: {confirmations}/{self.confirmations}" 409, f"confirmations not enough: {confirmations}/{self.confirmations}"
) )
event_match = self._extract_matching_event(receipt, intent) event_match = self._extract_matching_event(receipt, intent)
if not event_match: if not event_match:
raise PaymentCheckoutError( raise PaymentCheckoutError(
400, 400,
"OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)",
) )
points_result = self._consume_points_for_intent(user_id, intent)
now_iso = _to_iso(_now_utc()) now_iso = _to_iso(_now_utc())
confirmed_metadata = dict(intent.metadata or {})
redemption_meta = confirmed_metadata.get("points_redemption")
if isinstance(redemption_meta, dict):
redemption_meta["consumed"] = bool(points_result.get("points_redeemed"))
redemption_meta["consumed_points"] = int(points_result.get("points_redeemed") or 0)
redemption_meta["points_after"] = points_result.get("points_after")
redemption_meta["consumed_at"] = now_iso
confirmed_metadata["points_redemption"] = redemption_meta
self._rest( self._rest(
"PATCH", "PATCH",
"payment_intents", "payment_intents",
@@ -1054,6 +1334,7 @@ class PaymentContractCheckoutService:
"status": "confirmed", "status": "confirmed",
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"confirmed_at": now_iso, "confirmed_at": now_iso,
"metadata": confirmed_metadata,
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="return=representation", prefer="return=representation",
@@ -1078,12 +1359,12 @@ class PaymentContractCheckoutService:
prefer="resolution=merge-duplicates,return=representation", prefer="resolution=merge-duplicates,return=representation",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
payload = { payload = {
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"block_number": block_number, "block_number": block_number,
"confirmations": confirmations, "confirmations": confirmations,
"event": event_match, "event": event_match,
"points_redemption": points_result,
} }
plan = self._select_plan(intent.plan_code) plan = self._select_plan(intent.plan_code)
payment_row = self._insert_payment_record( payment_row = self._insert_payment_record(
@@ -1111,8 +1392,8 @@ class PaymentContractCheckoutService:
"transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None, "transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None,
"payment": payment_row, "payment": payment_row,
"subscription": subscription_row, "subscription": subscription_row,
"points_redemption": points_result,
"tx": payload, "tx": payload,
} }
PAYMENT_CHECKOUT = PaymentContractCheckoutService() PAYMENT_CHECKOUT = PaymentContractCheckoutService()
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,4 +1,4 @@
""" """
PolyWeather Web Map API PolyWeather Web Map API
~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
FastAPI backend that reuses existing weather data collection and analysis modules. FastAPI backend that reuses existing weather data collection and analysis modules.
@@ -194,6 +194,8 @@ class CreatePaymentIntentRequest(BaseModel):
plan_code: str = Field(default="pro_monthly", min_length=2) plan_code: str = Field(default="pro_monthly", min_length=2)
payment_mode: str = Field(default="strict") payment_mode: str = Field(default="strict")
allowed_wallet: Optional[str] = None allowed_wallet: Optional[str] = None
use_points: bool = False
points_to_consume: Optional[int] = None
metadata: Dict[str, Any] = Field(default_factory=dict) metadata: Dict[str, Any] = Field(default_factory=dict)
@@ -1173,6 +1175,8 @@ async def payment_create_intent(request: Request, body: CreatePaymentIntentReque
plan_code=body.plan_code, plan_code=body.plan_code,
payment_mode=body.payment_mode, payment_mode=body.payment_mode,
allowed_wallet=body.allowed_wallet, allowed_wallet=body.allowed_wallet,
use_points=body.use_points,
points_to_consume=body.points_to_consume,
metadata=body.metadata, metadata=body.metadata,
) )
except PaymentCheckoutError as exc: except PaymentCheckoutError as exc: