Improve trial upgrade conversion

This commit is contained in:
2569718930@qq.com
2026-06-09 18:17:19 +08:00
parent c98ff34c1e
commit 7b965ac60d
11 changed files with 221 additions and 8 deletions
+4 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { I18nProvider } from "@/hooks/useI18n";
import { AccountEntry } from "@/components/account/AccountEntry";
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
export default function AccountPage() {
return (
<I18nProvider>
<AccountEntry />
<Suspense fallback={null}>
<AccountEntry />
</Suspense>
</I18nProvider>
);
}
+11 -2
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import type { User } from "@supabase/supabase-js";
import {
User as UserIcon,
@@ -68,6 +68,7 @@ import { useAccountPayment } from "./useAccountPayment";
export function AccountCenter() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale } = useI18n();
const isEn = locale === "en-US";
const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
@@ -401,10 +402,11 @@ export function AccountCenter() {
!isSubscribed && expiryInfo && expiryInfo.expired,
);
const paymentFeatureReady = paymentReadyForRecovery;
const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
const canOpenCheckoutOverlay = Boolean(
paymentFeatureReady &&
!isSubscriptionUnknown &&
(!isSubscribed || showExpiringSoon || showExpiredReminder),
(canTrialUpgrade || !isSubscribed || showExpiringSoon || showExpiredReminder),
);
const subscriptionStatusTitle = showExpiredReminder
? copy.proExpiredTitle
@@ -472,6 +474,13 @@ export function AccountCenter() {
showOverlay,
]);
useEffect(() => {
if (showOverlay || !canOpenCheckoutOverlay) return;
if (searchParams.get("checkout") === "1") {
setShowOverlay(true);
}
}, [canOpenCheckoutOverlay, searchParams, showOverlay]);
// ── Referral points display ────────────────────────────
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const accountCenter = fs.readFileSync(path.join(accountDir, "AccountCenter.tsx"), "utf8");
const paymentFlow = fs.readFileSync(path.join(accountDir, "usePaymentFlow.ts"), "utf8");
const accountPayment = fs.readFileSync(path.join(accountDir, "useAccountPayment.ts"), "utf8");
const productAccess = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "ProductAccessRequired.tsx"),
"utf8",
);
assert(
accountCenter.includes("canTrialUpgrade") &&
accountCenter.includes("isTrialSubscription") &&
accountCenter.includes("canTrialUpgrade || !isSubscribed"),
"trial subscribers must be allowed to open the Pro checkout before expiry",
);
assert(
accountCenter.includes("useSearchParams") &&
accountCenter.includes('searchParams.get("checkout") === "1"') &&
accountCenter.includes("setShowOverlay(true)"),
"account page must support /account?checkout=1 for direct upgrade entry",
);
assert(
productAccess.includes('href="/account?checkout=1"') &&
productAccess.includes("Subscribe & Activate") &&
productAccess.includes("立即订阅并激活"),
"expired terminal gate must send users directly to the checkout entry, not a generic account page",
);
assert(
accountPayment.includes("authUserId,") &&
paymentFlow.includes("authUserId: string") &&
paymentFlow.includes("user_id: authUserId || null"),
"payment_start and payment_success analytics must carry the authenticated user id for trial-to-paid attribution",
);
}
@@ -487,6 +487,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
isEn,
copy,
backend,
authUserId,
paymentConfig,
setPaymentConfig,
selectedPlanCode,
@@ -40,6 +40,7 @@ export interface UsePaymentFlowParams {
isEn: boolean;
copy: Record<string, string>;
backend: AuthMeResponse | null;
authUserId: string;
// Shared payment config state
paymentConfig: PaymentConfig | null;
@@ -122,6 +123,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
isEn,
copy,
backend,
authUserId,
paymentConfig,
setPaymentConfig,
selectedPlanCode,
@@ -528,6 +530,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
trackPaymentStart({
entry: "account_center",
user_id: authUserId || null,
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
use_points: billing.canRedeem && usePoints,
@@ -634,6 +637,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
trackPaymentSuccess({
entry: "account_center",
user_id: authUserId || null,
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
@@ -732,6 +736,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
);
trackPaymentStart({
entry: "account_center_manual_transfer",
user_id: authUserId || null,
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
payment_mode: "direct",
@@ -803,6 +808,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setTxValidation({ loading: false, checked: false });
trackPaymentSuccess({
entry: "account_center_manual_transfer",
user_id: authUserId || null,
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentIdVal,
tx_hash: txHashNorm,
@@ -9,6 +9,7 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Crown,
GraduationCap,
Menu,
MessageSquare,
@@ -612,6 +613,8 @@ function PolyWeatherTerminal({
selectedRow,
setSelectedRow,
toggleLocale,
isTrialTerminalAccess,
trialSubscriptionExpiresAt,
userLocalTime,
searchQuery,
setSearchQuery,
@@ -632,6 +635,8 @@ function PolyWeatherTerminal({
selectedRow: ScanOpportunityRow | null;
setSelectedRow: (row: ScanOpportunityRow) => void;
toggleLocale: () => void;
isTrialTerminalAccess: boolean;
trialSubscriptionExpiresAt: string | null;
userLocalTime: string;
searchQuery: string;
setSearchQuery: (val: string) => void;
@@ -668,6 +673,17 @@ function PolyWeatherTerminal({
const [onlineCount, setOnlineCount] = useState<number | null>(null);
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
const trialExpiryMs = Date.parse(String(trialSubscriptionExpiresAt || ""));
const trialHoursLeft = Number.isFinite(trialExpiryMs)
? Math.max(0, Math.ceil((trialExpiryMs - Date.now()) / 3_600_000))
: null;
const trialUpgradeLabel = isEn
? trialHoursLeft != null
? `Trial ${trialHoursLeft}h left`
: "Trial access"
: trialHoursLeft != null
? `试用剩 ${trialHoursLeft} 小时`
: "试用中";
const handleSelectNav = useCallback((key: string) => {
setActiveNavKey(key);
}, []);
@@ -957,6 +973,17 @@ function PolyWeatherTerminal({
<div className="hidden lg:block">
<UpdateAnnouncementButton isEn={isEn} />
</div>
{isTrialTerminalAccess && (
<Link
href="/account?checkout=1"
className="inline-flex h-7 items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2.5 text-[11px] font-black text-amber-800 transition hover:bg-amber-100"
title={isEn ? "Upgrade to Pro" : "升级 Pro"}
>
<Crown size={12} />
<span className="hidden sm:inline">{trialUpgradeLabel}</span>
<span>{isEn ? "Upgrade" : "升级"}</span>
</Link>
)}
{onlineCount != null && (
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
<Users size={12} />
@@ -1630,6 +1657,10 @@ function ScanTerminalScreen() {
},
[cityFallbackRows, terminalData?.rows],
);
const isTrialTerminalAccess = Boolean(
isPro &&
String(proAccess.subscriptionPlanCode || "").toLowerCase().includes("signup_trial"),
);
const fallbackFetchedRef = useRef(false);
useEffect(() => {
@@ -1743,6 +1774,10 @@ function ScanTerminalScreen() {
selectedRow={selectedRow}
setSelectedRow={handleSelectRow}
toggleLocale={toggleLocale}
isTrialTerminalAccess={isTrialTerminalAccess}
trialSubscriptionExpiresAt={
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
}
userLocalTime={userLocalTime}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
@@ -7,7 +7,7 @@ const ACCESS_TERM = {
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
month: { en: "/ 30 days", zh: "/ 30 天" },
subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
subscribeNow: { en: "Subscribe & Activate", zh: "立即订阅并激活" },
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
} as const;
@@ -77,7 +77,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
</ul>
<Link
href="/account"
href="/account?checkout=1"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-black text-white shadow-sm transition hover:bg-blue-700"
>
<CreditCard size={16} />
@@ -0,0 +1,26 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const dashboardSource = fs.readFileSync(
path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
assert(
dashboardSource.includes("isTrialTerminalAccess") &&
dashboardSource.includes("trialUpgradeLabel") &&
dashboardSource.includes('href="/account?checkout=1"'),
"terminal header must show a non-blocking trial countdown with a direct Pro upgrade entry",
);
assert(
dashboardSource.includes("subscriptionPlanCode") &&
dashboardSource.includes("signup_trial"),
"terminal trial upgrade nudge must be driven by the active trial subscription plan, not a generic subscribed state",
);
}
@@ -0,0 +1,25 @@
import { getOpsPaidConversionKpi } from "@/lib/ops-conversion";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const trialKpi = getOpsPaidConversionKpi([
{ key: "landing_view", label: "访问落地页", count: 909, uniqueActors: 500 },
{ key: "trial_created", label: "试用开通", count: 24, uniqueActors: 20 },
{ key: "payment_success", label: "支付成功", count: 11, uniqueActors: 10 },
]);
assert(trialKpi.rateLabel === "50.0%", "ops overview paid conversion should use trial unique actors before landing sessions");
assert(trialKpi.subLabel === "试用 20 → 10 · 访客 2.0%", "ops overview paid conversion should keep visitor conversion as secondary context");
const signupKpi = getOpsPaidConversionKpi([
{ key: "landing_view", label: "访问落地页", count: 100, uniqueActors: 80 },
{ key: "signup_success", label: "注册成功", count: 8, uniqueActors: 8 },
{ key: "payment_success", label: "支付成功", count: 2, uniqueActors: 2 },
]);
assert(signupKpi.rateLabel === "25.0%", "ops overview paid conversion should fall back to signup unique actors when no trial data exists");
assert(signupKpi.subLabel === "注册 8 → 2 · 访客 2.5%", "ops overview paid conversion should label the active denominator");
}
@@ -7,6 +7,7 @@ import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getOpsPaidConversionKpi } from "@/lib/ops-conversion";
import { opsApi } from "@/lib/ops-api";
import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
@@ -76,10 +77,9 @@ export function OverviewPageClient() {
const paid = memberships.filter((m) => !m.is_trial).length;
const trials = memberships.filter((m) => m.is_trial).length;
const steps = funnel?.steps ?? [];
const totalUsers = steps[0]?.count ?? 0;
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
const payingUsers = stepByKey.payment_success?.count ?? 0;
const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—";
const paidConversion = getOpsPaidConversionKpi(steps);
const cache = status?.cache;
const cacheAnalysis = cache?.analysis;
const td = status?.training_data;
@@ -133,7 +133,7 @@ export function OverviewPageClient() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<KpiCard href="/ops/system" icon={Activity} label="系统" value={status?.db?.ok ? "OK" : "FAIL"} color={status?.db?.ok ? "text-emerald-400" : "text-red-400"} />
<KpiCard href="/ops/memberships" icon={Users} label="付费会员" value={paid} color="text-cyan-400" sub={trials > 0 ? `+${trials} 体验` : undefined} />
<KpiCard href="/ops/analytics" icon={TrendingUp} label="30天转" value={`${convRate}%`} color="text-blue-400" sub={`${totalUsers}${payingUsers}`} />
<KpiCard href="/ops/analytics" icon={TrendingUp} label="30天转付费" value={paidConversion.rateLabel} color="text-blue-400" sub={paidConversion.subLabel} />
<KpiCard href="/ops/training" icon={Database} label="真值记录" value={truthRows} color="text-purple-400" sub={`${coverage?.with_truth_rows ?? 0} 城市`} />
<KpiCard href="/ops/payments" icon={CreditCard} label="支付成功" value={payingUsers} color="text-emerald-400" sub="30天内" />
<KpiCard href="/ops/system" icon={Cpu} label="概率引擎" value={status?.probability?.engine_mode ?? "—"} color="text-amber-400" />
+62
View File
@@ -0,0 +1,62 @@
type OpsFunnelStep = {
key?: string;
label?: string;
count?: number;
uniqueActors?: number;
};
type ConversionBase = {
key: string;
label: string;
};
const PAID_CONVERSION_BASES: ConversionBase[] = [
{ key: "trial_created", label: "试用" },
{ key: "signup_success", label: "注册" },
{ key: "enter_terminal", label: "终端" },
{ key: "landing_view", label: "访客" },
];
function finiteNonNegative(value: unknown) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
function stepValue(step?: OpsFunnelStep) {
return finiteNonNegative(step?.uniqueActors) || finiteNonNegative(step?.count);
}
function formatRate(numerator: number, denominator: number) {
if (denominator <= 0) return "—";
return `${((numerator / denominator) * 100).toFixed(1)}%`;
}
export function getOpsPaidConversionKpi(steps: OpsFunnelStep[]) {
const stepByKey = Object.fromEntries(
steps.map((step) => [step.key || step.label || "", step]),
);
const paid = stepValue(stepByKey.payment_success);
const base = PAID_CONVERSION_BASES
.map((candidate) => ({
...candidate,
value: stepValue(stepByKey[candidate.key]),
}))
.find((candidate) => candidate.value > 0);
const denominator = base?.value ?? 0;
const landing = stepValue(stepByKey.landing_view);
const visitorRate = landing > 0 ? formatRate(paid, landing) : "—";
const visitorContext =
base && base.key !== "landing_view" && visitorRate !== "—"
? ` · 访客 ${visitorRate}`
: "";
return {
rateLabel: formatRate(paid, denominator),
subLabel: base
? `${base.label} ${denominator}${paid}${visitorContext}`
: `支付 ${paid}`,
numerator: paid,
denominator,
denominatorKey: base?.key ?? null,
};
}