feat: add telegram group pricing and direct payments

This commit is contained in:
2569718930@qq.com
2026-05-18 16:18:26 +08:00
parent d99a3c25e9
commit 6041d25f23
18 changed files with 1479 additions and 35 deletions
+340 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "@supabase/supabase-js";
@@ -68,6 +68,26 @@ type AuthMeResponse = {
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null;
};
type TelegramPricing = {
configured?: boolean;
telegram_id?: number | null;
telegram_status?: string | null;
is_group_member?: boolean;
amount_usdc?: string;
pricing_source?: string;
};
type TelegramAuthPayload = {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
};
type PaymentPlan = {
@@ -133,6 +153,18 @@ type CreatedIntent = {
token_symbol?: string;
token_decimals?: number;
};
direct_payment?: {
chain_id: number;
chain?: string;
token_symbol?: string;
token_address: string;
token_decimals?: number;
receiver_address: string;
amount_units: string;
amount_usdc: string;
intent_id: string;
expires_at: string;
};
};
type IntentStatusResponse = {
@@ -218,6 +250,11 @@ const TELEGRAM_GROUP_URL = String(
const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim();
const TELEGRAM_LOGIN_BOT_USERNAME = String(
process.env.NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME || "WeatherQuant_bot",
)
.replace(/^@/, "")
.trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
@@ -859,6 +896,10 @@ export function AccountCenter() {
const [paymentError, setPaymentError] = useState("");
const [lastIntentId, setLastIntentId] = useState("");
const [lastTxHash, setLastTxHash] = useState("");
const [manualPayment, setManualPayment] = useState<CreatedIntent["direct_payment"] | null>(null);
const [manualTxHash, setManualTxHash] = useState("");
const [telegramLoginBusy, setTelegramLoginBusy] = useState(false);
const telegramLoginWidgetRef = useRef<HTMLDivElement | null>(null);
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
const [showSecondarySections, setShowSecondarySections] = useState(false);
const [reconcileBusy, setReconcileBusy] = useState(false);
@@ -1360,6 +1401,8 @@ export function AccountCenter() {
if (!backend?.subscription_active) return;
setLastIntentId("");
setLastTxHash("");
setManualPayment(null);
setManualTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
}, [backend?.subscription_active]);
@@ -1467,6 +1510,64 @@ export function AccountCenter() {
const userId = backend?.user_id || user?.id || "";
const isAuthenticated = Boolean(userId);
const email = backend?.email || user?.email || "";
useEffect(() => {
const container = telegramLoginWidgetRef.current;
if (!container || !TELEGRAM_LOGIN_BOT_USERNAME || !isAuthenticated) return;
container.innerHTML = "";
const callbackName = "onPolyWeatherTelegramAuth";
(window as unknown as Record<string, unknown>)[callbackName] = async (
payload: TelegramAuthPayload,
) => {
setTelegramLoginBusy(true);
setPaymentError("");
setPaymentInfo("");
try {
const authHeaders = await buildAuthedHeaders(true, false);
const res = await fetch("/api/auth/telegram/login", {
method: "POST",
headers: authHeaders,
body: JSON.stringify(payload),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 350);
throw new Error(`telegram login failed: ${raw}`);
}
const data = (await res.json()) as {
telegram_pricing?: TelegramPricing | null;
};
const amount = data.telegram_pricing?.amount_usdc || "10";
setPaymentInfo(
data.telegram_pricing?.is_group_member
? `Telegram 群成员验证成功,当前会员价 ${amount}U。`
: `Telegram 已登录,但未检测到群成员身份,当前价格 ${amount}U。`,
);
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
} finally {
setTelegramLoginBusy(false);
}
};
const script = document.createElement("script");
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.async = true;
script.setAttribute("data-telegram-login", TELEGRAM_LOGIN_BOT_USERNAME);
script.setAttribute("data-size", "large");
script.setAttribute("data-radius", "12");
script.setAttribute("data-userpic", "false");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", `${callbackName}(user)`);
container.appendChild(script);
return () => {
container.innerHTML = "";
};
}, [
buildAuthedHeaders,
isAuthenticated,
loadPaymentSnapshot,
loadSnapshot,
]);
const displayName =
String(user?.user_metadata?.full_name || "").trim() ||
(email ? String(email).split("@")[0] : "") ||
@@ -1673,7 +1774,9 @@ export function AccountCenter() {
);
const billing = useMemo(() => {
const parsedPlanAmount = Number(selectedPlan?.amount_usdc ?? 5);
const parsedPlanAmount = Number(
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 5,
);
const planAmount =
Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0
? parsedPlanAmount
@@ -1718,6 +1821,7 @@ export function AccountCenter() {
};
}, [
paymentConfig?.points_redemption,
backend?.telegram_pricing?.amount_usdc,
selectedPlan?.amount_usdc,
totalPoints,
usePoints,
@@ -2366,6 +2470,145 @@ export function AccountCenter() {
}
};
const createManualPaymentIntent = async () => {
setPaymentError("");
setPaymentInfo("");
setManualPayment(null);
setManualTxHash("");
setLastIntentId("");
setLastTxHash("");
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
),
);
return;
}
if (!isAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!paymentConfig?.configured) {
setPaymentError(copy.payNotReady);
return;
}
setPaymentBusy(true);
try {
const authHeaders = await buildAuthedHeaders(true, false);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct",
token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume:
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: {
source: "account_center_manual_transfer",
frontend_host: currentPaymentHost || null,
account_email: email || null,
},
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
throw new Error(`create manual intent failed: ${raw}`);
}
const created = (await createRes.json()) as CreatedIntent;
const direct = created.direct_payment;
const intentId = String(created.intent?.intent_id || direct?.intent_id || "");
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
throw new Error("manual payment payload invalid");
}
setLastIntentId(intentId);
setManualPayment(direct);
setShowOverlay(false);
setPaymentInfo(
`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${shortAddress(direct.receiver_address)},完成后提交 tx hash。`,
);
trackAppEvent("checkout_started", {
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
payment_mode: "direct",
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
} finally {
setPaymentBusy(false);
}
};
const submitManualPaymentTx = async () => {
const txHashNorm = String(manualTxHash || "").trim().toLowerCase();
const intentId = String(lastIntentId || manualPayment?.intent_id || "").trim();
if (!intentId || !manualPayment) {
setPaymentError("请先创建手动转账订单。");
return;
}
if (!txHashNorm.startsWith("0x") || txHashNorm.length !== 66) {
setPaymentError("请输入有效的 tx hash。");
return;
}
setPaymentBusy(true);
setPaymentError("");
try {
const authHeaders = await buildAuthedHeaders(true, false);
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
throw new Error(`submit tx failed: ${raw}`);
}
const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
(confirmRes.status === 409 &&
(lowerRaw.includes("confirmations not enough") ||
lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
return;
}
throw new Error(`confirm failed: ${raw}`);
}
setLastTxHash(txHashNorm);
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
setManualPayment(null);
setManualTxHash("");
trackAppEvent("checkout_succeeded", {
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
} finally {
setPaymentBusy(false);
}
};
const handleOverlayCheckout = async () => {
if (!paymentHostAllowed) {
setPaymentError(
@@ -2736,9 +2979,11 @@ export function AccountCenter() {
pointsPerUsd: billing.pointsPerUsdc,
}}
onPay={() => void handleOverlayCheckout()}
onManualPay={() => void createManualPaymentIntent()}
onClose={() => setShowOverlay(false)}
payBusy={paymentBusy}
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
manualPayLabel="手动转账"
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
@@ -2766,6 +3011,28 @@ export function AccountCenter() {
<p className="text-slate-400 text-sm mb-6">
{copy.telegramHint}
</p>
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3">
<p className="text-xs font-bold text-emerald-200">
Telegram
</p>
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
Telegram 5U 10U
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<div ref={telegramLoginWidgetRef} />
{telegramLoginBusy ? (
<span className="text-[11px] text-cyan-200">
Telegram...
</span>
) : null}
{backend?.telegram_pricing?.amount_usdc ? (
<span className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] font-bold text-white">
: {backend.telegram_pricing.amount_usdc}U
{backend.telegram_pricing.is_group_member ? " · 群成员" : " · 普通价"}
</span>
) : null}
</div>
</div>
{/* Channel Upgrade / Migration Notice — Pro users only */}
{isSubscribed ? (
@@ -2934,6 +3201,77 @@ export function AccountCenter() {
</div>
</div>
)}
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold text-emerald-200">
</p>
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
tx hash 使
</p>
</div>
<button
type="button"
onClick={() => void createManualPaymentIntent()}
disabled={paymentBusy || !isAuthenticated}
className="shrink-0 rounded-xl border border-emerald-400/35 bg-emerald-500/15 px-3 py-2 text-[11px] font-bold text-emerald-100 transition-all hover:bg-emerald-500/25 disabled:opacity-50"
>
</button>
</div>
{manualPayment ? (
<div className="mt-3 space-y-3 rounded-xl border border-white/10 bg-black/25 p-3">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
Amount
</p>
<p className="font-mono text-sm font-bold text-white">
{manualPayment.amount_usdc}{" "}
{manualPayment.token_symbol || selectedTokenLabel}
</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
Receiver
</p>
<div className="mt-1 flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg bg-black/40 px-2 py-2 font-mono text-[11px] text-blue-200">
{manualPayment.receiver_address}
</code>
<button
type="button"
onClick={() =>
handleCopy(manualPayment.receiver_address)
}
className="rounded-lg bg-blue-600 px-2 text-xs font-bold text-white"
>
</button>
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
Tx Hash
</p>
<input
value={manualTxHash}
onChange={(event) => setManualTxHash(event.target.value)}
placeholder="0x..."
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50"
/>
</div>
<button
type="button"
onClick={() => void submitManualPaymentTx()}
disabled={paymentBusy}
className="w-full rounded-xl bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-500 disabled:opacity-50"
>
tx hash
</button>
</div>
) : null}
</div>
{boundWallets.length ? (
<div className="space-y-3">
{boundWallets.map((w) => (