Improve landing performance and analytics funnel
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
trackAppEvent("checkout_started", payload);
|
||||
trackAppEvent("payment_start", payload);
|
||||
}, []);
|
||||
const trackPaymentSuccess = useCallback((payload: Record<string, unknown>) => {
|
||||
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,
|
||||
|
||||
@@ -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<HTMLInputElement>(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<string | null>(null);
|
||||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -606,7 +662,7 @@ export function LiveTemperatureThresholdChart({
|
||||
{zoomRange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomRange(null)}
|
||||
onClick={handleZoomReset}
|
||||
className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer"
|
||||
>
|
||||
{isEn ? "Reset Zoom" : "重置缩放"}
|
||||
@@ -617,10 +673,7 @@ export function LiveTemperatureThresholdChart({
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode(mode);
|
||||
setZoomRange(null);
|
||||
}}
|
||||
onClick={() => handleViewModeChange(mode)}
|
||||
className={clsx(
|
||||
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
|
||||
viewMode === mode
|
||||
@@ -670,45 +723,7 @@ export function LiveTemperatureThresholdChart({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
); 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 (
|
||||
<Panel
|
||||
@@ -768,14 +783,9 @@ export function LiveTemperatureThresholdChart({
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onZoomReset={() => setZoomRange(null)}
|
||||
onZoomReset={handleZoomReset}
|
||||
isSeriesVisible={isSeriesVisible}
|
||||
onSeriesToggle={(seriesKey) => {
|
||||
setUserToggledKeys((prev) => ({
|
||||
...prev,
|
||||
[seriesKey]: !isSeriesVisible(seriesKey),
|
||||
}));
|
||||
}}
|
||||
onSeriesToggle={handleSeriesToggle}
|
||||
onShowRunwayDetailsChange={setShowRunwayDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/terminal"
|
||||
onClick={() => 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() {
|
||||
<>
|
||||
<Link
|
||||
href="/auth/login?next=%2Fterminal"
|
||||
onClick={() => 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" : "登录"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/login?next=%2Fterminal&mode=signup"
|
||||
onClick={() => 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() {
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={authChecked && isAuthenticated ? "/terminal" : "/auth/login?next=%2Fterminal"}
|
||||
onClick={() =>
|
||||
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() {
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
|
||||
<span className="ml-2 text-xs font-semibold text-slate-400">polyweather.app/terminal</span>
|
||||
</div>
|
||||
<img
|
||||
src="/static/web.png"
|
||||
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
|
||||
className="mt-2 aspect-[16/9] w-full rounded-md border border-slate-100 object-cover object-top"
|
||||
/>
|
||||
<div className="mt-2 aspect-[16/9] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
|
||||
<img
|
||||
src="/static/web.webp"
|
||||
width="680"
|
||||
height="340"
|
||||
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
|
||||
className="h-full w-full object-cover object-top"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
sizes="(min-width: 1024px) 960px, calc(100vw - 48px)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 grid max-w-5xl gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -28,14 +28,15 @@ export const opsApi = {
|
||||
rates?: Record<string, number>;
|
||||
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<string, string> = {
|
||||
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];
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+30
-13
@@ -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"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user