清理死代码:移除 14 个孤立组件/模块,Koyfin 品牌改为 PolyWeather
删除的文件: - ContinentGroupHeader, AiPinnedForecastView, DataFreshnessBar, MarketDecisionLine - opportunity-ai-meta/evidence-summary/v4-decision/v4-risk - HeaderBar, IntradaySignalScene, MapCanvas, PolyWeatherDashboard - ScanFilterPanel, WeatherAuraLayer 修改: - decisionClass 死函数移除 - KoyfinWeatherTerminal → PolyWeatherTerminal - Koyfin-style grid → Multi-panel grid - 欢迎覆盖层 CSS 清理
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
getAllowedPaymentHosts,
|
||||
getCurrentPaymentHost,
|
||||
isPaymentHostAllowed,
|
||||
} from "@/lib/payment-host";
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
PaymentConfig,
|
||||
PaymentRecoveryState,
|
||||
TelegramPricing,
|
||||
} from "./types";
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
PAYMENT_RECOVERY_TTL_MS,
|
||||
} from "./constants";
|
||||
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
|
||||
import { normalizePaymentError } from "./payment-utils";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
// ============================================================
|
||||
export interface UseBillingParams {
|
||||
isEn: boolean;
|
||||
copy: Record<string, string>;
|
||||
backend: AuthMeResponse | null;
|
||||
supabaseReady: boolean;
|
||||
|
||||
// Shared state from master
|
||||
paymentConfig: PaymentConfig | null;
|
||||
authUserId: string;
|
||||
authIsAuthenticated: boolean;
|
||||
usePoints: boolean;
|
||||
setUsePoints: (v: boolean) => void;
|
||||
|
||||
// Payment UI state from usePaymentState
|
||||
lastIntentId: string;
|
||||
lastTxHash: string;
|
||||
lastPaymentStartedAt: number;
|
||||
setLastIntentId: (v: string) => void;
|
||||
setLastTxHash: (v: string) => void;
|
||||
setLastPaymentStartedAt: (v: number) => void;
|
||||
setPaymentBusy: (v: boolean) => void;
|
||||
setPaymentInfo: (v: string) => void;
|
||||
setPaymentError: (v: string) => void;
|
||||
setTelegramBindOpening: (v: boolean) => void;
|
||||
clearPaymentState: () => void;
|
||||
|
||||
// Derived / callbacks from master
|
||||
selectedPlan?: { plan_code?: string; amount_usdc?: string } | undefined;
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
||||
loadSnapshot: () => Promise<void>;
|
||||
loadPaymentSnapshot: () => Promise<void>;
|
||||
|
||||
// User for metadata points
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
export function useBilling(params: UseBillingParams) {
|
||||
const {
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
supabaseReady,
|
||||
paymentConfig,
|
||||
authUserId,
|
||||
authIsAuthenticated,
|
||||
usePoints,
|
||||
setUsePoints,
|
||||
lastIntentId,
|
||||
lastTxHash,
|
||||
lastPaymentStartedAt,
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setTelegramBindOpening,
|
||||
clearPaymentState,
|
||||
selectedPlan,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
loadPaymentSnapshot,
|
||||
user,
|
||||
} = params;
|
||||
|
||||
// ── Billing-specific state ────────────────────────────────
|
||||
const [reconcileBusy, setReconcileBusy] = useState(false);
|
||||
const [telegramBindUrl, setTelegramBindUrl] = useState("");
|
||||
|
||||
// ── Derived values ──────────────────────────────────────
|
||||
const paymentReadyForRecovery = Boolean(paymentConfig?.enabled && paymentConfig?.configured);
|
||||
const hasRecentPaymentRecovery =
|
||||
Boolean(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt) &&
|
||||
Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS;
|
||||
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
|
||||
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
|
||||
const paymentHostAllowed = useMemo(() => isPaymentHostAllowed(currentPaymentHost), [currentPaymentHost]);
|
||||
|
||||
// ── Points computation ──────────────────────────────────
|
||||
const backendPointsRaw = Number(backend?.points);
|
||||
const metadataPointsRaw = Number(
|
||||
user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0,
|
||||
);
|
||||
const metadataPointsSafe = Number.isFinite(metadataPointsRaw) ? metadataPointsRaw : 0;
|
||||
const pointsRaw = Number.isFinite(backendPointsRaw)
|
||||
? Math.max(backendPointsRaw, metadataPointsSafe)
|
||||
: metadataPointsSafe;
|
||||
const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0;
|
||||
|
||||
// ── Billing ──────────────────────────────────────────────
|
||||
const billing = useMemo(() => {
|
||||
const parsedPlanAmount = Number(
|
||||
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
|
||||
);
|
||||
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10;
|
||||
|
||||
const pointsCfg = paymentConfig?.points_redemption || {};
|
||||
const pointsEnabled = pointsCfg.enabled !== false;
|
||||
const pointsPerUsdcRaw = Number(pointsCfg.points_per_usdc ?? 500);
|
||||
const pointsPerUsdc =
|
||||
Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500;
|
||||
|
||||
const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3);
|
||||
const maxDiscountUsdc = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.floor(Number.isFinite(maxDiscountRaw) ? maxDiscountRaw : 3),
|
||||
Math.floor(planAmount),
|
||||
),
|
||||
);
|
||||
|
||||
const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc;
|
||||
const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0;
|
||||
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
|
||||
const pointsUsed = discountUnits * pointsPerUsdc;
|
||||
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
||||
|
||||
return {
|
||||
planAmount,
|
||||
pointsEnabled,
|
||||
pointsPerUsdc,
|
||||
maxDiscountUsdc,
|
||||
pointsUsed,
|
||||
discountAmount: discountUnits,
|
||||
payAmount: planAmount - (applyDiscount ? discountUnits : 0),
|
||||
canRedeem,
|
||||
};
|
||||
}, [
|
||||
paymentConfig?.points_redemption,
|
||||
backend?.telegram_pricing?.amount_usdc,
|
||||
selectedPlan?.amount_usdc,
|
||||
totalPoints,
|
||||
usePoints,
|
||||
]);
|
||||
|
||||
// ── reconcileLatestPayment ──────────────────────────────
|
||||
const reconcileLatestPayment = useCallback(async () => {
|
||||
if (!authIsAuthenticated || reconcileBusy) return false;
|
||||
setReconcileBusy(true);
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, true);
|
||||
const res = await fetch("/api/payments/reconcile-latest", {
|
||||
method: "POST", headers,
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = (await res.json()) as {
|
||||
ok?: boolean; action?: string; subscription?: { plan_code?: string | null } | null;
|
||||
};
|
||||
if (json.ok) {
|
||||
setPaymentInfo(copy.walletRecoveryDone);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setReconcileBusy(false);
|
||||
}
|
||||
}, [
|
||||
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
|
||||
loadPaymentSnapshot, loadSnapshot, reconcileBusy,
|
||||
]);
|
||||
|
||||
// ── handleSubmit409 ────────────────────────────────────
|
||||
const handleSubmit409 = useCallback(
|
||||
async (intentId: string, txHashNorm: string, raw: string) => {
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
if (lowerRaw.includes("已支付") || lowerRaw.includes("already confirmed") || lowerRaw.includes("already paid")) {
|
||||
const ok = await reconcileLatestPayment();
|
||||
if (ok) return;
|
||||
setPaymentInfo(copy.orderAlreadyPaid);
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
if (lowerRaw.includes("expired")) {
|
||||
throw new Error(copy.orderExpired);
|
||||
}
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, false);
|
||||
const intentRes = await fetch(`/api/payments/intents/${intentId}`, { headers, cache: "no-store" });
|
||||
if (intentRes.ok) {
|
||||
const intentJson = (await intentRes.json()) as {
|
||||
intent?: { status?: string; tx_hash?: string };
|
||||
};
|
||||
const status = intentJson.intent?.status;
|
||||
if (status === "confirmed") {
|
||||
await reconcileLatestPayment();
|
||||
const txHash = intentJson.intent?.tx_hash || txHashNorm;
|
||||
setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return;
|
||||
}
|
||||
if (status === "expired") throw new Error(copy.orderExpired);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message !== raw) throw e;
|
||||
}
|
||||
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
|
||||
},
|
||||
[buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment],
|
||||
);
|
||||
|
||||
// ── openTelegramBotBindLink ──────────────────────────────
|
||||
const openTelegramBotBindLink = async () => {
|
||||
setTelegramBindOpening(true);
|
||||
setPaymentError("");
|
||||
setTelegramBindUrl("");
|
||||
const popup = window.open("about:blank", "_blank", "noopener,noreferrer");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bot-bind-link", {
|
||||
method: "POST", headers: authHeaders,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 300);
|
||||
throw new Error(raw || copy.telegramBindFailed);
|
||||
}
|
||||
const data = (await res.json()) as { bot_url?: string };
|
||||
const botUrl = String(data.bot_url || "").trim();
|
||||
if (!botUrl) throw new Error(copy.telegramBindLinkMissing);
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.href = botUrl;
|
||||
setPaymentInfo(copy.telegramBindClickHint);
|
||||
} else {
|
||||
setPaymentInfo(copy.telegramPopupBlocked);
|
||||
setTelegramBindUrl(botUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
if (popup && !popup.closed) popup.close();
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
} finally {
|
||||
setTelegramBindOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── loadPaymentSnapshot effect ────────────────────────────
|
||||
useEffect(() => {
|
||||
void loadPaymentSnapshot();
|
||||
}, [loadPaymentSnapshot]);
|
||||
|
||||
// ── Recovery save effect ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt)) {
|
||||
clearStoredPaymentRecovery();
|
||||
return;
|
||||
}
|
||||
const payload: PaymentRecoveryState = {
|
||||
intentId: lastIntentId,
|
||||
txHash: lastTxHash,
|
||||
userId: authUserId,
|
||||
createdAt: lastPaymentStartedAt,
|
||||
};
|
||||
window.sessionStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(payload));
|
||||
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
|
||||
|
||||
// ── Recovery restore effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!authUserId) return;
|
||||
if (lastIntentId && lastTxHash && lastPaymentStartedAt) return;
|
||||
const raw = window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PaymentRecoveryState;
|
||||
const userId = String(parsed?.userId || "").trim();
|
||||
const intentId = String(parsed?.intentId || "").trim();
|
||||
const txHash = String(parsed?.txHash || "").trim().toLowerCase();
|
||||
const createdAt = Number(parsed?.createdAt || 0);
|
||||
const expired = !createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
|
||||
if (expired || !intentId || !txHash || !userId || userId !== authUserId) {
|
||||
clearStoredPaymentRecovery();
|
||||
return;
|
||||
}
|
||||
setLastIntentId(intentId);
|
||||
setLastTxHash(txHash);
|
||||
setLastPaymentStartedAt(createdAt);
|
||||
} catch {
|
||||
clearStoredPaymentRecovery();
|
||||
}
|
||||
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
|
||||
|
||||
// ── Subscription cleanup effect ──────────────────────────
|
||||
useEffect(() => {
|
||||
if (!backend?.subscription_active) return;
|
||||
clearPaymentState();
|
||||
clearStoredPaymentRecovery();
|
||||
}, [backend?.subscription_active]);
|
||||
|
||||
// ── Auto-reconcile effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated) return;
|
||||
if (backend?.subscription_active) return;
|
||||
if (!paymentReadyForRecovery) return;
|
||||
if (!hasRecentPaymentRecovery) return;
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setPaymentInfo(copy.walletRecoveryBusy);
|
||||
const repaired = await reconcileLatestPayment();
|
||||
if (cancelled) return;
|
||||
if (!repaired && !backend?.subscription_active) {
|
||||
setPaymentInfo("");
|
||||
setPaymentError(copy.walletRecoveryFailed);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
backend?.subscription_active, authIsAuthenticated, copy.walletRecoveryBusy,
|
||||
copy.walletRecoveryFailed, hasRecentPaymentRecovery,
|
||||
paymentReadyForRecovery, reconcileLatestPayment,
|
||||
]);
|
||||
|
||||
// ── bind_token URL effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("bind_token");
|
||||
if (!token) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("bind_token");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
(async () => {
|
||||
setPaymentError("");
|
||||
setPaymentInfo("");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bind-by-token", {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 350);
|
||||
throw new Error(copy.bindFailed.replace("{raw}", raw));
|
||||
}
|
||||
const data = (await res.json()) as { telegram_pricing?: TelegramPricing | null };
|
||||
if (data.telegram_pricing?.is_group_member) {
|
||||
const amount = data.telegram_pricing.amount_usdc || "10";
|
||||
setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount));
|
||||
}
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
}
|
||||
})();
|
||||
}, [authIsAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]);
|
||||
|
||||
// ==========================================================
|
||||
return {
|
||||
reconcileBusy,
|
||||
telegramBindUrl,
|
||||
setTelegramBindUrl,
|
||||
reconcileLatestPayment,
|
||||
handleSubmit409,
|
||||
openTelegramBotBindLink,
|
||||
paymentReadyForRecovery,
|
||||
hasRecentPaymentRecovery,
|
||||
allowedPaymentHosts,
|
||||
currentPaymentHost,
|
||||
paymentHostAllowed,
|
||||
totalPoints,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user