Fix Pro payment recovery and wallet provider selection

This commit is contained in:
2569718930@qq.com
2026-03-21 12:19:53 +08:00
parent a19925fe7a
commit 4413b31396
7 changed files with 517 additions and 8 deletions
+220 -5
View File
@@ -171,6 +171,10 @@ type ProviderSelection = {
mode: ProviderMode;
};
type InjectedProviderOption = ProviderSelection & {
key: string;
};
type ConnectBindOptions = {
openOverlayAfterBind?: boolean;
};
@@ -292,6 +296,43 @@ function getEvmProvider(): EvmProvider | null {
return typeof root.request === "function" ? root : null;
}
function listInjectedProviders(): InjectedProviderOption[] {
if (typeof window === "undefined") return [];
const root = window.ethereum;
if (!root) return [];
const candidates = Array.isArray(root.providers)
? root.providers.filter(
(p): p is EvmProvider => !!p && typeof p.request === "function",
)
: typeof root.request === "function"
? [root]
: [];
const seen = new Set<string>();
const out: InjectedProviderOption[] = [];
candidates.forEach((provider, index) => {
const label = getEvmWalletLabel(provider);
const key = [
provider.isMetaMask ? "metamask" : "",
provider.isRabby ? "rabby" : "",
provider.isOkxWallet ? "okx" : "",
provider.isBitKeep ? "bitget" : "",
label.toLowerCase().replace(/\s+/g, "-"),
String(index),
]
.filter(Boolean)
.join(":");
if (seen.has(key)) return;
seen.add(key);
out.push({
key,
provider,
label,
mode: "auto",
});
});
return out;
}
function getEvmWalletLabel(provider: EvmProvider | null): string {
if (!provider) return "EVM 钱包";
if (provider.isMetaMask) return "MetaMask";
@@ -549,6 +590,21 @@ export function AccountCenter() {
walletConnectMissing: isEn
? "WalletConnect disabled: please configure"
: "未启用 WalletConnect:请配置",
walletExtensionDetected: isEn
? "Detected browser wallets"
: "检测到的浏览器钱包",
walletExtensionChoose: isEn
? "Choose extension wallet"
: "选择浏览器钱包",
walletRecoveryBusy: isEn
? "Recovering Pro entitlement after on-chain payment..."
: "正在根据链上支付恢复 Pro 权限...",
walletRecoveryDone: isEn
? "Pro entitlement recovered."
: "Pro 权限已恢复。",
walletRecoveryFailed: isEn
? "Paid detected but entitlement is still pending. Please refresh in a minute or contact support."
: "已检测到支付,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
unbind: isEn ? "Unbind" : "解绑",
unbindConfirm: isEn
? "Unbind wallet {address}? You can bind it again later."
@@ -607,15 +663,26 @@ export function AccountCenter() {
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
const [injectedProviderOptions, setInjectedProviderOptions] = useState<
InjectedProviderOption[]
>([]);
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] =
useState("");
const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentInfo, setPaymentInfo] = useState("");
const [paymentError, setPaymentError] = useState("");
const [lastIntentId, setLastIntentId] = useState("");
const [lastTxHash, setLastTxHash] = useState("");
const [showSecondarySections, setShowSecondarySections] = useState(false);
const [reconcileBusy, setReconcileBusy] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
const authUserId = backend?.user_id || user?.id || "";
const authIsAuthenticated = Boolean(authUserId);
const paymentReadyForRecovery = Boolean(
paymentConfig?.enabled && paymentConfig?.configured,
);
useEffect(() => {
let canceled = false;
@@ -648,6 +715,30 @@ export function AccountCenter() {
};
}, []);
useEffect(() => {
const syncProviders = () => {
const nextOptions = listInjectedProviders();
setInjectedProviderOptions(nextOptions);
setSelectedInjectedProviderKey((current) => {
if (current && nextOptions.some((row) => row.key === current)) {
return current;
}
return nextOptions[0]?.key || "";
});
};
syncProviders();
if (typeof window === "undefined") return;
window.addEventListener("ethereum#initialized", syncProviders as EventListener, {
once: false,
});
return () => {
window.removeEventListener(
"ethereum#initialized",
syncProviders as EventListener,
);
};
}, []);
/**
* Returns a valid access token, refreshing the session if the stored one
* is missing or close to expiry. Throws if the user is not authenticated.
@@ -736,14 +827,23 @@ export function AccountCenter() {
);
const resolvePaymentProvider = useCallback(
async (mode: ProviderMode = "auto"): Promise<ProviderSelection> => {
async (
mode: ProviderMode = "auto",
preferredInjectedKey = "",
): Promise<ProviderSelection> => {
const targetChainId = Number(paymentConfig?.chain_id || 137);
if (mode !== "walletconnect") {
const injected = getEvmProvider();
const injectedOptions = listInjectedProviders();
const injected =
injectedOptions.find((row) => row.key === preferredInjectedKey)
?.provider || getEvmProvider();
const label =
injectedOptions.find((row) => row.key === preferredInjectedKey)?.label ||
getEvmWalletLabel(injected);
if (injected) {
return {
provider: injected,
label: getEvmWalletLabel(injected),
label,
mode: "auto",
};
}
@@ -936,6 +1036,22 @@ export function AccountCenter() {
void loadPaymentSnapshot();
}, [loadPaymentSnapshot]);
useEffect(() => {
if (typeof window === "undefined") return;
if (!lastIntentId) return;
window.sessionStorage.setItem("polyweather:lastPaymentIntentId", lastIntentId);
}, [lastIntentId]);
useEffect(() => {
if (typeof window === "undefined") return;
const storedIntentId = window.sessionStorage.getItem(
"polyweather:lastPaymentIntentId",
);
if (storedIntentId && !lastIntentId) {
setLastIntentId(storedIntentId);
}
}, [lastIntentId]);
const onRefresh = async () => {
setRefreshing(true);
await loadSnapshot();
@@ -943,6 +1059,74 @@ export function AccountCenter() {
setRefreshing(false);
};
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,
]);
useEffect(() => {
if (!authIsAuthenticated) return;
if (backend?.subscription_active) return;
if (!paymentReadyForRecovery) return;
if (!lastIntentId) 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,
lastIntentId,
paymentReadyForRecovery,
reconcileLatestPayment,
]);
const onSignOut = async () => {
if (walletConnectProviderCache?.disconnect) {
try {
@@ -1285,7 +1469,10 @@ export function AccountCenter() {
setPaymentBusy(true);
try {
const providerSelection = await resolvePaymentProvider(mode);
const providerSelection = await resolvePaymentProvider(
mode,
selectedInjectedProviderKey,
);
const eth = providerSelection.provider;
const walletLabel = providerSelection.label;
@@ -1480,7 +1667,10 @@ export function AccountCenter() {
setPaymentBusy(true);
let approvedInThisRun = false;
try {
const providerSelection = await resolvePaymentProvider(providerMode);
const providerSelection = await resolvePaymentProvider(
providerMode,
selectedInjectedProviderKey,
);
const eth = providerSelection.provider;
const activeAccounts = (await eth.request({
method: "eth_requestAccounts",
@@ -2153,6 +2343,31 @@ export function AccountCenter() {
</div>
<div className="mt-6 grid grid-cols-1 gap-2">
{injectedProviderOptions.length > 1 && (
<label className="mb-2 block">
<span className="mb-2 block text-[11px] uppercase tracking-widest text-slate-500">
{copy.walletExtensionDetected}
</span>
<select
value={selectedInjectedProviderKey}
onChange={(event) =>
setSelectedInjectedProviderKey(event.target.value)
}
disabled={paymentBusy}
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-xs text-slate-200 outline-none transition-all hover:bg-white/10 disabled:opacity-60"
>
{injectedProviderOptions.map((option) => (
<option
key={option.key}
value={option.key}
className="bg-slate-900 text-slate-200"
>
{option.label}
</option>
))}
</select>
</label>
)}
<button
onClick={() => {
setProviderMode("auto");