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 08:24:18 +08:00
type LucideIcon ,
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-03-31 07:15:54 +08:00
import { 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
// --- Types ---
2026-03-13 02:23:01 +08:00
type AuthMeResponse = {
authenticated? : boolean ;
user_id? : string | null ;
email? : string | null ;
2026-03-13 09:50:04 +08:00
points? : number ;
2026-03-13 10:14:13 +08:00
weekly_points? : number ;
weekly_rank? : number | string | null ;
2026-03-13 02:23:01 +08:00
entitlement_mode? : string | null ;
2026-03-13 03:27:56 +08:00
auth_required? : boolean ;
2026-03-13 02:23:01 +08:00
subscription_required? : boolean ;
subscription_active? : boolean | null ;
2026-03-13 15:39:25 +08:00
subscription_plan_code? : string | null ;
subscription_starts_at? : string | null ;
subscription_expires_at? : string | null ;
2026-04-13 16:35:22 +08:00
subscription_total_expires_at? : string | null ;
subscription_queued_days? : number | null ;
subscription_queued_count? : number | null ;
2026-05-18 16:18:26 +08:00
telegram_pricing? : TelegramPricing | null ;
};
type TelegramPricing = {
configured? : boolean ;
telegram_id? : number | null ;
telegram_status? : string | null ;
is_group_member? : boolean ;
amount_usdc? : string ;
pricing_source? : string ;
};
2026-03-13 05:13:48 +08:00
type PaymentPlan = {
plan_code : string ;
plan_id : number ;
amount_usdc : string ;
duration_days : number ;
};
2026-03-13 13:58:41 +08:00
type PaymentTokenOption = {
code : string ;
symbol : string ;
name : string ;
address : string ;
decimals : number ;
receiver_contract? : string ;
is_default? : boolean ;
};
2026-03-13 08:15:27 +08:00
type PointsRedemptionConfig = {
enabled? : boolean ;
points_per_usdc? : number ;
max_discount_usdc? : number ;
};
2026-03-13 05:13:48 +08:00
type PaymentConfig = {
enabled? : boolean ;
configured? : boolean ;
chain_id? : number ;
token_address? : string ;
token_decimals? : number ;
2026-03-13 13:58:41 +08:00
default_token_address? : string ;
tokens? : PaymentTokenOption [];
2026-03-13 05:13:48 +08:00
receiver_contract? : string ;
confirmations? : number ;
2026-03-13 08:15:27 +08:00
points_redemption? : PointsRedemptionConfig ;
2026-03-13 05:13:48 +08:00
plans? : PaymentPlan [];
};
type BoundWallet = {
chain_id : number ;
address : string ;
status : string ;
is_primary : boolean ;
verified_at? : string | null ;
};
type CreatedIntent = {
intent ?: {
intent_id : string ;
order_id_hex : string ;
plan_code : string ;
amount_usdc : string ;
allowed_wallet? : string | null ;
};
tx_payload ?: {
chain_id : number ;
to : string ;
data : string ;
value : string ;
2026-03-13 05:25:46 +08:00
amount_units : string ;
token_address : string ;
2026-03-13 13:58:41 +08:00
token_symbol? : string ;
token_decimals? : number ;
2026-03-13 05:13:48 +08:00
};
2026-05-18 16:18:26 +08:00
direct_payment ?: {
chain_id : number ;
chain? : string ;
token_symbol? : string ;
token_address : string ;
token_decimals? : number ;
receiver_address : string ;
amount_units : string ;
amount_usdc : string ;
intent_id : string ;
expires_at : string ;
};
2026-03-13 05:13:48 +08:00
};
2026-03-14 10:35:30 +08:00
type IntentStatusResponse = {
intent ?: {
intent_id? : string ;
status? : string ;
tx_hash? : string | null ;
};
};
2026-03-13 05:13:48 +08:00
declare global {
interface Window {
2026-03-13 10:24:14 +08:00
ethereum? : EvmProvider ;
2026-03-21 12:27:54 +08:00
okxwallet ?: {
ethereum? : EvmProvider ;
};
okexchain? : EvmProvider ;
rabby? : EvmProvider ;
bitkeep ?: {
ethereum? : EvmProvider ;
};
2026-03-13 05:13:48 +08:00
}
}
2026-03-13 10:24:14 +08:00
type EvmProvider = {
request : ( args : { method : string ; params? : any [] | object }) => Promise < any >;
providers? : EvmProvider [];
2026-03-13 16:47:25 +08:00
connect ?: ( args? : any ) => Promise < void >;
disconnect ?: () => Promise < void >;
session? : unknown ;
2026-03-13 10:24:14 +08:00
isMetaMask? : boolean ;
isRabby? : boolean ;
isOkxWallet? : boolean ;
isBitKeep? : boolean ;
};
2026-03-13 16:47:25 +08:00
type ProviderMode = "auto" | "walletconnect" ;
type ProviderSelection = {
provider : EvmProvider ;
label : string ;
mode : ProviderMode ;
};
2026-03-21 12:19:53 +08:00
type InjectedProviderOption = ProviderSelection & {
key : string ;
};
2026-03-21 12:34:36 +08:00
type Eip6963ProviderInfo = {
uuid : string ;
name : string ;
icon : string ;
rdns : string ;
};
type Eip6963ProviderDetail = {
info : Eip6963ProviderInfo ;
provider : EvmProvider ;
};
2026-03-13 21:08:57 +08:00
type ConnectBindOptions = {
openOverlayAfterBind? : boolean ;
};
2026-04-06 20:40:26 +08:00
type PaymentRecoveryState = {
intentId : string ;
txHash : string ;
userId : string ;
createdAt : number ;
};
2026-03-13 16:47:25 +08:00
const WALLETCONNECT_PROJECT_ID = String (
process . env . NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "" ,
). trim ();
const WALLETCONNECT_POLYGON_RPC_URL = String (
process . env . NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ||
"https://polygon-bor-rpc.publicnode.com" ,
). trim ();
2026-03-13 20:38:10 +08:00
const TELEGRAM_GROUP_URL = String (
process . env . NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
"https://t.me/+nMG7SjziUKYyZmM1" ,
). trim ();
2026-03-17 19:18:37 +08:00
const TELEGRAM_BOT_URL = String (
process . env . NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot" ,
). trim ();
2026-03-13 20:38:10 +08:00
const SUBSCRIPTION_HELP_HREF = "/subscription-help" ;
2026-04-06 20:40:26 +08:00
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery" ;
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000 ;
2026-03-13 16:47:25 +08:00
let walletConnectProviderCache : EvmProvider | null = null ;
let walletConnectProviderChainId : number | null = null ;
2026-03-21 12:34:36 +08:00
const eip6963Providers = new Map < string , Eip6963ProviderDetail >();
2026-03-13 16:47:25 +08:00
2026-03-13 20:09:49 +08:00
function isWalletConnectResetError ( error : unknown ) : boolean {
const source = error as any ;
const message = String (
source ? . shortMessage ||
source ? . message ||
source ? . reason ||
source ? . data ? . message ||
source ? . cause ? . message ||
source ? . error ? . message ||
( error instanceof Error ? error . message : "" ) ||
( typeof error === "string" ? error : "" ),
). toLowerCase ();
return (
message . includes ( "connection request reset" ) ||
message . includes ( "pairing aborted" ) ||
message . includes ( "pairing attempt" ) ||
message . includes ( "unable to connect" )
);
}
async function resetWalletConnectProvider () : Promise < void > {
if ( walletConnectProviderCache ? . disconnect ) {
try {
await walletConnectProviderCache . disconnect ();
} catch {
// ignore
}
}
walletConnectProviderCache = null ;
walletConnectProviderChainId = null ;
}
2026-03-13 08:15:27 +08:00
// --- Helpers ---
2026-03-13 08:24:18 +08:00
type InfoRowProps = {
icon? : LucideIcon ;
label : string ;
value : string ;
isPrimary? : boolean ;
};
2026-03-13 10:14:13 +08:00
const InfoRow = ({
icon : Icon ,
label ,
value ,
isPrimary = false ,
} : InfoRowProps ) => (
2026-04-29 12:40:37 +08:00
< div className = "flex min-w-0 flex-col gap-3 p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group sm:flex-row sm:items-center sm:justify-between" >
< div className = "flex min-w-0 items-center gap-3" >
< div className = "shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors" >
2026-03-13 08:15:27 +08:00
{ Icon && < Icon size = { 18 } />}
</ div >
2026-04-29 12:40:37 +08:00
< span className = "min-w-0 text-slate-400 text-sm font-medium leading-5" >{ label }</ span >
2026-03-13 08:15:27 +08:00
</ div >
< span
2026-04-29 12:40:37 +08:00
className = { `min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${ isPrimary ? "text-blue-400" : "text-slate-200" } ` }
2026-03-13 08:15:27 +08:00
>
{ value }
</ span >
</ div >
);
2026-03-13 03:47:56 +08:00
2026-03-13 02:23:01 +08:00
function formatTime ( value : string | undefined | null , locale : string ) {
2026-03-13 03:47:56 +08:00
if ( ! value ) return "--" ;
2026-03-13 02:23:01 +08:00
try {
const dt = new Date ( value );
2026-03-13 03:47:56 +08:00
if ( Number . isNaN ( dt . getTime ())) return "--" ;
2026-03-13 02:23:01 +08:00
return new Intl . DateTimeFormat ( locale , {
year : "numeric" ,
month : "2-digit" ,
day : "2-digit" ,
}). format ( dt );
} catch {
2026-03-13 03:47:56 +08:00
return "--" ;
2026-03-13 02:23:01 +08:00
}
}
2026-03-30 00:58:43 +08:00
function parseSubscriptionExpiry ( value : string | undefined | null ) {
const raw = String ( value || "" ). trim ();
if ( ! raw ) return null ;
const dt = new Date ( raw );
if ( Number . isNaN ( dt . getTime ())) return null ;
const diffMs = dt . getTime () - Date . now ();
return {
raw ,
date : dt ,
expired : diffMs <= 0 ,
daysLeft : Math.ceil ( diffMs / 86 _400_000 ),
};
}
2026-03-13 05:13:48 +08:00
function shortAddress ( address : string ) {
const text = String ( address || "" );
if ( ! text . startsWith ( "0x" ) || text . length < 12 ) return text || "--" ;
return ` ${ text . slice ( 0 , 8 ) } ... ${ text . slice ( - 6 ) } ` ;
}
2026-04-06 20:40:26 +08:00
function clearStoredPaymentRecovery() {
if ( typeof window === "undefined" ) return ;
window . sessionStorage . removeItem ( PAYMENT_RECOVERY_STORAGE_KEY );
}
2026-03-13 10:24:14 +08:00
function getEvmProvider () : EvmProvider | null {
2026-03-21 12:27:54 +08:00
return listInjectedProviders ()[ 0 ] ? . provider || null ;
}
2026-03-21 12:34:36 +08:00
function getEip6963Providers () : Eip6963ProviderDetail [] {
return Array . from ( eip6963Providers . values ());
}
function detectWalletLabel (
provider : EvmProvider | null ,
detail? : Eip6963ProviderDetail ,
) : string {
if ( ! provider && ! detail ) return "EVM 钱包" ;
const announcedName = String ( detail ? . info ? . name || "" ). trim ();
const announcedRdns = String ( detail ? . info ? . rdns || "" ). toLowerCase ();
if (
provider ? . isOkxWallet ||
announcedName . toLowerCase (). includes ( "okx" ) ||
announcedRdns . includes ( "okx" )
) {
return "OKX Wallet" ;
}
if (
provider ? . isMetaMask ||
announcedName . toLowerCase (). includes ( "metamask" ) ||
announcedRdns . includes ( "metamask" )
) {
return "MetaMask" ;
}
if (
provider ? . isRabby ||
announcedName . toLowerCase (). includes ( "rabby" ) ||
announcedRdns . includes ( "rabby" )
) {
return "Rabby" ;
}
if (
provider ? . isBitKeep ||
announcedName . toLowerCase (). includes ( "bitget" ) ||
announcedRdns . includes ( "bitkeep" ) ||
announcedRdns . includes ( "bitget" )
) {
return "Bitget Wallet" ;
}
if ( announcedName ) return announcedName ;
return "EVM 钱包" ;
}
2026-03-21 12:27:54 +08:00
function collectInjectedProviders () : EvmProvider [] {
if ( typeof window === "undefined" ) return [];
const out : EvmProvider [] = [];
const seen = new Set < EvmProvider >();
const push = ( provider : unknown ) => {
if ( ! provider || typeof provider !== "object" ) return ;
const candidate = provider as EvmProvider ;
if ( typeof candidate . request !== "function" ) return ;
if ( seen . has ( candidate )) return ;
seen . add ( candidate );
out . push ( candidate );
};
2026-03-13 10:24:14 +08:00
const root = window . ethereum ;
2026-03-21 12:27:54 +08:00
if ( Array . isArray ( root ? . providers )) {
root . providers . forEach ( push );
2026-03-13 10:24:14 +08:00
}
2026-03-21 12:27:54 +08:00
push ( root );
push ( window . okxwallet ? . ethereum );
push ( window . okexchain );
push ( window . rabby );
push ( window . bitkeep ? . ethereum );
return out ;
}
2026-03-21 12:34:36 +08:00
function getInjectedProviderStableId (
provider : EvmProvider ,
index : number ,
detail? : Eip6963ProviderDetail ,
) : string {
const rdns = String ( detail ? . info ? . rdns || "" ). toLowerCase ();
2026-03-23 21:32:18 +08:00
const announcedName = String ( detail ? . info ? . name || "" )
. toLowerCase ()
. trim ();
2026-03-21 12:40:11 +08:00
if ( rdns ) return `rdns: ${ rdns } ` ;
if ( announcedName ) return `name: ${ announcedName } ` ;
2026-03-21 12:34:36 +08:00
if ( provider . isOkxWallet || rdns . includes ( "okx" )) return `okx: ${ index } ` ;
2026-03-23 21:32:18 +08:00
if ( provider . isMetaMask || rdns . includes ( "metamask" ))
return `metamask: ${ index } ` ;
2026-03-21 12:34:36 +08:00
if ( provider . isRabby || rdns . includes ( "rabby" )) return `rabby: ${ index } ` ;
if (
provider . isBitKeep ||
rdns . includes ( "bitkeep" ) ||
rdns . includes ( "bitget" )
) {
return `bitget: ${ index } ` ;
}
2026-03-21 12:27:54 +08:00
return `evm: ${ index } ` ;
2026-03-13 10:24:14 +08:00
}
2026-03-21 12:19:53 +08:00
function listInjectedProviders () : InjectedProviderOption [] {
2026-03-21 12:34:36 +08:00
const detailByProvider = new Map < EvmProvider , Eip6963ProviderDetail >();
getEip6963Providers (). forEach (( detail ) => {
if ( detail ? . provider && typeof detail . provider . request === "function" ) {
detailByProvider . set ( detail . provider , detail );
}
});
2026-03-21 12:27:54 +08:00
const candidates = collectInjectedProviders ();
2026-03-21 12:34:36 +08:00
detailByProvider . forEach (( _detail , provider ) => {
if ( ! candidates . includes ( provider )) {
candidates . push ( provider );
}
});
2026-03-21 12:19:53 +08:00
const seen = new Set < string >();
2026-03-21 12:44:19 +08:00
const seenLabels = new Set < string >();
2026-03-21 12:19:53 +08:00
const out : InjectedProviderOption [] = [];
candidates . forEach (( provider , index ) => {
2026-03-21 12:34:36 +08:00
const detail = detailByProvider . get ( provider );
const label = detectWalletLabel ( provider , detail );
const key = getInjectedProviderStableId ( provider , index , detail );
2026-03-21 12:19:53 +08:00
if ( seen . has ( key )) return ;
2026-03-21 12:44:19 +08:00
const normalizedLabel = label . trim (). toLowerCase ();
if ( normalizedLabel && seenLabels . has ( normalizedLabel )) return ;
2026-03-21 12:19:53 +08:00
seen . add ( key );
2026-03-21 12:44:19 +08:00
if ( normalizedLabel ) seenLabels . add ( normalizedLabel );
2026-03-21 12:19:53 +08:00
out . push ({
key ,
provider ,
label ,
mode : "auto" ,
});
});
return out ;
}
2026-03-13 10:24:14 +08:00
function getEvmWalletLabel ( provider : EvmProvider | null ) : string {
2026-03-21 12:34:36 +08:00
return detectWalletLabel ( provider );
2026-03-13 10:24:14 +08:00
}
2026-03-13 16:47:25 +08:00
async function getWalletConnectProvider (
chainId : number ,
rpcUrl : string ,
) : Promise < EvmProvider > {
if ( ! WALLETCONNECT_PROJECT_ID ) {
throw new Error (
"WalletConnect 未配置:缺少 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID。" ,
);
}
2026-03-13 18:06:08 +08:00
if ( walletConnectProviderCache && walletConnectProviderChainId === chainId ) {
2026-03-13 16:47:25 +08:00
return walletConnectProviderCache ;
}
2026-03-13 18:06:08 +08:00
const { EthereumProvider } = await import ( "@walletconnect/ethereum-provider" );
2026-03-13 16:47:25 +08:00
const rpcMap : Record < number , string > = {
[ chainId ] : rpcUrl || WALLETCONNECT_POLYGON_RPC_URL ,
};
const origin =
typeof window !== "undefined"
? window . location . origin
: "https://polyweather-pro.vercel.app" ;
const provider = ( await EthereumProvider . init ({
projectId : WALLETCONNECT_PROJECT_ID ,
chains : [ chainId ],
optionalChains : [ chainId ],
showQrModal : true ,
methods : [
"eth_sendTransaction" ,
"personal_sign" ,
"eth_signTypedData" ,
"eth_signTypedData_v4" ,
"eth_sign" ,
"eth_call" ,
"eth_chainId" ,
"eth_accounts" ,
"eth_requestAccounts" ,
],
events : [ "accountsChanged" , "chainChanged" , "disconnect" ],
rpcMap ,
metadata : {
name : "PolyWeather" ,
description : "PolyWeather Pro checkout" ,
url : origin ,
icons : [ ` ${ origin } /favicon.ico` ],
},
})) as unknown as EvmProvider ;
walletConnectProviderCache = provider ;
walletConnectProviderChainId = chainId ;
return provider ;
}
2026-03-13 05:25:46 +08:00
function toPaddedHex ( value : bigint ) {
return value . toString ( 16 ). padStart ( 64 , "0" );
}
function toPaddedAddress ( address : string ) {
2026-03-13 08:15:27 +08:00
return String ( address || "" )
. toLowerCase ()
. replace ( /^0x/ , "" )
. padStart ( 64 , "0" );
2026-03-13 05:25:46 +08:00
}
function buildAllowanceCalldata ( owner : string , spender : string ) {
return `0xdd62ed3e ${ toPaddedAddress ( owner ) }${ toPaddedAddress ( spender ) } ` ;
}
function buildApproveCalldata ( spender : string , amount : bigint ) {
return `0x095ea7b3 ${ toPaddedAddress ( spender ) }${ toPaddedHex ( amount ) } ` ;
}
2026-03-13 13:13:51 +08:00
function buildBalanceOfCalldata ( owner : string ) {
return `0x70a08231 ${ toPaddedAddress ( owner ) } ` ;
}
function formatTokenUnits ( amount : bigint , decimals : number ) {
2026-03-13 15:39:25 +08:00
const safeDecimals =
Number . isFinite ( decimals ) && decimals >= 0 ? Math . floor ( decimals ) : 6 ;
2026-03-13 13:13:51 +08:00
const base = 10 n ** BigInt ( safeDecimals );
const whole = amount / base ;
const fraction = amount % base ;
if ( fraction === 0 n ) return whole . toString ();
const rawFraction = fraction . toString (). padStart ( safeDecimals , "0" );
const trimmed = rawFraction . replace ( /0+$/ , "" );
return ` ${ whole . toString () } . ${ trimmed } ` ;
}
2026-03-13 13:01:57 +08:00
type NormalizedPaymentError = {
message : string ;
pending : boolean ;
userRejected : boolean ;
};
function normalizePaymentError ( error : unknown ) : NormalizedPaymentError {
const source = error as any ;
2026-03-13 13:13:51 +08:00
const code = Number (
source ? . code ??
source ? . error ? . code ??
source ? . data ? . code ??
source ? . cause ? . code ??
NaN ,
);
2026-03-13 13:01:57 +08:00
const messageCandidates = [
source ? . shortMessage ,
source ? . message ,
source ? . reason ,
source ? . data ? . message ,
2026-03-13 13:13:51 +08:00
source ? . cause ? . message ,
2026-03-13 13:01:57 +08:00
source ? . error ? . message ,
error instanceof Error ? error . message : "" ,
typeof error === "string" ? error : "" ,
];
const rawMessage = messageCandidates
2026-03-13 13:13:51 +08:00
. find (
( item ) =>
typeof item === "string" &&
item . trim () &&
item . trim (). toLowerCase () !== "[object object]" ,
)
2026-03-13 13:01:57 +08:00
? . trim ();
const lower = String ( rawMessage || "" ). toLowerCase ();
2026-03-21 12:34:36 +08:00
if (
lower . includes ( "confirm pending" ) ||
lower . includes ( "payment pending timeout" )
) {
2026-03-13 13:01:57 +08:00
return {
message : "链上交易已提交,正在确认中,请稍后刷新查看状态。" ,
pending : true ,
userRejected : false ,
};
}
2026-03-13 20:09:49 +08:00
if ( isWalletConnectResetError ( error )) {
return {
message :
"WalletConnect 连接已重置,请重新扫码连接;若仍失败,请先在钱包里断开旧连接后再试。" ,
pending : false ,
userRejected : false ,
};
}
2026-03-13 13:01:57 +08:00
const userRejected =
code === 4001 ||
2026-03-13 13:13:51 +08:00
/user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消|签名请求已拒绝/ . test (
2026-03-13 13:01:57 +08:00
lower ,
);
if ( userRejected ) {
return {
message : "你已取消钱包操作。" ,
pending : false ,
userRejected : true ,
};
}
const insufficientGas =
2026-03-13 15:39:25 +08:00
( code === - 32000 &&
/insufficient funds/ . test ( lower ) &&
/(gas|fee|native|pol|matic)/ . test ( lower )) ||
2026-03-13 13:13:51 +08:00
/not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/ . test (
lower ,
);
2026-03-13 13:01:57 +08:00
if ( insufficientGas ) {
return {
message : "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。" ,
pending : false ,
userRejected : false ,
};
}
if ( rawMessage ) {
return {
message : rawMessage ,
pending : false ,
userRejected : false ,
};
}
try {
return {
message : JSON.stringify ( error ),
pending : false ,
userRejected : false ,
};
} catch {
return {
message : "发生未知错误,请稍后重试。" ,
pending : false ,
userRejected : false ,
};
}
}
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" ;
const copy = useMemo (
() => ({
backHome : isEn ? "Back to Home" : "返回首页" ,
accountCenter : isEn ? "Account Center" : "账户中心" ,
loadingAccount : isEn ? "Loading account info..." : "加载账户信息中..." ,
refresh : isEn ? "Refresh" : "刷新" ,
signOut : isEn ? "Sign Out" : "退出" ,
signIn : isEn ? "Sign In" : "登录" ,
upgradePro : isEn ? "Upgrade Pro" : "升级 Pro" ,
guestUser : isEn ? "Guest User" : "游客用户" ,
joinedAt : isEn ? "Joined" : "加入时间" ,
totalPoints : isEn ? "Total Points" : "总积分 (荣誉)" ,
2026-03-23 21:32:18 +08:00
weeklyPoints : isEn ? "Weekly Points" : "本周积分 (竞技)" ,
2026-03-16 20:30:46 +08:00
weeklyRank : isEn ? "Weekly Rank" : "周排行 (竞技)" ,
weeklyRewards : isEn ? "Weekly Rewards" : "周榜奖励" ,
membershipDetails : isEn ? "Membership Details" : "会员权限详情" ,
identityStatus : isEn ? "Identity Status" : "身份状态" ,
authMode : isEn ? "Auth Mode" : "鉴权模式" ,
weatherEngine : isEn ? "Weather Engine" : "气象引擎" ,
intradayAnalysis : isEn ? "Intraday Analysis" : "今日内分析" ,
2026-03-21 12:34:36 +08:00
historyFuture : isEn
2026-05-18 23:25:16 +08:00
? "Future-date + Decision Card Analysis"
: "未来日期分析 + 城市决策卡" ,
2026-03-21 12:34:36 +08:00
smartPush : isEn
? "Cross-platform Smart Weather Push"
: "全平台智能气象查询" ,
deepMode : isEn
? "Deep mode (incl. high-temp window)"
: "深度版(含高温时段)" ,
2026-03-16 20:30:46 +08:00
compactVisible : isEn ? "Compact visible" : "简版可见" ,
enabled : isEn ? "Enabled" : "已开启" ,
locked : isEn ? "Locked" : "锁定" ,
boundEmail : isEn ? "Bound Email" : "绑定邮箱" ,
loginMethod : isEn ? "Sign-in Method" : "登录方式" ,
renewalDate : isEn ? "Renewal Date" : "续费日期" ,
2026-04-13 16:35:22 +08:00
accessUntil : isEn ? "Access Until" : "可用至" ,
2026-03-16 20:30:46 +08:00
authResult : isEn ? "Auth Result" : "鉴权结果" ,
passed : isEn ? "Passed" : "通过" ,
restricted : isEn ? "Restricted" : "受限" ,
telegramBind : isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定" ,
telegramHint : isEn
? "Send the command below to the polyweather bot to sync notifications and access."
2026-03-21 12:34:36 +08:00
: "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。" ,
2026-03-22 20:24:48 +08:00
paymentManualSupport : isEn
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
2026-03-23 21:32:18 +08:00
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!" ,
2026-03-17 19:18:37 +08:00
telegramBotLink : isEn
? "Open Bot (@WeatherQuant_bot)"
: "打开机器人 (@WeatherQuant_bot)" ,
telegramGroupLink : isEn ? "Join Telegram Group" : "加入 Telegram 群组" ,
2026-03-16 20:30:46 +08:00
copyCommand : isEn ? "Copy command" : "复制命令" ,
paymentMgmt : isEn ? "Payment Management" : "支付管理" ,
paymentToken : isEn ? "Payment Token" : "支付币种" ,
2026-03-22 13:42:48 +08:00
paymentAccount : isEn ? "Subscription Account" : "订阅归属账号" ,
paymentWallet : isEn ? "Paying Wallet" : "付款钱包" ,
paymentReceiver : isEn ? "Receiver Contract" : "当前收款合约" ,
paymentHost : isEn ? "Payment Host" : "支付域名" ,
2026-03-16 20:30:46 +08:00
primary : "Primary" ,
polygonChain : "Polygon Chain" ,
noWallet : isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包" ,
2026-03-21 12:34:36 +08:00
bindExt : isEn
? "Bind Browser Wallet (EVM Extension)"
: "绑定浏览器钱包(EVM扩展)" ,
bindQr : isEn
? "Bind via QR (WalletConnect)"
: "扫码绑定(WalletConnect) " ,
2026-03-16 20:30:46 +08:00
walletConnectMissing : isEn
? "WalletConnect disabled: please configure"
: "未启用 WalletConnect:请配置" ,
2026-03-21 12:19:53 +08:00
walletExtensionDetected : isEn
? "Detected browser wallets"
: "检测到的浏览器钱包" ,
walletExtensionChoose : isEn
? "Choose extension wallet"
: "选择浏览器钱包" ,
walletRecoveryBusy : isEn
? "Recovering Pro entitlement after on-chain payment..."
: "正在根据链上支付恢复 Pro 权限..." ,
walletRecoveryDone : isEn
? "Pro entitlement recovered."
: "Pro 权限已恢复。" ,
walletRecoveryFailed : isEn
2026-04-06 20:40:26 +08:00
? "A recent on-chain payment is still syncing to your subscription. Please refresh in a minute or contact support."
: "检测到最近的链上支付流程,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。" ,
2026-03-16 20:30:46 +08:00
unbind : isEn ? "Unbind" : "解绑" ,
unbindConfirm : isEn
? "Unbind wallet {address}? You can bind it again later."
: "确认解绑钱包 {address}?后续可重新绑定。" ,
unbindDone : isEn ? "Wallet unbound." : "钱包已解绑。" ,
unbindDonePrimary : isEn
? "Wallet unbound. New primary: {address}"
: "钱包已解绑,新的主钱包:{address}" ,
unbindFailed : isEn ? "Failed to unbind wallet" : "解绑钱包失败" ,
2026-03-17 14:18:25 +08:00
authExpired : isEn
? "Session expired. Please sign out and sign in again."
: "登录会话已失效,请退出后重新登录。" ,
2026-03-16 20:30:46 +08:00
payNow : isEn ? "Subscribe & Activate" : "立即订阅并激活服务" ,
connectAndPay : isEn ? "Connect Wallet & Pay" : "连接钱包并支付" ,
loginBeforeBind : isEn
? "Please sign in before binding wallet."
: "请先登录后再绑定钱包。" ,
loginBeforePay : isEn
? "Please sign in before payment."
: "请先登录后再支付。" ,
bindFirstBeforePay : isEn
? "Please bind a wallet first."
: "请先绑定钱包。" ,
payNotReady : isEn
? "Payment service is not fully configured."
: "支付服务未配置完成。" ,
2026-03-22 13:42:48 +08:00
paymentHostBlocked : isEn
? "Payments are disabled on this host. Please return to the production site: {host}"
: "当前域名不允许发起支付,请回到主站后重试:{host}" ,
paymentGuardHint : isEn
? "Payment will be credited to the current account and bound wallet shown below."
: "支付将记入下方显示的当前账号和绑定钱包,请先核对。" ,
2026-03-16 20:30:46 +08:00
openBindFlow : isEn
? "Please bind a wallet first. Opening bind flow..."
: "请先完成钱包绑定,正在拉起绑定流程..." ,
walletBoundCreatingOrder : isEn
? "Wallet bound. Creating order and sending payment..."
: "钱包已绑定,正在创建订单并发起支付..." ,
proMember : "PRO MEMBER" ,
freeTier : "FREE TIER" ,
proPendingSync : isEn ? "Activated (pending sync)" : "已开通(待同步)" ,
noProSubscription : isEn ? "No Pro subscription" : "暂无 Pro 订阅" ,
2026-03-30 00:58:43 +08:00
trialEndsSoonTitle : isEn ? "Trial ending soon" : "试用即将结束" ,
trialEndsSoonBody : isEn
2026-05-18 23:25:16 +08:00
? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and decision cards."
: "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和城市决策卡。" ,
2026-03-30 00:58:43 +08:00
trialExpiredTitle : isEn ? "Trial ended" : "试用已结束" ,
trialExpiredBody : isEn
? "Your trial access has ended. Renew with Pro to restore full access."
: "试用权限已结束。开通 Pro 后可恢复完整权限。" ,
proEndsSoonTitle : isEn ? "Pro renewal due soon" : "Pro 即将到期" ,
proEndsSoonBody : isEn
? "Your Pro membership will expire soon. Renew now to avoid interruption."
: "你的 Pro 会员即将到期。现在续费可避免权限中断。" ,
proExpiredTitle : isEn ? "Pro expired" : "Pro 已到期" ,
proExpiredBody : isEn
? "Your Pro membership has expired. Renew now to restore premium access."
: "你的 Pro 会员已到期。立即续费可恢复高级权限。" ,
renewNow : isEn ? "Renew Now" : "立即续费" ,
trialBadge : isEn ? "TRIAL" : "试用中" ,
daysLeft : isEn ? "{days} days left" : "剩余 {days} 天" ,
2026-04-13 16:35:22 +08:00
queuedExtensionSummary : isEn
? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}."
: "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。" ,
2026-03-16 20:30:46 +08:00
}),
[ 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-03-13 05:13:48 +08:00
const [ paymentBusy , setPaymentBusy ] = useState ( false );
const [ paymentInfo , setPaymentInfo ] = useState ( "" );
const [ paymentError , setPaymentError ] = useState ( "" );
const [ lastIntentId , setLastIntentId ] = useState ( "" );
const [ lastTxHash , setLastTxHash ] = useState ( "" );
2026-05-18 16:18:26 +08:00
const [ manualPayment , setManualPayment ] = useState < CreatedIntent [ "direct_payment" ] | null >( null );
const [ manualTxHash , setManualTxHash ] = useState ( "" );
2026-04-06 20:40:26 +08:00
const [ lastPaymentStartedAt , setLastPaymentStartedAt ] = useState ( 0 );
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-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 ( "" );
try {
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-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 ());
} 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 ();
const txHash = String ( parsed ? . txHash || "" ). trim (). toLowerCase ();
const createdAt = Number ( parsed ? . createdAt || 0 );
const expired =
! createdAt || Date . now () - createdAt > PAYMENT_RECOVERY_TTL_MS ;
if (
expired ||
! intentId ||
! txHash ||
! userId ||
userId !== authUserId
) {
clearStoredPaymentRecovery ();
return ;
}
setLastIntentId ( intentId );
setLastTxHash ( txHash );
setLastPaymentStartedAt ( createdAt );
} catch {
clearStoredPaymentRecovery ();
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 ;
setLastIntentId ( "" );
setLastTxHash ( "" );
2026-05-18 16:18:26 +08:00
setManualPayment ( null );
setManualTxHash ( "" );
2026-04-06 20:40:26 +08:00
setLastPaymentStartedAt ( 0 );
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 ,
]);
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-04-06 20:40:26 +08:00
setLastIntentId ( "" );
setLastTxHash ( "" );
setLastPaymentStartedAt ( 0 );
clearStoredPaymentRecovery ();
2026-03-13 16:47:25 +08:00
if ( walletConnectProviderCache ? . disconnect ) {
try {
await walletConnectProviderCache . disconnect ();
} catch {
// ignore
}
walletConnectProviderCache = null ;
walletConnectProviderChainId = null ;
}
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 ) {
const amount = data . telegram_pricing . amount_usdc || "5" ;
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 );
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw ;
const reminderExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw || totalExpiryRaw ;
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 &&
! hasQueuedExtension &&
expiryInfo &&
! expiryInfo . expired &&
expiryInfo . daysLeft <= 3 ,
);
2026-03-30 00:58:43 +08:00
const showExpiredReminder = Boolean ( ! isSubscribed && expiryInfo && expiryInfo . expired );
2026-04-10 09:00:37 +08:00
const paymentFeatureReady = paymentReadyForRecovery ;
const canOpenCheckoutOverlay = Boolean (
paymentFeatureReady &&
2026-04-17 20:01:18 +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
? isTrialPlan
? copy.trialExpiredTitle
: copy.proExpiredTitle
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonTitle
: copy.proEndsSoonTitle
: "" ;
const subscriptionStatusBody = showExpiredReminder
? isTrialPlan
? copy.trialExpiredBody
: copy.proExpiredBody
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonBody
: copy.proEndsSoonBody
: "" ;
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-04-10 09:00:37 +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-03-22 13:42:48 +08:00
const paymentReceiverAddress = String (
selectedPaymentToken ? . receiver_contract ||
paymentConfig ? . receiver_contract ||
"" ,
). toLowerCase ();
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 (
backend ? . telegram_pricing ? . amount_usdc ?? selectedPlan ? . amount_usdc ?? 5 ,
);
2026-03-13 08:15:27 +08:00
const planAmount =
Number . isFinite ( parsedPlanAmount ) && parsedPlanAmount > 0
? parsedPlanAmount
: 5 ;
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 );
});
};
// --- Payment Logic (preserved) ---
const waitForReceipt = async (
txHash : string ,
timeoutMs = 120000 ,
pollMs = 3000 ,
) => {
2026-03-13 10:24:14 +08:00
const eth = getEvmProvider ();
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-03-13 08:15:27 +08:00
const receipt = ( await eth . request ({
method : "eth_getTransactionReceipt" ,
params : [ txHash ],
})) as { status? : string } | null ;
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 (
( await eth . request ({ method : "eth_chainId" })) || "" ,
);
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 {
await eth . request ({
method : "wallet_switchEthereumChain" ,
params : [{ chainId : targetChainHex }],
});
} catch ( err : any ) {
const code = Number ( err ? . code );
if ( code !== 4902 || targetChainId !== 137 ) throw err ;
await eth . request ({
method : "wallet_addEthereumChain" ,
params : [
{
chainId : "0x89" ,
chainName : "Polygon Mainnet" ,
nativeCurrency : { name : "POL" , symbol : "POL" , decimals : 18 },
rpcUrls : [ "https://polygon-rpc.com" ],
blockExplorerUrls : [ "https://polygonscan.com" ],
},
],
});
}
};
2026-03-13 21:08:57 +08:00
const connectAndBindWallet = async (
mode : ProviderMode = "auto" ,
options : ConnectBindOptions = {},
) : Promise < boolean > => {
2026-03-13 05:13:48 +08:00
setPaymentError ( "" );
setPaymentInfo ( "" );
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-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-03-13 08:15:27 +08:00
const accounts = ( await eth . request ({
method : "eth_requestAccounts" ,
})) as string [];
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 (
` ${ walletLabel } 已绑定: ${ shortAddress ( address ) } 。现在可点击“立即订阅并激活服务”。` ,
);
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 (
` ${ walletLabel } 绑定成功: ${ shortAddress ( address ) } 。现在可点击“立即订阅并激活服务”。` ,
);
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 );
setPaymentError ( "" );
setPaymentInfo ( "" );
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 () => {
setPaymentError ( "" );
setPaymentInfo ( "" );
2026-04-06 20:40:26 +08:00
setLastIntentId ( "" );
2026-03-13 13:13:51 +08:00
setLastTxHash ( "" );
2026-04-06 20:40:26 +08:00
setLastPaymentStartedAt ( 0 );
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 ;
const activeAccounts = ( await eth . request ({
method : "eth_requestAccounts" ,
})) as string [];
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 ();
if ( ! expectedReceiver . startsWith ( "0x" )) {
throw new Error ( "payment receiver contract is not configured" );
}
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 <= 0 n )
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
const balanceHex = ( await eth . request ({
method : "eth_call" ,
params : [
{
to : tokenAddress ,
data : buildBalanceOfCalldata ( payingWallet ),
},
"latest" ,
],
})) as string ;
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
const allowanceHex = ( await eth . request ({
method : "eth_call" ,
2026-03-13 08:15:27 +08:00
params : [
{
to : tokenAddress ,
data : buildAllowanceCalldata ( payingWallet , txPayload . to ),
},
"latest" ,
],
2026-03-13 05:25:46 +08:00
})) as string ;
const allowance = BigInt ( String ( allowanceHex || "0x0" ));
if ( allowance < amountUnits ) {
2026-03-13 13:58:41 +08:00
setPaymentInfo ( `检测到授权不足,正在发起 ${ tokenSymbol } 授权...` );
2026-03-13 05:25:46 +08:00
const approveHash = ( await eth . request ({
method : "eth_sendTransaction" ,
2026-03-13 08:15:27 +08:00
params : [
{
from : payingWallet ,
to : tokenAddress ,
data : buildApproveCalldata ( txPayload . to , amountUnits ),
value : "0x0" ,
},
],
2026-03-13 05:25:46 +08:00
})) as string ;
await waitForReceipt ( String ( approveHash || "" ));
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-03-13 05:13:48 +08:00
const txHash = ( await eth . request ({
method : "eth_sendTransaction" ,
2026-03-13 08:15:27 +08:00
params : [
{
from : payingWallet ,
to : txPayload.to ,
data : txPayload.data ,
value : txPayload.value || "0x0" ,
},
],
2026-03-13 05:13:48 +08:00
})) as string ;
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 );
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 () => {
setPaymentError ( "" );
setPaymentInfo ( "" );
setManualPayment ( null );
setManualTxHash ( "" );
setLastIntentId ( "" );
setLastTxHash ( "" );
if ( ! paymentHostAllowed ) {
setPaymentError (
copy . paymentHostBlocked . replace (
"{host}" ,
allowedPaymentHosts [ 0 ] || "polyweather-pro.vercel.app" ,
),
);
return ;
}
if ( ! isAuthenticated ) {
setPaymentError ( copy . loginBeforePay );
return ;
}
if ( ! paymentConfig ? . configured ) {
setPaymentError ( copy . payNotReady );
return ;
}
setPaymentBusy ( true );
try {
const authHeaders = await buildAuthedHeaders ( true , false );
const createRes = await fetch ( "/api/payments/intents" , {
method : "POST" ,
headers : authHeaders ,
body : JSON.stringify ({
plan_code : selectedPlan?.plan_code || "pro_monthly" ,
payment_mode : "direct" ,
token_address : resolvedSelectedTokenAddress || undefined ,
use_points : billing.canRedeem && usePoints ,
points_to_consume :
billing.canRedeem && usePoints ? billing.pointsUsed : 0 ,
metadata : {
source : "account_center_manual_transfer" ,
frontend_host : currentPaymentHost || null ,
account_email : email || null ,
},
}),
});
if ( ! createRes . ok ) {
const raw = ( await createRes . text ()). slice ( 0 , 350 );
throw new Error ( `create manual intent failed: ${ raw } ` );
}
const created = ( await createRes . json ()) as CreatedIntent ;
const direct = created . direct_payment ;
const intentId = String ( created . intent ? . intent_id || direct ? . intent_id || "" );
if ( ! intentId || ! direct ? . receiver_address || ! direct ? . amount_usdc ) {
throw new Error ( "manual payment payload invalid" );
}
setLastIntentId ( intentId );
setManualPayment ( direct );
setShowOverlay ( false );
setPaymentInfo (
`手动转账订单已创建:请在 Polygon 网络转 ${ direct . amount_usdc } ${ direct . token_symbol || selectedTokenLabel } 到 ${ shortAddress ( direct . receiver_address ) } ,完成后提交 tx hash。` ,
);
trackAppEvent ( "checkout_started" , {
entry : "account_center_manual_transfer" ,
plan_code : selectedPlan?.plan_code || "pro_monthly" ,
intent_id : intentId ,
payment_mode : "direct" ,
use_points : billing.canRedeem && usePoints ,
pay_amount_usd : billing.payAmount ,
});
} catch ( error ) {
setPaymentError ( normalizePaymentError ( error ). message );
} finally {
setPaymentBusy ( false );
}
};
const submitManualPaymentTx = async () => {
const txHashNorm = String ( manualTxHash || "" ). trim (). toLowerCase ();
const intentId = String ( lastIntentId || manualPayment ? . intent_id || "" ). trim ();
if ( ! intentId || ! manualPayment ) {
setPaymentError ( "请先创建手动转账订单。" );
return ;
}
if ( ! txHashNorm . startsWith ( "0x" ) || txHashNorm . length !== 66 ) {
setPaymentError ( "请输入有效的 tx hash。" );
return ;
}
setPaymentBusy ( true );
setPaymentError ( "" );
try {
const authHeaders = await buildAuthedHeaders ( true , false );
const submitRes = await fetch ( `/api/payments/intents/ ${ intentId } /submit` , {
method : "POST" ,
headers : authHeaders ,
body : JSON.stringify ({ tx_hash : txHashNorm }),
});
if ( ! submitRes . ok ) {
const raw = ( await submitRes . text ()). slice ( 0 , 350 );
throw new Error ( `submit tx failed: ${ raw } ` );
}
const confirmRes = await fetch ( `/api/payments/intents/ ${ intentId } /confirm` , {
method : "POST" ,
headers : authHeaders ,
body : JSON.stringify ({ tx_hash : txHashNorm }),
});
if ( ! confirmRes . ok ) {
const raw = ( await confirmRes . text ()). slice ( 0 , 350 );
const lowerRaw = raw . toLowerCase ();
const maybePending =
confirmRes . status === 408 ||
( confirmRes . status === 409 &&
( lowerRaw . includes ( "confirmations not enough" ) ||
lowerRaw . includes ( "tx indexed partially" )));
if ( maybePending ) {
setPaymentInfo ( `交易已提交: ${ shortAddress ( txHashNorm ) } ,等待链上确认中...` );
await pollIntentUntilConfirmed ( intentId , authHeaders , txHashNorm );
return ;
}
throw new Error ( `confirm failed: ${ raw } ` );
}
setLastTxHash ( txHashNorm );
setPaymentInfo ( `支付确认成功,交易: ${ shortAddress ( txHashNorm ) } ` );
setManualPayment ( null );
setManualTxHash ( "" );
trackAppEvent ( "checkout_succeeded" , {
entry : "account_center_manual_transfer" ,
plan_code : selectedPlan?.plan_code || "pro_monthly" ,
intent_id : intentId ,
tx_hash : txHashNorm ,
});
await loadSnapshot ();
await loadPaymentSnapshot ();
} catch ( error ) {
setPaymentError ( normalizePaymentError ( error ). message );
} finally {
setPaymentBusy ( false );
}
};
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-04-10 09:00:37 +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" >
当前可用 { billing . pointsUsed } 积分抵扣 $ { billing . discountAmount . toFixed ( 2 )} ,
续费时会自动生效。
</ 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
? isTrialPlan
? copy.trialBadge
: copy.proMember
: 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-11 13:35:52 +08:00
+ 100 积分 & 3 天 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-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-11 13:35:52 +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-04-10 09:00:37 +08:00
< div
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-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 }
telegramGroupUrl = { TELEGRAM_GROUP_URL }
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-18 18:44:20 +08:00
{ /* Telegram Bot Section — paid users only */ }
{ showSecondarySections && isSubscribed ? (
2026-03-21 12:34:36 +08:00
< div className = "lg:col-span-12 grid grid-cols-1 md:flex gap-6" >
< section className = "flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group" >
< 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-18 17:10:44 +08:00
{ backend ? . telegram_pricing ? . is_group_member ? (
< div className = "mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3" >
< p className = "text-xs font-bold text-emerald-200" >
Telegram 群成员价格
</ p >
< p className = "mt-1 text-[11px] leading-5 text-emerald-100/75" >
已验证群成员身份,当前会员价 { backend . telegram_pricing . amount_usdc ?? "5" } U 。
</ p >
< div className = "mt-3" >
2026-05-18 16:18:26 +08:00
< span className = "rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] font-bold text-white" >
2026-05-18 17:10:44 +08:00
当前价格 : { backend . telegram_pricing . amount_usdc ?? "5" } U · 群成员
2026-05-18 16:18:26 +08:00
</ span >
2026-05-18 17:10:44 +08:00
</ div >
2026-05-18 16:18:26 +08:00
</ div >
2026-05-18 17:10:44 +08:00
) : null }
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" >
{ TELEGRAM_BOT_URL ? (
< Link
href = { TELEGRAM_BOT_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-cyan-400/30 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
2026-03-13 08:15:27 +08:00
>
2026-03-21 12:34:36 +08:00
{ copy . telegramBotLink }
< ExternalLink size = { 12 } />
</ Link >
) : null }
{ TELEGRAM_GROUP_URL ? (
< 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-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-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 >
{ /* Payment Details / Wallet Management */ }
< section className = "w-full md:w-96 bg-white/5 border border-white/10 rounded-[2rem] p-8 flex flex-col justify-between" >
< 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 ) || "--" }
/>
< 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-18 16:18:26 +08:00
< div className = "mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 p-4" >
< div className = "mb-2 flex items-center justify-between gap-3" >
< div >
< p className = "text-xs font-bold text-emerald-200" >
手动转账(无需绑定钱包)
</ p >
< p className = "mt-1 text-[11px] leading-5 text-emerald-100/75" >
先创建订单,向唯一收款地址转账,完成后提交 tx hash 自动开通。请不要和钱包支付同时使用。
</ p >
</ div >
< button
type = "button"
onClick = {() => void createManualPaymentIntent ()}
disabled = { paymentBusy || ! isAuthenticated }
className = "shrink-0 rounded-xl border border-emerald-400/35 bg-emerald-500/15 px-3 py-2 text-[11px] font-bold text-emerald-100 transition-all hover:bg-emerald-500/25 disabled:opacity-50"
>
创建转账订单
</ button >
</ div >
{ manualPayment ? (
< div className = "mt-3 space-y-3 rounded-xl border border-white/10 bg-black/25 p-3" >
< div >
< p className = "text-[10px] uppercase tracking-widest text-slate-500" >
Amount
</ p >
< p className = "font-mono text-sm font-bold text-white" >
{ manualPayment . amount_usdc }{ " " }
{ manualPayment . token_symbol || selectedTokenLabel }
</ p >
</ div >
< div >
< p className = "text-[10px] uppercase tracking-widest text-slate-500" >
Receiver
</ p >
< div className = "mt-1 flex gap-2" >
< code className = "min-w-0 flex-1 truncate rounded-lg bg-black/40 px-2 py-2 font-mono text-[11px] text-blue-200" >
{ manualPayment . receiver_address }
</ code >
< button
type = "button"
onClick = {() =>
handleCopy ( manualPayment . receiver_address )
}
className = "rounded-lg bg-blue-600 px-2 text-xs font-bold text-white"
>
复制
</ button >
</ div >
</ div >
< div >
< p className = "text-[10px] uppercase tracking-widest text-slate-500" >
Tx Hash
</ p >
< input
value = { manualTxHash }
onChange = {( event ) => setManualTxHash ( event . target . value )}
placeholder = "0x..."
className = "mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50"
/>
</ div >
< button
type = "button"
onClick = {() => void submitManualPaymentTx ()}
disabled = { paymentBusy }
className = "w-full rounded-xl bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-500 disabled:opacity-50"
>
提交 tx hash 并自动确认
</ button >
</ div >
) : null }
</ div >
2026-03-21 12:34:36 +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" >
{ 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 >
) : (
< p className = "text-xs text-slate-500 italic" >
{ copy . noWallet }
</ p >
)}
</ div >
< div className = "mt-6 grid grid-cols-1 gap-2" >
{ injectedProviderOptions . length > 1 && (
< label className = "mb-2 block" >
< span className = "mb-2 block text-[11px] uppercase tracking-widest text-slate-500" >
{ copy . walletExtensionDetected }
</ span >
< select
value = { selectedInjectedProviderKey }
onChange = {( event ) =>
setSelectedInjectedProviderKey ( event . target . value )
}
disabled = { paymentBusy }
className = "w-full rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-xs text-slate-200 outline-none transition-all hover:bg-white/10 disabled:opacity-60"
>
{ injectedProviderOptions . map (( option ) => (
< option
key = { option . key }
value = { option . key }
className = "bg-slate-900 text-slate-200"
>
{ option . label }
</ option >
))}
</ select >
</ label >
)}
< button
onClick = {() => {
setProviderMode ( "auto" );
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"
>
< 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" >
{ copy . walletConnectMissing }
< code className = "mx-1" >
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
</ code >
</ p >
)}
</ 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
function PlusIcon ({ className } : { className? : string }) {
return (
< svg
className = { className }
width = "24"
height = "24"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2"
strokeLinecap = "round"
strokeLinejoin = "round"
>
< line x1 = "12" y1 = "5" x2 = "12" y2 = "19" ></ line >
< line x1 = "5" y1 = "12" x2 = "19" y2 = "12" ></ line >
</ svg >
);
}