Improve trial upgrade conversion
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user