Improve trial upgrade conversion
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { I18nProvider } from "@/hooks/useI18n";
|
import { I18nProvider } from "@/hooks/useI18n";
|
||||||
import { AccountEntry } from "@/components/account/AccountEntry";
|
import { AccountEntry } from "@/components/account/AccountEntry";
|
||||||
|
|
||||||
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
|
|||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
return (
|
return (
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AccountEntry />
|
<Suspense fallback={null}>
|
||||||
|
<AccountEntry />
|
||||||
|
</Suspense>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import type { User } from "@supabase/supabase-js";
|
import type { User } from "@supabase/supabase-js";
|
||||||
import {
|
import {
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
@@ -68,6 +68,7 @@ import { useAccountPayment } from "./useAccountPayment";
|
|||||||
|
|
||||||
export function AccountCenter() {
|
export function AccountCenter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
|
const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
|
||||||
@@ -401,10 +402,11 @@ export function AccountCenter() {
|
|||||||
!isSubscribed && expiryInfo && expiryInfo.expired,
|
!isSubscribed && expiryInfo && expiryInfo.expired,
|
||||||
);
|
);
|
||||||
const paymentFeatureReady = paymentReadyForRecovery;
|
const paymentFeatureReady = paymentReadyForRecovery;
|
||||||
|
const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
|
||||||
const canOpenCheckoutOverlay = Boolean(
|
const canOpenCheckoutOverlay = Boolean(
|
||||||
paymentFeatureReady &&
|
paymentFeatureReady &&
|
||||||
!isSubscriptionUnknown &&
|
!isSubscriptionUnknown &&
|
||||||
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
(canTrialUpgrade || !isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||||
);
|
);
|
||||||
const subscriptionStatusTitle = showExpiredReminder
|
const subscriptionStatusTitle = showExpiredReminder
|
||||||
? copy.proExpiredTitle
|
? copy.proExpiredTitle
|
||||||
@@ -472,6 +474,13 @@ export function AccountCenter() {
|
|||||||
showOverlay,
|
showOverlay,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showOverlay || !canOpenCheckoutOverlay) return;
|
||||||
|
if (searchParams.get("checkout") === "1") {
|
||||||
|
setShowOverlay(true);
|
||||||
|
}
|
||||||
|
}, [canOpenCheckoutOverlay, searchParams, showOverlay]);
|
||||||
|
|
||||||
// ── Referral points display ────────────────────────────
|
// ── Referral points display ────────────────────────────
|
||||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||||
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
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,
|
isEn,
|
||||||
copy,
|
copy,
|
||||||
backend,
|
backend,
|
||||||
|
authUserId,
|
||||||
paymentConfig,
|
paymentConfig,
|
||||||
setPaymentConfig,
|
setPaymentConfig,
|
||||||
selectedPlanCode,
|
selectedPlanCode,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface UsePaymentFlowParams {
|
|||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
copy: Record<string, string>;
|
copy: Record<string, string>;
|
||||||
backend: AuthMeResponse | null;
|
backend: AuthMeResponse | null;
|
||||||
|
authUserId: string;
|
||||||
|
|
||||||
// Shared payment config state
|
// Shared payment config state
|
||||||
paymentConfig: PaymentConfig | null;
|
paymentConfig: PaymentConfig | null;
|
||||||
@@ -122,6 +123,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
isEn,
|
isEn,
|
||||||
copy,
|
copy,
|
||||||
backend,
|
backend,
|
||||||
|
authUserId,
|
||||||
paymentConfig,
|
paymentConfig,
|
||||||
setPaymentConfig,
|
setPaymentConfig,
|
||||||
selectedPlanCode,
|
selectedPlanCode,
|
||||||
@@ -528,6 +530,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
|
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
|
||||||
trackPaymentStart({
|
trackPaymentStart({
|
||||||
entry: "account_center",
|
entry: "account_center",
|
||||||
|
user_id: authUserId || null,
|
||||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||||
intent_id: intentId,
|
intent_id: intentId,
|
||||||
use_points: billing.canRedeem && usePoints,
|
use_points: billing.canRedeem && usePoints,
|
||||||
@@ -634,6 +637,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
|
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
|
||||||
trackPaymentSuccess({
|
trackPaymentSuccess({
|
||||||
entry: "account_center",
|
entry: "account_center",
|
||||||
|
user_id: authUserId || null,
|
||||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||||
intent_id: intentId,
|
intent_id: intentId,
|
||||||
tx_hash: txHashNorm,
|
tx_hash: txHashNorm,
|
||||||
@@ -732,6 +736,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
);
|
);
|
||||||
trackPaymentStart({
|
trackPaymentStart({
|
||||||
entry: "account_center_manual_transfer",
|
entry: "account_center_manual_transfer",
|
||||||
|
user_id: authUserId || null,
|
||||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||||
intent_id: intentId,
|
intent_id: intentId,
|
||||||
payment_mode: "direct",
|
payment_mode: "direct",
|
||||||
@@ -803,6 +808,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
setTxValidation({ loading: false, checked: false });
|
setTxValidation({ loading: false, checked: false });
|
||||||
trackPaymentSuccess({
|
trackPaymentSuccess({
|
||||||
entry: "account_center_manual_transfer",
|
entry: "account_center_manual_transfer",
|
||||||
|
user_id: authUserId || null,
|
||||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||||
intent_id: intentIdVal,
|
intent_id: intentIdVal,
|
||||||
tx_hash: txHashNorm,
|
tx_hash: txHashNorm,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Crown,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -612,6 +613,8 @@ function PolyWeatherTerminal({
|
|||||||
selectedRow,
|
selectedRow,
|
||||||
setSelectedRow,
|
setSelectedRow,
|
||||||
toggleLocale,
|
toggleLocale,
|
||||||
|
isTrialTerminalAccess,
|
||||||
|
trialSubscriptionExpiresAt,
|
||||||
userLocalTime,
|
userLocalTime,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -632,6 +635,8 @@ function PolyWeatherTerminal({
|
|||||||
selectedRow: ScanOpportunityRow | null;
|
selectedRow: ScanOpportunityRow | null;
|
||||||
setSelectedRow: (row: ScanOpportunityRow) => void;
|
setSelectedRow: (row: ScanOpportunityRow) => void;
|
||||||
toggleLocale: () => void;
|
toggleLocale: () => void;
|
||||||
|
isTrialTerminalAccess: boolean;
|
||||||
|
trialSubscriptionExpiresAt: string | null;
|
||||||
userLocalTime: string;
|
userLocalTime: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (val: string) => void;
|
setSearchQuery: (val: string) => void;
|
||||||
@@ -668,6 +673,17 @@ function PolyWeatherTerminal({
|
|||||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
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) => {
|
const handleSelectNav = useCallback((key: string) => {
|
||||||
setActiveNavKey(key);
|
setActiveNavKey(key);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -957,6 +973,17 @@ function PolyWeatherTerminal({
|
|||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<UpdateAnnouncementButton isEn={isEn} />
|
<UpdateAnnouncementButton isEn={isEn} />
|
||||||
</div>
|
</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 && (
|
{onlineCount != null && (
|
||||||
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
|
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
|
||||||
<Users size={12} />
|
<Users size={12} />
|
||||||
@@ -1630,6 +1657,10 @@ function ScanTerminalScreen() {
|
|||||||
},
|
},
|
||||||
[cityFallbackRows, terminalData?.rows],
|
[cityFallbackRows, terminalData?.rows],
|
||||||
);
|
);
|
||||||
|
const isTrialTerminalAccess = Boolean(
|
||||||
|
isPro &&
|
||||||
|
String(proAccess.subscriptionPlanCode || "").toLowerCase().includes("signup_trial"),
|
||||||
|
);
|
||||||
|
|
||||||
const fallbackFetchedRef = useRef(false);
|
const fallbackFetchedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1743,6 +1774,10 @@ function ScanTerminalScreen() {
|
|||||||
selectedRow={selectedRow}
|
selectedRow={selectedRow}
|
||||||
setSelectedRow={handleSelectRow}
|
setSelectedRow={handleSelectRow}
|
||||||
toggleLocale={toggleLocale}
|
toggleLocale={toggleLocale}
|
||||||
|
isTrialTerminalAccess={isTrialTerminalAccess}
|
||||||
|
trialSubscriptionExpiresAt={
|
||||||
|
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
|
||||||
|
}
|
||||||
userLocalTime={userLocalTime}
|
userLocalTime={userLocalTime}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={setSearchQuery}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const ACCESS_TERM = {
|
|||||||
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
|
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
|
||||||
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
|
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
|
||||||
month: { en: "/ 30 days", zh: "/ 30 天" },
|
month: { en: "/ 30 days", zh: "/ 30 天" },
|
||||||
subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
|
subscribeNow: { en: "Subscribe & Activate", zh: "立即订阅并激活" },
|
||||||
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
|
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Link
|
<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"
|
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} />
|
<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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getOpsPaidConversionKpi } from "@/lib/ops-conversion";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
|
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 paid = memberships.filter((m) => !m.is_trial).length;
|
||||||
const trials = memberships.filter((m) => m.is_trial).length;
|
const trials = memberships.filter((m) => m.is_trial).length;
|
||||||
const steps = funnel?.steps ?? [];
|
const steps = funnel?.steps ?? [];
|
||||||
const totalUsers = steps[0]?.count ?? 0;
|
|
||||||
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
|
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
|
||||||
const payingUsers = stepByKey.payment_success?.count ?? 0;
|
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 cache = status?.cache;
|
||||||
const cacheAnalysis = cache?.analysis;
|
const cacheAnalysis = cache?.analysis;
|
||||||
const td = status?.training_data;
|
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">
|
<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/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/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/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/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" />
|
<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