diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 07bebbaf..5d8339e7 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -196,19 +196,49 @@ export function AccountCenter() { 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, }); } - }, [authIsAuthenticated, 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(() => { diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index 4f6701f6..e225ebc3 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -203,6 +203,27 @@ export function runTests() { accountCenterSource.includes('trackAppEvent("dashboard_active"'), "account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data", ); + assert( + appAnalyticsSource.includes('| "landing_view"') && + appAnalyticsSource.includes('| "enter_terminal"') && + appAnalyticsSource.includes('| "login_start"') && + appAnalyticsSource.includes('| "signup_success"') && + appAnalyticsSource.includes('| "trial_created"') && + appAnalyticsSource.includes('| "payment_start"') && + appAnalyticsSource.includes('| "payment_success"'), + "app analytics must expose the standard growth funnel event names", + ); + assert( + accountCenterSource.includes('trackAppEvent("signup_success"') && + accountCenterSource.includes('trackAppEvent("trial_created"') && + accountCenterSource.includes("subscription_is_trial"), + "account center must emit signup_success and trial_created for the standard funnel", + ); + assert( + paymentFlowSource.includes('trackAppEvent("payment_start"') && + paymentFlowSource.includes('trackAppEvent("payment_success"'), + "payment flow must emit payment_start and payment_success alongside legacy checkout events", + ); assert( accountCenterSource.includes("isSubscriptionUnknown") && accountCenterSource.includes("subscriptionStatusLabel") && diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 5f1f6540..869d654f 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -174,6 +174,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { const planList = paymentConfig?.plans || []; const effectivePlanList = planList; const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; + const trackPaymentStart = useCallback((payload: Record) => { + trackAppEvent("checkout_started", payload); + trackAppEvent("payment_start", payload); + }, []); + const trackPaymentSuccess = useCallback((payload: Record) => { + trackAppEvent("checkout_succeeded", payload); + trackAppEvent("payment_success", payload); + }, []); const availableChainList: PaymentChainOption[] = useMemo(() => { const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : []; @@ -345,7 +353,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { if (status === "confirmed") { setPaymentError(""); setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash))); - trackAppEvent("checkout_succeeded", { + trackPaymentSuccess({ entry: "account_center", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentId, @@ -363,7 +371,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { } throw new Error(copy.paymentPendingTimeout); }, - [loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code], + [loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess], ); // ── createIntentAndPay ────────────────────────────────── @@ -484,7 +492,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { const intentId = String(created.intent?.intent_id || ""); const txPayload = created.tx_payload; if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid); - trackAppEvent("checkout_started", { + trackPaymentStart({ entry: "account_center", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentId, @@ -582,7 +590,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { } setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm))); - trackAppEvent("checkout_succeeded", { + trackPaymentSuccess({ entry: "account_center", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentId, @@ -676,7 +684,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { .replace("{chain}", chainName) .replace("{receiver}", shortAddress(direct.receiver_address)), ); - trackAppEvent("checkout_started", { + trackPaymentStart({ entry: "account_center_manual_transfer", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentId, @@ -739,7 +747,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { setManualPayment(null); setManualTxHash(""); setTxValidation({ loading: false, checked: false }); - trackAppEvent("checkout_succeeded", { + trackPaymentSuccess({ entry: "account_center_manual_transfer", plan_code: selectedPlan?.plan_code || "pro_monthly", intent_id: intentIdVal, diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index e3616d06..be8dec7f 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -14,7 +14,7 @@ import { UserRound, Users, } from "lucide-react"; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; @@ -60,6 +60,7 @@ import { cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, } from "@/components/dashboard/scan-terminal/city-fallback-rows"; +import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; function createEmptyAccess(loading = true): ProAccessState { return { @@ -1184,6 +1185,17 @@ function ScanTerminalScreen() { timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null, tradingRegion: selectedRegionKey, }); + + useEffect(() => { + if (!hydrated || !isAuthenticated || !isPro) return; + const actorKey = String(proAccess.userId || "local").toLowerCase(); + if (markAnalyticsOnce(`enter_terminal:${actorKey}`, "session")) { + trackAppEvent("enter_terminal", { + entry: "terminal", + user_id: proAccess.userId || null, + }); + } + }, [hydrated, isAuthenticated, isPro, proAccess.userId]); const handleRefresh = useCallback(() => { clearCityDetailCache(); refreshScanTerminalManually(); @@ -1223,11 +1235,12 @@ function ScanTerminalScreen() { return () => controller.abort(); }, [isPro]); const [searchQuery, setSearchQuery] = useState(""); + const deferredSearchQuery = useDeferredValue(searchQuery); const searchInputRef = useRef(null); const filteredRows = useMemo(() => { - if (!searchQuery.trim()) return rows; - const q = searchQuery.toLowerCase().trim(); + if (!deferredSearchQuery.trim()) return rows; + const q = deferredSearchQuery.toLowerCase().trim(); return rows.filter((row) => { const haystack = [ row.city, @@ -1246,7 +1259,7 @@ function ScanTerminalScreen() { .map((v) => String(v).toLowerCase()); return haystack.some((s) => s.includes(q)); }); - }, [rows, searchQuery]); + }, [rows, deferredSearchQuery]); const [selectedId, setSelectedId] = useState(null); const [selectedCity, setSelectedCity] = useState(null); diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 4b8adb9a..af0fc9a4 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -1,7 +1,7 @@ "use client"; import clsx from "clsx"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; @@ -465,12 +465,12 @@ export function LiveTemperatureThresholdChart({ return series; }, [series]); - const isSeriesVisible = (sKey: string) => { + const isSeriesVisible = useCallback((sKey: string) => { if (userToggledKeys[sKey] !== undefined) { return userToggledKeys[sKey]; } return isTemperatureSeriesVisibleByDefault(city, sKey); - }; + }, [city, userToggledKeys]); const activeSeries = useMemo(() => { return getActiveTemperatureSeries( @@ -568,6 +568,62 @@ export function LiveTemperatureThresholdChart({ const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : ""; + const handleZoomReset = useCallback(() => { + setZoomRange(null); + }, []); + + const handleViewModeChange = useCallback((mode: "auto" | "full") => { + setViewMode(mode); + setZoomRange(null); + }, []); + + const handleMouseDown = useCallback((e: any) => { + if (compact || !e) return; + if (typeof e.activeTooltipIndex === "number") { + setRefAreaLeft(e.activeTooltipIndex); + setRefAreaRight(e.activeTooltipIndex); + } + }, [compact]); + + const handleMouseMove = useCallback((e: any) => { + if (compact || !e || refAreaLeft === null) return; + if (typeof e.activeTooltipIndex === "number") { + setRefAreaRight(e.activeTooltipIndex); + } + }, [compact, refAreaLeft]); + + const handleMouseUp = useCallback(() => { + if (refAreaLeft === null || refAreaRight === null) { + setRefAreaLeft(null); + setRefAreaRight(null); + return; + } + + let leftIdx = refAreaLeft; + let rightIdx = refAreaRight; + + if (leftIdx > rightIdx) { + [leftIdx, rightIdx] = [rightIdx, leftIdx]; + } + + if (rightIdx - leftIdx >= 1) { + const originalStartIndex = visibleRange ? visibleRange[0] : 0; + const newStart = originalStartIndex + leftIdx; + const newEnd = originalStartIndex + rightIdx; + setZoomRange([newStart, newEnd]); + } + + setRefAreaLeft(null); + setRefAreaRight(null); + }, [refAreaLeft, refAreaRight, visibleRange]); + + const handleSeriesToggle = useCallback((seriesKey: string) => { + setUserToggledKeys((prev) => ({ + ...prev, + [seriesKey]: !isSeriesVisible(seriesKey), + })); + }, [isSeriesVisible]); + const panelTitle = row ? (
)} - ); const handleMouseDown = (e: any) => { - if (compact || !e) return; - if (typeof e.activeTooltipIndex === "number") { - setRefAreaLeft(e.activeTooltipIndex); - setRefAreaRight(e.activeTooltipIndex); - } - }; - - const handleMouseMove = (e: any) => { - if (compact || !e || refAreaLeft === null) return; - if (typeof e.activeTooltipIndex === "number") { - setRefAreaRight(e.activeTooltipIndex); - } - }; - - const handleMouseUp = () => { - if (refAreaLeft === null || refAreaRight === null) { - setRefAreaLeft(null); - setRefAreaRight(null); - return; - } - - let leftIdx = refAreaLeft; - let rightIdx = refAreaRight; - - if (leftIdx > rightIdx) { - [leftIdx, rightIdx] = [rightIdx, leftIdx]; - } - - if (rightIdx - leftIdx >= 1) { - const originalStartIndex = visibleRange ? visibleRange[0] : 0; - const newStart = originalStartIndex + leftIdx; - const newEnd = originalStartIndex + rightIdx; - setZoomRange([newStart, newEnd]); - } - - setRefAreaLeft(null); - setRefAreaRight(null); - }; + ); return ( setZoomRange(null)} + onZoomReset={handleZoomReset} isSeriesVisible={isSeriesVisible} - onSeriesToggle={(seriesKey) => { - setUserToggledKeys((prev) => ({ - ...prev, - [seriesKey]: !isSeriesVisible(seriesKey), - })); - }} + onSeriesToggle={handleSeriesToggle} onShowRunwayDetailsChange={setShowRunwayDetails} /> diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx index 0ca50dba..07e4a532 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx @@ -1,7 +1,7 @@ "use client"; import clsx from "clsx"; -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { Area, CartesianGrid, @@ -28,7 +28,7 @@ type CityThreshold = { kind: "gte" | "lte"; }; -export function TemperatureChartCanvas({ +function TemperatureChartCanvasComponent({ isEn, compact, timeframe, @@ -349,3 +349,5 @@ export function TemperatureChartCanvas({ ); } + +export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index b503971d..21044681 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -102,4 +102,20 @@ export function runTests() { dashboardSource.indexOf('event === "TOKEN_REFRESHED"'), "terminal auth listener must hydrate access from Supabase INITIAL_SESSION events during first navigation from the landing page", ); + assert( + dashboardSource.includes("useDeferredValue") && + dashboardSource.includes("deferredSearchQuery") && + dashboardSource.includes("[rows, deferredSearchQuery]"), + "terminal search must defer expensive row filtering so typing stays responsive", + ); + assert( + dashboardSource.includes('trackAppEvent("enter_terminal"') && + dashboardSource.includes('entry: "terminal"'), + "terminal must emit enter_terminal when an entitled user reaches the dashboard", + ); + assert( + chartCanvasSource.includes("memo(") && + chartCanvasSource.includes("TemperatureChartCanvasComponent"), + "temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts", + ); } diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index 43e62814..c17a4cb0 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -21,6 +21,7 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; const COVERAGE_EN = [ "Live airport observations", @@ -76,6 +77,12 @@ function InstitutionalLandingScreen() { const [isAuthenticated, setIsAuthenticated] = useState(false); const [authChecked, setAuthChecked] = useState(false); + useEffect(() => { + if (markAnalyticsOnce("landing_view", "session")) { + trackAppEvent("landing_view", { entry: "landing" }); + } + }, []); + useEffect(() => { if (!hasSupabasePublicEnv()) { setAuthChecked(true); @@ -90,6 +97,26 @@ function InstitutionalLandingScreen() { const coverage = isEn ? COVERAGE_EN : COVERAGE_ZH; + const trackLoginStart = (mode: "login" | "signup") => { + trackAppEvent("login_start", { + entry: "landing", + mode, + next: "/terminal", + }); + }; + + const trackEnterTerminal = (entry: string) => { + trackAppEvent("enter_terminal", { + entry, + authenticated: isAuthenticated, + }); + }; + + const trackTerminalAuthStart = (entry: string, mode: "login" | "signup") => { + trackEnterTerminal(entry); + trackLoginStart(mode); + }; + const platformCards = isEn ? [ { @@ -176,6 +203,7 @@ function InstitutionalLandingScreen() {
trackEnterTerminal("landing_header")} className="inline-flex h-9 items-center gap-2 rounded-md bg-slate-950 px-3 text-sm font-semibold text-white hover:bg-slate-800" > {isEn ? "Open" : "进入"} @@ -193,12 +221,14 @@ function InstitutionalLandingScreen() { <> trackTerminalAuthStart("landing_header_login", "login")} className="hidden h-9 items-center rounded-md px-3 text-sm font-semibold text-slate-600 hover:text-slate-950 sm:inline-flex" > {isEn ? "Log in" : "登录"} trackTerminalAuthStart("landing_header_signup", "signup")} className="inline-flex h-9 items-center gap-2 rounded-md bg-slate-950 px-3 text-sm font-semibold text-white hover:bg-slate-800" > {isEn ? "Start" : "开始使用"} @@ -226,6 +256,11 @@ function InstitutionalLandingScreen() {
+ authChecked && isAuthenticated + ? trackEnterTerminal("landing_hero") + : trackTerminalAuthStart("landing_hero_login", "login") + } className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-slate-950 px-5 text-sm font-bold text-white shadow-sm hover:bg-slate-800" > {isEn ? "Open product" : "进入产品"} @@ -252,11 +287,19 @@ function InstitutionalLandingScreen() { polyweather.app/terminal
- {isEn +
+ {isEn +
diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts index 3f9ffef9..b890cb15 100644 --- a/frontend/components/landing/__tests__/landingPricingReferral.test.ts +++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts @@ -6,18 +6,40 @@ function assert(condition: unknown, message: string) { } export function runTests() { + const root = projectRoot(); const source = fs.readFileSync( - path.join(projectRoot(), "components", "landing", "InstitutionalLandingPage.tsx"), + path.join(root, "components", "landing", "InstitutionalLandingPage.tsx"), "utf8", ); - const appPageSource = fs.readFileSync(path.join(projectRoot(), "app", "page.tsx"), "utf8"); + const appPageSource = fs.readFileSync(path.join(root, "app", "page.tsx"), "utf8"); + const pngPath = path.join(root, "public", "static", "web.png"); + const webpPath = path.join(root, "public", "static", "web.webp"); assert(source.includes("3 天免费试用"), "landing page must advertise the 3-day trial"); assert(source.includes("试用期权益和 Pro 一致,除了不显示付费 Telegram 群链接"), "landing page must state trial access matches Pro except the paid group link"); assert(!source.includes("高频刷新与 API 仍为 Pro 权益"), "landing page must not incorrectly exclude high-frequency refresh or API from trial access"); assert(source.includes("bg-[#fbfbfa]"), "landing page must use a light Notion-style background"); assert(source.includes("WeatherWorkflowIllustration"), "landing page must include a friendly illustration surface"); - assert(source.includes("/static/web.png"), "landing page must show the product preview image"); + assert(fs.existsSync(webpPath), "landing page must ship a WebP preview image for the LCP product screenshot"); + assert( + fs.statSync(webpPath).size < fs.statSync(pngPath).size * 0.65, + "WebP preview must be materially smaller than the PNG LCP image", + ); + assert(source.includes("/static/web.webp"), "landing page must load the lighter WebP product preview image"); + assert(!source.includes('src="/static/web.png"'), "landing hero must not use the heavy PNG as its primary LCP image"); + assert( + source.includes('width="680"') && + source.includes('height="340"') && + source.includes('fetchPriority="high"') && + source.includes('decoding="async"'), + "landing product preview must expose stable intrinsic dimensions and high fetch priority", + ); + assert( + source.includes('trackAppEvent("landing_view"') && + source.includes('trackAppEvent("login_start"') && + source.includes('trackAppEvent("enter_terminal"'), + "landing page must emit the top-of-funnel analytics events", + ); assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing"); assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing"); assert(source.includes("20 USDC") && source.includes("+3500 积分"), "landing page must describe referral discount and reward"); diff --git a/frontend/lib/app-analytics.ts b/frontend/lib/app-analytics.ts index dd16be3d..24b7712b 100644 --- a/frontend/lib/app-analytics.ts +++ b/frontend/lib/app-analytics.ts @@ -4,6 +4,13 @@ const ANALYTICS_ENABLED = process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false"; type TrackableAnalyticsEvent = + | "landing_view" + | "enter_terminal" + | "login_start" + | "signup_success" + | "trial_created" + | "payment_start" + | "payment_success" | "signup_completed" | "dashboard_active" | "paywall_feature_clicked" diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index 29503b6a..f99849a8 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -28,14 +28,15 @@ export const opsApi = { rates?: Record; window_days?: number; }>(`/api/ops/analytics/funnel?days=${days}`); - const stepOrder = ["signup_completed", "dashboard_active", "paywall_feature_clicked", "paywall_viewed", "checkout_started", "checkout_succeeded"]; + const stepOrder = ["landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"]; const stepLabels: Record = { - signup_completed: "注册", - dashboard_active: "活跃", - paywall_feature_clicked: "点击高级功能", - paywall_viewed: "看到付费墙", - checkout_started: "发起支付", - checkout_succeeded: "支付成功", + landing_view: "访问落地页", + enter_terminal: "进入终端", + login_start: "开始登录", + signup_success: "注册成功", + trial_created: "试用开通", + payment_start: "发起支付", + payment_success: "支付成功", }; const steps = stepOrder.map((key, i) => { const evt = raw?.events?.[key]; diff --git a/frontend/public/static/web.webp b/frontend/public/static/web.webp new file mode 100644 index 00000000..ff59896d Binary files /dev/null and b/frontend/public/static/web.webp differ diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 34368bcc..f3d98c36 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -942,13 +942,28 @@ class DBManager: since_dt = datetime.now() - timedelta(days=safe_days) rows = self.list_app_analytics_events(limit=5000, since_iso=since_dt.isoformat()) event_names = [ - "signup_completed", - "dashboard_active", - "paywall_feature_clicked", - "paywall_viewed", - "checkout_started", - "checkout_succeeded", + "landing_view", + "enter_terminal", + "login_start", + "signup_success", + "trial_created", + "payment_start", + "payment_success", ] + event_aliases = { + "landing_view": ("landing_view",), + "enter_terminal": ("enter_terminal", "dashboard_active"), + "login_start": ("login_start",), + "signup_success": ("signup_success", "signup_completed"), + "trial_created": ("trial_created",), + "payment_start": ("payment_start", "checkout_started"), + "payment_success": ("payment_success", "checkout_succeeded"), + } + alias_to_event = { + alias: event_name + for event_name, aliases in event_aliases.items() + for alias in aliases + } summary: Dict[str, Dict[str, Any]] = { name: { "total": 0, @@ -961,8 +976,9 @@ class DBManager: user_sets: Dict[str, set[str]] = {name: set() for name in event_names} for row in rows: - event_type = str(row.get("event_type") or "").strip().lower() - if event_type not in summary: + raw_event_type = str(row.get("event_type") or "").strip().lower() + event_type = alias_to_event.get(raw_event_type) + if not event_type: continue summary[event_type]["total"] += 1 user_id = str(row.get("user_id") or "").strip().lower() @@ -996,11 +1012,12 @@ class DBManager: "since": since_dt.isoformat(), "events": summary, "rates": { - "login_active_rate": _rate("dashboard_active", "signup_completed"), - "paywall_click_rate": _rate("paywall_feature_clicked", "dashboard_active"), - "paywall_view_rate": _rate("paywall_viewed", "paywall_feature_clicked"), - "checkout_start_rate": _rate("checkout_started", "paywall_viewed"), - "checkout_success_rate": _rate("checkout_succeeded", "checkout_started"), + "enter_terminal_rate": _rate("enter_terminal", "landing_view"), + "login_start_rate": _rate("login_start", "enter_terminal"), + "signup_success_rate": _rate("signup_success", "login_start"), + "trial_created_rate": _rate("trial_created", "signup_success"), + "payment_start_rate": _rate("payment_start", "trial_created"), + "payment_success_rate": _rate("payment_success", "payment_start"), }, } diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 06aa9972..b4a59b9c 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -11,6 +11,7 @@ import web.routes as routes import web.services.ops_api as ops_api import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_service as scan_terminal_service +import web.services.city_runtime as city_runtime from web.scan_terminal_cache import scan_terminal_cache_key from src.database.runtime_state import TruthRecordRepository @@ -60,6 +61,49 @@ def test_metrics_endpoint_returns_prometheus_payload(): assert 'polyweather_http_requests_total' in response.text +def test_standard_growth_funnel_events_are_trackable(): + assert { + "landing_view", + "enter_terminal", + "login_start", + "signup_success", + "trial_created", + "payment_start", + "payment_success", + }.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS) + + +def test_standard_growth_funnel_summary_order(monkeypatch): + from src.database.db_manager import DBManager + + rows = [ + {"id": 1, "event_type": "landing_view", "user_id": "", "client_id": "c1", "session_id": "s1"}, + {"id": 2, "event_type": "enter_terminal", "user_id": "", "client_id": "c1", "session_id": "s1"}, + {"id": 3, "event_type": "login_start", "user_id": "", "client_id": "c1", "session_id": "s1"}, + {"id": 4, "event_type": "signup_success", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, + {"id": 5, "event_type": "trial_created", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, + {"id": 6, "event_type": "payment_start", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, + {"id": 7, "event_type": "payment_success", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, + ] + monkeypatch.setattr( + DBManager, + "list_app_analytics_events", + lambda self, limit=5000, since_iso=None: rows, + ) + + summary = DBManager().get_app_analytics_funnel_summary(days=7) + assert list(summary["events"].keys()) == [ + "landing_view", + "enter_terminal", + "login_start", + "signup_success", + "trial_created", + "payment_start", + "payment_success", + ] + assert summary["rates"]["payment_success_rate"] == 1.0 + + def test_cities_endpoint_uses_denver_display_name_for_aurora_market(): response = client.get("/api/cities") diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py index 3c02e0bd..952295cc 100644 --- a/web/services/city_runtime.py +++ b/web/services/city_runtime.py @@ -66,6 +66,13 @@ _DEB_RECENT_MIN_SAMPLES = 3 _daily_record_repo = DailyRecordRepository() TRACKABLE_ANALYTICS_EVENTS = { + "landing_view", + "enter_terminal", + "login_start", + "signup_success", + "trial_created", + "payment_start", + "payment_success", "signup_completed", "dashboard_active", "paywall_feature_clicked",