568 lines
20 KiB
TypeScript
568 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import type { User } from "@supabase/supabase-js";
|
|
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
|
|
|
import type {
|
|
AuthMeResponse,
|
|
BoundWallet,
|
|
PaymentConfig,
|
|
ProviderMode,
|
|
InjectedProviderOption,
|
|
} from "./types";
|
|
import { usePaymentState } from "./usePaymentState";
|
|
import { useWalletBind } from "./useWalletBind";
|
|
import { usePaymentFlow } from "./usePaymentFlow";
|
|
import { useBilling } from "./useBilling";
|
|
|
|
// ============================================================
|
|
export interface UseAccountPaymentParams {
|
|
isEn: boolean;
|
|
supabaseReady: boolean;
|
|
walletConnectEnabled: boolean;
|
|
copy: Record<string, string>;
|
|
backend: AuthMeResponse | null;
|
|
user: User | null;
|
|
setUser: (user: User | null) => void;
|
|
setBackend: (backend: AuthMeResponse | null) => void;
|
|
setErrorText: (text: string) => void;
|
|
setUpdatedAt: (text: string) => void;
|
|
showOverlay: boolean;
|
|
setShowOverlay: (v: boolean) => void;
|
|
usePoints: boolean;
|
|
setUsePoints: (v: boolean) => void;
|
|
}
|
|
|
|
// ============================================================
|
|
export function useAccountPayment(params: UseAccountPaymentParams) {
|
|
const {
|
|
isEn,
|
|
supabaseReady,
|
|
walletConnectEnabled,
|
|
copy,
|
|
backend,
|
|
user,
|
|
setUser,
|
|
setBackend,
|
|
setErrorText,
|
|
setUpdatedAt,
|
|
showOverlay,
|
|
setShowOverlay,
|
|
usePoints,
|
|
setUsePoints,
|
|
} = params;
|
|
|
|
// ── Base payment state (from usePaymentState) ──────────────
|
|
const {
|
|
paymentBusy,
|
|
setPaymentBusy,
|
|
paymentInfo,
|
|
setPaymentInfo,
|
|
paymentError,
|
|
setPaymentError,
|
|
lastIntentId,
|
|
setLastIntentId,
|
|
lastTxHash,
|
|
setLastTxHash,
|
|
telegramBindOpening,
|
|
setTelegramBindOpening,
|
|
manualPayment,
|
|
setManualPayment,
|
|
manualTxHash,
|
|
setManualTxHash,
|
|
txValidation,
|
|
setTxValidation,
|
|
paymentMethodTab,
|
|
setPaymentMethodTab,
|
|
lastPaymentStartedAt,
|
|
setLastPaymentStartedAt,
|
|
clearPaymentMessages,
|
|
clearPaymentState,
|
|
} = usePaymentState();
|
|
|
|
// ── Derived values ──────────────────────────────────────
|
|
const authUserId = backend?.user_id || user?.id || "";
|
|
const authIsAuthenticated = Boolean(authUserId);
|
|
|
|
// ── getValidAccessToken ──────────────────────────────────
|
|
const getValidAccessToken = useCallback(async (): Promise<string> => {
|
|
if (!supabaseReady)
|
|
throw new Error(
|
|
isEn
|
|
? "Supabase is not configured. Unable to get auth token."
|
|
: "Supabase 未配置,无法获取登录凭证。",
|
|
);
|
|
const client = getSupabaseBrowserClient();
|
|
const { data: { session: cached } } = await client.auth.getSession();
|
|
const cachedToken = String(cached?.access_token || "").trim();
|
|
const expiresAtSec = Number(cached?.expires_at || 0);
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
const refreshLeadSec = 90;
|
|
if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec + refreshLeadSec) {
|
|
return cachedToken;
|
|
}
|
|
if (cachedToken && (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0)) {
|
|
return cachedToken;
|
|
}
|
|
const { data: { session: refreshed }, error } = await client.auth.refreshSession();
|
|
const refreshedToken = String(refreshed?.access_token || "").trim();
|
|
if (refreshedToken) return refreshedToken;
|
|
if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec) {
|
|
return cachedToken;
|
|
}
|
|
throw new Error(
|
|
error?.message
|
|
? isEn
|
|
? `Session expired (${error.message}). Please sign out and sign in again.`
|
|
: `登录会话已失效 (${error.message}),请退出后重新登录。`
|
|
: isEn
|
|
? "Session expired. Please sign out and sign in again."
|
|
: "登录会话已失效,请退出后重新登录。",
|
|
);
|
|
}, [isEn, supabaseReady]);
|
|
|
|
// ── buildAuthedHeaders ──────────────────────────────────
|
|
const buildAuthedHeaders = useCallback(
|
|
async (withJson = false, requireAuth = false): Promise<Record<string, string>> => {
|
|
const headers: Record<string, string> = {};
|
|
if (withJson) headers["Content-Type"] = "application/json";
|
|
if (!supabaseReady) return headers;
|
|
try {
|
|
const token = await getValidAccessToken();
|
|
headers.Authorization = `Bearer ${token}`;
|
|
} catch (error) {
|
|
if (requireAuth) throw error;
|
|
try {
|
|
const { data: { session } } = await getSupabaseBrowserClient().auth.getSession();
|
|
const fallbackToken = String(session?.access_token || "").trim();
|
|
if (fallbackToken) headers.Authorization = `Bearer ${fallbackToken}`;
|
|
} catch {
|
|
// Non-authenticated page load — silently skip.
|
|
}
|
|
}
|
|
return headers;
|
|
},
|
|
[supabaseReady, getValidAccessToken],
|
|
);
|
|
|
|
// ── Auth snapshot refresh ────────────────────────────────
|
|
const fetchAuthSnapshot = useCallback(
|
|
async (retry: boolean): Promise<AuthMeResponse> => {
|
|
const userPromise = supabaseReady
|
|
? getSupabaseBrowserClient()
|
|
.auth.getSession()
|
|
.then(({ data: { session } }) => session?.user ?? null)
|
|
.catch(() => null as User | null)
|
|
: Promise.resolve(null as User | null);
|
|
const authHeadersPromise = buildAuthedHeaders(false);
|
|
const backendPromise = authHeadersPromise.then((headers) =>
|
|
fetch("/api/auth/me", { cache: "no-store", headers }),
|
|
);
|
|
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
|
|
setUser(localUser);
|
|
if (!backendResult.ok) {
|
|
if (retry && backendResult.status === 401) {
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
return fetchAuthSnapshot(false);
|
|
}
|
|
const raw = (await backendResult.text()).slice(0, 260);
|
|
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
|
}
|
|
let backendJson = (await backendResult.json()) as AuthMeResponse;
|
|
if (
|
|
retry &&
|
|
supabaseReady &&
|
|
localUser &&
|
|
backendJson.authenticated === false
|
|
) {
|
|
try {
|
|
const {
|
|
data: { session: refreshedSession },
|
|
} = await getSupabaseBrowserClient().auth.refreshSession();
|
|
const refreshedToken = String(
|
|
refreshedSession?.access_token || "",
|
|
).trim();
|
|
if (refreshedToken) {
|
|
const retriedBackendResult = await fetch("/api/auth/me", {
|
|
cache: "no-store",
|
|
headers: { Authorization: `Bearer ${refreshedToken}` },
|
|
});
|
|
if (retriedBackendResult.ok) {
|
|
const retriedBackendJson =
|
|
(await retriedBackendResult.json()) as AuthMeResponse;
|
|
backendJson = retriedBackendJson;
|
|
}
|
|
}
|
|
} catch {
|
|
// Keep the first response; the UI treats a logged-in local user with
|
|
// an unauthenticated backend snapshot as a temporary sync state.
|
|
}
|
|
}
|
|
setBackend(backendJson);
|
|
setUpdatedAt(new Date().toISOString());
|
|
return backendJson;
|
|
},
|
|
[
|
|
buildAuthedHeaders,
|
|
copy.httpError,
|
|
setBackend,
|
|
setUpdatedAt,
|
|
setUser,
|
|
supabaseReady,
|
|
],
|
|
);
|
|
|
|
// ── loadSnapshot ──────────────────────────────────────────
|
|
const loadSnapshot = useCallback(async () => {
|
|
setErrorText("");
|
|
try { await fetchAuthSnapshot(true); }
|
|
catch (error) { setErrorText(String(error)); }
|
|
}, [fetchAuthSnapshot, setErrorText]);
|
|
|
|
const refreshEntitlementAfterPayment = useCallback(async () => {
|
|
setErrorText("");
|
|
const maxAttempts = 5;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
try {
|
|
const backendJson = await fetchAuthSnapshot(true);
|
|
if (backendJson.subscription_active === true) return;
|
|
} catch (error) {
|
|
if (attempt === maxAttempts - 1) {
|
|
setErrorText(String(error));
|
|
return;
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
}
|
|
}, [fetchAuthSnapshot, setErrorText]);
|
|
|
|
// ── Shared state for sub-hooks ───────────────────────────
|
|
// These state variables are managed in the master hook and passed
|
|
// to the relevant sub-hooks. loadPaymentSnapshot crosses domains.
|
|
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
|
|
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
|
const [walletAddress, setWalletAddress] = useState("");
|
|
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
|
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
|
|
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
|
const [selectedWallet, setSelectedWallet] = useState("");
|
|
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
|
|
const [injectedProviderOptions, setInjectedProviderOptions] = useState<InjectedProviderOption[]>([]);
|
|
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
|
|
|
|
// ── Chain ID derived from payment config ────────────────
|
|
const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
|
|
|
|
// ── loadPaymentSnapshot ──────────────────────────────────
|
|
// Defined in master because it sets state across multiple sub-hook domains.
|
|
const loadPaymentSnapshotImpl = useCallback(async () => {
|
|
if (!backend?.authenticated) {
|
|
setPaymentConfig(null);
|
|
setBoundWallets([]);
|
|
return;
|
|
}
|
|
try {
|
|
const authHeadersPromise = buildAuthedHeaders(false);
|
|
const [configRes, walletsRes] = await Promise.all([
|
|
authHeadersPromise.then((headers) => fetch("/api/payments/config", { cache: "no-store", headers })),
|
|
authHeadersPromise.then((headers) => fetch("/api/payments/wallets", { cache: "no-store", headers })),
|
|
]);
|
|
if (configRes.ok) {
|
|
const configJson = (await configRes.json()) as PaymentConfig;
|
|
setPaymentConfig(configJson);
|
|
if (!selectedPlanCode && configJson.plans?.length) {
|
|
setSelectedPlanCode(configJson.plans[0].plan_code);
|
|
}
|
|
const tokenOptions = Array.isArray(configJson.tokens)
|
|
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
|
|
: [];
|
|
const chainOptions = Array.isArray(configJson.chains)
|
|
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
|
|
: [];
|
|
const defaultChainId = Number(
|
|
configJson.default_chain_id ||
|
|
chainOptions.find((row) => row.is_default)?.chain_id ||
|
|
configJson.chain_id ||
|
|
tokenOptions.find((row) => row.is_default)?.chain_id ||
|
|
137,
|
|
);
|
|
const supportedChainIds = new Set(
|
|
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
|
|
.map((row) => Number(row.chain_id))
|
|
.filter((value) => Number.isFinite(value) && value > 0),
|
|
);
|
|
setSelectedPaymentChainId((prev) =>
|
|
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
|
|
);
|
|
const activeChainId =
|
|
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
|
|
? selectedPaymentChainId
|
|
: defaultChainId;
|
|
const tokenOptionsForChain = tokenOptions.filter(
|
|
(row) => Number(row.chain_id || activeChainId) === activeChainId,
|
|
);
|
|
const defaultTokenAddress = String(
|
|
configJson.default_token_address ||
|
|
tokenOptionsForChain.find((row) => row.is_default)?.address ||
|
|
tokenOptionsForChain[0]?.address ||
|
|
tokenOptions.find((row) => row.is_default)?.address ||
|
|
tokenOptions[0]?.address ||
|
|
configJson.token_address || "",
|
|
).toLowerCase();
|
|
if (defaultTokenAddress) {
|
|
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
|
|
}
|
|
}
|
|
if (walletsRes.ok) {
|
|
const walletsJson = (await walletsRes.json()) as { wallets?: BoundWallet[] };
|
|
const wallets = (Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [])
|
|
.filter((row) => {
|
|
const status = String(row?.status || "active").toLowerCase();
|
|
const address = String(row?.address || "");
|
|
return status === "active" && address.startsWith("0x");
|
|
})
|
|
.map((row) => ({ ...row, address: String(row.address || "").toLowerCase() }));
|
|
setBoundWallets(wallets);
|
|
if (wallets.length) {
|
|
const currentSelected = String(selectedWallet || "").toLowerCase();
|
|
const hasCurrent = wallets.some((row) => String(row.address || "").toLowerCase() === currentSelected);
|
|
const fallback = wallets.find((row) => Boolean(row.is_primary))?.address || wallets[0].address;
|
|
if (!currentSelected || !hasCurrent) setSelectedWallet(fallback);
|
|
const currentWalletAddress = String(walletAddress || "").toLowerCase();
|
|
const hasWalletAddress = wallets.some((row) => String(row.address || "").toLowerCase() === currentWalletAddress);
|
|
if (!currentWalletAddress || !hasWalletAddress) setWalletAddress(fallback);
|
|
} else {
|
|
setSelectedWallet("");
|
|
setWalletAddress("");
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [
|
|
backend?.authenticated,
|
|
buildAuthedHeaders,
|
|
selectedPlanCode,
|
|
selectedPaymentChainId,
|
|
selectedWallet,
|
|
walletAddress,
|
|
]);
|
|
|
|
// ── Selected plan (derived, shared across sub-hooks) ───
|
|
const effectivePlanList = paymentConfig?.plans || [];
|
|
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
|
|
|
|
// ── useWalletBind ──────────────────────────────────────
|
|
const walletBind = useWalletBind({
|
|
isEn,
|
|
walletConnectEnabled,
|
|
copy,
|
|
chainId,
|
|
boundWallets,
|
|
setBoundWallets,
|
|
walletAddress,
|
|
setWalletAddress,
|
|
selectedWallet,
|
|
setSelectedWallet,
|
|
providerMode,
|
|
setProviderMode,
|
|
injectedProviderOptions,
|
|
setInjectedProviderOptions,
|
|
selectedInjectedProviderKey,
|
|
setSelectedInjectedProviderKey,
|
|
setPaymentBusy,
|
|
setPaymentInfo,
|
|
setPaymentError,
|
|
setShowOverlay,
|
|
clearPaymentMessages,
|
|
authIsAuthenticated,
|
|
getValidAccessToken,
|
|
buildAuthedHeaders,
|
|
loadSnapshot,
|
|
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
|
});
|
|
|
|
// Ref to break circular dependency: usePaymentFlow needs connectAndBindWallet from useWalletBind
|
|
const connectAndBindWalletRef = useRef(walletBind.connectAndBindWallet);
|
|
connectAndBindWalletRef.current = walletBind.connectAndBindWallet;
|
|
|
|
// ── useBilling ──────────────────────────────────────────
|
|
const billing = useBilling({
|
|
isEn,
|
|
copy,
|
|
backend,
|
|
supabaseReady,
|
|
paymentConfig,
|
|
authUserId,
|
|
authIsAuthenticated,
|
|
usePoints,
|
|
setUsePoints,
|
|
lastIntentId,
|
|
lastTxHash,
|
|
lastPaymentStartedAt,
|
|
setLastIntentId,
|
|
setLastTxHash,
|
|
setLastPaymentStartedAt,
|
|
setPaymentBusy,
|
|
setPaymentInfo,
|
|
setPaymentError,
|
|
setTelegramBindOpening,
|
|
clearPaymentState,
|
|
selectedPlan,
|
|
getValidAccessToken,
|
|
buildAuthedHeaders,
|
|
loadSnapshot,
|
|
refreshEntitlementAfterPayment,
|
|
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
|
user,
|
|
});
|
|
|
|
// Ref to break circular dependency: usePaymentFlow needs handleSubmit409 from useBilling
|
|
const handleSubmit409Ref = useRef(billing.handleSubmit409);
|
|
handleSubmit409Ref.current = billing.handleSubmit409;
|
|
|
|
// ── usePaymentFlow ──────────────────────────────────────
|
|
const paymentFlow = usePaymentFlow({
|
|
isEn,
|
|
copy,
|
|
backend,
|
|
paymentConfig,
|
|
setPaymentConfig,
|
|
selectedPlanCode,
|
|
setSelectedPlanCode,
|
|
selectedPaymentChainId: chainId,
|
|
setSelectedPaymentChainId,
|
|
selectedTokenAddress,
|
|
setSelectedTokenAddress,
|
|
boundWallets,
|
|
selectedWallet,
|
|
walletAddress,
|
|
providerMode,
|
|
selectedInjectedProviderKey,
|
|
manualTxHash,
|
|
manualPayment,
|
|
lastIntentId,
|
|
setPaymentBusy,
|
|
setPaymentInfo,
|
|
setPaymentError,
|
|
setLastIntentId,
|
|
setLastTxHash,
|
|
setLastPaymentStartedAt,
|
|
setShowOverlay,
|
|
setManualPayment,
|
|
setManualTxHash,
|
|
setTxValidation,
|
|
setPaymentMethodTab,
|
|
clearPaymentMessages,
|
|
clearPaymentState,
|
|
setSelectedWallet,
|
|
setProviderMode,
|
|
selectedPlan,
|
|
billing: billing.billing,
|
|
usePoints,
|
|
currentPaymentHost: billing.currentPaymentHost,
|
|
paymentHostAllowed: billing.paymentHostAllowed,
|
|
allowedPaymentHosts: billing.allowedPaymentHosts,
|
|
authIsAuthenticated,
|
|
hasPayingWallet: walletBind.hasPayingWallet,
|
|
getValidAccessToken,
|
|
buildAuthedHeaders,
|
|
loadSnapshot,
|
|
refreshEntitlementAfterPayment,
|
|
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
|
waitForReceipt: walletBind.waitForReceipt,
|
|
ensureTargetChain: walletBind.ensureTargetChain,
|
|
resolvePaymentProvider: walletBind.resolvePaymentProvider,
|
|
connectAndBindWalletRef,
|
|
handleSubmit409Ref,
|
|
});
|
|
|
|
// ==========================================================
|
|
return {
|
|
// State from usePaymentState
|
|
paymentBusy,
|
|
paymentInfo,
|
|
paymentError,
|
|
lastIntentId,
|
|
lastTxHash,
|
|
lastPaymentStartedAt,
|
|
telegramBindOpening,
|
|
telegramBindUrl: billing.telegramBindUrl,
|
|
manualPayment,
|
|
manualTxHash,
|
|
txValidation,
|
|
paymentMethodTab,
|
|
clearPaymentMessages,
|
|
clearPaymentState,
|
|
|
|
// Setters from usePaymentState (needed by component render)
|
|
setPaymentBusy,
|
|
setPaymentInfo,
|
|
setPaymentError,
|
|
setLastIntentId,
|
|
setLastTxHash,
|
|
setLastPaymentStartedAt,
|
|
setTelegramBindOpening,
|
|
setPaymentMethodTab,
|
|
setManualPayment,
|
|
setManualTxHash,
|
|
setTxValidation,
|
|
|
|
// Additional state
|
|
paymentConfig,
|
|
boundWallets,
|
|
walletAddress,
|
|
selectedPlanCode,
|
|
selectedPaymentChainId: chainId,
|
|
selectedTokenAddress,
|
|
selectedWallet,
|
|
providerMode,
|
|
injectedProviderOptions,
|
|
selectedInjectedProviderKey,
|
|
reconcileBusy: billing.reconcileBusy,
|
|
|
|
// Setters for shared state
|
|
setSelectedTokenAddress,
|
|
setSelectedPaymentChainId,
|
|
setSelectedPlanCode,
|
|
setSelectedWallet,
|
|
setSelectedInjectedProviderKey,
|
|
setProviderMode,
|
|
|
|
// Derived values
|
|
authUserId,
|
|
authIsAuthenticated,
|
|
paymentReadyForRecovery: billing.paymentReadyForRecovery,
|
|
hasRecentPaymentRecovery: billing.hasRecentPaymentRecovery,
|
|
allowedPaymentHosts: billing.allowedPaymentHosts,
|
|
currentPaymentHost: billing.currentPaymentHost,
|
|
paymentHostAllowed: billing.paymentHostAllowed,
|
|
selectedPlan,
|
|
selectedPaymentToken: paymentFlow.selectedPaymentToken,
|
|
selectedTokenLabel: paymentFlow.selectedTokenLabel,
|
|
availableTokenList: paymentFlow.availableTokenList,
|
|
availableChainList: paymentFlow.availableChainList,
|
|
selectedPaymentChain: paymentFlow.selectedPaymentChain,
|
|
effectivePlanList,
|
|
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
|
|
paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
|
|
paymentWalletLabel: walletBind.paymentWalletLabel,
|
|
hasPayingWallet: walletBind.hasPayingWallet,
|
|
totalPoints: billing.totalPoints,
|
|
billing: billing.billing,
|
|
|
|
// Callbacks
|
|
loadSnapshot,
|
|
loadPaymentSnapshot: loadPaymentSnapshotImpl,
|
|
connectAndBindWallet: walletBind.connectAndBindWallet,
|
|
handleUnbindWallet: walletBind.handleUnbindWallet,
|
|
createIntentAndPay: paymentFlow.createIntentAndPay,
|
|
createManualPaymentIntent: paymentFlow.createManualPaymentIntent,
|
|
submitManualPaymentTx: paymentFlow.submitManualPaymentTx,
|
|
validateTxHash: paymentFlow.validateTxHash,
|
|
handleOverlayCheckout: paymentFlow.handleOverlayCheckout,
|
|
openTelegramBotBindLink: billing.openTelegramBotBindLink,
|
|
};
|
|
}
|