894 lines
33 KiB
TypeScript
894 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import type { User } from "@supabase/supabase-js";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
BadgeCheck,
|
|
Bot,
|
|
CheckCircle2,
|
|
ChevronLeft,
|
|
Clock,
|
|
Copy,
|
|
Crown,
|
|
CreditCard,
|
|
Fingerprint,
|
|
Hash,
|
|
Loader2,
|
|
LogIn,
|
|
LogOut,
|
|
Mail,
|
|
Wallet,
|
|
RefreshCw,
|
|
Shield,
|
|
Sparkles,
|
|
User as UserIcon,
|
|
UserCheck,
|
|
} from "lucide-react";
|
|
import {
|
|
getSupabaseBrowserClient,
|
|
hasSupabasePublicEnv,
|
|
} from "@/lib/supabase/client";
|
|
|
|
type AuthMeResponse = {
|
|
authenticated?: boolean;
|
|
user_id?: string | null;
|
|
email?: string | null;
|
|
entitlement_mode?: string | null;
|
|
auth_required?: boolean;
|
|
subscription_required?: boolean;
|
|
subscription_active?: boolean | null;
|
|
};
|
|
|
|
type PaymentPlan = {
|
|
plan_code: string;
|
|
plan_id: number;
|
|
amount_usdc: string;
|
|
duration_days: number;
|
|
};
|
|
|
|
type PaymentConfig = {
|
|
enabled?: boolean;
|
|
configured?: boolean;
|
|
chain_id?: number;
|
|
token_address?: string;
|
|
token_decimals?: number;
|
|
receiver_contract?: string;
|
|
confirmations?: number;
|
|
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;
|
|
amount_units: string;
|
|
token_address: string;
|
|
};
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
ethereum?: {
|
|
request: (args: { method: string; params?: any[] | object }) => Promise<any>;
|
|
};
|
|
}
|
|
}
|
|
|
|
type InfoItemProps = {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
value: string;
|
|
status?: "default" | "primary";
|
|
};
|
|
|
|
function formatTime(value: string | undefined | null, locale: string) {
|
|
if (!value) return "--";
|
|
try {
|
|
const dt = new Date(value);
|
|
if (Number.isNaN(dt.getTime())) return "--";
|
|
return new Intl.DateTimeFormat(locale, {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(dt);
|
|
} catch {
|
|
return "--";
|
|
}
|
|
}
|
|
|
|
function normalizeProvider(user: User | null) {
|
|
const provider = String(user?.app_metadata?.provider || "").trim().toLowerCase();
|
|
if (provider) return provider;
|
|
const providers = user?.app_metadata?.providers;
|
|
if (Array.isArray(providers) && providers.length) {
|
|
return String(providers[0] || "").trim().toLowerCase();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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)}`;
|
|
}
|
|
|
|
function toPaddedHex(value: bigint) {
|
|
return value.toString(16).padStart(64, "0");
|
|
}
|
|
|
|
function toPaddedAddress(address: string) {
|
|
return String(address || "")
|
|
.toLowerCase()
|
|
.replace(/^0x/, "")
|
|
.padStart(64, "0");
|
|
}
|
|
|
|
function buildAllowanceCalldata(owner: string, spender: string) {
|
|
// allowance(address owner, address spender)
|
|
return `0xdd62ed3e${toPaddedAddress(owner)}${toPaddedAddress(spender)}`;
|
|
}
|
|
|
|
function buildApproveCalldata(spender: string, amount: bigint) {
|
|
// approve(address spender, uint256 amount)
|
|
return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`;
|
|
}
|
|
|
|
function InfoItem({ icon: Icon, label, value, status = "default" }: InfoItemProps) {
|
|
return (
|
|
<div className="group flex items-center justify-between rounded-2xl border border-white/5 bg-white/5 p-4 transition-all hover:bg-white/10">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-lg bg-slate-800 p-2 text-slate-400 transition-colors group-hover:text-blue-400">
|
|
<Icon size={18} />
|
|
</div>
|
|
<span className="text-sm font-medium text-slate-400">{label}</span>
|
|
</div>
|
|
<span
|
|
className={`text-sm font-semibold ${
|
|
status === "primary" ? "text-blue-400" : "text-slate-200"
|
|
}`}
|
|
>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AccountCenter() {
|
|
const router = useRouter();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [errorText, setErrorText] = useState("");
|
|
const [copied, setCopied] = useState(false);
|
|
const [updatedAt, setUpdatedAt] = useState<string>("");
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
|
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
|
|
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
|
const [walletAddress, setWalletAddress] = useState("");
|
|
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
|
const [selectedWallet, setSelectedWallet] = useState("");
|
|
const [paymentBusy, setPaymentBusy] = useState(false);
|
|
const [paymentInfo, setPaymentInfo] = useState("");
|
|
const [paymentError, setPaymentError] = useState("");
|
|
const [lastIntentId, setLastIntentId] = useState("");
|
|
const [lastTxHash, setLastTxHash] = useState("");
|
|
|
|
const supabaseReady = hasSupabasePublicEnv();
|
|
|
|
const loadPaymentSnapshot = useCallback(async () => {
|
|
if (!backend?.authenticated) {
|
|
setPaymentConfig(null);
|
|
setBoundWallets([]);
|
|
return;
|
|
}
|
|
try {
|
|
const [configRes, walletsRes] = await Promise.all([
|
|
fetch("/api/payments/config", { cache: "no-store" }),
|
|
fetch("/api/payments/wallets", { cache: "no-store" }),
|
|
]);
|
|
if (configRes.ok) {
|
|
const configJson = (await configRes.json()) as PaymentConfig;
|
|
setPaymentConfig(configJson);
|
|
if (!selectedPlanCode && Array.isArray(configJson.plans) && configJson.plans.length) {
|
|
setSelectedPlanCode(configJson.plans[0].plan_code);
|
|
}
|
|
}
|
|
if (walletsRes.ok) {
|
|
const walletsJson = (await walletsRes.json()) as {
|
|
wallets?: BoundWallet[];
|
|
};
|
|
const wallets = Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [];
|
|
setBoundWallets(wallets);
|
|
if (wallets.length && !selectedWallet) {
|
|
setSelectedWallet(wallets[0].address);
|
|
}
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
}, [backend?.authenticated, selectedPlanCode, selectedWallet]);
|
|
|
|
const loadSnapshot = useCallback(async () => {
|
|
setErrorText("");
|
|
try {
|
|
const userPromise = supabaseReady
|
|
? getSupabaseBrowserClient().auth.getUser()
|
|
: Promise.resolve({ data: { user: null as User | null } });
|
|
const backendPromise = fetch("/api/auth/me", { cache: "no-store" });
|
|
|
|
const [userResult, backendResult] = await Promise.all([
|
|
userPromise,
|
|
backendPromise,
|
|
]);
|
|
|
|
setUser(userResult.data?.user ?? null);
|
|
|
|
if (!backendResult.ok) {
|
|
const raw = (await backendResult.text()).slice(0, 260);
|
|
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));
|
|
}
|
|
}, [supabaseReady]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
setLoading(true);
|
|
await loadSnapshot();
|
|
if (!cancelled) setLoading(false);
|
|
};
|
|
void run();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [loadSnapshot]);
|
|
|
|
useEffect(() => {
|
|
void loadPaymentSnapshot();
|
|
}, [loadPaymentSnapshot]);
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true);
|
|
await loadSnapshot();
|
|
await loadPaymentSnapshot();
|
|
setRefreshing(false);
|
|
};
|
|
|
|
const onSignOut = async () => {
|
|
if (supabaseReady) {
|
|
try {
|
|
await getSupabaseBrowserClient().auth.signOut();
|
|
} catch {}
|
|
}
|
|
router.replace("/");
|
|
};
|
|
|
|
const userId = backend?.user_id || user?.id || "";
|
|
const isAuthenticated = Boolean(userId);
|
|
const email = backend?.email || user?.email || "";
|
|
const displayName =
|
|
String(user?.user_metadata?.full_name || "").trim() ||
|
|
(email ? String(email).split("@")[0] : "") ||
|
|
"PolyWeather 用户";
|
|
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
|
const providerRaw = normalizeProvider(user);
|
|
const provider = providerRaw ? providerRaw.toUpperCase() : "--";
|
|
const lastSignIn = formatTime(
|
|
user?.last_sign_in_at,
|
|
typeof navigator !== "undefined" ? navigator.language : "zh-CN",
|
|
);
|
|
const updatedAtLabel = formatTime(
|
|
updatedAt,
|
|
typeof navigator !== "undefined" ? navigator.language : "zh-CN",
|
|
);
|
|
|
|
const modeLabel = useMemo(() => {
|
|
const mode = String(backend?.entitlement_mode || "").trim().toLowerCase();
|
|
if (mode === "supabase_required") return "Supabase 强制登录";
|
|
if (mode === "supabase_optional") return "Supabase 可选登录";
|
|
if (mode === "legacy_token") return "Legacy Token 鉴权";
|
|
if (mode === "disabled") return "未启用鉴权";
|
|
return "未知模式";
|
|
}, [backend?.entitlement_mode]);
|
|
|
|
const backendStatus = useMemo(() => {
|
|
if (backend?.authenticated) return "通过";
|
|
if (backend?.auth_required) return "未登录";
|
|
return "游客模式";
|
|
}, [backend?.authenticated, backend?.auth_required]);
|
|
|
|
const subscriptionRequirement = backend?.subscription_required
|
|
? "已启用订阅校验"
|
|
: "当前未强制订阅";
|
|
const subscriptionResult = !backend?.subscription_required
|
|
? "当前未强制订阅"
|
|
: backend?.subscription_active
|
|
? "有效订阅"
|
|
: "无有效订阅";
|
|
|
|
const bindCommand = userId
|
|
? `/bind ${userId}${email ? ` ${email}` : ""}`
|
|
: "/bind <supabase_user_id> <email>";
|
|
|
|
const copyBindCommand = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(bindCommand);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
} catch {}
|
|
};
|
|
|
|
const waitForReceipt = async (
|
|
txHash: string,
|
|
timeoutMs = 120000,
|
|
pollMs = 3000,
|
|
) => {
|
|
const eth = window.ethereum;
|
|
if (!eth) {
|
|
throw new Error("MetaMask not found");
|
|
}
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
const receipt = (await eth.request({
|
|
method: "eth_getTransactionReceipt",
|
|
params: [txHash],
|
|
})) as { status?: string } | null;
|
|
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}`);
|
|
};
|
|
|
|
const planList = paymentConfig?.plans || [];
|
|
const selectedPlan = planList.find((p) => p.plan_code === selectedPlanCode) || planList[0];
|
|
const paymentFeatureReady = Boolean(paymentConfig?.enabled && paymentConfig?.configured);
|
|
|
|
const connectAndBindWallet = async () => {
|
|
setPaymentError("");
|
|
setPaymentInfo("");
|
|
if (!isAuthenticated) {
|
|
setPaymentError("请先登录后再绑定钱包。");
|
|
return;
|
|
}
|
|
const eth = window.ethereum;
|
|
if (!eth) {
|
|
setPaymentError("未检测到 MetaMask,请先安装扩展。");
|
|
return;
|
|
}
|
|
setPaymentBusy(true);
|
|
try {
|
|
const accounts = (await eth.request({
|
|
method: "eth_requestAccounts",
|
|
})) as string[];
|
|
const address = String(accounts?.[0] || "").toLowerCase();
|
|
if (!address) {
|
|
throw new Error("钱包账户为空");
|
|
}
|
|
setWalletAddress(address);
|
|
const challengeRes = await fetch("/api/payments/wallets/challenge", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ address }),
|
|
});
|
|
if (!challengeRes.ok) {
|
|
const raw = (await challengeRes.text()).slice(0, 300);
|
|
throw new Error(`challenge failed: ${raw}`);
|
|
}
|
|
const challengeJson = (await challengeRes.json()) as {
|
|
nonce?: string;
|
|
message?: string;
|
|
};
|
|
const message = String(challengeJson.message || "");
|
|
const nonce = String(challengeJson.nonce || "");
|
|
if (!message || !nonce) {
|
|
throw new Error("challenge payload invalid");
|
|
}
|
|
const signature = (await eth.request({
|
|
method: "personal_sign",
|
|
params: [message, address],
|
|
})) as string;
|
|
const verifyRes = await fetch("/api/payments/wallets/verify", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ address, nonce, signature }),
|
|
});
|
|
if (!verifyRes.ok) {
|
|
const raw = (await verifyRes.text()).slice(0, 300);
|
|
throw new Error(`verify failed: ${raw}`);
|
|
}
|
|
setPaymentInfo(`钱包绑定成功: ${shortAddress(address)}`);
|
|
await loadPaymentSnapshot();
|
|
} catch (error) {
|
|
setPaymentError(String(error));
|
|
} finally {
|
|
setPaymentBusy(false);
|
|
}
|
|
};
|
|
|
|
const createIntentAndPay = async () => {
|
|
setPaymentError("");
|
|
setPaymentInfo("");
|
|
if (!isAuthenticated) {
|
|
setPaymentError("请先登录后再支付。");
|
|
return;
|
|
}
|
|
if (!paymentConfig?.configured) {
|
|
setPaymentError("支付服务未配置完成。");
|
|
return;
|
|
}
|
|
const eth = window.ethereum;
|
|
if (!eth) {
|
|
setPaymentError("未检测到 MetaMask。");
|
|
return;
|
|
}
|
|
const payingWallet = (selectedWallet || walletAddress || "").toLowerCase();
|
|
if (!payingWallet) {
|
|
setPaymentError("请先绑定钱包。");
|
|
return;
|
|
}
|
|
|
|
setPaymentBusy(true);
|
|
try {
|
|
const currentChainIdHex = String(
|
|
(await eth.request({ method: "eth_chainId" })) || "",
|
|
);
|
|
const targetChainId = Number(paymentConfig.chain_id || 137);
|
|
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
|
if (currentChainIdHex.toLowerCase() !== targetChainHex.toLowerCase()) {
|
|
await eth.request({
|
|
method: "wallet_switchEthereumChain",
|
|
params: [{ chainId: targetChainHex }],
|
|
});
|
|
}
|
|
|
|
const createRes = await fetch("/api/payments/intents", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
plan_code: selectedPlanCode || "pro_monthly",
|
|
payment_mode: "strict",
|
|
allowed_wallet: payingWallet,
|
|
metadata: { source: "account_center" },
|
|
}),
|
|
});
|
|
if (!createRes.ok) {
|
|
const raw = (await createRes.text()).slice(0, 350);
|
|
throw new Error(`create intent failed: ${raw}`);
|
|
}
|
|
const created = (await createRes.json()) as CreatedIntent;
|
|
const intentId = String(created.intent?.intent_id || "");
|
|
const txPayload = created.tx_payload;
|
|
if (!intentId || !txPayload?.to || !txPayload?.data) {
|
|
throw new Error("intent payload invalid");
|
|
}
|
|
setLastIntentId(intentId);
|
|
|
|
const tokenAddress = String(txPayload.token_address || "").toLowerCase();
|
|
const amountUnits = BigInt(String(txPayload.amount_units || "0"));
|
|
if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) {
|
|
throw new Error("intent token/amount invalid");
|
|
}
|
|
|
|
const allowanceHex = (await eth.request({
|
|
method: "eth_call",
|
|
params: [
|
|
{
|
|
to: tokenAddress,
|
|
data: buildAllowanceCalldata(payingWallet, txPayload.to),
|
|
},
|
|
"latest",
|
|
],
|
|
})) as string;
|
|
const allowance = BigInt(String(allowanceHex || "0x0"));
|
|
|
|
if (allowance < amountUnits) {
|
|
setPaymentInfo("检测到授权不足,正在发起 USDC 授权...");
|
|
const approveHash = (await eth.request({
|
|
method: "eth_sendTransaction",
|
|
params: [
|
|
{
|
|
from: payingWallet,
|
|
to: tokenAddress,
|
|
data: buildApproveCalldata(txPayload.to, amountUnits),
|
|
value: "0x0",
|
|
},
|
|
],
|
|
})) as string;
|
|
await waitForReceipt(String(approveHash || ""));
|
|
setPaymentInfo("USDC 授权成功,正在发起支付...");
|
|
} else {
|
|
setPaymentInfo("授权额度充足,正在发起支付...");
|
|
}
|
|
|
|
const txHash = (await eth.request({
|
|
method: "eth_sendTransaction",
|
|
params: [
|
|
{
|
|
from: payingWallet,
|
|
to: txPayload.to,
|
|
data: txPayload.data,
|
|
value: txPayload.value || "0x0",
|
|
},
|
|
],
|
|
})) as string;
|
|
const txHashNorm = String(txHash || "").toLowerCase();
|
|
setLastTxHash(txHashNorm);
|
|
|
|
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
tx_hash: txHashNorm,
|
|
from_address: payingWallet,
|
|
}),
|
|
});
|
|
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: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ tx_hash: txHashNorm }),
|
|
});
|
|
if (!confirmRes.ok) {
|
|
const raw = (await confirmRes.text()).slice(0, 350);
|
|
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`);
|
|
throw new Error(`confirm pending: ${raw}`);
|
|
}
|
|
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
|
|
await loadSnapshot();
|
|
await loadPaymentSnapshot();
|
|
} catch (error) {
|
|
setPaymentError(String(error));
|
|
} finally {
|
|
setPaymentBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative min-h-screen w-full overflow-hidden bg-[#0b0f1a] p-4 font-sans text-slate-200 md:p-8">
|
|
<div className="pointer-events-none absolute right-0 top-0 h-[500px] w-[500px] rounded-full bg-blue-600/10 blur-[120px]" />
|
|
<div className="pointer-events-none absolute bottom-0 left-0 h-[500px] w-[500px] rounded-full bg-indigo-600/10 blur-[120px]" />
|
|
|
|
<div className="relative z-10 mx-auto max-w-5xl">
|
|
<header className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
|
<div>
|
|
<h1 className="bg-gradient-to-r from-white to-slate-400 bg-clip-text text-2xl font-bold text-transparent">
|
|
账户中心
|
|
</h1>
|
|
<p className="mt-1 flex items-center gap-2 text-sm text-slate-500">
|
|
<Shield size={14} /> 管理您的身份、权限与 Bot 绑定
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Link
|
|
href="/"
|
|
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm transition-all active:scale-95 hover:bg-white/10"
|
|
>
|
|
<ChevronLeft size={16} /> 返回看板
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => void onRefresh()}
|
|
disabled={refreshing || loading}
|
|
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm transition-all active:scale-95 hover:bg-white/10 disabled:opacity-70"
|
|
>
|
|
{refreshing || loading ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<RefreshCw size={16} />
|
|
)}
|
|
刷新
|
|
</button>
|
|
{isAuthenticated ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void onSignOut()}
|
|
className="flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-400 transition-all active:scale-95 hover:bg-red-500/20"
|
|
>
|
|
<LogOut size={16} /> 退出登录
|
|
</button>
|
|
) : (
|
|
<Link
|
|
href="/auth/login?next=%2Faccount"
|
|
className="flex items-center gap-2 rounded-xl border border-blue-500/20 bg-blue-500/10 px-4 py-2 text-sm text-blue-300 transition-all active:scale-95 hover:bg-blue-500/20"
|
|
>
|
|
<LogIn size={16} /> 登录 / 注册
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{errorText ? (
|
|
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
|
加载失败: {errorText}
|
|
</div>
|
|
) : null}
|
|
|
|
{!supabaseReady ? (
|
|
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
|
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 未配置。
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
|
<div className="relative overflow-hidden rounded-[2.5rem] border border-white/10 bg-gradient-to-br from-white/10 to-transparent p-8 shadow-2xl backdrop-blur-md lg:col-span-12">
|
|
<div className="absolute right-0 top-0 p-6">
|
|
<span className="font-mono text-xs text-slate-500">
|
|
最近同步: {updatedAtLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center gap-8 md:flex-row">
|
|
<div className="group relative">
|
|
<div className="relative z-10 flex h-24 w-24 items-center justify-center rounded-3xl bg-gradient-to-tr from-blue-600 to-indigo-400 text-3xl font-bold shadow-xl shadow-blue-500/20">
|
|
{initials}
|
|
</div>
|
|
<div className="absolute -inset-2 bg-blue-500/20 opacity-0 blur-xl transition-opacity group-hover:opacity-100" />
|
|
</div>
|
|
|
|
<div className="flex-grow text-center md:text-left">
|
|
<div className="mb-2 flex flex-col items-center gap-3 md:flex-row">
|
|
<h2 className="text-3xl font-bold">{displayName}</h2>
|
|
<span className="flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold text-slate-400">
|
|
<UserCheck size={12} />
|
|
{isAuthenticated ? "已登录" : "游客模式"}
|
|
</span>
|
|
</div>
|
|
<p className="font-mono text-slate-400">{email || "--"}</p>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<div className="min-w-[120px] rounded-2xl border border-white/5 bg-black/20 px-6 py-4 text-center">
|
|
<p className="mb-1 text-xs uppercase tracking-wider text-slate-500">
|
|
当前角色
|
|
</p>
|
|
<p className="flex items-center justify-center gap-2 font-bold text-white">
|
|
<Crown size={14} className="text-yellow-500" /> Free Tier
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6 lg:col-span-6">
|
|
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
|
<h3 className="mb-6 flex items-center gap-2 text-sm font-semibold uppercase tracking-widest text-blue-400">
|
|
<Shield size={16} /> 会员与权限
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<InfoItem icon={Shield} label="鉴权模式" value={modeLabel} status="primary" />
|
|
<InfoItem icon={UserIcon} label="后端状态" value={backendStatus} />
|
|
<InfoItem icon={Crown} label="订阅要求" value={subscriptionRequirement} />
|
|
<InfoItem icon={CheckCircle2} label="订阅结果" value={subscriptionResult} />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="space-y-6 lg:col-span-6">
|
|
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
|
<h3 className="mb-6 flex items-center gap-2 text-sm font-semibold uppercase tracking-widest text-indigo-400">
|
|
<Fingerprint size={16} /> 身份信息
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<InfoItem icon={Mail} label="邮箱" value={email || "--"} />
|
|
<InfoItem icon={Hash} label="用户 ID" value={userId || "--"} />
|
|
<InfoItem icon={LogIn} label="登录方式" value={provider} />
|
|
<InfoItem icon={Clock} label="最近登录" value={lastSignIn} />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="lg:col-span-12">
|
|
<section className="rounded-3xl border border-emerald-500/20 bg-gradient-to-r from-emerald-600/10 to-cyan-600/10 p-8 backdrop-blur-sm">
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
<h3 className="flex items-center gap-2 text-lg font-bold">
|
|
<Wallet size={20} className="text-emerald-300" /> 钱包支付(P1 合约收款)
|
|
</h3>
|
|
<span className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs text-slate-300">
|
|
Chain #{paymentConfig?.chain_id || 137}
|
|
</span>
|
|
</div>
|
|
|
|
{!isAuthenticated ? (
|
|
<p className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
|
请先登录,再绑定钱包并完成支付。
|
|
</p>
|
|
) : null}
|
|
|
|
{isAuthenticated && !paymentFeatureReady ? (
|
|
<p className="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
|
|
支付服务未配置完成(需要后端开启 `POLYWEATHER_PAYMENT_*` 配置)。
|
|
</p>
|
|
) : null}
|
|
|
|
{isAuthenticated && paymentFeatureReady ? (
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<p className="flex items-center gap-2 text-sm font-semibold text-slate-200">
|
|
<BadgeCheck size={16} className="text-emerald-300" />
|
|
已绑定钱包
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => void connectAndBindWallet()}
|
|
disabled={paymentBusy}
|
|
className="rounded-lg border border-emerald-400/30 bg-emerald-500/20 px-3 py-1 text-xs text-emerald-200 transition hover:bg-emerald-500/30 disabled:opacity-60"
|
|
>
|
|
连接并绑定 MetaMask
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{boundWallets.length ? (
|
|
boundWallets.map((wallet) => (
|
|
<label
|
|
key={wallet.address}
|
|
className="flex cursor-pointer items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-200"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
name="payWallet"
|
|
className="h-4 w-4"
|
|
checked={selectedWallet === wallet.address}
|
|
onChange={() => setSelectedWallet(wallet.address)}
|
|
/>
|
|
{shortAddress(wallet.address)}
|
|
{wallet.is_primary ? (
|
|
<span className="rounded bg-blue-500/30 px-1.5 py-0.5 text-[10px] text-blue-100">
|
|
主
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="text-xs text-slate-400">{wallet.status}</span>
|
|
</label>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-slate-400">暂无已绑定钱包。</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
|
<p className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-200">
|
|
<CreditCard size={16} className="text-cyan-300" />
|
|
选择套餐并支付
|
|
</p>
|
|
<div className="mb-3 space-y-2">
|
|
{planList.map((plan) => (
|
|
<label
|
|
key={plan.plan_code}
|
|
className="flex cursor-pointer items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm"
|
|
>
|
|
<span className="flex items-center gap-2 text-slate-200">
|
|
<input
|
|
type="radio"
|
|
name="payPlan"
|
|
className="h-4 w-4"
|
|
checked={selectedPlanCode === plan.plan_code}
|
|
onChange={() => setSelectedPlanCode(plan.plan_code)}
|
|
/>
|
|
{plan.plan_code}
|
|
</span>
|
|
<span className="text-cyan-200">
|
|
{plan.amount_usdc} USDC / {plan.duration_days} 天
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void createIntentAndPay()}
|
|
disabled={paymentBusy || !selectedPlan || !selectedWallet}
|
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-cyan-600 px-4 py-3 text-sm font-semibold text-white transition hover:from-emerald-500 hover:to-cyan-500 disabled:opacity-60"
|
|
>
|
|
{paymentBusy ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
|
创建订单并支付
|
|
</button>
|
|
{lastIntentId ? (
|
|
<p className="mt-2 text-xs text-slate-400">
|
|
Intent: {lastIntentId}
|
|
</p>
|
|
) : null}
|
|
{lastTxHash ? (
|
|
<p className="mt-1 text-xs text-slate-400">Tx: {shortAddress(lastTxHash)}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{paymentInfo ? (
|
|
<p className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
|
{paymentInfo}
|
|
</p>
|
|
) : null}
|
|
{paymentError ? (
|
|
<p className="mt-3 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-300">
|
|
{paymentError}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="lg:col-span-12">
|
|
<section className="group relative overflow-hidden rounded-3xl border border-blue-500/20 bg-gradient-to-r from-blue-600/10 to-indigo-600/10 p-8 backdrop-blur-sm">
|
|
<div className="absolute right-0 top-0 translate-x-1/4 -translate-y-1/4">
|
|
<Bot
|
|
size={200}
|
|
className="rotate-12 text-blue-500/5 transition-transform duration-700 group-hover:rotate-0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="relative z-10">
|
|
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold">
|
|
<Bot size={20} className="text-blue-400" /> Bot 绑定
|
|
</h3>
|
|
<p className="mb-6 max-w-2xl text-sm text-slate-400">
|
|
将下面命令发送至 Telegram Bot,即可把网页账户与机器人权限绑定,实现全平台气象推送。
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-3 md:flex-row">
|
|
<div className="flex flex-grow items-center rounded-xl border border-white/10 bg-black/40 px-4 py-4 font-mono text-sm text-blue-300">
|
|
{bindCommand}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyBindCommand()}
|
|
className={`flex items-center justify-center gap-2 rounded-xl px-8 py-4 font-bold transition-all active:scale-95 ${
|
|
copied
|
|
? "bg-green-500 text-white"
|
|
: "bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-500"
|
|
}`}
|
|
>
|
|
{copied ? <CheckCircle2 size={18} /> : <Copy size={18} />}
|
|
{copied ? "已复制" : "复制命令"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<footer className="mt-12 text-center text-xs text-slate-600">
|
|
<p>© 2026 PolyWeather 全球高精度气象引擎 - 云端身份管理系统</p>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|