2026-03-13 02:23:01 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-05-18 17:10:44 +08:00
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
2026-03-13 02:23:01 +08:00
|
|
|
|
import Link from "next/link";
|
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
|
import type { User } from "@supabase/supabase-js";
|
|
|
|
|
|
import {
|
2026-03-13 03:47:56 +08:00
|
|
|
|
User as UserIcon,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
Shield,
|
|
|
|
|
|
Fingerprint,
|
|
|
|
|
|
Bot,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
LogOut,
|
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
CheckCircle2,
|
2026-03-13 03:47:56 +08:00
|
|
|
|
UserCheck,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
Mail,
|
|
|
|
|
|
Hash,
|
|
|
|
|
|
LogIn,
|
|
|
|
|
|
Clock,
|
|
|
|
|
|
Crown,
|
|
|
|
|
|
ExternalLink,
|
|
|
|
|
|
Trophy,
|
|
|
|
|
|
Coins,
|
|
|
|
|
|
TrendingUp,
|
|
|
|
|
|
Info,
|
2026-03-13 07:31:47 +08:00
|
|
|
|
Wallet,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
Zap,
|
|
|
|
|
|
Minus,
|
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
|
BarChart3,
|
|
|
|
|
|
Sparkles,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
CreditCard,
|
2026-03-13 02:23:01 +08:00
|
|
|
|
} from "lucide-react";
|
2026-03-13 08:15:27 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getSupabaseBrowserClient,
|
|
|
|
|
|
hasSupabasePublicEnv,
|
|
|
|
|
|
} from "@/lib/supabase/client";
|
2026-03-22 13:42:48 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getAllowedPaymentHosts,
|
|
|
|
|
|
getCurrentPaymentHost,
|
|
|
|
|
|
isPaymentHostAllowed,
|
|
|
|
|
|
} from "@/lib/payment-host";
|
2026-05-24 18:33:47 +08:00
|
|
|
|
import {
|
|
|
|
|
|
assertExpectedPaymentReceiver,
|
|
|
|
|
|
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
|
|
|
|
|
} from "@/lib/payment-receiver";
|
2026-05-20 20:13:18 +08:00
|
|
|
|
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
2026-03-16 20:30:46 +08:00
|
|
|
|
import { useI18n } from "@/hooks/useI18n";
|
2026-05-15 00:58:40 +08:00
|
|
|
|
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
|
2026-03-13 08:15:27 +08:00
|
|
|
|
|
2026-05-23 23:30:48 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
AuthMeResponse,
|
|
|
|
|
|
BoundWallet,
|
|
|
|
|
|
ConnectBindOptions,
|
|
|
|
|
|
CreatedIntent,
|
|
|
|
|
|
Eip6963ProviderDetail,
|
|
|
|
|
|
EvmProvider,
|
|
|
|
|
|
InjectedProviderOption,
|
|
|
|
|
|
IntentStatusResponse,
|
|
|
|
|
|
PaymentConfig,
|
|
|
|
|
|
PaymentTokenOption,
|
|
|
|
|
|
PaymentRecoveryState,
|
|
|
|
|
|
ProviderMode,
|
|
|
|
|
|
ProviderSelection,
|
|
|
|
|
|
TelegramPricing,
|
|
|
|
|
|
} from "./types";
|
|
|
|
|
|
import {
|
|
|
|
|
|
PAYMENT_RECOVERY_STORAGE_KEY,
|
|
|
|
|
|
PAYMENT_RECOVERY_TTL_MS,
|
|
|
|
|
|
SUBSCRIPTION_HELP_HREF,
|
|
|
|
|
|
TELEGRAM_BOT_URL,
|
|
|
|
|
|
TELEGRAM_GROUP_URL,
|
|
|
|
|
|
TELEGRAM_TOPICS_GROUP_URL,
|
|
|
|
|
|
WALLETCONNECT_PROJECT_ID,
|
|
|
|
|
|
WALLETCONNECT_POLYGON_RPC_URL,
|
|
|
|
|
|
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
|
|
|
|
|
} from "./constants";
|
|
|
|
|
|
import { InfoRow, PlusIcon } from "./AccountInfoRow";
|
|
|
|
|
|
import {
|
|
|
|
|
|
chainIdToDisplayName,
|
|
|
|
|
|
clearStoredPaymentRecovery,
|
|
|
|
|
|
formatTime,
|
|
|
|
|
|
parseSubscriptionExpiry,
|
|
|
|
|
|
shortAddress,
|
|
|
|
|
|
} from "./formatters";
|
|
|
|
|
|
import {
|
|
|
|
|
|
buildAllowanceCalldata,
|
|
|
|
|
|
buildApproveCalldata,
|
|
|
|
|
|
buildBalanceOfCalldata,
|
|
|
|
|
|
formatTokenUnits,
|
|
|
|
|
|
normalizePaymentError,
|
|
|
|
|
|
requestWalletWithTimeout,
|
|
|
|
|
|
} from "./payment-utils";
|
|
|
|
|
|
import { createAccountCopy } from "./account-copy";
|
|
|
|
|
|
import { usePaymentState } from "./usePaymentState";
|
|
|
|
|
|
import {
|
|
|
|
|
|
eip6963Providers,
|
|
|
|
|
|
getEvmProvider,
|
|
|
|
|
|
getEvmWalletLabel,
|
|
|
|
|
|
getWalletConnectProvider,
|
|
|
|
|
|
isWalletConnectResetError,
|
|
|
|
|
|
listInjectedProviders,
|
|
|
|
|
|
resetWalletConnectProvider,
|
|
|
|
|
|
} from "./wallet";
|
2026-03-13 13:01:57 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
// --- Main Component ---
|
2026-03-13 03:47:56 +08:00
|
|
|
|
|
2026-03-13 02:23:01 +08:00
|
|
|
|
export function AccountCenter() {
|
|
|
|
|
|
const router = useRouter();
|
2026-03-16 20:30:46 +08:00
|
|
|
|
const { locale } = useI18n();
|
|
|
|
|
|
const isEn = locale === "en-US";
|
2026-05-23 23:30:48 +08:00
|
|
|
|
const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
|
|
const [errorText, setErrorText] = useState("");
|
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
2026-03-13 07:31:47 +08:00
|
|
|
|
const [showOverlay, setShowOverlay] = useState(false);
|
|
|
|
|
|
const [usePoints, setUsePoints] = useState(true);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const [updatedAt, setUpdatedAt] = useState<string>("");
|
|
|
|
|
|
const [user, setUser] = useState<User | null>(null);
|
|
|
|
|
|
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(
|
|
|
|
|
|
null,
|
|
|
|
|
|
);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
|
|
|
|
|
const [walletAddress, setWalletAddress] = useState("");
|
|
|
|
|
|
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
2026-03-13 13:58:41 +08:00
|
|
|
|
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const [selectedWallet, setSelectedWallet] = useState("");
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
|
2026-03-21 12:19:53 +08:00
|
|
|
|
const [injectedProviderOptions, setInjectedProviderOptions] = useState<
|
|
|
|
|
|
InjectedProviderOption[]
|
|
|
|
|
|
>([]);
|
|
|
|
|
|
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] =
|
|
|
|
|
|
useState("");
|
2026-05-23 23:30:48 +08:00
|
|
|
|
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();
|
2026-03-18 20:38:43 +08:00
|
|
|
|
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
2026-03-21 12:19:53 +08:00
|
|
|
|
const [reconcileBusy, setReconcileBusy] = useState(false);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
|
|
|
|
|
|
const supabaseReady = hasSupabasePublicEnv();
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
|
2026-03-21 12:19:53 +08:00
|
|
|
|
const authUserId = backend?.user_id || user?.id || "";
|
|
|
|
|
|
const authIsAuthenticated = Boolean(authUserId);
|
|
|
|
|
|
const paymentReadyForRecovery = Boolean(
|
|
|
|
|
|
paymentConfig?.enabled && paymentConfig?.configured,
|
|
|
|
|
|
);
|
2026-04-06 20:40:26 +08:00
|
|
|
|
const hasRecentPaymentRecovery =
|
|
|
|
|
|
Boolean(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt) &&
|
|
|
|
|
|
Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS;
|
2026-03-22 13:42:48 +08:00
|
|
|
|
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
|
|
|
|
|
|
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
|
|
|
|
|
|
const paymentHostAllowed = useMemo(
|
|
|
|
|
|
() => isPaymentHostAllowed(currentPaymentHost),
|
|
|
|
|
|
[currentPaymentHost],
|
|
|
|
|
|
);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
|
2026-05-20 20:13:18 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!authIsAuthenticated || !authUserId) return;
|
|
|
|
|
|
const actorKey = authUserId.toLowerCase();
|
|
|
|
|
|
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
|
|
|
|
|
|
trackAppEvent("signup_completed", {
|
|
|
|
|
|
entry: "account_center",
|
|
|
|
|
|
user_id: authUserId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
|
|
|
|
|
|
trackAppEvent("dashboard_active", {
|
|
|
|
|
|
entry: "account_center",
|
|
|
|
|
|
user_id: authUserId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [authIsAuthenticated, authUserId]);
|
|
|
|
|
|
|
2026-03-18 20:38:43 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let canceled = false;
|
|
|
|
|
|
let timeoutId: number | null = null;
|
|
|
|
|
|
let idleId: number | null = null;
|
|
|
|
|
|
const win = typeof window !== "undefined" ? (window as any) : null;
|
|
|
|
|
|
|
|
|
|
|
|
const reveal = () => {
|
|
|
|
|
|
if (!canceled) {
|
|
|
|
|
|
setShowSecondarySections(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (win && typeof win.requestIdleCallback === "function") {
|
|
|
|
|
|
idleId = win.requestIdleCallback(reveal, { timeout: 320 });
|
|
|
|
|
|
} else if (typeof window !== "undefined") {
|
|
|
|
|
|
timeoutId = window.setTimeout(reveal, 140);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setShowSecondarySections(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
canceled = true;
|
2026-03-21 12:34:36 +08:00
|
|
|
|
if (
|
|
|
|
|
|
win &&
|
|
|
|
|
|
idleId != null &&
|
|
|
|
|
|
typeof win.cancelIdleCallback === "function"
|
|
|
|
|
|
) {
|
2026-03-18 20:38:43 +08:00
|
|
|
|
win.cancelIdleCallback(idleId);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (timeoutId != null && typeof window !== "undefined") {
|
|
|
|
|
|
window.clearTimeout(timeoutId);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-21 12:19:53 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const syncProviders = () => {
|
|
|
|
|
|
const nextOptions = listInjectedProviders();
|
|
|
|
|
|
setInjectedProviderOptions(nextOptions);
|
|
|
|
|
|
setSelectedInjectedProviderKey((current) => {
|
|
|
|
|
|
if (current && nextOptions.some((row) => row.key === current)) {
|
|
|
|
|
|
return current;
|
|
|
|
|
|
}
|
|
|
|
|
|
return nextOptions[0]?.key || "";
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-03-21 12:34:36 +08:00
|
|
|
|
|
|
|
|
|
|
const handleAnnounce = (event: Event) => {
|
|
|
|
|
|
const customEvent = event as CustomEvent<Eip6963ProviderDetail>;
|
|
|
|
|
|
const detail = customEvent.detail;
|
|
|
|
|
|
if (!detail?.provider || typeof detail.provider.request !== "function") {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const uuid = String(detail.info?.uuid || "").trim();
|
|
|
|
|
|
const fallbackKey = `${String(detail.info?.rdns || "wallet").toLowerCase()}:${String(
|
|
|
|
|
|
detail.info?.name || "wallet",
|
|
|
|
|
|
).toLowerCase()}`;
|
|
|
|
|
|
eip6963Providers.set(uuid || fallbackKey, detail);
|
|
|
|
|
|
syncProviders();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-21 12:19:53 +08:00
|
|
|
|
syncProviders();
|
|
|
|
|
|
if (typeof window === "undefined") return;
|
2026-03-21 12:34:36 +08:00
|
|
|
|
window.addEventListener(
|
|
|
|
|
|
"eip6963:announceProvider",
|
|
|
|
|
|
handleAnnounce as EventListener,
|
|
|
|
|
|
);
|
|
|
|
|
|
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
|
|
|
|
|
window.addEventListener(
|
|
|
|
|
|
"ethereum#initialized",
|
|
|
|
|
|
syncProviders as EventListener,
|
|
|
|
|
|
{
|
|
|
|
|
|
once: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-21 12:19:53 +08:00
|
|
|
|
return () => {
|
2026-03-21 12:34:36 +08:00
|
|
|
|
window.removeEventListener(
|
|
|
|
|
|
"eip6963:announceProvider",
|
|
|
|
|
|
handleAnnounce as EventListener,
|
|
|
|
|
|
);
|
2026-03-21 12:19:53 +08:00
|
|
|
|
window.removeEventListener(
|
|
|
|
|
|
"ethereum#initialized",
|
|
|
|
|
|
syncProviders as EventListener,
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-13 11:51:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getValidAccessToken = useCallback(async (): Promise<string> => {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
if (!supabaseReady)
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
isEn
|
|
|
|
|
|
? "Supabase is not configured. Unable to get auth token."
|
|
|
|
|
|
: "Supabase 未配置,无法获取登录凭证。",
|
|
|
|
|
|
);
|
2026-03-13 11:51:01 +08:00
|
|
|
|
const client = getSupabaseBrowserClient();
|
|
|
|
|
|
// First try the cached session.
|
|
|
|
|
|
const {
|
|
|
|
|
|
data: { session: cached },
|
|
|
|
|
|
} = await client.auth.getSession();
|
|
|
|
|
|
const cachedToken = String(cached?.access_token || "").trim();
|
2026-03-17 14:18:25 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-13 11:51:01 +08:00
|
|
|
|
// Session missing or expired — force a refresh.
|
|
|
|
|
|
const {
|
|
|
|
|
|
data: { session: refreshed },
|
|
|
|
|
|
error,
|
|
|
|
|
|
} = await client.auth.refreshSession();
|
|
|
|
|
|
const refreshedToken = String(refreshed?.access_token || "").trim();
|
|
|
|
|
|
if (refreshedToken) return refreshedToken;
|
2026-03-21 12:34:36 +08:00
|
|
|
|
if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec) {
|
2026-03-17 14:32:15 +08:00
|
|
|
|
return cachedToken;
|
|
|
|
|
|
}
|
2026-03-13 11:51:01 +08:00
|
|
|
|
throw new Error(
|
|
|
|
|
|
error?.message
|
2026-03-16 20:30:46 +08:00
|
|
|
|
? isEn
|
|
|
|
|
|
? `Session expired (${error.message}). Please sign out and sign in again.`
|
|
|
|
|
|
: `登录会话已失效 (${error.message}),请退出后重新登录。`
|
|
|
|
|
|
: isEn
|
|
|
|
|
|
? "Session expired. Please sign out and sign in again."
|
|
|
|
|
|
: "登录会话已失效,请退出后重新登录。",
|
2026-03-13 11:51:01 +08:00
|
|
|
|
);
|
2026-03-16 20:30:46 +08:00
|
|
|
|
}, [isEn, supabaseReady]);
|
2026-03-13 11:51:01 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const buildAuthedHeaders = useCallback(
|
2026-03-17 14:18:25 +08:00
|
|
|
|
async (
|
|
|
|
|
|
withJson = false,
|
|
|
|
|
|
requireAuth = false,
|
|
|
|
|
|
): Promise<Record<string, string>> => {
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const headers: Record<string, string> = {};
|
|
|
|
|
|
if (withJson) headers["Content-Type"] = "application/json";
|
|
|
|
|
|
if (!supabaseReady) return headers;
|
|
|
|
|
|
try {
|
2026-03-13 11:51:01 +08:00
|
|
|
|
const token = await getValidAccessToken();
|
|
|
|
|
|
headers.Authorization = `Bearer ${token}`;
|
2026-03-17 14:18:25 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (requireAuth) throw error;
|
2026-03-17 15:23:59 +08:00
|
|
|
|
// Best-effort fallback: use current cached session token (if any)
|
|
|
|
|
|
// even when refresh failed, so same-origin API routes can still auth.
|
|
|
|
|
|
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.
|
|
|
|
|
|
}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
return headers;
|
|
|
|
|
|
},
|
2026-03-13 11:51:01 +08:00
|
|
|
|
[supabaseReady, getValidAccessToken],
|
2026-03-13 08:15:27 +08:00
|
|
|
|
);
|
2026-03-13 06:41:33 +08:00
|
|
|
|
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const resolvePaymentProvider = useCallback(
|
2026-03-21 12:19:53 +08:00
|
|
|
|
async (
|
|
|
|
|
|
mode: ProviderMode = "auto",
|
|
|
|
|
|
preferredInjectedKey = "",
|
|
|
|
|
|
): Promise<ProviderSelection> => {
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const targetChainId = Number(paymentConfig?.chain_id || 137);
|
|
|
|
|
|
if (mode !== "walletconnect") {
|
2026-03-21 12:19:53 +08:00
|
|
|
|
const injectedOptions = listInjectedProviders();
|
|
|
|
|
|
const injected =
|
|
|
|
|
|
injectedOptions.find((row) => row.key === preferredInjectedKey)
|
|
|
|
|
|
?.provider || getEvmProvider();
|
|
|
|
|
|
const label =
|
2026-03-21 12:34:36 +08:00
|
|
|
|
injectedOptions.find((row) => row.key === preferredInjectedKey)
|
|
|
|
|
|
?.label || getEvmWalletLabel(injected);
|
2026-03-13 16:47:25 +08:00
|
|
|
|
if (injected) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
provider: injected,
|
2026-03-21 12:19:53 +08:00
|
|
|
|
label,
|
2026-03-13 16:47:25 +08:00
|
|
|
|
mode: "auto",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!walletConnectEnabled) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
"未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const wcProvider = await getWalletConnectProvider(
|
|
|
|
|
|
targetChainId,
|
|
|
|
|
|
WALLETCONNECT_POLYGON_RPC_URL,
|
|
|
|
|
|
);
|
|
|
|
|
|
const existingAccounts = (await wcProvider
|
|
|
|
|
|
.request({ method: "eth_accounts" })
|
|
|
|
|
|
.catch(() => [])) as string[];
|
|
|
|
|
|
if (!Array.isArray(existingAccounts) || existingAccounts.length === 0) {
|
|
|
|
|
|
if (typeof wcProvider.connect === "function") {
|
2026-03-13 20:09:49 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await wcProvider.connect({ chains: [targetChainId] });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!isWalletConnectResetError(err)) throw err;
|
|
|
|
|
|
await resetWalletConnectProvider();
|
|
|
|
|
|
const freshProvider = await getWalletConnectProvider(
|
|
|
|
|
|
targetChainId,
|
|
|
|
|
|
WALLETCONNECT_POLYGON_RPC_URL,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (typeof freshProvider.connect === "function") {
|
|
|
|
|
|
await freshProvider.connect({ chains: [targetChainId] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
provider: freshProvider,
|
|
|
|
|
|
label: "WalletConnect",
|
|
|
|
|
|
mode: "walletconnect",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-13 16:47:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
provider: wcProvider,
|
|
|
|
|
|
label: "WalletConnect",
|
|
|
|
|
|
mode: "walletconnect",
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
[paymentConfig?.chain_id, walletConnectEnabled],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const loadPaymentSnapshot = useCallback(async () => {
|
|
|
|
|
|
if (!backend?.authenticated) {
|
|
|
|
|
|
setPaymentConfig(null);
|
|
|
|
|
|
setBoundWallets([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-03-14 13:33:06 +08:00
|
|
|
|
const authHeadersPromise = buildAuthedHeaders(false);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const [configRes, walletsRes] = await Promise.all([
|
2026-03-14 13:33:06 +08:00
|
|
|
|
authHeadersPromise.then((headers) =>
|
|
|
|
|
|
fetch("/api/payments/config", {
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
headers,
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
|
|
|
|
|
authHeadersPromise.then((headers) =>
|
|
|
|
|
|
fetch("/api/payments/wallets", {
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
headers,
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
2026-03-13 05:13:48 +08:00
|
|
|
|
]);
|
|
|
|
|
|
if (configRes.ok) {
|
|
|
|
|
|
const configJson = (await configRes.json()) as PaymentConfig;
|
|
|
|
|
|
setPaymentConfig(configJson);
|
2026-03-13 07:31:47 +08:00
|
|
|
|
if (!selectedPlanCode && configJson.plans?.length) {
|
2026-03-13 05:13:48 +08:00
|
|
|
|
setSelectedPlanCode(configJson.plans[0].plan_code);
|
|
|
|
|
|
}
|
2026-03-13 13:58:41 +08:00
|
|
|
|
const tokenOptions = Array.isArray(configJson.tokens)
|
|
|
|
|
|
? configJson.tokens.filter(
|
|
|
|
|
|
(row) =>
|
|
|
|
|
|
typeof row?.address === "string" &&
|
|
|
|
|
|
String(row.address).startsWith("0x"),
|
|
|
|
|
|
)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const defaultTokenAddress = String(
|
|
|
|
|
|
configJson.default_token_address ||
|
|
|
|
|
|
tokenOptions.find((row) => row.is_default)?.address ||
|
|
|
|
|
|
tokenOptions[0]?.address ||
|
|
|
|
|
|
configJson.token_address ||
|
|
|
|
|
|
"",
|
|
|
|
|
|
).toLowerCase();
|
|
|
|
|
|
if (defaultTokenAddress) {
|
|
|
|
|
|
setSelectedTokenAddress((prev) => prev || defaultTokenAddress);
|
|
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (walletsRes.ok) {
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const walletsJson = (await walletsRes.json()) as {
|
|
|
|
|
|
wallets?: BoundWallet[];
|
|
|
|
|
|
};
|
2026-03-17 13:57:44 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
}));
|
2026-03-13 05:13:48 +08:00
|
|
|
|
setBoundWallets(wallets);
|
2026-03-17 13:57:44 +08:00
|
|
|
|
if (wallets.length) {
|
|
|
|
|
|
const currentSelected = String(selectedWallet || "").toLowerCase();
|
|
|
|
|
|
const hasCurrent = wallets.some(
|
2026-03-21 12:34:36 +08:00
|
|
|
|
(row) =>
|
|
|
|
|
|
String(row.address || "").toLowerCase() === currentSelected,
|
2026-03-17 13:57:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
const fallback =
|
|
|
|
|
|
wallets.find((row) => Boolean(row.is_primary))?.address ||
|
|
|
|
|
|
wallets[0].address;
|
|
|
|
|
|
if (!currentSelected || !hasCurrent) {
|
|
|
|
|
|
setSelectedWallet(fallback);
|
|
|
|
|
|
}
|
2026-03-21 12:34:36 +08:00
|
|
|
|
const currentWalletAddress = String(
|
|
|
|
|
|
walletAddress || "",
|
|
|
|
|
|
).toLowerCase();
|
2026-03-17 13:57:44 +08:00
|
|
|
|
const hasWalletAddress = wallets.some(
|
|
|
|
|
|
(row) =>
|
|
|
|
|
|
String(row.address || "").toLowerCase() === currentWalletAddress,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!currentWalletAddress || !hasWalletAddress) {
|
|
|
|
|
|
setWalletAddress(fallback);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedWallet("");
|
|
|
|
|
|
setWalletAddress("");
|
|
|
|
|
|
}
|
2026-03-13 06:41:33 +08:00
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
} catch {
|
2026-03-13 07:31:47 +08:00
|
|
|
|
// ignore
|
2026-03-13 05:13:48 +08:00
|
|
|
|
}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
backend?.authenticated,
|
|
|
|
|
|
buildAuthedHeaders,
|
|
|
|
|
|
selectedPlanCode,
|
|
|
|
|
|
selectedWallet,
|
2026-03-17 13:57:44 +08:00
|
|
|
|
walletAddress,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
]);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
|
2026-03-21 13:49:08 +08:00
|
|
|
|
const fetchLatestPaymentConfig = useCallback(
|
|
|
|
|
|
async (
|
|
|
|
|
|
authHeaders?: Record<string, string>,
|
|
|
|
|
|
syncState = true,
|
|
|
|
|
|
): Promise<PaymentConfig> => {
|
|
|
|
|
|
const headers = authHeaders || (await buildAuthedHeaders(false));
|
|
|
|
|
|
const configRes = await fetch("/api/payments/config", {
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
headers,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!configRes.ok) {
|
|
|
|
|
|
const raw = (await configRes.text()).slice(0, 350);
|
|
|
|
|
|
throw new Error(`load payment config failed: ${raw}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const configJson = (await configRes.json()) as PaymentConfig;
|
|
|
|
|
|
if (syncState) {
|
|
|
|
|
|
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 defaultTokenAddress = String(
|
|
|
|
|
|
configJson.default_token_address ||
|
|
|
|
|
|
tokenOptions.find((row) => row.is_default)?.address ||
|
|
|
|
|
|
tokenOptions[0]?.address ||
|
|
|
|
|
|
configJson.token_address ||
|
|
|
|
|
|
"",
|
|
|
|
|
|
).toLowerCase();
|
|
|
|
|
|
if (defaultTokenAddress) {
|
|
|
|
|
|
setSelectedTokenAddress((prev) => prev || defaultTokenAddress);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return configJson;
|
|
|
|
|
|
},
|
|
|
|
|
|
[buildAuthedHeaders, selectedPlanCode],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const loadSnapshot = useCallback(async () => {
|
|
|
|
|
|
setErrorText("");
|
2026-05-20 18:50:17 +08:00
|
|
|
|
const attempt = async (retry: boolean): Promise<void> => {
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const userPromise = supabaseReady
|
|
|
|
|
|
? getSupabaseBrowserClient().auth.getUser()
|
|
|
|
|
|
: Promise.resolve({ data: { user: null as User | null } });
|
2026-03-14 13:33:06 +08:00
|
|
|
|
const authHeadersPromise = buildAuthedHeaders(false);
|
|
|
|
|
|
const backendPromise = authHeadersPromise.then((headers) =>
|
|
|
|
|
|
fetch("/api/auth/me", {
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
headers,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const [userResult, backendResult] = await Promise.all([
|
|
|
|
|
|
userPromise,
|
|
|
|
|
|
backendPromise,
|
|
|
|
|
|
]);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
setUser(userResult.data?.user ?? null);
|
|
|
|
|
|
if (!backendResult.ok) {
|
2026-05-20 18:50:17 +08:00
|
|
|
|
if (retry && backendResult.status === 401) {
|
|
|
|
|
|
// Supabase session may not be hydrated yet; wait and retry once.
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
|
|
|
|
return attempt(false);
|
|
|
|
|
|
}
|
2026-03-13 03:47:56 +08:00
|
|
|
|
const raw = (await backendResult.text()).slice(0, 260);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
throw new Error(`HTTP ${backendResult.status} ${raw}`.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
const backendJson = (await backendResult.json()) as AuthMeResponse;
|
|
|
|
|
|
setBackend(backendJson);
|
|
|
|
|
|
setUpdatedAt(new Date().toISOString());
|
2026-05-20 18:50:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
await attempt(true);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setErrorText(String(error));
|
|
|
|
|
|
}
|
2026-03-13 06:41:33 +08:00
|
|
|
|
}, [buildAuthedHeaders, supabaseReady]);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
const run = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
await loadSnapshot();
|
|
|
|
|
|
if (!cancelled) setLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
void run();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [loadSnapshot]);
|
|
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void loadPaymentSnapshot();
|
|
|
|
|
|
}, [loadPaymentSnapshot]);
|
|
|
|
|
|
|
2026-03-21 12:19:53 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (typeof window === "undefined") return;
|
2026-04-06 20:40:26 +08:00
|
|
|
|
if (!(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt)) {
|
|
|
|
|
|
clearStoredPaymentRecovery();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const payload: PaymentRecoveryState = {
|
|
|
|
|
|
intentId: lastIntentId,
|
|
|
|
|
|
txHash: lastTxHash,
|
|
|
|
|
|
userId: authUserId,
|
|
|
|
|
|
createdAt: lastPaymentStartedAt,
|
|
|
|
|
|
};
|
2026-03-21 12:34:36 +08:00
|
|
|
|
window.sessionStorage.setItem(
|
2026-04-06 20:40:26 +08:00
|
|
|
|
PAYMENT_RECOVERY_STORAGE_KEY,
|
|
|
|
|
|
JSON.stringify(payload),
|
2026-03-21 12:34:36 +08:00
|
|
|
|
);
|
2026-04-06 20:40:26 +08:00
|
|
|
|
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
|
2026-03-21 12:19:53 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (typeof window === "undefined") return;
|
2026-04-06 20:40:26 +08:00
|
|
|
|
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();
|
2026-05-19 17:47:51 +08:00
|
|
|
|
const txHash = String(parsed?.txHash || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase();
|
2026-04-06 20:40:26 +08:00
|
|
|
|
const createdAt = Number(parsed?.createdAt || 0);
|
|
|
|
|
|
const expired =
|
|
|
|
|
|
!createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
|
2026-05-19 17:47:51 +08:00
|
|
|
|
if (expired || !intentId || !txHash || !userId || userId !== authUserId) {
|
2026-04-06 20:40:26 +08:00
|
|
|
|
clearStoredPaymentRecovery();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setLastIntentId(intentId);
|
|
|
|
|
|
setLastTxHash(txHash);
|
|
|
|
|
|
setLastPaymentStartedAt(createdAt);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
clearStoredPaymentRecovery();
|
2026-03-21 12:19:53 +08:00
|
|
|
|
}
|
2026-04-06 20:40:26 +08:00
|
|
|
|
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!backend?.subscription_active) return;
|
2026-05-23 23:30:48 +08:00
|
|
|
|
clearPaymentState();
|
2026-04-06 20:40:26 +08:00
|
|
|
|
clearStoredPaymentRecovery();
|
|
|
|
|
|
}, [backend?.subscription_active]);
|
2026-03-21 12:19:53 +08:00
|
|
|
|
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const onRefresh = async () => {
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
|
await loadSnapshot();
|
2026-03-13 05:13:48 +08:00
|
|
|
|
await loadPaymentSnapshot();
|
2026-03-13 02:23:01 +08:00
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-21 12:19:53 +08:00
|
|
|
|
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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-22 04:44:14 +08:00
|
|
|
|
const handleSubmit409 = useCallback(
|
|
|
|
|
|
async (intentId: string, txHashNorm: string, raw: string) => {
|
|
|
|
|
|
const lowerRaw = raw.toLowerCase();
|
|
|
|
|
|
// If intent was already confirmed (maybe by confirm loop), reconcile
|
|
|
|
|
|
if (
|
|
|
|
|
|
lowerRaw.includes("已支付") ||
|
|
|
|
|
|
lowerRaw.includes("already confirmed") ||
|
|
|
|
|
|
lowerRaw.includes("already paid")
|
|
|
|
|
|
) {
|
|
|
|
|
|
const ok = await reconcileLatestPayment();
|
|
|
|
|
|
if (ok) return;
|
|
|
|
|
|
setPaymentInfo("该订单已支付,正在恢复订阅...");
|
|
|
|
|
|
await loadSnapshot();
|
|
|
|
|
|
await loadPaymentSnapshot();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// If intent expired, tell user to create a new order
|
|
|
|
|
|
if (lowerRaw.includes("expired")) {
|
|
|
|
|
|
throw new Error("支付订单已过期(30分钟有效),请重新创建订单。");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Try fetching intent status as fallback
|
|
|
|
|
|
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("支付订单已过期(30分钟有效),请重新创建订单。");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (e instanceof Error && e.message !== raw) throw e;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`submit tx failed: ${raw}`);
|
|
|
|
|
|
},
|
|
|
|
|
|
[
|
|
|
|
|
|
buildAuthedHeaders,
|
|
|
|
|
|
loadPaymentSnapshot,
|
|
|
|
|
|
loadSnapshot,
|
|
|
|
|
|
reconcileLatestPayment,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-21 12:19:53 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!authIsAuthenticated) return;
|
|
|
|
|
|
if (backend?.subscription_active) return;
|
|
|
|
|
|
if (!paymentReadyForRecovery) return;
|
2026-04-06 20:40:26 +08:00
|
|
|
|
if (!hasRecentPaymentRecovery) return;
|
2026-03-21 12:19:53 +08:00
|
|
|
|
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,
|
2026-04-06 20:40:26 +08:00
|
|
|
|
hasRecentPaymentRecovery,
|
2026-03-21 12:19:53 +08:00
|
|
|
|
paymentReadyForRecovery,
|
|
|
|
|
|
reconcileLatestPayment,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const onSignOut = async () => {
|
2026-05-23 23:30:48 +08:00
|
|
|
|
clearPaymentState();
|
2026-04-06 20:40:26 +08:00
|
|
|
|
clearStoredPaymentRecovery();
|
2026-05-23 23:30:48 +08:00
|
|
|
|
await resetWalletConnectProvider();
|
2026-03-13 02:23:01 +08:00
|
|
|
|
if (supabaseReady) {
|
|
|
|
|
|
try {
|
2026-03-13 03:47:56 +08:00
|
|
|
|
await getSupabaseBrowserClient().auth.signOut();
|
2026-03-13 07:31:47 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-03-13 02:23:01 +08:00
|
|
|
|
}
|
2026-03-13 03:47:56 +08:00
|
|
|
|
router.replace("/");
|
2026-03-13 02:23:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
// --- Derived State ---
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const userId = backend?.user_id || user?.id || "";
|
2026-03-13 03:27:56 +08:00
|
|
|
|
const isAuthenticated = Boolean(userId);
|
2026-03-13 02:23:01 +08:00
|
|
|
|
const email = backend?.email || user?.email || "";
|
2026-05-18 17:10:44 +08:00
|
|
|
|
// Handle ?bind_token=xxx from Telegram bot /bind deep link
|
2026-05-18 16:18:26 +08:00
|
|
|
|
useEffect(() => {
|
2026-05-18 17:10:44 +08:00
|
|
|
|
if (!isAuthenticated) return;
|
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
const token = params.get("bind_token");
|
|
|
|
|
|
if (!token) return;
|
|
|
|
|
|
// Remove token from URL so refresh doesn't retry
|
|
|
|
|
|
const url = new URL(window.location.href);
|
|
|
|
|
|
url.searchParams.delete("bind_token");
|
|
|
|
|
|
window.history.replaceState(null, "", url.toString());
|
|
|
|
|
|
(async () => {
|
2026-05-18 16:18:26 +08:00
|
|
|
|
setPaymentError("");
|
|
|
|
|
|
setPaymentInfo("");
|
|
|
|
|
|
try {
|
|
|
|
|
|
const authHeaders = await buildAuthedHeaders(true, false);
|
2026-05-18 17:10:44 +08:00
|
|
|
|
const res = await fetch("/api/auth/telegram/bind-by-token", {
|
2026-05-18 16:18:26 +08:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders,
|
2026-05-18 17:10:44 +08:00
|
|
|
|
body: JSON.stringify({ token }),
|
2026-05-18 16:18:26 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const raw = (await res.text()).slice(0, 350);
|
2026-05-18 17:10:44 +08:00
|
|
|
|
throw new Error(`bind failed: ${raw}`);
|
2026-05-18 16:18:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
const data = (await res.json()) as {
|
|
|
|
|
|
telegram_pricing?: TelegramPricing | null;
|
|
|
|
|
|
};
|
2026-05-18 17:10:44 +08:00
|
|
|
|
if (data.telegram_pricing?.is_group_member) {
|
2026-05-20 11:12:29 +08:00
|
|
|
|
const amount = data.telegram_pricing.amount_usdc || "10";
|
2026-05-18 17:10:44 +08:00
|
|
|
|
setPaymentInfo(`Telegram 群成员验证成功,当前会员价 ${amount}U。`);
|
|
|
|
|
|
}
|
2026-05-18 16:18:26 +08:00
|
|
|
|
await loadSnapshot();
|
|
|
|
|
|
await loadPaymentSnapshot();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setPaymentError(normalizePaymentError(error).message);
|
|
|
|
|
|
}
|
2026-05-18 17:10:44 +08:00
|
|
|
|
})();
|
|
|
|
|
|
}, [isAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]);
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const displayName =
|
|
|
|
|
|
String(user?.user_metadata?.full_name || "").trim() ||
|
|
|
|
|
|
(email ? String(email).split("@")[0] : "") ||
|
2026-03-16 20:30:46 +08:00
|
|
|
|
copy.guestUser;
|
2026-03-13 03:47:56 +08:00
|
|
|
|
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
2026-03-16 20:30:46 +08:00
|
|
|
|
const joinedAt = formatTime(user?.created_at, locale);
|
2026-03-13 07:31:47 +08:00
|
|
|
|
const isSubscribed = Boolean(backend?.subscription_active);
|
2026-03-30 00:58:43 +08:00
|
|
|
|
const planCode = String(backend?.subscription_plan_code || "").trim();
|
|
|
|
|
|
const isTrialPlan = /trial/i.test(planCode);
|
2026-04-13 16:35:22 +08:00
|
|
|
|
const currentExpiryRaw = String(
|
2026-03-13 15:39:25 +08:00
|
|
|
|
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
|
|
|
|
|
).trim();
|
2026-04-13 16:35:22 +08:00
|
|
|
|
const totalExpiryRaw = String(
|
|
|
|
|
|
backend?.subscription_total_expires_at ||
|
|
|
|
|
|
backend?.subscription_expires_at ||
|
|
|
|
|
|
user?.user_metadata?.pro_expiry ||
|
|
|
|
|
|
"",
|
|
|
|
|
|
).trim();
|
|
|
|
|
|
const queuedExtensionDays = Math.max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
Number(backend?.subscription_queued_days || 0),
|
|
|
|
|
|
);
|
|
|
|
|
|
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
2026-05-19 18:26:45 +08:00
|
|
|
|
const canAccessPaidTelegramGroup = Boolean(
|
|
|
|
|
|
isSubscribed && (!isTrialPlan || hasQueuedExtension),
|
|
|
|
|
|
);
|
|
|
|
|
|
const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
|
2026-04-13 16:35:22 +08:00
|
|
|
|
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
2026-05-19 17:47:51 +08:00
|
|
|
|
const reminderExpiryRaw = isSubscribed
|
|
|
|
|
|
? totalExpiryRaw
|
|
|
|
|
|
: currentExpiryRaw || totalExpiryRaw;
|
2026-04-13 16:35:22 +08:00
|
|
|
|
const expiryInfo = parseSubscriptionExpiry(reminderExpiryRaw);
|
|
|
|
|
|
const expiryFormatted = formatTime(displayExpiryRaw, locale);
|
|
|
|
|
|
const currentExpiryFormatted = formatTime(currentExpiryRaw, locale);
|
|
|
|
|
|
const totalExpiryFormatted = formatTime(totalExpiryRaw, locale);
|
2026-03-13 15:39:25 +08:00
|
|
|
|
const proExpiry = isSubscribed
|
|
|
|
|
|
? expiryFormatted !== "--"
|
|
|
|
|
|
? expiryFormatted
|
2026-04-13 16:35:22 +08:00
|
|
|
|
: displayExpiryRaw || copy.proPendingSync
|
2026-03-16 20:30:46 +08:00
|
|
|
|
: copy.noProSubscription;
|
2026-04-19 20:14:07 +08:00
|
|
|
|
const showExpiringSoon = Boolean(
|
|
|
|
|
|
isSubscribed &&
|
2026-05-19 17:47:51 +08:00
|
|
|
|
!hasQueuedExtension &&
|
|
|
|
|
|
expiryInfo &&
|
|
|
|
|
|
!expiryInfo.expired &&
|
|
|
|
|
|
expiryInfo.daysLeft <= 3,
|
|
|
|
|
|
);
|
|
|
|
|
|
const showExpiredReminder = Boolean(
|
|
|
|
|
|
!isSubscribed && expiryInfo && expiryInfo.expired,
|
2026-04-19 20:14:07 +08:00
|
|
|
|
);
|
2026-04-10 09:00:37 +08:00
|
|
|
|
const paymentFeatureReady = paymentReadyForRecovery;
|
|
|
|
|
|
const canOpenCheckoutOverlay = Boolean(
|
|
|
|
|
|
paymentFeatureReady &&
|
2026-05-20 11:27:46 +08:00
|
|
|
|
(!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder),
|
2026-04-10 09:00:37 +08:00
|
|
|
|
);
|
2026-03-30 00:58:43 +08:00
|
|
|
|
const subscriptionStatusTitle = showExpiredReminder
|
2026-05-20 11:12:29 +08:00
|
|
|
|
? copy.proExpiredTitle
|
2026-03-30 00:58:43 +08:00
|
|
|
|
: showExpiringSoon
|
2026-05-20 11:12:29 +08:00
|
|
|
|
? copy.proEndsSoonTitle
|
2026-03-30 00:58:43 +08:00
|
|
|
|
: "";
|
|
|
|
|
|
const subscriptionStatusBody = showExpiredReminder
|
2026-05-20 11:12:29 +08:00
|
|
|
|
? copy.proExpiredBody
|
2026-03-30 00:58:43 +08:00
|
|
|
|
: showExpiringSoon
|
2026-05-20 11:12:29 +08:00
|
|
|
|
? copy.proEndsSoonBody
|
2026-03-30 00:58:43 +08:00
|
|
|
|
: "";
|
|
|
|
|
|
const subscriptionStatusMeta =
|
|
|
|
|
|
expiryInfo && (showExpiringSoon || showExpiredReminder)
|
|
|
|
|
|
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
|
|
|
|
|
|
: "";
|
2026-04-13 16:35:22 +08:00
|
|
|
|
const queuedExtensionSummary = hasQueuedExtension
|
|
|
|
|
|
? copy.queuedExtensionSummary
|
|
|
|
|
|
.replace("{current}", currentExpiryFormatted)
|
|
|
|
|
|
.replace("{days}", String(queuedExtensionDays))
|
|
|
|
|
|
.replace("{total}", totalExpiryFormatted)
|
|
|
|
|
|
: "";
|
|
|
|
|
|
const expiryLabel = hasQueuedExtension ? copy.accessUntil : copy.renewalDate;
|
2026-03-13 02:23:01 +08:00
|
|
|
|
|
2026-03-31 07:15:54 +08:00
|
|
|
|
useEffect(() => {
|
2026-04-10 09:00:37 +08:00
|
|
|
|
if (!showOverlay || !canOpenCheckoutOverlay) return;
|
2026-03-31 07:15:54 +08:00
|
|
|
|
trackAppEvent("paywall_viewed", {
|
|
|
|
|
|
entry: "account_center",
|
|
|
|
|
|
user_state: isAuthenticated ? "logged_in" : "guest",
|
|
|
|
|
|
expired: showExpiredReminder,
|
|
|
|
|
|
expiring_soon: showExpiringSoon,
|
|
|
|
|
|
subscription_plan_code: planCode || null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [
|
|
|
|
|
|
isAuthenticated,
|
2026-05-19 17:47:51 +08:00
|
|
|
|
canOpenCheckoutOverlay,
|
|
|
|
|
|
planCode,
|
|
|
|
|
|
showExpiredReminder,
|
|
|
|
|
|
showExpiringSoon,
|
|
|
|
|
|
showOverlay,
|
|
|
|
|
|
]);
|
2026-03-31 07:15:54 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
// Points Logic
|
2026-03-13 09:50:04 +08:00
|
|
|
|
const backendPointsRaw = Number(backend?.points);
|
|
|
|
|
|
const metadataPointsRaw = Number(
|
2026-03-13 08:15:27 +08:00
|
|
|
|
user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0,
|
|
|
|
|
|
);
|
2026-03-13 09:52:06 +08:00
|
|
|
|
const metadataPointsSafe = Number.isFinite(metadataPointsRaw)
|
|
|
|
|
|
? metadataPointsRaw
|
|
|
|
|
|
: 0;
|
2026-03-13 09:50:04 +08:00
|
|
|
|
const pointsRaw = Number.isFinite(backendPointsRaw)
|
2026-03-13 09:52:06 +08:00
|
|
|
|
? Math.max(backendPointsRaw, metadataPointsSafe)
|
|
|
|
|
|
: metadataPointsSafe;
|
2026-03-13 10:14:13 +08:00
|
|
|
|
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
|
2026-03-13 10:19:59 +08:00
|
|
|
|
const metadataWeeklyPointsRaw = Number(
|
|
|
|
|
|
user?.user_metadata?.weekly_points ?? 0,
|
|
|
|
|
|
);
|
2026-03-13 10:14:13 +08:00
|
|
|
|
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw)
|
|
|
|
|
|
? backendWeeklyPointsRaw
|
|
|
|
|
|
: metadataWeeklyPointsRaw;
|
2026-03-13 10:19:59 +08:00
|
|
|
|
const weeklyRankRaw =
|
|
|
|
|
|
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
|
2026-03-13 07:31:47 +08:00
|
|
|
|
const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0;
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
|
|
|
|
|
? Math.max(0, weeklyPointsRaw)
|
|
|
|
|
|
: 0;
|
2026-03-13 07:31:47 +08:00
|
|
|
|
const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw);
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const planList = paymentConfig?.plans || [];
|
|
|
|
|
|
const monthlyPlanList = planList.filter(
|
|
|
|
|
|
(plan) =>
|
|
|
|
|
|
String(plan.plan_code || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase() === "pro_monthly",
|
|
|
|
|
|
);
|
|
|
|
|
|
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
|
|
|
|
|
|
const selectedPlan =
|
|
|
|
|
|
effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) ||
|
|
|
|
|
|
effectivePlanList[0];
|
2026-03-13 13:58:41 +08:00
|
|
|
|
const availableTokenList: PaymentTokenOption[] = useMemo(() => {
|
|
|
|
|
|
const configured = Array.isArray(paymentConfig?.tokens)
|
|
|
|
|
|
? paymentConfig?.tokens || []
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const clean = configured
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(row) =>
|
|
|
|
|
|
row &&
|
|
|
|
|
|
typeof row.address === "string" &&
|
|
|
|
|
|
row.address.startsWith("0x"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.map((row) => ({
|
|
|
|
|
|
...row,
|
|
|
|
|
|
address: String(row.address).toLowerCase(),
|
|
|
|
|
|
symbol: String(row.symbol || "USDC"),
|
|
|
|
|
|
name: String(row.name || row.symbol || "USDC"),
|
|
|
|
|
|
code: String(row.code || "usdc"),
|
|
|
|
|
|
decimals: Number.isFinite(Number(row.decimals))
|
|
|
|
|
|
? Number(row.decimals)
|
|
|
|
|
|
: Number(paymentConfig?.token_decimals ?? 6),
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (clean.length) return clean;
|
2026-03-13 15:39:25 +08:00
|
|
|
|
const fallbackAddress = String(
|
|
|
|
|
|
paymentConfig?.token_address || "",
|
|
|
|
|
|
).toLowerCase();
|
2026-03-13 13:58:41 +08:00
|
|
|
|
if (!fallbackAddress.startsWith("0x")) return [];
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
code: "usdc",
|
|
|
|
|
|
symbol: "USDC",
|
|
|
|
|
|
name: "USDC",
|
|
|
|
|
|
address: fallbackAddress,
|
|
|
|
|
|
decimals: Number(paymentConfig?.token_decimals ?? 6),
|
|
|
|
|
|
receiver_contract: paymentConfig?.receiver_contract,
|
|
|
|
|
|
is_default: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}, [paymentConfig]);
|
|
|
|
|
|
const resolvedSelectedTokenAddress = String(
|
|
|
|
|
|
selectedTokenAddress ||
|
|
|
|
|
|
paymentConfig?.default_token_address ||
|
|
|
|
|
|
availableTokenList.find((row) => row.is_default)?.address ||
|
|
|
|
|
|
availableTokenList[0]?.address ||
|
|
|
|
|
|
paymentConfig?.token_address ||
|
|
|
|
|
|
"",
|
|
|
|
|
|
).toLowerCase();
|
|
|
|
|
|
const selectedPaymentToken =
|
2026-03-13 15:39:25 +08:00
|
|
|
|
availableTokenList.find(
|
|
|
|
|
|
(row) => row.address === resolvedSelectedTokenAddress,
|
|
|
|
|
|
) || availableTokenList[0];
|
2026-03-13 13:58:41 +08:00
|
|
|
|
const selectedTokenLabel =
|
|
|
|
|
|
selectedPaymentToken?.symbol ||
|
2026-03-13 15:39:25 +08:00
|
|
|
|
(resolvedSelectedTokenAddress.startsWith("0x")
|
|
|
|
|
|
? shortAddress(resolvedSelectedTokenAddress)
|
|
|
|
|
|
: "USDC");
|
2026-05-24 18:33:47 +08:00
|
|
|
|
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
|
2026-03-22 13:42:48 +08:00
|
|
|
|
const paymentWalletLabel = String(
|
|
|
|
|
|
selectedWallet ||
|
|
|
|
|
|
walletAddress ||
|
|
|
|
|
|
boundWallets.find((row) => row.is_primary)?.address ||
|
|
|
|
|
|
boundWallets[0]?.address ||
|
|
|
|
|
|
"",
|
|
|
|
|
|
).toLowerCase();
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const hasPayingWallet = Boolean(
|
|
|
|
|
|
String(
|
|
|
|
|
|
selectedWallet || walletAddress || boundWallets[0]?.address || "",
|
|
|
|
|
|
).trim(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-13 07:31:47 +08:00
|
|
|
|
const billing = useMemo(() => {
|
2026-05-18 16:18:26 +08:00
|
|
|
|
const parsedPlanAmount = Number(
|
2026-05-20 11:12:29 +08:00
|
|
|
|
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
|
2026-05-18 16:18:26 +08:00
|
|
|
|
);
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const planAmount =
|
|
|
|
|
|
Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0
|
|
|
|
|
|
? parsedPlanAmount
|
2026-05-20 18:16:27 +08:00
|
|
|
|
: 10;
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
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;
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
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;
|
2026-03-13 10:14:13 +08:00
|
|
|
|
const canRedeem =
|
|
|
|
|
|
pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
planAmount,
|
|
|
|
|
|
pointsEnabled,
|
|
|
|
|
|
pointsPerUsdc,
|
|
|
|
|
|
maxDiscountUsdc,
|
|
|
|
|
|
pointsUsed,
|
|
|
|
|
|
discountAmount: discountUnits,
|
|
|
|
|
|
payAmount: planAmount - (applyDiscount ? discountUnits : 0),
|
|
|
|
|
|
canRedeem,
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [
|
|
|
|
|
|
paymentConfig?.points_redemption,
|
2026-05-18 16:18:26 +08:00
|
|
|
|
backend?.telegram_pricing?.amount_usdc,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
selectedPlan?.amount_usdc,
|
|
|
|
|
|
totalPoints,
|
|
|
|
|
|
usePoints,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const bindCommand = userId
|
|
|
|
|
|
? `/bind ${userId}${email ? ` ${email}` : ""}`
|
|
|
|
|
|
: "/bind <supabase_user_id> <email>";
|
|
|
|
|
|
|
|
|
|
|
|
const handleCopy = (text: string) => {
|
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
|
|
|
|
setCopied(true);
|
|
|
|
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-19 18:07:39 +08:00
|
|
|
|
const openTelegramBotBindLink = async () => {
|
|
|
|
|
|
setTelegramBindOpening(true);
|
|
|
|
|
|
setPaymentError("");
|
2026-05-24 18:12:07 +08:00
|
|
|
|
const popup = window.open("about:blank", "_blank", "noopener,noreferrer");
|
2026-05-19 18:07:39 +08:00
|
|
|
|
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 || "failed to create telegram bind link");
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = (await res.json()) as { bot_url?: string };
|
|
|
|
|
|
const botUrl = String(data.bot_url || "").trim();
|
|
|
|
|
|
if (!botUrl) throw new Error("telegram bind link missing");
|
2026-05-24 18:12:07 +08:00
|
|
|
|
if (popup && !popup.closed) {
|
|
|
|
|
|
popup.location.href = botUrl;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.open(botUrl, "_blank", "noopener,noreferrer");
|
|
|
|
|
|
}
|
2026-05-19 18:26:45 +08:00
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
"已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
|
|
|
|
|
|
);
|
2026-05-19 18:07:39 +08:00
|
|
|
|
} catch (error) {
|
2026-05-24 18:12:07 +08:00
|
|
|
|
if (popup && !popup.closed) popup.close();
|
2026-05-19 18:07:39 +08:00
|
|
|
|
setPaymentError(normalizePaymentError(error).message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setTelegramBindOpening(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
// --- Payment Logic (preserved) ---
|
|
|
|
|
|
|
|
|
|
|
|
const waitForReceipt = async (
|
|
|
|
|
|
txHash: string,
|
2026-05-20 19:38:07 +08:00
|
|
|
|
provider?: EvmProvider,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
timeoutMs = 120000,
|
|
|
|
|
|
pollMs = 3000,
|
|
|
|
|
|
) => {
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const eth = provider || getEvmProvider();
|
2026-03-13 10:24:14 +08:00
|
|
|
|
if (!eth) throw new Error("No EVM wallet provider found");
|
2026-03-13 05:25:46 +08:00
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
while (Date.now() - started < timeoutMs) {
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const receipt = await requestWalletWithTimeout<{ status?: string } | null>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "eth_getTransactionReceipt",
|
|
|
|
|
|
params: [txHash],
|
|
|
|
|
|
},
|
|
|
|
|
|
"查询授权交易确认",
|
|
|
|
|
|
15_000,
|
|
|
|
|
|
);
|
2026-03-13 05:25:46 +08:00
|
|
|
|
if (receipt && receipt.status) {
|
|
|
|
|
|
if (receipt.status === "0x1") return receipt;
|
|
|
|
|
|
throw new Error(`transaction reverted: ${txHash}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`transaction confirmation timeout: ${txHash}`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 10:35:30 +08:00
|
|
|
|
const pollIntentUntilConfirmed = useCallback(
|
|
|
|
|
|
async (
|
|
|
|
|
|
intentId: string,
|
|
|
|
|
|
authHeaders: Record<string, string>,
|
|
|
|
|
|
txHashHint = "",
|
|
|
|
|
|
timeoutMs = 180000,
|
|
|
|
|
|
pollMs = 5000,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const startedAt = Date.now();
|
|
|
|
|
|
const shortTx = shortAddress(txHashHint);
|
|
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
|
|
|
|
const statusRes = await fetch(`/api/payments/intents/${intentId}`, {
|
|
|
|
|
|
method: "GET",
|
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!statusRes.ok) {
|
|
|
|
|
|
if (statusRes.status >= 500 || statusRes.status === 429) {
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const raw = (await statusRes.text()).slice(0, 260);
|
|
|
|
|
|
throw new Error(`query intent failed: ${raw}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const statusJson = (await statusRes.json()) as IntentStatusResponse;
|
|
|
|
|
|
const intent = statusJson.intent || {};
|
|
|
|
|
|
const status = String(intent.status || "").toLowerCase();
|
|
|
|
|
|
const txHash = String(intent.tx_hash || txHashHint || "").toLowerCase();
|
|
|
|
|
|
if (status === "confirmed") {
|
|
|
|
|
|
setPaymentError("");
|
|
|
|
|
|
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
|
2026-03-31 07:15:54 +08:00
|
|
|
|
trackAppEvent("checkout_succeeded", {
|
|
|
|
|
|
entry: "account_center",
|
|
|
|
|
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
|
|
|
|
|
intent_id: intentId,
|
|
|
|
|
|
tx_hash: txHash || null,
|
|
|
|
|
|
});
|
2026-03-14 10:35:30 +08:00
|
|
|
|
await loadSnapshot();
|
|
|
|
|
|
await loadPaymentSnapshot();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-21 12:34:36 +08:00
|
|
|
|
if (
|
|
|
|
|
|
status === "failed" ||
|
|
|
|
|
|
status === "cancelled" ||
|
|
|
|
|
|
status === "expired"
|
|
|
|
|
|
) {
|
2026-03-14 10:35:30 +08:00
|
|
|
|
throw new Error(`payment ${status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
`交易已提交: ${shortTx},正在链上确认(状态: ${status || "submitted"})...`,
|
|
|
|
|
|
);
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error("payment pending timeout");
|
|
|
|
|
|
},
|
2026-03-31 07:15:54 +08:00
|
|
|
|
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
|
2026-03-14 10:35:30 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-13 10:24:14 +08:00
|
|
|
|
const signBindMessage = async (
|
|
|
|
|
|
eth: EvmProvider,
|
|
|
|
|
|
address: string,
|
|
|
|
|
|
message: string,
|
|
|
|
|
|
): Promise<string> => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return (await eth.request({
|
|
|
|
|
|
method: "personal_sign",
|
|
|
|
|
|
params: [message, address],
|
|
|
|
|
|
})) as string;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Some injected wallets still use the reversed param order.
|
|
|
|
|
|
return (await eth.request({
|
|
|
|
|
|
method: "personal_sign",
|
|
|
|
|
|
params: [address, message],
|
|
|
|
|
|
})) as string;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ensureTargetChain = async (
|
|
|
|
|
|
eth: EvmProvider,
|
|
|
|
|
|
targetChainId: number,
|
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
|
const currentChainIdHex = String(
|
2026-05-20 19:38:07 +08:00
|
|
|
|
(await requestWalletWithTimeout<string>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{ method: "eth_chainId" },
|
2026-05-21 12:25:57 +08:00
|
|
|
|
copy.chainReadError,
|
2026-05-20 19:38:07 +08:00
|
|
|
|
)) || "",
|
2026-03-13 10:24:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
2026-03-13 10:52:31 +08:00
|
|
|
|
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase())
|
|
|
|
|
|
return;
|
2026-03-13 10:24:14 +08:00
|
|
|
|
try {
|
2026-05-20 19:38:07 +08:00
|
|
|
|
await requestWalletWithTimeout(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "wallet_switchEthereumChain",
|
|
|
|
|
|
params: [{ chainId: targetChainHex }],
|
|
|
|
|
|
},
|
2026-05-21 12:25:57 +08:00
|
|
|
|
copy.chainSwitchError,
|
2026-05-20 19:38:07 +08:00
|
|
|
|
);
|
2026-03-13 10:24:14 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
const code = Number(err?.code);
|
2026-05-20 19:18:29 +08:00
|
|
|
|
const msg = String(err?.message || "").toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// If the error code indicates the chain is not added (4902), or it's Polygon (137)
|
|
|
|
|
|
if (code === 4902 || targetChainId === 137) {
|
|
|
|
|
|
try {
|
2026-05-20 19:38:07 +08:00
|
|
|
|
await requestWalletWithTimeout(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "wallet_addEthereumChain",
|
|
|
|
|
|
params: [
|
2026-05-20 19:18:29 +08:00
|
|
|
|
{
|
|
|
|
|
|
chainId: "0x89",
|
|
|
|
|
|
chainName: "Polygon Mainnet",
|
|
|
|
|
|
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
|
|
|
|
|
|
rpcUrls: ["https://polygon-rpc.com"],
|
|
|
|
|
|
blockExplorerUrls: ["https://polygonscan.com"],
|
|
|
|
|
|
},
|
2026-05-20 19:38:07 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-05-21 12:25:57 +08:00
|
|
|
|
copy.chainAddPolygon,
|
2026-05-20 19:38:07 +08:00
|
|
|
|
);
|
2026-05-20 19:18:29 +08:00
|
|
|
|
return;
|
|
|
|
|
|
} catch (addErr: any) {
|
|
|
|
|
|
err = addErr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-21 12:25:57 +08:00
|
|
|
|
throw new Error(
|
|
|
|
|
|
`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
|
|
|
|
|
|
);
|
2026-03-13 10:24:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 21:08:57 +08:00
|
|
|
|
const connectAndBindWallet = async (
|
|
|
|
|
|
mode: ProviderMode = "auto",
|
|
|
|
|
|
options: ConnectBindOptions = {},
|
|
|
|
|
|
): Promise<boolean> => {
|
2026-05-23 23:30:48 +08:00
|
|
|
|
clearPaymentMessages();
|
2026-05-24 18:33:47 +08:00
|
|
|
|
if (!paymentHostAllowed) {
|
|
|
|
|
|
setPaymentError(
|
|
|
|
|
|
copy.paymentHostBlocked.replace(
|
|
|
|
|
|
"{host}",
|
|
|
|
|
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
if (!isAuthenticated) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentError(copy.loginBeforeBind);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
return false;
|
2026-03-13 05:13:48 +08:00
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
setPaymentBusy(true);
|
|
|
|
|
|
try {
|
2026-03-21 12:19:53 +08:00
|
|
|
|
const providerSelection = await resolvePaymentProvider(
|
|
|
|
|
|
mode,
|
|
|
|
|
|
selectedInjectedProviderKey,
|
|
|
|
|
|
);
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const eth = providerSelection.provider;
|
|
|
|
|
|
const walletLabel = providerSelection.label;
|
2026-05-20 19:48:13 +08:00
|
|
|
|
const binanceBindHint = walletLabel.toLowerCase().includes("binance")
|
|
|
|
|
|
? " Binance 扩展已绑定;如支付卡住,请优先使用 WalletConnect 扫码支付。"
|
|
|
|
|
|
: "";
|
2026-03-13 16:47:25 +08:00
|
|
|
|
|
2026-03-13 11:51:01 +08:00
|
|
|
|
// Ensure we have a valid token BEFORE opening the wallet modal.
|
|
|
|
|
|
let accessToken: string;
|
|
|
|
|
|
try {
|
|
|
|
|
|
accessToken = await getValidAccessToken();
|
|
|
|
|
|
} catch (tokenErr) {
|
2026-03-13 13:01:57 +08:00
|
|
|
|
setPaymentError(normalizePaymentError(tokenErr).message);
|
2026-03-13 11:51:01 +08:00
|
|
|
|
setPaymentBusy(false);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
return false;
|
2026-03-13 11:51:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
const authHeaders: Record<string, string> = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
Authorization: `Bearer ${accessToken}`,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const accounts = await requestWalletWithTimeout<string[]>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{ method: "eth_requestAccounts" },
|
|
|
|
|
|
"连接绑定钱包",
|
|
|
|
|
|
);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const address = String(accounts?.[0] || "").toLowerCase();
|
2026-03-21 12:34:36 +08:00
|
|
|
|
if (!address)
|
|
|
|
|
|
throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 12:45:39 +08:00
|
|
|
|
const existingWallet = boundWallets.find(
|
|
|
|
|
|
(w) => String(w.address || "").toLowerCase() === address,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (existingWallet) {
|
|
|
|
|
|
setWalletAddress(address);
|
|
|
|
|
|
setSelectedWallet(address);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
setPaymentInfo(
|
2026-05-20 19:48:13 +08:00
|
|
|
|
`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`,
|
2026-03-13 21:08:57 +08:00
|
|
|
|
);
|
2026-03-21 12:40:11 +08:00
|
|
|
|
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
if (options.openOverlayAfterBind) setShowOverlay(true);
|
2026-03-13 12:45:39 +08:00
|
|
|
|
setPaymentBusy(false);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
return true;
|
2026-03-13 12:45:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
setWalletAddress(address);
|
|
|
|
|
|
const challengeRes = await fetch("/api/payments/wallets/challenge", {
|
|
|
|
|
|
method: "POST",
|
2026-03-13 06:41:33 +08:00
|
|
|
|
headers: authHeaders,
|
2026-03-13 05:13:48 +08:00
|
|
|
|
body: JSON.stringify({ address }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!challengeRes.ok) {
|
|
|
|
|
|
const raw = (await challengeRes.text()).slice(0, 300);
|
|
|
|
|
|
throw new Error(`challenge failed: ${raw}`);
|
|
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const challengeJson = (await challengeRes.json()) as {
|
|
|
|
|
|
nonce?: string;
|
|
|
|
|
|
message?: string;
|
|
|
|
|
|
};
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const message = String(challengeJson.message || "");
|
|
|
|
|
|
const nonce = String(challengeJson.nonce || "");
|
2026-03-13 07:31:47 +08:00
|
|
|
|
if (!message || !nonce) throw new Error("challenge payload invalid");
|
|
|
|
|
|
|
2026-03-13 10:24:14 +08:00
|
|
|
|
const signature = await signBindMessage(eth, address, message);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const verifyRes = await fetch("/api/payments/wallets/verify", {
|
|
|
|
|
|
method: "POST",
|
2026-03-13 06:41:33 +08:00
|
|
|
|
headers: authHeaders,
|
2026-03-13 05:13:48 +08:00
|
|
|
|
body: JSON.stringify({ address, nonce, signature }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!verifyRes.ok) {
|
|
|
|
|
|
const raw = (await verifyRes.text()).slice(0, 300);
|
|
|
|
|
|
throw new Error(`verify failed: ${raw}`);
|
|
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 21:08:57 +08:00
|
|
|
|
setPaymentInfo(
|
2026-05-20 19:48:13 +08:00
|
|
|
|
`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`,
|
2026-03-13 21:08:57 +08:00
|
|
|
|
);
|
2026-03-13 16:47:25 +08:00
|
|
|
|
setProviderMode(providerSelection.mode);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
if (options.openOverlayAfterBind) setShowOverlay(true);
|
2026-03-21 12:40:11 +08:00
|
|
|
|
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
return true;
|
2026-03-13 05:13:48 +08:00
|
|
|
|
} catch (error) {
|
2026-03-13 13:01:57 +08:00
|
|
|
|
setPaymentInfo("");
|
|
|
|
|
|
setPaymentError(normalizePaymentError(error).message);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
return false;
|
2026-03-13 05:13:48 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setPaymentBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-16 20:30:46 +08:00
|
|
|
|
const handleUnbindWallet = async (address: string) => {
|
|
|
|
|
|
const target = String(address || "").toLowerCase();
|
|
|
|
|
|
if (!target) return;
|
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
|
setPaymentError(copy.loginBeforeBind);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const confirmed = window.confirm(
|
|
|
|
|
|
copy.unbindConfirm.replace("{address}", shortAddress(target)),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!confirmed) return;
|
|
|
|
|
|
|
|
|
|
|
|
setPaymentBusy(true);
|
2026-05-23 23:30:48 +08:00
|
|
|
|
clearPaymentMessages();
|
2026-03-16 20:30:46 +08:00
|
|
|
|
try {
|
2026-03-17 15:13:57 +08:00
|
|
|
|
// Do not hard-fail on client-side token refresh here.
|
|
|
|
|
|
// The same-origin API route can still authenticate via server-side Supabase session cookies.
|
|
|
|
|
|
const headers = await buildAuthedHeaders(true, false);
|
2026-03-16 20:30:46 +08:00
|
|
|
|
const res = await fetch("/api/payments/wallets", {
|
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
|
headers,
|
|
|
|
|
|
body: JSON.stringify({ address: target }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const raw = await res.text();
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
let detail = raw;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
|
detail = String(parsed?.detail || parsed?.error || raw);
|
2026-03-17 14:18:25 +08:00
|
|
|
|
if (detail.trim().startsWith("{")) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const nested = JSON.parse(detail);
|
|
|
|
|
|
detail = String(nested?.detail || nested?.error || detail);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore nested parse failure
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(detail || `HTTP ${res.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let data: Record<string, unknown> = {};
|
|
|
|
|
|
try {
|
|
|
|
|
|
data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
data = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
const newPrimary = String(data?.new_primary || "").toLowerCase();
|
2026-03-17 13:57:44 +08:00
|
|
|
|
const selectedWalletNorm = String(selectedWallet || "").toLowerCase();
|
|
|
|
|
|
const walletAddressNorm = String(walletAddress || "").toLowerCase();
|
|
|
|
|
|
if (selectedWalletNorm === target) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setSelectedWallet(newPrimary || "");
|
|
|
|
|
|
}
|
2026-03-17 13:57:44 +08:00
|
|
|
|
if (walletAddressNorm === target) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setWalletAddress(newPrimary || "");
|
|
|
|
|
|
}
|
2026-03-17 13:57:44 +08:00
|
|
|
|
setBoundWallets((prev) =>
|
|
|
|
|
|
prev.filter(
|
|
|
|
|
|
(row) => String(row.address || "").toLowerCase() !== String(target),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-03-16 20:30:46 +08:00
|
|
|
|
await loadPaymentSnapshot();
|
|
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
newPrimary
|
2026-03-21 12:34:36 +08:00
|
|
|
|
? copy.unbindDonePrimary.replace(
|
|
|
|
|
|
"{address}",
|
|
|
|
|
|
shortAddress(newPrimary),
|
|
|
|
|
|
)
|
2026-03-16 20:30:46 +08:00
|
|
|
|
: copy.unbindDone,
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const message = normalizePaymentError(error).message;
|
2026-03-17 14:18:25 +08:00
|
|
|
|
const lower = String(message || "").toLowerCase();
|
|
|
|
|
|
if (
|
|
|
|
|
|
lower.includes("unauthorized") ||
|
|
|
|
|
|
lower.includes("session required") ||
|
|
|
|
|
|
lower.includes("401")
|
|
|
|
|
|
) {
|
|
|
|
|
|
setPaymentError(`${copy.unbindFailed}: ${copy.authExpired}`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentError(`${copy.unbindFailed}: ${message}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setPaymentBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const createIntentAndPay = async () => {
|
2026-05-23 23:30:48 +08:00
|
|
|
|
clearPaymentMessages();
|
|
|
|
|
|
clearPaymentState();
|
2026-04-06 20:40:26 +08:00
|
|
|
|
clearStoredPaymentRecovery();
|
2026-03-22 13:42:48 +08:00
|
|
|
|
if (!paymentHostAllowed) {
|
|
|
|
|
|
setPaymentError(
|
|
|
|
|
|
copy.paymentHostBlocked.replace(
|
|
|
|
|
|
"{host}",
|
|
|
|
|
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
if (!isAuthenticated) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentError(copy.loginBeforePay);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!paymentConfig?.configured) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentError(copy.payNotReady);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const fallbackWallet = String(
|
2026-03-13 08:15:27 +08:00
|
|
|
|
selectedWallet || walletAddress || boundWallets[0]?.address || "",
|
|
|
|
|
|
).toLowerCase();
|
2026-03-13 16:47:25 +08:00
|
|
|
|
if (!fallbackWallet) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentError(copy.bindFirstBeforePay);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setPaymentBusy(true);
|
2026-03-13 13:01:57 +08:00
|
|
|
|
let approvedInThisRun = false;
|
2026-03-13 05:13:48 +08:00
|
|
|
|
try {
|
2026-03-21 12:19:53 +08:00
|
|
|
|
const providerSelection = await resolvePaymentProvider(
|
|
|
|
|
|
providerMode,
|
|
|
|
|
|
selectedInjectedProviderKey,
|
|
|
|
|
|
);
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const eth = providerSelection.provider;
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const activeAccounts = await requestWalletWithTimeout<string[]>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{ method: "eth_requestAccounts" },
|
|
|
|
|
|
"连接付款钱包",
|
|
|
|
|
|
);
|
2026-03-13 16:47:25 +08:00
|
|
|
|
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
|
2026-03-21 12:34:36 +08:00
|
|
|
|
if (!activeAddress)
|
|
|
|
|
|
throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
2026-03-13 16:47:25 +08:00
|
|
|
|
|
|
|
|
|
|
const boundAddrSet = new Set(
|
|
|
|
|
|
boundWallets.map((row) => String(row.address || "").toLowerCase()),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (boundAddrSet.size > 0 && !boundAddrSet.has(activeAddress)) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`当前连接钱包 ${shortAddress(activeAddress)} 未绑定,请先绑定该地址后支付。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const payingWallet = boundAddrSet.has(activeAddress)
|
|
|
|
|
|
? activeAddress
|
|
|
|
|
|
: fallbackWallet;
|
|
|
|
|
|
|
|
|
|
|
|
setSelectedWallet(payingWallet);
|
|
|
|
|
|
setProviderMode(providerSelection.mode);
|
|
|
|
|
|
|
2026-03-13 11:51:01 +08:00
|
|
|
|
// Ensure we have a valid token BEFORE switching chain / sending tx.
|
|
|
|
|
|
let accessToken: string;
|
|
|
|
|
|
try {
|
|
|
|
|
|
accessToken = await getValidAccessToken();
|
|
|
|
|
|
} catch (tokenErr) {
|
2026-03-13 13:01:57 +08:00
|
|
|
|
setPaymentError(normalizePaymentError(tokenErr).message);
|
2026-03-13 11:51:01 +08:00
|
|
|
|
setPaymentBusy(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const authHeaders: Record<string, string> = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
Authorization: `Bearer ${accessToken}`,
|
|
|
|
|
|
};
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-21 13:49:08 +08:00
|
|
|
|
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
|
|
|
|
|
|
if (!latestConfig?.enabled || !latestConfig?.configured) {
|
|
|
|
|
|
throw new Error(copy.payNotReady);
|
|
|
|
|
|
}
|
|
|
|
|
|
const expectedReceiver = String(
|
|
|
|
|
|
latestConfig.receiver_contract || "",
|
|
|
|
|
|
).toLowerCase();
|
2026-05-24 18:33:47 +08:00
|
|
|
|
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
|
2026-03-21 13:49:08 +08:00
|
|
|
|
if (
|
|
|
|
|
|
paymentConfig?.receiver_contract &&
|
2026-03-23 21:32:18 +08:00
|
|
|
|
String(paymentConfig.receiver_contract).toLowerCase() !==
|
|
|
|
|
|
expectedReceiver
|
2026-03-21 13:49:08 +08:00
|
|
|
|
) {
|
|
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
`检测到支付配置已更新,已切换到最新地址 ${shortAddress(expectedReceiver)}。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPaymentInfo(`当前收款合约: ${shortAddress(expectedReceiver)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const targetChainId = Number(latestConfig.chain_id || 137);
|
2026-03-13 10:24:14 +08:00
|
|
|
|
await ensureTargetChain(eth, targetChainId);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
|
|
|
|
|
|
const createRes = await fetch("/api/payments/intents", {
|
|
|
|
|
|
method: "POST",
|
2026-03-13 06:41:33 +08:00
|
|
|
|
headers: authHeaders,
|
2026-03-13 05:13:48 +08:00
|
|
|
|
body: JSON.stringify({
|
2026-03-13 06:41:33 +08:00
|
|
|
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
2026-03-13 05:13:48 +08:00
|
|
|
|
payment_mode: "strict",
|
|
|
|
|
|
allowed_wallet: payingWallet,
|
2026-03-13 13:58:41 +08:00
|
|
|
|
token_address: resolvedSelectedTokenAddress || undefined,
|
2026-03-13 08:15:27 +08:00
|
|
|
|
use_points: billing.canRedeem && usePoints,
|
2026-03-13 10:14:13 +08:00
|
|
|
|
points_to_consume:
|
|
|
|
|
|
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
2026-03-22 13:42:48 +08:00
|
|
|
|
metadata: {
|
|
|
|
|
|
source: "account_center",
|
|
|
|
|
|
frontend_host: currentPaymentHost || null,
|
|
|
|
|
|
account_email: email || null,
|
|
|
|
|
|
},
|
2026-03-13 05:13:48 +08:00
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!createRes.ok) {
|
|
|
|
|
|
const raw = (await createRes.text()).slice(0, 350);
|
|
|
|
|
|
throw new Error(`create intent failed: ${raw}`);
|
|
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const created = (await createRes.json()) as CreatedIntent;
|
|
|
|
|
|
const intentId = String(created.intent?.intent_id || "");
|
|
|
|
|
|
const txPayload = created.tx_payload;
|
2026-03-13 08:15:27 +08:00
|
|
|
|
if (!intentId || !txPayload?.to || !txPayload?.data)
|
|
|
|
|
|
throw new Error("intent payload invalid");
|
2026-03-31 07:15:54 +08:00
|
|
|
|
trackAppEvent("checkout_started", {
|
|
|
|
|
|
entry: "account_center",
|
|
|
|
|
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
|
|
|
|
|
intent_id: intentId,
|
|
|
|
|
|
use_points: billing.canRedeem && usePoints,
|
|
|
|
|
|
pay_amount_usd: billing.payAmount,
|
|
|
|
|
|
});
|
2026-03-21 13:49:08 +08:00
|
|
|
|
const intentReceiver = String(txPayload.to || "").toLowerCase();
|
|
|
|
|
|
if (intentReceiver !== expectedReceiver) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`payment receiver changed: expected ${expectedReceiver}, got ${intentReceiver}. 请刷新页面后重试。`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
setLastIntentId(intentId);
|
|
|
|
|
|
|
2026-03-13 05:25:46 +08:00
|
|
|
|
const tokenAddress = String(txPayload.token_address || "").toLowerCase();
|
|
|
|
|
|
const amountUnits = BigInt(String(txPayload.amount_units || "0"));
|
2026-03-13 08:15:27 +08:00
|
|
|
|
if (!tokenAddress.startsWith("0x") || amountUnits <= 0n)
|
|
|
|
|
|
throw new Error("intent token/amount invalid");
|
2026-03-13 13:58:41 +08:00
|
|
|
|
const tokenSymbol = String(
|
|
|
|
|
|
txPayload.token_symbol ||
|
|
|
|
|
|
selectedPaymentToken?.symbol ||
|
|
|
|
|
|
selectedTokenLabel ||
|
|
|
|
|
|
"USDC",
|
|
|
|
|
|
);
|
|
|
|
|
|
const tokenDecimals = Number(
|
|
|
|
|
|
txPayload.token_decimals ??
|
|
|
|
|
|
selectedPaymentToken?.decimals ??
|
2026-03-21 13:49:08 +08:00
|
|
|
|
latestConfig?.token_decimals ??
|
2026-03-13 13:58:41 +08:00
|
|
|
|
6,
|
|
|
|
|
|
);
|
2026-03-13 13:13:51 +08:00
|
|
|
|
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const balanceHex = await requestWalletWithTimeout<string>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "eth_call",
|
|
|
|
|
|
params: [
|
2026-03-13 13:13:51 +08:00
|
|
|
|
{
|
|
|
|
|
|
to: tokenAddress,
|
|
|
|
|
|
data: buildBalanceOfCalldata(payingWallet),
|
|
|
|
|
|
},
|
|
|
|
|
|
"latest",
|
2026-05-20 19:38:07 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
`读取 ${tokenSymbol} 余额`,
|
|
|
|
|
|
);
|
2026-03-13 13:13:51 +08:00
|
|
|
|
const tokenBalance = BigInt(String(balanceHex || "0x0"));
|
|
|
|
|
|
if (tokenBalance < amountUnits) {
|
|
|
|
|
|
const need = formatTokenUnits(amountUnits, tokenDecimals);
|
|
|
|
|
|
const have = formatTokenUnits(tokenBalance, tokenDecimals);
|
|
|
|
|
|
throw new Error(
|
2026-03-13 13:58:41 +08:00
|
|
|
|
`支付代币余额不足:需要 ${need} ${tokenSymbol},当前 ${have} ${tokenSymbol}。请确认你钱包里持有该支付币种。`,
|
2026-03-13 13:13:51 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-13 05:25:46 +08:00
|
|
|
|
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const allowanceHex = await requestWalletWithTimeout<string>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "eth_call",
|
|
|
|
|
|
params: [
|
2026-03-13 08:15:27 +08:00
|
|
|
|
{
|
|
|
|
|
|
to: tokenAddress,
|
|
|
|
|
|
data: buildAllowanceCalldata(payingWallet, txPayload.to),
|
|
|
|
|
|
},
|
|
|
|
|
|
"latest",
|
2026-05-20 19:38:07 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
`读取 ${tokenSymbol} 授权额度`,
|
|
|
|
|
|
);
|
2026-03-13 05:25:46 +08:00
|
|
|
|
const allowance = BigInt(String(allowanceHex || "0x0"));
|
|
|
|
|
|
|
|
|
|
|
|
if (allowance < amountUnits) {
|
2026-03-13 13:58:41 +08:00
|
|
|
|
setPaymentInfo(`检测到授权不足,正在发起 ${tokenSymbol} 授权...`);
|
2026-05-20 19:18:29 +08:00
|
|
|
|
const approveParams: Record<string, any> = {
|
|
|
|
|
|
from: payingWallet,
|
|
|
|
|
|
to: tokenAddress,
|
|
|
|
|
|
data: buildApproveCalldata(txPayload.to, amountUnits),
|
|
|
|
|
|
};
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const approveHash = await requestWalletWithTimeout<string>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "eth_sendTransaction",
|
|
|
|
|
|
params: [approveParams],
|
|
|
|
|
|
},
|
|
|
|
|
|
`发起 ${tokenSymbol} 授权`,
|
|
|
|
|
|
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
|
|
|
|
|
);
|
|
|
|
|
|
await waitForReceipt(String(approveHash || ""), eth);
|
2026-03-13 13:01:57 +08:00
|
|
|
|
approvedInThisRun = true;
|
2026-03-13 13:58:41 +08:00
|
|
|
|
setPaymentInfo(`${tokenSymbol} 授权成功,正在发起支付...`);
|
2026-03-13 05:25:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
setPaymentInfo("授权额度充足,正在发起支付...");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 19:18:29 +08:00
|
|
|
|
const payParams: Record<string, any> = {
|
|
|
|
|
|
from: payingWallet,
|
|
|
|
|
|
to: txPayload.to,
|
|
|
|
|
|
data: txPayload.data,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (txPayload.value && txPayload.value !== "0x0" && txPayload.value !== "0") {
|
|
|
|
|
|
payParams.value = txPayload.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 19:38:07 +08:00
|
|
|
|
const txHash = await requestWalletWithTimeout<string>(
|
|
|
|
|
|
eth,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "eth_sendTransaction",
|
|
|
|
|
|
params: [payParams],
|
|
|
|
|
|
},
|
|
|
|
|
|
"发起支付交易",
|
|
|
|
|
|
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
|
|
|
|
|
);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
const txHashNorm = String(txHash || "").toLowerCase();
|
|
|
|
|
|
setLastTxHash(txHashNorm);
|
2026-04-06 20:40:26 +08:00
|
|
|
|
setLastPaymentStartedAt(Date.now());
|
2026-03-13 05:13:48 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const submitRes = await fetch(
|
|
|
|
|
|
`/api/payments/intents/${intentId}/submit`,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
tx_hash: txHashNorm,
|
|
|
|
|
|
from_address: payingWallet,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
if (!submitRes.ok) {
|
|
|
|
|
|
const raw = (await submitRes.text()).slice(0, 350);
|
2026-05-22 04:44:14 +08:00
|
|
|
|
if (submitRes.status === 409) {
|
|
|
|
|
|
await handleSubmit409(intentId, txHashNorm, raw);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
throw new Error(`submit tx failed: ${raw}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const confirmRes = await fetch(
|
|
|
|
|
|
`/api/payments/intents/${intentId}/confirm`,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
|
body: JSON.stringify({ tx_hash: txHashNorm }),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
if (!confirmRes.ok) {
|
|
|
|
|
|
const raw = (await confirmRes.text()).slice(0, 350);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
const lowerRaw = raw.toLowerCase();
|
|
|
|
|
|
const maybePending =
|
|
|
|
|
|
(confirmRes.status === 404 &&
|
|
|
|
|
|
!lowerRaw.includes("payment intent not found")) ||
|
|
|
|
|
|
confirmRes.status === 408 ||
|
|
|
|
|
|
(confirmRes.status === 409 &&
|
|
|
|
|
|
(lowerRaw.includes("confirmations not enough") ||
|
|
|
|
|
|
lowerRaw.includes("tx indexed partially")));
|
|
|
|
|
|
if (maybePending) {
|
2026-03-14 10:35:30 +08:00
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
|
|
|
|
|
|
);
|
|
|
|
|
|
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
|
|
|
|
|
|
return;
|
2026-03-13 21:08:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`confirm failed: ${raw}`);
|
2026-03-13 05:13:48 +08:00
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 05:13:48 +08:00
|
|
|
|
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
|
2026-03-31 07:15:54 +08:00
|
|
|
|
trackAppEvent("checkout_succeeded", {
|
|
|
|
|
|
entry: "account_center",
|
|
|
|
|
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
|
|
|
|
|
intent_id: intentId,
|
|
|
|
|
|
tx_hash: txHashNorm,
|
|
|
|
|
|
});
|
2026-03-13 05:13:48 +08:00
|
|
|
|
await loadSnapshot();
|
|
|
|
|
|
await loadPaymentSnapshot();
|
|
|
|
|
|
} catch (error) {
|
2026-03-13 13:01:57 +08:00
|
|
|
|
const normalized = normalizePaymentError(error);
|
|
|
|
|
|
if (normalized.pending) {
|
|
|
|
|
|
setPaymentError(normalized.message);
|
|
|
|
|
|
} else if (normalized.userRejected) {
|
|
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
approvedInThisRun
|
2026-03-13 13:58:41 +08:00
|
|
|
|
? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。`
|
2026-03-13 13:01:57 +08:00
|
|
|
|
: "",
|
|
|
|
|
|
);
|
|
|
|
|
|
setPaymentError(normalized.message);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPaymentInfo(
|
2026-03-13 13:58:41 +08:00
|
|
|
|
approvedInThisRun
|
|
|
|
|
|
? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。`
|
|
|
|
|
|
: "",
|
2026-03-13 13:01:57 +08:00
|
|
|
|
);
|
|
|
|
|
|
setPaymentError(normalized.message);
|
|
|
|
|
|
}
|
2026-03-13 05:13:48 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setPaymentBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-18 16:18:26 +08:00
|
|
|
|
const createManualPaymentIntent = async () => {
|
2026-05-23 23:30:48 +08:00
|
|
|
|
clearPaymentMessages();
|
2026-05-18 16:18:26 +08:00
|
|
|
|
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;
|
2026-05-19 17:47:51 +08:00
|
|
|
|
const intentId = String(
|
|
|
|
|
|
created.intent?.intent_id || direct?.intent_id || "",
|
|
|
|
|
|
);
|
2026-05-18 16:18:26 +08:00
|
|
|
|
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
|
|
|
|
|
|
throw new Error("manual payment payload invalid");
|
|
|
|
|
|
}
|
2026-05-24 18:33:47 +08:00
|
|
|
|
assertExpectedPaymentReceiver(
|
|
|
|
|
|
direct.receiver_address,
|
|
|
|
|
|
"manual payment receiver",
|
|
|
|
|
|
);
|
2026-05-18 16:18:26 +08:00
|
|
|
|
setLastIntentId(intentId);
|
|
|
|
|
|
setManualPayment(direct);
|
2026-05-20 08:33:59 +08:00
|
|
|
|
setPaymentMethodTab("manual");
|
2026-05-18 16:18:26 +08:00
|
|
|
|
setShowOverlay(false);
|
|
|
|
|
|
setPaymentInfo(
|
2026-05-20 09:20:07 +08:00
|
|
|
|
`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel} 到 ${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`,
|
2026-05-18 16:18:26 +08:00
|
|
|
|
);
|
|
|
|
|
|
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 () => {
|
2026-05-19 17:47:51 +08:00
|
|
|
|
const txHashNorm = String(manualTxHash || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
const intentId = String(
|
|
|
|
|
|
lastIntentId || manualPayment?.intent_id || "",
|
|
|
|
|
|
).trim();
|
2026-05-18 16:18:26 +08:00
|
|
|
|
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);
|
2026-05-19 17:47:51 +08:00
|
|
|
|
const submitRes = await fetch(
|
|
|
|
|
|
`/api/payments/intents/${intentId}/submit`,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
|
body: JSON.stringify({ tx_hash: txHashNorm }),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-05-18 16:18:26 +08:00
|
|
|
|
if (!submitRes.ok) {
|
|
|
|
|
|
const raw = (await submitRes.text()).slice(0, 350);
|
2026-05-22 04:44:14 +08:00
|
|
|
|
if (submitRes.status === 409) {
|
|
|
|
|
|
await handleSubmit409(intentId, txHashNorm, raw);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-18 16:18:26 +08:00
|
|
|
|
throw new Error(`submit tx failed: ${raw}`);
|
|
|
|
|
|
}
|
2026-05-19 17:47:51 +08:00
|
|
|
|
const confirmRes = await fetch(
|
|
|
|
|
|
`/api/payments/intents/${intentId}/confirm`,
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
|
body: JSON.stringify({ tx_hash: txHashNorm }),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-05-18 16:18:26 +08:00
|
|
|
|
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) {
|
2026-05-19 17:47:51 +08:00
|
|
|
|
setPaymentInfo(
|
|
|
|
|
|
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
|
|
|
|
|
|
);
|
2026-05-18 16:18:26 +08:00
|
|
|
|
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`confirm failed: ${raw}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
setLastTxHash(txHashNorm);
|
|
|
|
|
|
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
|
|
|
|
|
|
setManualPayment(null);
|
|
|
|
|
|
setManualTxHash("");
|
2026-05-22 04:44:14 +08:00
|
|
|
|
setTxValidation({ loading: false, checked: false });
|
2026-05-18 16:18:26 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-22 04:44:14 +08:00
|
|
|
|
const validateTxHash = useCallback(
|
|
|
|
|
|
async (intentId: string, hash: string) => {
|
|
|
|
|
|
const hashNorm = String(hash || "").trim().toLowerCase();
|
|
|
|
|
|
if (!hashNorm.startsWith("0x") || hashNorm.length !== 66) {
|
|
|
|
|
|
setTxValidation({ loading: false, checked: false });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setTxValidation({ loading: true, checked: false });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const headers = await buildAuthedHeaders(true, false);
|
|
|
|
|
|
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers,
|
|
|
|
|
|
body: JSON.stringify({ tx_hash: hashNorm }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = (await res.json()) as {
|
|
|
|
|
|
valid?: boolean;
|
|
|
|
|
|
reason?: string;
|
|
|
|
|
|
detail?: string;
|
|
|
|
|
|
checks?: Record<string, unknown>;
|
|
|
|
|
|
};
|
|
|
|
|
|
setTxValidation({
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
checked: true,
|
|
|
|
|
|
valid: Boolean(json.valid),
|
|
|
|
|
|
reason: json.reason,
|
|
|
|
|
|
detail: json.detail,
|
|
|
|
|
|
checks: json.checks,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setTxValidation({ loading: false, checked: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[buildAuthedHeaders],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-13 07:31:47 +08:00
|
|
|
|
const handleOverlayCheckout = async () => {
|
2026-03-22 13:42:48 +08:00
|
|
|
|
if (!paymentHostAllowed) {
|
|
|
|
|
|
setPaymentError(
|
|
|
|
|
|
copy.paymentHostBlocked.replace(
|
|
|
|
|
|
"{host}",
|
|
|
|
|
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
if (!isAuthenticated) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentError(copy.loginBeforePay);
|
2026-03-13 07:31:47 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!hasPayingWallet) {
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentInfo(copy.openBindFlow);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
const bound = await connectAndBindWallet(providerMode, {
|
|
|
|
|
|
openOverlayAfterBind: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!bound) return;
|
2026-03-16 20:30:46 +08:00
|
|
|
|
setPaymentInfo(copy.walletBoundCreatingOrder);
|
2026-03-13 21:08:57 +08:00
|
|
|
|
await createIntentAndPay();
|
2026-03-13 07:31:47 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await createIntentAndPay();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
// --- Render ---
|
|
|
|
|
|
|
|
|
|
|
|
if (loading && !refreshing) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-screen w-full items-center justify-center bg-[#0b0f1a]">
|
|
|
|
|
|
<div className="flex flex-col items-center gap-4">
|
|
|
|
|
|
<Loader2 className="h-12 w-12 animate-spin text-blue-500" />
|
2026-03-16 20:30:46 +08:00
|
|
|
|
<p className="text-slate-400 font-medium">{copy.loadingAccount}</p>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 02:23:01 +08:00
|
|
|
|
return (
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<div className="min-h-screen w-full bg-[#0b0f1a] text-slate-200 p-4 md:p-8 font-sans relative overflow-hidden flex flex-col items-center">
|
|
|
|
|
|
{/* Aurora Shadows */}
|
|
|
|
|
|
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-blue-600/10 rounded-full blur-[140px] pointer-events-none"></div>
|
|
|
|
|
|
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-purple-600/10 rounded-full blur-[140px] pointer-events-none"></div>
|
2026-03-13 03:47:56 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<header className="w-full max-w-6xl flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8 z-20">
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href="/"
|
|
|
|
|
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-slate-400 hover:text-white transition-all active:scale-90 group"
|
2026-03-16 20:30:46 +08:00
|
|
|
|
title={copy.backHome}
|
|
|
|
|
|
aria-label={copy.backHome}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft
|
|
|
|
|
|
size={20}
|
|
|
|
|
|
className="group-hover:-translate-x-0.5 transition-transform"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{copy.accountCenter}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</h1>
|
2026-03-13 06:41:33 +08:00
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-19 17:47:51 +08:00
|
|
|
|
{!showOverlay && canOpenCheckoutOverlay && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowOverlay(true)}
|
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Crown size={16} />{" "}
|
|
|
|
|
|
{showExpiringSoon || showExpiredReminder
|
|
|
|
|
|
? copy.renewNow
|
|
|
|
|
|
: copy.upgradePro}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void onRefresh()}
|
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm transition-all disabled:opacity-50"
|
|
|
|
|
|
disabled={refreshing}
|
|
|
|
|
|
>
|
|
|
|
|
|
{refreshing ? (
|
|
|
|
|
|
<RefreshCw size={16} className="animate-spin" />
|
2026-03-13 03:27:56 +08:00
|
|
|
|
) : (
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<RefreshCw size={16} />
|
|
|
|
|
|
)}{" "}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{copy.refresh}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
{isAuthenticated ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void onSignOut()}
|
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 text-red-400 rounded-xl text-sm transition-all"
|
|
|
|
|
|
>
|
2026-03-16 20:30:46 +08:00
|
|
|
|
<LogOut size={16} /> {copy.signOut}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href="/auth/login?next=%2Faccount"
|
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 text-blue-400 rounded-xl text-sm transition-all"
|
|
|
|
|
|
>
|
2026-03-16 20:30:46 +08:00
|
|
|
|
<LogIn size={16} /> {copy.signIn}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</Link>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6 z-10 relative">
|
2026-03-30 00:58:43 +08:00
|
|
|
|
{(showExpiringSoon || showExpiredReminder) && (
|
|
|
|
|
|
<div className="lg:col-span-12 rounded-[2rem] border border-amber-400/30 bg-amber-500/10 px-6 py-5 shadow-xl">
|
|
|
|
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-bold text-amber-300">
|
|
|
|
|
|
<Crown size={16} />
|
|
|
|
|
|
<span>{subscriptionStatusTitle}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-1 text-sm text-amber-50/90">
|
|
|
|
|
|
{subscriptionStatusBody}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{subscriptionStatusMeta ? (
|
|
|
|
|
|
<p className="mt-1 text-xs text-amber-200/80">
|
|
|
|
|
|
{subscriptionStatusMeta}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-04-10 09:00:37 +08:00
|
|
|
|
{billing.canRedeem ? (
|
|
|
|
|
|
<p className="mt-2 text-xs text-emerald-200/90">
|
2026-05-19 17:47:51 +08:00
|
|
|
|
当前可用 {billing.pointsUsed} 积分抵扣 $
|
|
|
|
|
|
{billing.discountAmount.toFixed(2)}, 续费时会自动生效。
|
2026-04-10 09:00:37 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-03-30 00:58:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowOverlay(true)}
|
|
|
|
|
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-amber-300/35 bg-amber-300/12 px-4 py-2 text-sm font-bold text-amber-100 transition-all hover:bg-amber-300/20"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Crown size={16} />
|
|
|
|
|
|
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
{/* User Card */}
|
|
|
|
|
|
<div className="lg:col-span-8 bg-white/5 backdrop-blur-xl border border-white/10 rounded-[2.5rem] p-8 shadow-2xl flex flex-col md:flex-row items-center gap-8">
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<div className="w-24 h-24 rounded-3xl bg-gradient-to-tr from-blue-600 to-indigo-400 flex items-center justify-center text-3xl font-bold text-white shadow-xl shadow-blue-500/30">
|
|
|
|
|
|
{initials}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`absolute -bottom-2 -right-2 p-1.5 rounded-xl border-4 border-[#0b0f1a] ${isSubscribed ? "bg-yellow-500 text-black" : "bg-slate-700 text-slate-400"}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Crown size={16} fill="currentColor" />
|
|
|
|
|
|
</div>
|
2026-03-13 02:23:01 +08:00
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<div className="flex-grow text-center md:text-left">
|
|
|
|
|
|
<div className="flex items-center justify-center md:justify-start gap-3 mb-1">
|
|
|
|
|
|
<h2 className="text-3xl font-bold text-white">{displayName}</h2>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
|
|
|
|
|
|
>
|
2026-03-30 00:58:43 +08:00
|
|
|
|
{isSubscribed
|
2026-05-20 11:12:29 +08:00
|
|
|
|
? copy.proMember
|
2026-03-30 00:58:43 +08:00
|
|
|
|
: copy.freeTier}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-slate-500 font-mono text-sm mb-4">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{email || copy.guestUser}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="flex flex-wrap justify-center md:justify-start gap-4">
|
|
|
|
|
|
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
|
|
|
|
|
|
<Hash size={14} />{" "}
|
|
|
|
|
|
<span className="font-mono">
|
|
|
|
|
|
{userId ? `${userId.substring(0, 12)}...` : "--"}
|
|
|
|
|
|
</span>
|
2026-03-13 07:31:47 +08:00
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
<Clock size={14} />{" "}
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{copy.joinedAt}: {joinedAt}
|
|
|
|
|
|
</span>
|
2026-03-13 07:31:47 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
|
<div className="px-6 py-4 bg-black/40 rounded-2xl border border-white/5 text-center min-w-[140px]">
|
|
|
|
|
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest mb-1">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{copy.totalPoints}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
|
|
|
|
|
<Coins size={16} className="text-yellow-500" />{" "}
|
|
|
|
|
|
{totalPoints.toLocaleString()}
|
|
|
|
|
|
</p>
|
2026-03-13 07:31:47 +08:00
|
|
|
|
</div>
|
2026-03-23 21:32:18 +08:00
|
|
|
|
<div className="px-6 py-4 bg-emerald-500/10 rounded-2xl border border-emerald-500/20 text-center min-w-[140px]">
|
|
|
|
|
|
<p className="text-[10px] text-emerald-300 uppercase tracking-widest mb-1 font-bold">
|
|
|
|
|
|
{copy.weeklyPoints}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
|
|
|
|
|
<TrendingUp size={16} className="text-emerald-400" />{" "}
|
|
|
|
|
|
{weeklyPoints.toLocaleString()}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<div className="px-6 py-4 bg-blue-500/10 rounded-2xl border border-blue-500/20 text-center min-w-[140px]">
|
|
|
|
|
|
<p className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-bold">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{copy.weeklyRank}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
2026-03-23 21:32:18 +08:00
|
|
|
|
<Trophy size={16} className="text-amber-400" />{" "}
|
|
|
|
|
|
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
{/* Weekly Ranking Motivation */}
|
2026-03-18 20:38:43 +08:00
|
|
|
|
{showSecondarySections ? (
|
|
|
|
|
|
<div className="lg:col-span-4 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 border border-indigo-500/30 rounded-[2.5rem] p-6 flex flex-col justify-between shadow-xl">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
|
|
|
|
|
|
<Sparkles size={20} className="text-yellow-400" />{" "}
|
|
|
|
|
|
{copy.weeklyRewards}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
|
|
|
|
|
<span className="text-sm flex items-center gap-2">
|
|
|
|
|
|
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
|
|
|
|
|
1
|
|
|
|
|
|
</div>{" "}
|
|
|
|
|
|
Top 1
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs font-bold text-yellow-500">
|
2026-05-11 13:35:52 +08:00
|
|
|
|
+200 积分 & 7天Pro
|
2026-03-18 20:38:43 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
|
|
|
|
|
<span className="text-sm flex items-center gap-2">
|
|
|
|
|
|
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
|
|
|
|
|
2
|
|
|
|
|
|
</div>{" "}
|
|
|
|
|
|
Top 2-3
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs font-bold text-slate-300">
|
2026-05-20 11:12:29 +08:00
|
|
|
|
+100 积分
|
2026-03-18 20:38:43 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
|
|
|
|
|
<span className="text-sm flex items-center gap-2">
|
|
|
|
|
|
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
|
|
|
|
|
|
4
|
|
|
|
|
|
</div>{" "}
|
|
|
|
|
|
Top 4-10
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs font-bold text-orange-400">
|
2026-05-11 13:35:52 +08:00
|
|
|
|
+50 积分
|
2026-03-18 20:38:43 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-18 20:38:43 +08:00
|
|
|
|
<div className="mt-6 flex items-start gap-2 p-3 bg-black/20 rounded-xl">
|
|
|
|
|
|
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
|
|
|
|
|
|
<p className="text-[10px] text-slate-500 leading-normal italic">
|
2026-05-19 17:47:51 +08:00
|
|
|
|
积分规则:群内有效发言(自动防刷检测)+
|
|
|
|
|
|
每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。
|
2026-03-18 20:38:43 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
2026-03-18 20:38:43 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="lg:col-span-4 rounded-[2.5rem] border border-white/10 bg-white/5 p-6">
|
|
|
|
|
|
<div className="h-6 w-40 animate-pulse rounded bg-slate-800/80" />
|
|
|
|
|
|
<div className="mt-4 space-y-2">
|
|
|
|
|
|
<div className="h-12 animate-pulse rounded-xl bg-slate-800/60" />
|
|
|
|
|
|
<div className="h-12 animate-pulse rounded-xl bg-slate-800/60" />
|
|
|
|
|
|
<div className="h-12 animate-pulse rounded-xl bg-slate-800/60" />
|
|
|
|
|
|
</div>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
2026-03-18 20:38:43 +08:00
|
|
|
|
)}
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
{/* Subscription Info & Paywall */}
|
|
|
|
|
|
<div className="lg:col-span-12 relative">
|
2026-05-19 17:47:51 +08:00
|
|
|
|
<div
|
2026-04-10 09:00:37 +08:00
|
|
|
|
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
|
2026-05-19 17:47:51 +08:00
|
|
|
|
>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
|
|
|
|
|
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{copy.membershipDetails}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</h3>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={ShieldCheck}
|
|
|
|
|
|
label={copy.authMode}
|
|
|
|
|
|
value="Supabase"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={BarChart3}
|
|
|
|
|
|
label={copy.weatherEngine}
|
|
|
|
|
|
value="DEB + 多模型"
|
|
|
|
|
|
/>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={Zap}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
label={copy.intradayAnalysis}
|
|
|
|
|
|
value={isSubscribed ? copy.deepMode : copy.compactVisible}
|
2026-03-13 15:39:25 +08:00
|
|
|
|
isPrimary={isSubscribed}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={Clock}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
label={copy.historyFuture}
|
|
|
|
|
|
value={isSubscribed ? copy.enabled : copy.locked}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
isPrimary={isSubscribed}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
2026-03-13 15:39:25 +08:00
|
|
|
|
icon={Bot}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
label={copy.smartPush}
|
|
|
|
|
|
value={isSubscribed ? copy.enabled : copy.locked}
|
2026-03-13 15:39:25 +08:00
|
|
|
|
isPrimary={isSubscribed}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
|
|
|
|
|
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-widest mb-4">
|
2026-03-16 20:30:46 +08:00
|
|
|
|
{copy.identityStatus}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</h3>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={Mail}
|
|
|
|
|
|
label={copy.boundEmail}
|
|
|
|
|
|
value={email || "--"}
|
|
|
|
|
|
/>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={LogIn}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
label={copy.loginMethod}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
value={user?.app_metadata?.provider?.toUpperCase() || "GOOGLE"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={Clock}
|
2026-04-13 16:35:22 +08:00
|
|
|
|
label={expiryLabel}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
value={proExpiry}
|
|
|
|
|
|
isPrimary
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={UserCheck}
|
2026-03-16 20:30:46 +08:00
|
|
|
|
label={copy.authResult}
|
|
|
|
|
|
value={backend?.authenticated ? copy.passed : copy.restricted}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
/>
|
2026-04-13 16:35:22 +08:00
|
|
|
|
{queuedExtensionSummary ? (
|
|
|
|
|
|
<p className="rounded-2xl border border-cyan-400/20 bg-cyan-500/10 px-4 py-3 text-xs text-cyan-100">
|
|
|
|
|
|
{queuedExtensionSummary}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
2026-03-13 07:31:47 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
{/* Paywall Mask */}
|
2026-04-10 09:00:37 +08:00
|
|
|
|
{canOpenCheckoutOverlay && showOverlay && (
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center p-4">
|
2026-03-13 08:51:06 +08:00
|
|
|
|
<UnlockProOverlay
|
|
|
|
|
|
points={totalPoints}
|
|
|
|
|
|
planPriceUsd={billing.planAmount}
|
|
|
|
|
|
usePoints={usePoints}
|
|
|
|
|
|
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
|
|
|
|
|
|
billing={{
|
|
|
|
|
|
pointsEnabled: billing.pointsEnabled,
|
|
|
|
|
|
isEligible: billing.canRedeem,
|
|
|
|
|
|
pointsUsed: billing.pointsUsed,
|
|
|
|
|
|
discountAmount: billing.discountAmount,
|
|
|
|
|
|
finalPrice: billing.payAmount,
|
|
|
|
|
|
maxDiscountUsd: billing.maxDiscountUsdc,
|
|
|
|
|
|
pointsPerUsd: billing.pointsPerUsdc,
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPay={() => void handleOverlayCheckout()}
|
2026-05-18 16:18:26 +08:00
|
|
|
|
onManualPay={() => void createManualPaymentIntent()}
|
2026-03-13 08:51:06 +08:00
|
|
|
|
onClose={() => setShowOverlay(false)}
|
|
|
|
|
|
payBusy={paymentBusy}
|
2026-03-21 12:34:36 +08:00
|
|
|
|
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
|
2026-05-18 16:18:26 +08:00
|
|
|
|
manualPayLabel="手动转账"
|
2026-03-13 08:51:06 +08:00
|
|
|
|
errorText={paymentError || undefined}
|
|
|
|
|
|
infoText={paymentInfo || undefined}
|
2026-03-13 13:13:51 +08:00
|
|
|
|
txHash={lastTxHash || undefined}
|
|
|
|
|
|
chainId={paymentConfig?.chain_id || 137}
|
2026-03-13 13:58:41 +08:00
|
|
|
|
paymentTokenLabel={selectedTokenLabel}
|
2026-03-13 20:38:10 +08:00
|
|
|
|
faqHref={SUBSCRIPTION_HELP_HREF}
|
2026-05-19 18:26:45 +08:00
|
|
|
|
telegramGroupUrl=""
|
2026-03-13 08:51:06 +08:00
|
|
|
|
/>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-13 03:47:56 +08:00
|
|
|
|
|
2026-05-20 09:59:27 +08:00
|
|
|
|
{/* Telegram Bot Section & Payment Details */}
|
|
|
|
|
|
{showSecondarySections ? (
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
|
2026-05-20 09:59:27 +08:00
|
|
|
|
{canAccessPaidTelegramGroup && (
|
|
|
|
|
|
<section className="flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group">
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<Bot
|
|
|
|
|
|
size={140}
|
|
|
|
|
|
className="absolute -right-8 -bottom-8 text-white/5 -rotate-12 group-hover:rotate-0 transition-transform duration-1000"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="relative z-10">
|
|
|
|
|
|
<h3 className="text-lg font-bold mb-2 flex items-center gap-2 text-blue-400">
|
|
|
|
|
|
<Bot size={22} /> {copy.telegramBind}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-slate-400 text-sm mb-6">
|
|
|
|
|
|
{copy.telegramHint}
|
|
|
|
|
|
</p>
|
2026-05-17 17:05:47 +08:00
|
|
|
|
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<div className="mb-4 flex flex-wrap gap-2">
|
2026-05-19 18:07:39 +08:00
|
|
|
|
{TELEGRAM_TOPICS_GROUP_URL &&
|
2026-05-19 18:26:45 +08:00
|
|
|
|
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL &&
|
|
|
|
|
|
telegramBound ? (
|
2026-05-19 17:15:16 +08:00
|
|
|
|
<Link
|
|
|
|
|
|
href={TELEGRAM_TOPICS_GROUP_URL}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
|
|
|
|
|
|
>
|
|
|
|
|
|
{copy.telegramTopicsGroupLink}
|
|
|
|
|
|
<ExternalLink size={12} />
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
) : null}
|
2026-05-19 18:26:45 +08:00
|
|
|
|
{TELEGRAM_GROUP_URL && telegramBound ? (
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<Link
|
|
|
|
|
|
href={TELEGRAM_GROUP_URL}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
2026-04-29 12:40:37 +08:00
|
|
|
|
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-blue-400/30 bg-blue-500/10 px-3 py-2 text-xs font-semibold text-blue-200 hover:bg-blue-500/20"
|
2026-03-21 12:34:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
{copy.telegramGroupLink}
|
|
|
|
|
|
<ExternalLink size={12} />
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
) : null}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</div>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
|
|
|
|
{bindCommand}
|
2026-03-13 18:06:08 +08:00
|
|
|
|
</code>
|
2026-05-19 18:07:39 +08:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void openTelegramBotBindLink()}
|
|
|
|
|
|
disabled={telegramBindOpening || !isAuthenticated}
|
|
|
|
|
|
className="px-4 py-3 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg text-white text-xs font-bold"
|
|
|
|
|
|
title={copy.telegramBotBindLink}
|
|
|
|
|
|
aria-label={copy.telegramBotBindLink}
|
|
|
|
|
|
>
|
|
|
|
|
|
{telegramBindOpening ? "..." : copy.telegramBotBindLink}
|
|
|
|
|
|
</button>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleCopy(bindCommand)}
|
|
|
|
|
|
className="p-4 bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg text-white"
|
|
|
|
|
|
title={copy.copyCommand}
|
|
|
|
|
|
aria-label={copy.copyCommand}
|
|
|
|
|
|
>
|
|
|
|
|
|
{copied ? <CheckCircle2 size={20} /> : <Copy size={20} />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-05-19 18:07:39 +08:00
|
|
|
|
<p className="mt-2 text-[11px] leading-5 text-slate-400">
|
|
|
|
|
|
{copy.telegramFallbackHint}
|
|
|
|
|
|
</p>
|
2026-03-22 20:24:48 +08:00
|
|
|
|
<div className="mt-5 rounded-2xl border border-amber-400/25 bg-amber-500/8 px-4 py-3 text-xs leading-6 text-amber-100/90">
|
|
|
|
|
|
{copy.paymentManualSupport}
|
|
|
|
|
|
</div>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-05-20 09:59:27 +08:00
|
|
|
|
)}
|
2026-03-21 12:34:36 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Payment Details / Wallet Management */}
|
2026-05-20 09:59:27 +08:00
|
|
|
|
<section className={`bg-white/5 border border-white/10 rounded-[2rem] p-8 flex flex-col justify-between ${
|
|
|
|
|
|
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
|
|
|
|
|
|
}`}>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-blue-400 text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
|
|
|
|
|
|
<Wallet size={18} /> {copy.paymentMgmt}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
{paymentError ? (
|
|
|
|
|
|
<div className="mb-4 rounded-xl border border-red-400/40 bg-red-500/10 px-3 py-2 text-[11px] text-red-200">
|
|
|
|
|
|
{paymentError}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{!paymentError && paymentInfo ? (
|
|
|
|
|
|
<div className="mb-4 rounded-xl border border-cyan-400/35 bg-cyan-500/10 px-3 py-2 text-[11px] text-cyan-200">
|
|
|
|
|
|
{paymentInfo}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-03-22 13:42:48 +08:00
|
|
|
|
{!paymentHostAllowed ? (
|
|
|
|
|
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
|
|
|
|
|
|
{copy.paymentHostBlocked.replace(
|
|
|
|
|
|
"{host}",
|
|
|
|
|
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<div className="mb-5 space-y-3">
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={Mail}
|
|
|
|
|
|
label={copy.paymentAccount}
|
|
|
|
|
|
value={email || "--"}
|
|
|
|
|
|
isPrimary
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={Wallet}
|
|
|
|
|
|
label={copy.paymentWallet}
|
|
|
|
|
|
value={shortAddress(paymentWalletLabel) || "--"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={ShieldCheck}
|
|
|
|
|
|
label={copy.paymentReceiver}
|
|
|
|
|
|
value={shortAddress(paymentReceiverAddress) || "--"}
|
|
|
|
|
|
/>
|
2026-05-21 12:54:18 +08:00
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={ExternalLink}
|
|
|
|
|
|
label={copy.paymentNetwork}
|
|
|
|
|
|
value={chainIdToDisplayName(paymentConfig?.chain_id)}
|
|
|
|
|
|
/>
|
2026-03-22 13:42:48 +08:00
|
|
|
|
<InfoRow
|
|
|
|
|
|
icon={ExternalLink}
|
|
|
|
|
|
label={copy.paymentHost}
|
|
|
|
|
|
value={currentPaymentHost || "--"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-[11px] text-slate-500">
|
|
|
|
|
|
{copy.paymentGuardHint}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
{availableTokenList.length > 0 && (
|
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
|
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
|
|
|
|
|
|
{copy.paymentToken}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
{availableTokenList.map((token) => {
|
|
|
|
|
|
const active =
|
|
|
|
|
|
token.address ===
|
|
|
|
|
|
(resolvedSelectedTokenAddress || token.address);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
key={token.address}
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setSelectedTokenAddress(token.address)
|
|
|
|
|
|
}
|
|
|
|
|
|
disabled={paymentBusy}
|
|
|
|
|
|
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
|
|
|
|
|
active
|
|
|
|
|
|
? "bg-blue-500/15 border-blue-500/40 text-white"
|
|
|
|
|
|
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-xs font-bold">
|
|
|
|
|
|
{token.symbol}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-[10px] opacity-80 truncate">
|
|
|
|
|
|
{token.name}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 支付方式选择 Tabs */}
|
|
|
|
|
|
<div className="mt-6 border-t border-white/10 pt-6">
|
|
|
|
|
|
<p className="text-[10px] uppercase tracking-widest text-slate-500 mb-3 font-semibold">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentMethodLabel}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2 p-1 rounded-xl bg-black/40 border border-white/5 mb-5">
|
2026-05-18 16:18:26 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-05-20 08:33:59 +08:00
|
|
|
|
onClick={() => setPaymentMethodTab("wallet")}
|
|
|
|
|
|
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
|
|
|
|
|
|
paymentMethodTab === "wallet"
|
|
|
|
|
|
? "bg-blue-600/95 text-white shadow-lg"
|
|
|
|
|
|
: "text-slate-400 hover:text-slate-200 hover:bg-white/5"
|
|
|
|
|
|
}`}
|
2026-05-18 16:18:26 +08:00
|
|
|
|
>
|
2026-05-20 08:33:59 +08:00
|
|
|
|
<Wallet size={12} />
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentMethodWallet}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setPaymentMethodTab("manual")}
|
|
|
|
|
|
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
|
|
|
|
|
|
paymentMethodTab === "manual"
|
|
|
|
|
|
? "bg-emerald-600/95 text-white shadow-lg"
|
|
|
|
|
|
: "text-slate-400 hover:text-slate-200 hover:bg-white/5"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExternalLink size={12} />
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentMethodManual}
|
2026-05-18 16:18:26 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
|
2026-05-20 08:33:59 +08:00
|
|
|
|
{paymentMethodTab === "wallet" ? (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<p className="text-[11px] leading-relaxed text-slate-400">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentWalletDesc}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</p>
|
2026-05-20 20:00:32 +08:00
|
|
|
|
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-3 text-[11px] leading-relaxed text-amber-100">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentGasWarning}
|
2026-05-20 20:00:32 +08:00
|
|
|
|
</div>
|
2026-05-20 08:33:59 +08:00
|
|
|
|
|
|
|
|
|
|
{boundWallets.length ? (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{boundWallets.map((w) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={w.address}
|
|
|
|
|
|
className={`p-3 rounded-xl border transition-all ${
|
|
|
|
|
|
selectedWallet === w.address
|
|
|
|
|
|
? "bg-blue-500/10 border-blue-500/30 text-white"
|
|
|
|
|
|
: "bg-white/5 border-white/5 text-slate-400"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
|
<span className="text-[10px] font-mono">
|
|
|
|
|
|
{shortAddress(w.address)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{w.is_primary && (
|
|
|
|
|
|
<span className="text-[8px] bg-blue-500 px-1 rounded text-white font-semibold">
|
|
|
|
|
|
{copy.primary}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-[10px]">{copy.polygonChain}</div>
|
|
|
|
|
|
<div className="mt-2 flex justify-end">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void handleUnbindWallet(w.address)}
|
|
|
|
|
|
disabled={paymentBusy}
|
|
|
|
|
|
className="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-[10px] font-semibold text-red-300 transition-all hover:bg-red-500/20 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Minus size={12} />
|
|
|
|
|
|
{copy.unbind}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="p-4 rounded-xl border border-white/5 bg-white/5 text-center">
|
|
|
|
|
|
<p className="text-xs text-slate-400 italic">
|
|
|
|
|
|
{copy.noWallet}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-2 pt-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");
|
|
|
|
|
|
void connectAndBindWallet("auto");
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={paymentBusy || !isAuthenticated}
|
|
|
|
|
|
className="w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
|
2026-03-21 12:34:36 +08:00
|
|
|
|
>
|
2026-05-20 08:33:59 +08:00
|
|
|
|
<PlusIcon className="w-4 h-4" /> {copy.bindExt}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setProviderMode("walletconnect");
|
|
|
|
|
|
void connectAndBindWallet("walletconnect");
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
paymentBusy || !isAuthenticated || !walletConnectEnabled
|
|
|
|
|
|
}
|
|
|
|
|
|
className="w-full py-3 border border-cyan-400/30 bg-cyan-500/10 hover:bg-cyan-500/20 rounded-xl text-xs font-bold text-cyan-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CreditCard className="w-4 h-4" /> {copy.bindQr}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{!walletConnectEnabled && (
|
|
|
|
|
|
<p className="text-[11px] text-slate-500 text-center mt-1">
|
|
|
|
|
|
{copy.walletConnectMissing}
|
|
|
|
|
|
<code className="mx-1">
|
|
|
|
|
|
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
|
|
|
|
|
</code>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<p className="text-[11px] leading-relaxed text-slate-400">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentManualDesc}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-500/8 p-4">
|
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs font-bold text-emerald-200">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentManualTitle}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p className="mt-1 text-[11px] leading-relaxed text-emerald-100/75">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentManualHint}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void createManualPaymentIntent()}
|
|
|
|
|
|
disabled={paymentBusy || !isAuthenticated}
|
|
|
|
|
|
className="w-full rounded-xl border border-emerald-400/35 bg-emerald-500/15 py-2.5 text-xs font-bold text-emerald-100 transition-all hover:bg-emerald-500/25 disabled:opacity-50"
|
|
|
|
|
|
>
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentManualCreate}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{manualPayment ? (
|
|
|
|
|
|
<div className="mt-4 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">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentAmount}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</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">
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentReceiverLabel}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="mt-1 flex gap-2">
|
2026-05-20 09:05:14 +08:00
|
|
|
|
<code className="min-w-0 flex-1 break-all whitespace-normal rounded-lg bg-black/40 px-2 py-2 font-mono text-[11px] text-blue-200">
|
2026-05-20 08:33:59 +08:00
|
|
|
|
{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 transition-colors hover:bg-blue-500"
|
|
|
|
|
|
>
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentCopyAddress}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-[10px] uppercase tracking-widest text-slate-500">
|
|
|
|
|
|
Tx Hash
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={manualTxHash}
|
2026-05-22 04:44:14 +08:00
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setManualTxHash(event.target.value);
|
|
|
|
|
|
void validateTxHash(
|
|
|
|
|
|
manualPayment.intent_id ||
|
|
|
|
|
|
lastIntentId ||
|
|
|
|
|
|
"",
|
|
|
|
|
|
event.target.value,
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2026-05-22 04:44:14 +08:00
|
|
|
|
{txValidation.loading ? (
|
|
|
|
|
|
<p className="mt-1 text-[10px] text-slate-500">
|
|
|
|
|
|
验证中...
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : txValidation.checked && txValidation.valid ? (
|
|
|
|
|
|
<p className="mt-1 text-[10px] text-emerald-400">
|
|
|
|
|
|
收款地址和金额匹配
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : txValidation.checked &&
|
|
|
|
|
|
txValidation.valid === false ? (
|
|
|
|
|
|
<p className="mt-1 text-[10px] text-red-400">
|
|
|
|
|
|
{txValidation.reason ===
|
|
|
|
|
|
"tx_not_mined"
|
|
|
|
|
|
? "交易未上链,请等待"
|
|
|
|
|
|
: txValidation.reason === "receiver_mismatch"
|
|
|
|
|
|
? "收款地址不匹配!请检查是否转到了正确的地址"
|
|
|
|
|
|
: txValidation.reason ===
|
|
|
|
|
|
"amount_insufficient"
|
|
|
|
|
|
? "转账金额不足"
|
|
|
|
|
|
: txValidation.reason === "tx_reverted"
|
|
|
|
|
|
? "该交易已回滚"
|
|
|
|
|
|
: txValidation.detail ||
|
|
|
|
|
|
"验证失败: " +
|
|
|
|
|
|
(txValidation.reason ||
|
|
|
|
|
|
"未知错误")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void submitManualPaymentTx()}
|
2026-05-22 04:44:14 +08:00
|
|
|
|
disabled={
|
|
|
|
|
|
paymentBusy ||
|
|
|
|
|
|
(txValidation.checked && !txValidation.valid)
|
|
|
|
|
|
}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-05-21 12:25:57 +08:00
|
|
|
|
{copy.paymentManualSubmit}
|
2026-05-20 08:33:59 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-21 12:34:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
2026-03-18 20:38:43 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="lg:col-span-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
|
|
|
|
<div className="md:col-span-2 h-48 animate-pulse rounded-[2rem] border border-white/10 bg-white/5" />
|
|
|
|
|
|
<div className="h-48 animate-pulse rounded-[2rem] border border-white/10 bg-white/5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<footer className="mt-16 text-center text-slate-600 text-[10px] uppercase tracking-[0.3em] font-mono z-10 pb-8">
|
|
|
|
|
|
PolyWeather Global Meteorological Engine · Powered by AI
|
|
|
|
|
|
</footer>
|
2026-03-13 03:47:56 +08:00
|
|
|
|
</div>
|
2026-03-13 02:23:01 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-13 08:15:27 +08:00
|
|
|
|
|