From 9bcfd9d1eb76c2ec37d10723b8c4994f691d06ea Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 19:50:50 +0800 Subject: [PATCH] Improve landing performance and analytics funnel --- frontend/components/account/AccountCenter.tsx | 32 ++++- .../account/__tests__/paymentShell.test.ts | 21 ++++ frontend/components/account/usePaymentFlow.ts | 20 ++- .../dashboard/ScanTerminalDashboard.tsx | 21 +++- .../LiveTemperatureThresholdChart.tsx | 118 ++++++++++-------- .../scan-terminal/TemperatureChartCanvas.tsx | 6 +- .../__tests__/terminalGridPolicy.test.ts | 16 +++ .../landing/InstitutionalLandingPage.tsx | 53 +++++++- .../__tests__/landingPricingReferral.test.ts | 28 ++++- frontend/lib/app-analytics.ts | 7 ++ frontend/lib/ops-api.ts | 15 +-- frontend/public/static/web.webp | Bin 0 -> 14948 bytes src/database/db_manager.py | 43 +++++-- tests/test_web_observability.py | 44 +++++++ web/services/city_runtime.py | 7 ++ 15 files changed, 336 insertions(+), 95 deletions(-) create mode 100644 frontend/public/static/web.webp 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 0000000000000000000000000000000000000000..ff59896d6e5eace33a744fc527d804c8535f96ee GIT binary patch literal 14948 zcmV-qI-A8(Nk&FoIsgDyMM6+kP&gn^IsgDLIRTvkDyRZf0X~&To=c^qBB!MBN^tNJ ziDz!t+b*<}FC(!Fzjeg1*_N>^Edd?siAx8Fz8-!>2Y z|F?euKdb-R^$+`L>&NG(zdO-c>4WP9|1VKP(_ioZs`~Kqv-*2o7$f;l2YQ_EZ{#kZ zzO(adf!8sp5Ay%<{%C)l=O6F>pnMG;zw8D2uhp~tuQ9375h4$oQTR>6F)Y#03dF>q z;{lv5<-+u)#(2z{hT>$gr8YD6qXT`P6@KAd2JMQ9r+@M7BQBJz*<+_J zHc8Q8_#}Fc|2hop%Mw;fB`4BIcdXR)?;}ku-27F1<)-T>(|JTt#f2vowoGg1QD0>` zrc3MtJ9%qZ+R{sJ0u^WyOWh^tg)DtAQt9>N6ZQpv&+XR-lx09mEguYYs3 z$E)}^5fO|Yp=6f*wXs|@UTpvQWOozhgE$JqR1zKFBHP$LCMbTCl0_Vq1}@L$OEA(+ zVAY6MqxbyzQzFQ#tWwQ+M8}JF0d@J9{}`$RK@Z}|3%a+j-$l0xA?55q=kf!TEr-1* zxj140QanE|$*KMh9L+fbR7EFDtlmjw4!5S7nFT-h>NFo- z!9n_D=1_z8oneQuO;(ca1)@%^RgQp}Bg$-0|NnADJU2_IyjqyL_ENE0`F=Yy`;xK^~SShnGebiy(=c=CVZh*b{!bv?wSNI)%b*)4x4W=RU)6mRx*QEX{0p zxfCfsEF(DN$lxbI0(G{nH8%j!;b|>eJ?XG^c}|_jYU5|2<_}LPx(OE`H5F}!i&p(5 z1E7M+vc>7_?=aRss+Mz0a8sbMkQO=!H|qOZ-(I2|rW>;;NLIN{Dr{AuQ_fNJxVkxD zT`SFc#l0BZ9pGg}JxdWlKvzZN9`zSvE}ZA_>Ez9tCtUqvH`&Q@1i`aLt8a^Kce~FV z0F{WU=YlcIEqw5rrIs7Ki*(BO9pW3mixhJ-+-#Sox1I4O2B$SMo8+u1cv#yXha(Na z0o_w-MX9q1^6;KRK2uR;LQ6xosJAiUA!A;t&^@;#u!^x#d@fxh#kl|4$J6V(meUQzg>uVCA?kPjP+YpaGn_#nn6}ZrqFGz}q0PP!%JF36LxBeS8Pc!cKAce`2 zbzj$yZ%IXnb45q9;QcdFx2YPk!H|6JhmPO8(=rOV=+_1Yex~C#Qys11DT6waXFT7# zhcse)7jOO&4TmI56=6Bv;>U}T{-vQt@pnOJODV_+`Li6_*grqC;=#=^n_WIVUvG(^ zXCp+&CRzNOS%!nSP-iFkqCPLo)`~?|O2#<1f{fp!GFMGh{VmDf&x)o>9?NAxOr*`{ zgm>6y8~?)4zH4blvS$;w98+Q<_3#L=9l!jc2%dnUy~eQ-H)4+R#OQnHvGrEIOcAb< z?06(-=KMgY)sZ_Vm}4B{bjixAVJmPmaq8Q{vAFtI46fT@A_Y=V;yHKES8!21uG66( z(f8KKHA|ZQ>_nscMwS91_v-30E%p1})3mG?8G<__D1w-X8XX-ErZMOE8xnZe<)4ybQn&V5Jp9I+Pu!ZuShC%Q)rki^?&3e zO#b_&cInu@A=cEb)KJDWd`ea1y4B!57K|%q!2+kl4&ZH5yx|(dD6Mvf0wf!hxchJ; z1$N&FH-4&MkK#tRz(oY@lmM6-z*$$G*_8w*J!Gm?h7k2jb$YQlx%DeW$u>_PqmS;Y zmq;pEllYYtiieP zR)I;E}yHt=vyGi#q!j5sqG;M}p3{Q3!Bd z(eIRf9VsSwxt3E@dYSeB-1Pn!dZ3^E7^28vy$4Q%ljupuK!HA4(uz4U=B9AvWJnL1 zMRCHa!P~2j@UqYE-=ykVx4}QRfq|b|DU~SjqdGkx0TWuUfy8|7RC<_s1IT}W=OBo3 z-?2WUTo6xqa{pIaJ*NdbRj6>Ln2xDi5$b`^10II-;_)1^t!r=NRR}%p8$nO$>xsdqlTQ|-`L73->MZj^`+@d!RK_Llio$;a@VbNktv4= zdSxal^0@9V`YAx;BHtRO)i3*Xo7rn)TvNM5Up06mP9x{WP&plfp8;Wr-OeMhLdUO} zV|l%4Qo;I{%dv7WL&LA?2>oii%n@s6#_jbz*+mEb zPgKvp#bN`Gw&KbWNZ;YVan@gXvaK<1+_}- ziU#%`GqClaL$u6!cAj04HtkB=Tt93)fhoP=9OpyYzySAP6~}T(S5c#|HrnD&p5_}w z4j$LNQvS4=VnO?l4wM!k^*BnU(mg^D=5C)+M5ti?;4YNIS*>IgWsQkvI{zt;e$>&3 z*p083$64rkCihPcp~b7ma8dGC2jHL%&B6r1-NO`V{Y;^kZb^=w<66AEln5JEX?XiH` zzPpii-RP$uq>*-Nq?&xI!L^$Yrcs**g91yTPri#VTnWBW*=69?eLYIs&;Ta0pAsN! zsr;hk&?zZ7-OP2)L!t&YlnEnGwWg=F|D%uKHdpu~+t^*Gz+WWPU0HehGnr;6~Z{+7*Z+XFvTy)$i z?#3iq+hK+9jHb}I-X|o1=BFg*<6~fbvaDG5GQ6AyUzRA^sW2r<{1I~>A`*HHKzw_I zGrqBkZzwN>JWH?G7P z0QHW~PjU|pC)`cb@i{>LAb3Aw7ND-7mOD2G5sw^oN}77=^n)Xg{#76N$Yxb8m@9RG@+lzC)=FV;kpX(Z62l*>l-= z_hh^NFj%;0qkkGrQP>ye4^$CM52^!MtD8z@vCI3tDo+mjp?Pi*NpN*AF)e2|w7Xdf zEC+jHb`y^I@AREd++v+Cj7z3?=Ic7T z-l(Jc^VN1tM$>FF!1Q2|E2b_cQ*w+Fuj zlG7w-%dhbxVpPw3%#KC2*!t}#k+Ffd?B}D;_(TsEwnM!EDi*;d#f0MySD>_;vajyz z0?Cp#%|@w zun&p5v~}y1OW}i4UHA1Qa*EF!GP90-wI#x%$aFO?BAuw#R8P*31sfZfGG^@?-5X|3 zD`a^%M`+&QJmFvGwQRu_LSLm)rI7Pe#zU8U#1_4I=Nb%V@v&aX6$j2m5++@I7>bHt}Ur z3sZLn_EAtLmGV2+^5}Fz0h6v1SwhrU*jS;ivh6ye*K_~XF?EoZQj)FX7hczTUZ<4% zU&j=`;&G!eaXJ@Ab9pg|j(K|dQwtcKa5QGTcr>{NBU}TC2W)%d{QS-N(-SwZG8zdG zz@@xyki*HO?4(9Z0-Y^~F4#VbW%jOfJ7UNViu(U1Zj;$DyLRG*#@$RhMNw{#dJ=sxvwx!6`-*ZYfDtz zO7V)U%pxHcA#Zq|$ao|7aRRJqXOaK_000C&8Vn(yNYb_n3*AI~ASZ5&MG^33Xf~zH z1pO<9Wn-gU3qUw1M9U=RmnMdpqL!t;VE0U98P4t>@N^EMqBW7?kR7Uj_AuB+>4I|2 z>Kh$|55di1P(Nz(m}!B+4#tyT)JXKE;~_TcQsI=39YHBMd6=T8*h{<)nwytKu+Z-r z#>V%=+kQ6-5t0d;Y`&P9G?c!LNcB)YNVd!$F{Bdp>SIg~tEjj;wQ+1F;uS10!s@tJ z%?&YNgMT{0l=*7wY&xOwio{y)k^U8fLO7xY#0F(7_B&gp|w>}Y%nT(krQ zqg_34F^#=Q%57EM?oDpkZJRd^oG_G>_AZMUS4ohtc}Ms-vwx>5(Zly*VtWFEUcrUS zh9nCmzh6Bu5TO<%&%*}p*Whx7Xyl6FI%_~?pUr%3F4HM-GDu3Ct}_*^wJz2|+)^QX z-C~^XX~Im|u{arJxi*)?10A|p$>4vA+?`Qe3=fG#%Rd+2CG04Cr}M&)`&FJ|uf;U#Q>00Rc@2=T=}mwnP+36m7;E-AuXbf3-Bu|CMG2xIhxv}##iWb)J@J>>vJ^a zW>q+%dRQ2N#b@om1Uj`DBK6;^l^Bv8__x~p#i0I#;HAV@!@u=7zEqBt+@g$yooICE zUCZ)+#MrECr;iATk)5itwc*DBd*CDIAkvTM;~qx9c|I2Il~_UwY4V1M@t4YzcRdd0 z?g?ujbtf7hP)p&WUxpXtcOmQm`qo7iOw$0Z24liOz%wA#)eVA-tri%<6N`Z3#f z`w}D5@#4Kjq%ur34W*MzRq_BUa)qBAsc*Lk;MeNYEkEs}3)5vnwIF~kvedBsXoc1tj2_Mf!(1WB5PSh;e zf)p-g?AN|kF_B&!6G-T~Rw(2J9#G4c3{Dz*SZHnj6`XO9H9N4}1q@WE0byFKf`MOn zAR1Hrnyfx9>}69*MyO5ld^{$AA6~Iri|kGCL3P^;jLK@0O3Cn|?+3rUH4CGs9DSCK z4{>O9B7~Y}5$0|oqE8)*l>j+l3)$Wp6+bG}cJTR-8L(`vKBi z6-qCNf48$oQ^zZGT6~2cATcbc;Pz`Gab$V8zEn?_In+qx@rHY+{=^|YD6ip~*|y>%Z8%EL0d(ZlAFJ<;KelL){!n7NzjOkWHzNdICoTR8*z^7> z_ARP9?H-%Nai+Fo+Re2Y6@ctRQh_hO>;FOTKt{^{+x<+3F%s};n8Y6yzw;Z z(%SFvR?7@*D0aDy8wJH{*km7$&s&YG0S}d%B(Yt1yCze@TTuIm^CFmV6GfbgDAaJw zo|+@sh(C79xh*Q;RL-bRTThE+w2%NH!oi9KCtlKBgAxCHv}P0LIjMmrpTPrC2im6AD*av_YUXxp@Xmb2 zEtd}UM`}7FZ@AyGJQFKTN(jnb_>JhuUeW1FRx zbe*nkf8)>^tFMO0i3ff{BXOt1Ce`wV*NxJh;*_40g;rnB;x@C5*m_{V_V4O?uNXzX zf99<4PLl(d(|-(aK(2S20Nji;hJ>F9fneVP@Q}BMt02Y;1=w{{K#c(s*QTcH`MeV~ zh(%XQ{5e4E(qyut?AHgFi0zwII5XTETrj>I$N4iV{kP0pCE|C|ubA?7DML7(N;m4; zDF3YAqs`eSCW&nkAO8w7FeAEyc9`p7*AFLF18p$hRNvW^^Gx7psSy6lI{Jm$mtWGLAql&0lmbv9BT$2r!E z8SK?A;@q?@$a_P!(tkB<8R{I_8zV=$S|YdA`i<2fLfag1FGpn^#=!=DL z7*LHTQSe9%yX{E|Mo&TmfnC9~s^0ux-%N*~0SOT=VIpYGH)3$OKdR-j_`3e6)cv8H$~ZXj~4Hee8ErYjcH6Q&8aQsOj-HqHj6LkXWz*xN zzK!F=RzgP2MFXy^{T+aaynN<$>}zl08mhR!=jH&)y#`j|lg!7HPoTEfQ=ktew?Mj7 zG*7jq4~1335QU)ghg)hd7S5Y8L5+mCbrqC)p;sI zr#=zM1Yh8EkB=)TN35fuNXI#7(g)WTTn$*xR4Zo**l}NQzP30z-;cw#^JrZfoHH;) zaUEaIqhw5gEczY3QsVF^0dGBNlIUWW^@`M?{2+5Hf6Fr*`USBIPcv!wX+?r_cVEz@Tnn5ayYO|5;&~emiU- zg3&#d6-o?WAw4-h7lF>@U4p&@2N77bqKo3-XpRK%67G~P-%t#LcO^t-@qIS+=8-rY z!+M$f$1y3nBAJ6pgyiWte+SK`n$3Ql_G6WiMTq}nO_I;ZwlzLXnz{(F@mEX$tqVUd zUZoRWHRudM3x(|M32mU%U_rq884Nkrl7toe6ZlbZjZ5*t~O^) zGmrJ(66T?yXAxS|Dz{ns@fRzK9+09*O)Z7ker{2%_J}}oC1uk3L*)quu1N2)q{d8B zUE_<`*d-MW*og$HRb9h@uZyMbcY?r~?JK!%4XQy8W7MY_R?M8$)y2~;3|yLk1Y|`2 zO7Kf-)b!9+$+uCR7&FQjl#9z6atveFH0H7)lv_kdXXUUk-cRIVla%j7ev zmlhbMeO-c2K%jR-45?DfJ(3T%gvDso$ORblcl4eA{>3`|+^Zh8ZIGjxTJ<#@v>R|K z=kZ-tJxXe|Us4EIE5RF|s5)nO!Te$#NezoE(ZOk>iIWsvx4gU ziK^batHbWA=mCpcT5R&_yB>3IKXboglBA#_ma=8y1+NiqZ4GDwapG$wUR;)U(_L!vMpvtjiRQzqcni zXo+34dK5bVOR>8O{I`8Zljiz>ksFBNDl_j=Yv-S;0y1i6%68$g{1h2c64~<4HI3NT zLbTr^_=koBlNd-O^7V;5V0+UVWZzxYf6Bwx^=llF+cae!Fm4pmp(+=Rf#Yy=`S4}K z^=BsAcqYgX9XP7h;`+z60WK8j+gpK@1*iYC6f#_qnF>B-(mJrpv)zx;!lFwM_)K0u zZ;F2+z0g272I5RIpcTt21k&R;DS{q?cR3&w1ZW_c7o$nKZ52S%V?S1Tw9Kx)poeUv zSsDh#QReki_;s^jNG*ep@Jl7tJKb(-#sKS{i`QgXxIN!i^wiJm`Rfn5lFzly7hOz; z<)_wG?vuo%L(7=|F1nF!Jga=U_IIrQkme^>WR@Fioq#55VLewJ)YbBfe@uV-r$9jo zZ=@BJG%BNN^|l>d9w0M<#ny>XZvhZLKv#>AyZAt0T@tM54vv{KghYAHM@vWD{;kQb zgAB;;BFr8eZN1b(s$7Q|zdBRyaUpW`{zJK_E?T!_e8XEUZRCxX6SWaEn@?*XDxy3^ zWbt*PH813ak5j92;1M7~Barq8X_+gMk7T<81$g2b`qYAXjVQng7)Z^u@=63AZ7yih z6>9o0OtawJWVvg|>s3Ni-?Awp+|O8<6|yR4!k43PZ--Q$P?&bXhRLojiWNB0FrY#^%?WJ=J$I{S#+31Jjs&tk z5TG9MIF;PtXzcmY)wr#3kyYi)C>CI`O;i_zjT6gX6T~L7&;H#iC_Ov+KTs#9__e?u z$~T{TtXH0?77FQYcb(vx|6cqF2QB1#HdWAR!L*R~_Go>=VVU1Qib)COQ02p}yeF(Q zeaPIyHY7wl;=)`>*Mft09>uZX3;7#v2$e`mTH-9duSS8w9e2hyLg)PM($^C+&{vaz zJKZtdc)yN4_|lH&x8WVBB8!X75%eHg z

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 i%2d$vi|)K`6s`pT$fMR^hoDc8E6h