From 7b965ac60d32b96c5bbf71186b2e9714873ff6e7 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 9 Jun 2026 18:17:19 +0800 Subject: [PATCH] Improve trial upgrade conversion --- frontend/app/account/page.tsx | 5 +- frontend/components/account/AccountCenter.tsx | 13 +++- .../__tests__/trialUpgradeConversion.test.ts | 46 ++++++++++++++ .../components/account/useAccountPayment.ts | 1 + frontend/components/account/usePaymentFlow.ts | 6 ++ .../dashboard/ScanTerminalDashboard.tsx | 35 +++++++++++ .../scan-terminal/ProductAccessRequired.tsx | 4 +- .../__tests__/trialUpgradeNudge.test.ts | 26 ++++++++ .../ops/__tests__/opsConversionKpi.test.ts | 25 ++++++++ .../ops/overview/OverviewPageClient.tsx | 6 +- frontend/lib/ops-conversion.ts | 62 +++++++++++++++++++ 11 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 frontend/components/account/__tests__/trialUpgradeConversion.test.ts create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts create mode 100644 frontend/components/ops/__tests__/opsConversionKpi.test.ts create mode 100644 frontend/lib/ops-conversion.ts diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx index 8a9abd4f..d6b1df39 100644 --- a/frontend/app/account/page.tsx +++ b/frontend/app/account/page.tsx @@ -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 ( - + + + ); } diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 10d634dd..5df949cd 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -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) diff --git a/frontend/components/account/__tests__/trialUpgradeConversion.test.ts b/frontend/components/account/__tests__/trialUpgradeConversion.test.ts new file mode 100644 index 00000000..00ad2228 --- /dev/null +++ b/frontend/components/account/__tests__/trialUpgradeConversion.test.ts @@ -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", + ); +} diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index e9a599e7..98972c22 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -487,6 +487,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { isEn, copy, backend, + authUserId, paymentConfig, setPaymentConfig, selectedPlanCode, diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index db15dbad..f544dadb 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -40,6 +40,7 @@ export interface UsePaymentFlowParams { isEn: boolean; copy: Record; 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, diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 75f49e4b..9b119285 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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(null); const [feedbackDraft, setFeedbackDraft] = useState(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({
+ {isTrialTerminalAccess && ( + + + {trialUpgradeLabel} + {isEn ? "Upgrade" : "升级"} + + )} {onlineCount != null && (
@@ -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} diff --git a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx index 3c5cd7df..d03e3f76 100644 --- a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx +++ b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx @@ -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 }) { diff --git a/frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts new file mode 100644 index 00000000..3a46b591 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts @@ -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", + ); +} diff --git a/frontend/components/ops/__tests__/opsConversionKpi.test.ts b/frontend/components/ops/__tests__/opsConversionKpi.test.ts new file mode 100644 index 00000000..ff9c533d --- /dev/null +++ b/frontend/components/ops/__tests__/opsConversionKpi.test.ts @@ -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"); +} diff --git a/frontend/components/ops/overview/OverviewPageClient.tsx b/frontend/components/ops/overview/OverviewPageClient.tsx index b52d9d6e..02ab1cde 100644 --- a/frontend/components/ops/overview/OverviewPageClient.tsx +++ b/frontend/components/ops/overview/OverviewPageClient.tsx @@ -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() {
0 ? `+${trials} 体验` : undefined} /> - + diff --git a/frontend/lib/ops-conversion.ts b/frontend/lib/ops-conversion.ts new file mode 100644 index 00000000..88fb2f0b --- /dev/null +++ b/frontend/lib/ops-conversion.ts @@ -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, + }; +}