Files
PolyWeather/frontend/components/account/AccountCenter.tsx
2026-05-30 19:50:50 +08:00

1642 lines
71 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
User as UserIcon,
Shield,
Fingerprint,
Bot,
RefreshCw,
LogOut,
ChevronLeft,
Copy,
CheckCircle2,
UserCheck,
Mail,
Hash,
LogIn,
Clock,
Crown,
ExternalLink,
Trophy,
Coins,
TrendingUp,
Info,
Wallet,
Zap,
Minus,
ShieldCheck,
BarChart3,
Sparkles,
ChevronRight,
Loader2,
CreditCard,
} from "lucide-react";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import type { AuthMeResponse } from "./types";
import {
SUBSCRIPTION_HELP_HREF,
TELEGRAM_BOT_URL,
TELEGRAM_GROUP_URL,
TELEGRAM_TOPICS_GROUP_URL,
WALLETCONNECT_PROJECT_ID,
} from "./constants";
import { InfoRow, PlusIcon } from "./AccountInfoRow";
import {
chainIdToDisplayName,
clearStoredPaymentRecovery,
formatTime,
parseSubscriptionExpiry,
shortAddress,
} from "./formatters";
import { createAccountCopy } from "./account-copy";
import { resetWalletConnectProvider } from "./wallet";
import { useAccountPayment } from "./useAccountPayment";
// --- Main Component ---
export function AccountCenter() {
const router = useRouter();
const { locale } = useI18n();
const isEn = locale === "en-US";
const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
// ── UI-only state ──────────────────────────────────────
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [copied, setCopied] = useState(false);
const [showSecondarySections, setShowSecondarySections] = useState(false);
// ── Shared state (declared in component, written by hook via setters) ─
const [showOverlay, setShowOverlay] = useState(false);
const [usePoints, setUsePoints] = useState(true);
const [errorText, setErrorText] = useState("");
const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
// ── Hook ────────────────────────────────────────────────
const {
// State from usePaymentState
paymentBusy,
paymentInfo,
paymentError,
lastIntentId,
lastTxHash,
lastPaymentStartedAt,
telegramBindOpening,
telegramBindUrl,
manualPayment,
manualTxHash,
txValidation,
paymentMethodTab,
clearPaymentMessages,
clearPaymentState,
// Setters
setPaymentBusy,
setPaymentInfo,
setPaymentError,
setLastIntentId,
setLastTxHash,
setLastPaymentStartedAt,
setTelegramBindOpening,
setPaymentMethodTab,
setManualPayment,
setManualTxHash,
setTxValidation,
// Additional state
paymentConfig,
boundWallets,
walletAddress,
selectedPlanCode,
selectedPaymentChainId,
selectedTokenAddress,
selectedWallet,
providerMode,
injectedProviderOptions,
selectedInjectedProviderKey,
reconcileBusy,
// Shared setters
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
// Derived values
authUserId,
authIsAuthenticated,
paymentReadyForRecovery,
hasRecentPaymentRecovery,
allowedPaymentHosts,
currentPaymentHost,
paymentHostAllowed,
selectedPlan,
selectedPaymentToken,
selectedTokenLabel,
availableTokenList,
availableChainList,
selectedPaymentChain,
effectivePlanList,
resolvedSelectedTokenAddress,
paymentReceiverAddress,
paymentWalletLabel,
hasPayingWallet,
totalPoints,
billing,
// Callbacks
loadSnapshot,
loadPaymentSnapshot,
connectAndBindWallet,
handleUnbindWallet,
createIntentAndPay,
createManualPaymentIntent,
submitManualPaymentTx,
validateTxHash,
handleOverlayCheckout,
openTelegramBotBindLink,
} = useAccountPayment({
isEn,
supabaseReady,
walletConnectEnabled,
copy,
backend,
user,
setUser,
setBackend,
setErrorText,
setUpdatedAt,
showOverlay,
setShowOverlay,
usePoints,
setUsePoints,
});
// ── Auth analytics effect ──────────────────────────────
useEffect(() => {
if (!authIsAuthenticated || !authUserId) return;
const actorKey = authUserId.toLowerCase();
const subscriptionPlanCode = String(backend?.subscription_plan_code || "").trim();
const subscriptionSource = String(backend?.subscription_source || "").trim();
const trialSubscription = Boolean(
backend?.subscription_is_trial === true ||
subscriptionPlanCode.toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
trackAppEvent("signup_completed", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
trackAppEvent("signup_success", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
trackAppEvent("dashboard_active", {
entry: "account_center",
user_id: authUserId,
});
}
if (trialSubscription && markAnalyticsOnce(`trial_created:${actorKey}`, "local")) {
trackAppEvent("trial_created", {
entry: "account_center",
user_id: authUserId,
subscription_plan_code: subscriptionPlanCode || null,
subscription_source: subscriptionSource || null,
subscription_expires_at: backend?.subscription_expires_at || null,
subscription_is_trial: backend?.subscription_is_trial === true,
});
}
}, [
authIsAuthenticated,
authUserId,
backend?.subscription_expires_at,
backend?.subscription_is_trial,
backend?.subscription_plan_code,
backend?.subscription_source,
]);
// ── Idle callback effect ──────────────────────────────
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;
if (
win &&
idleId != null &&
typeof win.cancelIdleCallback === "function"
) {
win.cancelIdleCallback(idleId);
}
if (timeoutId != null && typeof window !== "undefined") {
window.clearTimeout(timeoutId);
}
};
}, []);
// ── Initial load effect ────────────────────────────────
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
await loadSnapshot();
if (!cancelled) setLoading(false);
};
void run();
return () => {
cancelled = true;
};
}, [loadSnapshot]);
// ── Refresh ────────────────────────────────────────────
const onRefresh = async () => {
setRefreshing(true);
await loadSnapshot();
await loadPaymentSnapshot();
setRefreshing(false);
};
// ── Sign out ────────────────────────────────────────────
const onSignOut = async () => {
clearPaymentState();
clearStoredPaymentRecovery();
await resetWalletConnectProvider();
if (supabaseReady) {
try {
await getSupabaseBrowserClient().auth.signOut();
} catch {
// ignore
}
}
router.replace("/");
};
// ── Derived display values ──────────────────────────────
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] : "") ||
copy.guestUser;
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
const joinedAt = formatTime(user?.created_at, locale);
const backendAuthenticated = backend?.authenticated === true;
const localAuthenticated = Boolean(user?.id);
const isSubscriptionUnknown = Boolean(
localAuthenticated &&
(!backend ||
backend.subscription_active == null ||
backend.authenticated === false),
);
const isSubscribed = backend?.subscription_active === true;
const subscriptionSource = String(backend?.subscription_source || "").trim();
const isTrialSubscription = Boolean(
backend?.subscription_is_trial === true ||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
const subscriptionStatusLabel = isSubscribed
? isTrialSubscription
? copy.trialBadge
: copy.proMember
: isSubscriptionUnknown
? copy.subscriptionChecking
: isEn
? "UNSUBSCRIBED"
: "未订阅";
const planCode = String(backend?.subscription_plan_code || "").trim();
const currentExpiryRaw = String(
backend?.subscription_expires_at ||
user?.user_metadata?.pro_expiry ||
"",
).trim();
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 canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 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);
const proExpiry = isSubscribed
? expiryFormatted !== "--"
? expiryFormatted
: displayExpiryRaw || copy.proPendingSync
: isSubscriptionUnknown
? copy.subscriptionUnknown
: copy.noProSubscription;
const showExpiringSoon = Boolean(
isSubscribed &&
!hasQueuedExtension &&
expiryInfo &&
!expiryInfo.expired &&
expiryInfo.daysLeft <= 3,
);
const showExpiredReminder = Boolean(
!isSubscribed && expiryInfo && expiryInfo.expired,
);
const paymentFeatureReady = paymentReadyForRecovery;
const canOpenCheckoutOverlay = Boolean(
paymentFeatureReady &&
!isSubscriptionUnknown &&
(!isSubscribed || showExpiringSoon || showExpiredReminder),
);
const subscriptionStatusTitle = showExpiredReminder
? copy.proExpiredTitle
: showExpiringSoon
? copy.proEndsSoonTitle
: "";
const subscriptionStatusBody = showExpiredReminder
? copy.proExpiredBody
: showExpiringSoon
? copy.proEndsSoonBody
: "";
const subscriptionStatusMeta =
expiryInfo && (showExpiringSoon || showExpiredReminder)
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
: "";
const queuedExtensionSummary = hasQueuedExtension
? copy.queuedExtensionSummary
.replace("{current}", currentExpiryFormatted)
.replace("{days}", String(queuedExtensionDays))
.replace("{total}", totalExpiryFormatted)
: "";
const expiryLabel = hasQueuedExtension
? copy.accessUntil
: copy.renewalDate;
const displayPlanList = effectivePlanList.length
? effectivePlanList
: [
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
const canApplyReferralCode = Boolean(
isAuthenticated &&
!isSubscribed &&
!appliedReferralCode &&
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ──────────────────────
useEffect(() => {
if (!showOverlay || !canOpenCheckoutOverlay) return;
trackAppEvent("paywall_viewed", {
entry: "account_center",
user_state: isAuthenticated ? "logged_in" : "guest",
expired: showExpiredReminder,
expiring_soon: showExpiringSoon,
subscription_plan_code: planCode || null,
});
}, [
isAuthenticated,
canOpenCheckoutOverlay,
planCode,
showExpiredReminder,
showExpiringSoon,
showOverlay,
]);
// ── Referral points display ────────────────────────────
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
? Math.max(0, referralRewardPointsRaw)
: 3500;
const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
? Math.max(0, monthlyReferralCountRaw)
: 0;
const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10);
const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw)
? Math.max(0, monthlyReferralLimitRaw)
: 10;
const monthlyReferralPointsRaw = Number(
referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints,
);
const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw)
? Math.max(0, monthlyReferralPointsRaw)
: 0;
const monthlyReferralPointsLimitRaw = Number(
referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints,
);
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
? Math.max(0, monthlyReferralPointsLimitRaw)
: monthlyReferralLimit * referralRewardPoints;
// ── Telegram bind command ──────────────────────────────
const bindCommand = userId
? `/bind ${userId}${email ? ` ${email}` : ""}`
: "/bind <supabase_user_id> <email>";
// ── Copy handler ──────────────────────────────────────
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
});
};
const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim();
if (!code || referralApplying) return;
setReferralApplying(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (supabaseReady) {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const token = String(session?.access_token || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("/api/auth/referral/apply", {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 240);
throw new Error(raw || copy.referralApplyFailed);
}
setPaymentInfo(copy.referralApplied);
setReferralCodeInput("");
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
error instanceof Error ? error.message : copy.referralApplyFailed,
);
} finally {
setReferralApplying(false);
}
}, [
copy.referralApplied,
copy.referralApplyFailed,
loadPaymentSnapshot,
loadSnapshot,
referralApplying,
referralCodeInput,
setPaymentError,
setPaymentInfo,
supabaseReady,
]);
// ── Render ────────────────────────────────────────────
if (loading && !refreshing) {
return (
<div className="flex h-screen w-full items-center justify-center bg-[#f4f7fb]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-12 w-12 animate-spin text-blue-500" />
<p className="font-medium text-slate-500">{copy.loadingAccount}</p>
</div>
</div>
);
}
return (
<div className="relative flex min-h-screen w-full flex-col items-center overflow-hidden bg-[#f4f7fb] p-4 font-sans text-slate-900 md:p-8">
<div className="absolute inset-x-0 top-0 h-20 border-b border-slate-200 bg-white"></div>
{/* 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="group rounded-lg border border-slate-200 bg-white p-2 text-slate-500 shadow-sm transition-all hover:border-slate-300 hover:text-slate-950 active:scale-95"
title={copy.backHome}
aria-label={copy.backHome}
>
<ChevronLeft
size={20}
className="group-hover:-translate-x-0.5 transition-transform"
/>
</Link>
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-slate-950">
{copy.accountCenter}
</h1>
</div>
</div>
<div className="flex items-center gap-2">
{!showOverlay && canOpenCheckoutOverlay && (
<button
onClick={() => setShowOverlay(true)}
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-100"
>
<Crown size={16} />{" "}
{showExpiringSoon || showExpiredReminder
? copy.renewNow
: copy.upgradePro}
</button>
)}
<button
type="button"
onClick={() => void onRefresh()}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:text-slate-950 disabled:opacity-50"
disabled={refreshing}
>
{refreshing ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<RefreshCw size={16} />
)}{" "}
{copy.refresh}
</button>
{isAuthenticated ? (
<button
onClick={() => void onSignOut()}
className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-semibold text-red-700 transition-all hover:bg-red-100"
>
<LogOut size={16} /> {copy.signOut}
</button>
) : (
<Link
href="/auth/login?next=%2Faccount"
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 transition-all hover:bg-blue-100"
>
<LogIn size={16} /> {copy.signIn}
</Link>
)}
</div>
</header>
<main className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6 z-10 relative">
{(showExpiringSoon || showExpiredReminder) && (
<div className="lg:col-span-12 rounded-2xl border border-amber-300 bg-amber-50 px-6 py-5 shadow-sm">
<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-700">
<Crown size={16} />
<span>{subscriptionStatusTitle}</span>
</div>
<p className="mt-1 text-sm text-amber-900">
{subscriptionStatusBody}
</p>
{subscriptionStatusMeta ? (
<p className="mt-1 text-xs text-amber-700">
{subscriptionStatusMeta}
</p>
) : null}
{billing.canRedeem ? (
<p className="mt-2 text-xs text-emerald-700">
当前可用 {billing.pointsUsed} 积分抵扣 $
{billing.discountAmount.toFixed(2)} 续费时会自动生效。
</p>
) : null}
</div>
<button
type="button"
onClick={() => setShowOverlay(true)}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-800 transition-all hover:bg-amber-100"
>
<Crown size={16} />
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
</button>
</div>
</div>
)}
{/* User Card */}
<div className="flex flex-col items-center gap-8 rounded-2xl border border-slate-200 bg-white p-8 shadow-sm lg:col-span-8 md:flex-row">
<div className="relative">
<div className="flex h-24 w-24 items-center justify-center rounded-xl border border-blue-200 bg-blue-600 text-3xl font-bold text-white shadow-sm">
{initials}
</div>
<div
className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${
isSubscribed
? "bg-amber-400 text-slate-950"
: isSubscriptionUnknown
? "bg-blue-100 text-blue-500"
: "bg-slate-100 text-slate-400"
}`}
>
<Crown size={16} fill="currentColor" />
</div>
</div>
<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-slate-950">{displayName}</h2>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${
isSubscribed
? "border-blue-200 bg-blue-50 text-blue-700"
: isSubscriptionUnknown
? "border-blue-200 bg-blue-50 text-blue-600"
: "border-slate-200 bg-slate-50 text-slate-500"
}`}
>
{subscriptionStatusLabel}
</span>
</div>
<p className="mb-4 font-mono text-sm text-slate-500">
{email || copy.guestUser}
</p>
<div className="flex flex-wrap justify-center md:justify-start gap-4">
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Hash size={14} />{" "}
<span className="font-mono">
{userId ? `${userId.substring(0, 12)}...` : "--"}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Clock size={14} />{" "}
<span>
{copy.joinedAt}: {joinedAt}
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="min-w-[140px] rounded-xl border border-slate-200 bg-slate-50 px-6 py-4 text-center">
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.totalPoints}
</p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<Coins size={16} className="text-yellow-500" />{" "}
{totalPoints.toLocaleString()}
</p>
</div>
<div className="min-w-[140px] rounded-xl border border-emerald-200 bg-emerald-50 px-6 py-4 text-center">
<p className="mb-1 text-[10px] font-bold uppercase text-emerald-700">
{copy.weeklyPoints}
</p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<TrendingUp size={16} className="text-emerald-400" />{" "}
{monthlyReferralCount.toLocaleString()}
</p>
</div>
<div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center">
<p className="mb-1 text-[10px] font-bold uppercase text-blue-700">
{copy.weeklyRank}
</p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<Trophy size={16} className="text-amber-400" />{" "}
{monthlyReferralCount}/{monthlyReferralLimit}
</p>
</div>
</div>
</div>
{/* Referral rewards */}
{showSecondarySections ? (
<div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4">
<div>
<h3 className="mb-6 flex items-center gap-2 text-lg font-bold text-slate-950">
<Sparkles size={20} className="text-amber-500" />{" "}
{copy.weeklyRewards}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2">
<Coins size={16} className="text-yellow-500" />{" "}
{copy.referralRewardHint}
</span>
<span className="text-xs font-bold text-amber-600">
+{referralRewardPoints.toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2">
<TrendingUp size={16} className="text-emerald-500" />{" "}
{copy.weeklyPoints}
</span>
<span className="text-xs font-bold text-slate-600">
{monthlyReferralCount}/{monthlyReferralLimit}
</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2">
<Trophy size={16} className="text-blue-500" />{" "}
{copy.totalPoints}
</span>
<span className="text-xs font-bold text-orange-400">
{monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
</span>
</div>
</div>
</div>
<div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic">
{copy.pointsRule}
</p>
</div>
</div>
) : (
<div className="rounded-2xl border border-slate-200 bg-white p-6 lg:col-span-4">
<div className="h-6 w-40 animate-pulse rounded bg-slate-200" />
<div className="mt-4 space-y-2">
<div className="h-12 animate-pulse rounded-xl bg-slate-100" />
<div className="h-12 animate-pulse rounded-xl bg-slate-100" />
<div className="h-12 animate-pulse rounded-xl bg-slate-100" />
</div>
</div>
)}
{/* Subscription Info & Paywall */}
<div className="lg:col-span-12 relative">
<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" : ""}`}
>
<section className="space-y-3 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="mb-4 text-sm font-bold uppercase text-blue-700">
{copy.membershipDetails}
</h3>
<InfoRow
icon={ShieldCheck}
label={copy.authMode}
value="Supabase"
/>
<InfoRow
icon={BarChart3}
label={copy.weatherEngine}
value="DEB + 多模型"
/>
<InfoRow
icon={Zap}
label={copy.intradayAnalysis}
value={
isSubscriptionUnknown
? copy.subscriptionChecking
: isSubscribed
? copy.deepMode
: copy.compactVisible
}
isPrimary={isSubscribed}
/>
<InfoRow
icon={Clock}
label={copy.historyFuture}
value={
isSubscriptionUnknown
? copy.subscriptionChecking
: isSubscribed
? copy.enabled
: copy.locked
}
isPrimary={isSubscribed}
/>
<InfoRow
icon={Bot}
label={copy.smartPush}
value={
isSubscriptionUnknown
? copy.subscriptionChecking
: isSubscribed
? copy.enabled
: copy.locked
}
isPrimary={isSubscribed}
/>
</section>
<section className="space-y-3 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="mb-4 text-sm font-bold uppercase text-indigo-700">
{copy.identityStatus}
</h3>
<InfoRow
icon={Mail}
label={copy.boundEmail}
value={email || "--"}
/>
<InfoRow
icon={LogIn}
label={copy.loginMethod}
value={user?.app_metadata?.provider?.toUpperCase() || "GOOGLE"}
/>
<InfoRow
icon={Clock}
label={expiryLabel}
value={proExpiry}
isPrimary
/>
<InfoRow
icon={UserCheck}
label={copy.authResult}
value={
backendAuthenticated
? copy.passed
: isSubscriptionUnknown
? copy.subscriptionChecking
: copy.restricted
}
/>
{queuedExtensionSummary ? (
<p className="rounded-xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-xs text-cyan-800">
{queuedExtensionSummary}
</p>
) : null}
</section>
</div>
{/* Paywall Mask */}
{canOpenCheckoutOverlay && showOverlay && (
<div className="absolute inset-0 z-30 flex items-center justify-center p-4">
<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()}
onManualPay={() => void createManualPaymentIntent()}
onClose={() => setShowOverlay(false)}
payBusy={paymentBusy}
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
manualPayLabel="手动转账"
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl=""
/>
</div>
)}
</div>
{/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? (
<div
className={`lg:col-span-12 grid grid-cols-1 items-start gap-6 ${
hasTelegramPanel ? "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" : ""
}`}
>
{isTrialSubscription && (
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-sm leading-6 text-amber-900">
{copy.trialPaidGroupLocked}
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
{copy.upgradePro}
</button>
</div>
</section>
)}
{canAccessPaidTelegramGroup && (
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-blue-700">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="mb-6 text-sm text-slate-500">
{copy.telegramHint}
</p>
<div className="mb-4 flex flex-wrap gap-2">
{TELEGRAM_TOPICS_GROUP_URL &&
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL &&
telegramBound ? (
<Link
href={TELEGRAM_TOPICS_GROUP_URL}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-700 hover:bg-emerald-100"
>
{copy.telegramTopicsGroupLink}
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_GROUP_URL && telegramBound ? (
<Link
href={TELEGRAM_GROUP_URL}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs font-semibold text-blue-700 hover:bg-blue-100"
>
{copy.telegramGroupLink}
<ExternalLink size={12} />
</Link>
) : null}
</div>
<div className="flex gap-2">
<code className="flex-grow overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-slate-200 bg-slate-50 p-4 font-mono text-xs text-blue-700">
{bindCommand}
</code>
<button
onClick={() => void openTelegramBotBindLink()}
disabled={telegramBindOpening || !isAuthenticated}
className="rounded-xl border border-cyan-700 bg-cyan-600 px-4 py-3 text-xs font-bold text-white shadow-sm transition-all hover:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-50"
title={copy.telegramBotBindLink}
aria-label={copy.telegramBotBindLink}
>
{telegramBindOpening
? "..."
: copy.telegramBotBindLink}
</button>
<button
onClick={() => handleCopy(bindCommand)}
className="rounded-xl border border-blue-700 bg-blue-600 p-4 text-white shadow-sm transition-all hover:bg-blue-700"
title={copy.copyCommand}
aria-label={copy.copyCommand}
>
{copied ? (
<CheckCircle2 size={20} />
) : (
<Copy size={20} />
)}
</button>
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-400">
{copy.telegramFallbackHint}
</p>
<div className="mt-5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-6 text-amber-800">
{copy.paymentManualSupport}
</div>
</div>
</section>
)}
{/* Payment Details / Wallet Management */}
<section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
<div>
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
<Wallet size={18} /> {copy.paymentMgmt}
</h3>
{paymentError ? (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-700">
{paymentError}
</div>
) : null}
{!paymentError && paymentInfo ? (
<div className="mb-4 rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-[11px] text-cyan-800">
{paymentInfo}
{telegramBindUrl ? (
<a
href={telegramBindUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block break-all text-cyan-700 underline hover:text-cyan-900"
>
{telegramBindUrl}
</a>
) : null}
</div>
) : null}
{!paymentHostAllowed ? (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] ||
"polyweather.top",
)}
</div>
) : null}
<div
data-testid="payment-management-grid"
className={`grid gap-6 lg:items-start ${
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
}`}
>
<div className="space-y-5">
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.proPlan}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{displayPlanList.map((plan) => {
const code = String(plan.plan_code || "");
const active = code === selectedPlanCode;
const isQuarterly = code === "pro_quarterly";
return (
<button
type="button"
key={code}
onClick={() => setSelectedPlanCode(code)}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-3 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="flex items-center justify-between gap-2 text-xs font-bold">
<span>
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
</span>
<span>{plan.amount_usdc} USDC</span>
</div>
<div className="mt-1 text-[10px] opacity-80">
{code} · {plan.duration_days}
</div>
</button>
);
})}
</div>
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
{copy.referralDiscountHint}
</p>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase text-slate-700">
{copy.referralTitle}
</p>
<span className="text-[10px] text-slate-500">
{copy.referralInviteLimit}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div>
) : (
<div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div>
)}
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div
data-testid="payment-guard-grid"
className={`grid gap-3 ${hasTelegramPanel ? "" : "sm:grid-cols-2"}`}
>
<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.paymentNetwork}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-[11px] leading-5 text-slate-500 sm:col-span-2">
{copy.paymentGuardHint}
</p>
</div>
</div>
<div className="space-y-5">
{availableChainList.length > 1 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentNetwork}
</p>
<div className="grid grid-cols-2 gap-2">
{availableChainList.map((chain) => {
const active = chain.chain_id === selectedPaymentChainId;
return (
<button
type="button"
key={chain.chain_id}
onClick={() => {
setSelectedPaymentChainId(chain.chain_id);
const nextToken =
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id &&
token.is_default,
) ||
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id,
);
if (nextToken?.address) {
setSelectedTokenAddress(
String(nextToken.address).toLowerCase(),
);
}
}}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div>
)}
{availableTokenList.length > 0 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{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.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
</div>
<div className="truncate text-[10px] opacity-80">
{token.name}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
{copy.paymentMethodLabel}
</p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
<button
type="button"
onClick={() => setPaymentMethodTab("wallet")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "wallet"
? "bg-white text-blue-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Wallet size={12} />
{copy.paymentMethodWallet}
</button>
<button
type="button"
onClick={() => setPaymentMethodTab("manual")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "manual"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button>
</div>
{paymentMethodTab === "wallet" ? (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentWalletDesc}
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
{copy.paymentGasWarning}
</div>
{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
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-mono">
{shortAddress(w.address)}
</span>
{w.is_primary && (
<span className="text-[8px] bg-blue-500 px-1 rounded text-white font-semibold">
{copy.primary}
</span>
)}
</div>
<div className="text-[10px]">
{chainIdToDisplayName(w.chain_id)}
</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-200 bg-red-50 px-2 py-1 text-[10px] font-semibold text-red-700 transition-all hover:bg-red-100 disabled:opacity-50"
>
<Minus size={12} />
{copy.unbind}
</button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p className="text-xs text-slate-400 italic">
{copy.noWallet}
</p>
</div>
)}
<div className="grid grid-cols-1 gap-2 pt-2">
{injectedProviderOptions.length > 1 && (
<label className="mb-2 block">
<span className="mb-2 block text-[11px] uppercase text-slate-500">
{copy.walletExtensionDetected}
</span>
<select
value={selectedInjectedProviderKey}
onChange={(event) =>
setSelectedInjectedProviderKey(
event.target.value,
)
}
disabled={paymentBusy}
className="w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-xs text-slate-900 outline-none transition-all hover:bg-slate-50 disabled:opacity-60"
>
{injectedProviderOptions.map(
(option) => (
<option
key={option.key}
value={option.key}
className="bg-white text-slate-900"
>
{option.label}
</option>
),
)}
</select>
</label>
)}
<button
onClick={() => {
setProviderMode("auto");
void connectAndBindWallet("auto");
}}
disabled={
paymentBusy || !isAuthenticated
}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white py-3 text-xs font-bold text-slate-700 transition-all hover:bg-slate-50 disabled:opacity-60"
>
<PlusIcon className="w-4 h-4" />{" "}
{copy.bindExt}
</button>
<button
onClick={() => {
setProviderMode("walletconnect");
void connectAndBindWallet(
"walletconnect",
);
}}
disabled={
paymentBusy ||
!isAuthenticated ||
!walletConnectEnabled
}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-cyan-200 bg-cyan-50 py-3 text-xs font-bold text-cyan-700 transition-all hover:bg-cyan-100 disabled:opacity-60"
>
<CreditCard className="w-4 h-4" />{" "}
{copy.bindQr}
</button>
{!walletConnectEnabled && (
<p className="text-[11px] text-slate-500 text-center mt-1">
{copy.walletConnectMissing}
<code className="mx-1">
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
</code>
</p>
)}
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentManualDesc}
</p>
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<div className="flex flex-col gap-3">
<div>
<p className="text-xs font-bold text-emerald-800">
{copy.paymentManualTitle}
</p>
<p className="mt-1 text-[11px] leading-relaxed text-emerald-700">
{copy.paymentManualHint}
</p>
</div>
<button
type="button"
onClick={() =>
void createManualPaymentIntent()
}
disabled={
paymentBusy || !isAuthenticated
}
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 py-2.5 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
>
{copy.paymentManualCreate}
</button>
</div>
{manualPayment ? (
<div className="mt-4 space-y-3 rounded-xl border border-emerald-200 bg-white p-3">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
{copy.paymentAmount}
</p>
<p className="font-mono text-sm font-bold text-slate-950">
{manualPayment.amount_usdc}{" "}
{manualPayment.token_symbol ||
selectedTokenLabel}
</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
{copy.paymentReceiverLabel}
</p>
<div className="mt-1 flex gap-2">
<code className="min-w-0 flex-1 break-all whitespace-normal rounded-lg border border-blue-200 bg-blue-50 px-2 py-2 font-mono text-[11px] text-blue-800">
{
manualPayment.receiver_address
}
</code>
<button
type="button"
onClick={() =>
handleCopy(
manualPayment.receiver_address,
)
}
className="rounded-lg border border-blue-700 bg-blue-600 px-2 text-xs font-bold text-white transition-colors hover:bg-blue-700"
>
{copy.paymentCopyAddress}
</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,
);
void validateTxHash(
manualPayment.intent_id ||
lastIntentId ||
"",
event.target.value,
);
}}
placeholder="0x..."
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
/>
{txValidation.loading ? (
<p className="mt-1 text-[10px] text-slate-500">
{copy.verifying}
</p>
) : txValidation.checked &&
txValidation.valid ? (
<p className="mt-1 text-[10px] text-emerald-700">
{copy.verifyAddressMatch}
</p>
) : txValidation.checked &&
txValidation.valid ===
false ? (
<p className="mt-1 text-[10px] text-red-700">
{txValidation.reason ===
"tx_not_mined"
? copy.verifyTxNotMined
: txValidation.reason ===
"receiver_mismatch"
? copy
.verifyAddressMismatch
: txValidation.reason ===
"amount_insufficient"
? copy
.verifyAmountLow
: txValidation.reason ===
"tx_reverted"
? copy
.verifyTxReverted
: txValidation.detail ||
copy.verifyFailed +
(txValidation.reason ||
copy
.verifyUnknown)}
</p>
) : null}
</div>
<button
type="button"
onClick={() =>
void submitManualPaymentTx()
}
disabled={
paymentBusy ||
!(txValidation.checked &&
txValidation.valid === true)
}
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
>
{copy.paymentManualSubmit}
</button>
</div>
) : null}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</section>
</div>
) : (
<div className="lg:col-span-12 grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="h-48 animate-pulse rounded-2xl border border-slate-200 bg-white md:col-span-2" />
<div className="h-48 animate-pulse rounded-2xl border border-slate-200 bg-white" />
</div>
)}
</main>
<footer className="mt-16 text-center text-slate-600 text-[10px] uppercase tracking-[0.3em] font-mono z-10 pb-8">
copy.footerEngine
</footer>
</div>
);
}