feat: Introduce premium map dashboard with dark theme, pro features, and payment processing.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user