diff --git a/frontend/app/api/analytics/events/route.ts b/frontend/app/api/analytics/events/route.ts new file mode 100644 index 00000000..73f8bb8c --- /dev/null +++ b/frontend/app/api/analytics/events/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const body = await req.json(); + const auth = await buildBackendRequestHeaders(req); + const headers = new Headers(auth.headers); + headers.set("Content-Type", "application/json"); + const res = await fetch(`${API_BASE}/api/analytics/events`, { + method: "POST", + headers, + body: JSON.stringify(body ?? {}), + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) }, + { status: res.status }, + ); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to track analytics event", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/ops/analytics/funnel/route.ts b/frontend/app/api/ops/analytics/funnel/route.ts new file mode 100644 index 00000000..0949e6e0 --- /dev/null +++ b/frontend/app/api/ops/analytics/funnel/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const url = new URL(`${API_BASE}/api/ops/analytics/funnel`); + const days = req.nextUrl.searchParams.get("days"); + if (days) { + url.searchParams.set("days", days); + } + const res = await fetch(url.toString(), { + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch analytics funnel", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index eba41626..763b05d3 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -46,6 +46,7 @@ import { getCurrentPaymentHost, isPaymentHostAllowed, } from "@/lib/payment-host"; +import { trackAppEvent } from "@/lib/app-analytics"; import { useI18n } from "@/hooks/useI18n"; const UnlockProOverlay = dynamic( @@ -1439,6 +1440,24 @@ export function AccountCenter() { ? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}` : ""; + useEffect(() => { + if (!showOverlay || isSubscribed) return; + trackAppEvent("paywall_viewed", { + entry: "account_center", + user_state: isAuthenticated ? "logged_in" : "guest", + expired: showExpiredReminder, + expiring_soon: showExpiringSoon, + subscription_plan_code: planCode || null, + }); + }, [ + isAuthenticated, + isSubscribed, + planCode, + showExpiredReminder, + showExpiringSoon, + showOverlay, + ]); + // Points Logic const backendPointsRaw = Number(backend?.points); const metadataPointsRaw = Number( @@ -1670,6 +1689,12 @@ export function AccountCenter() { if (status === "confirmed") { setPaymentError(""); setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`); + trackAppEvent("checkout_succeeded", { + entry: "account_center", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + tx_hash: txHash || null, + }); await loadSnapshot(); await loadPaymentSnapshot(); return; @@ -1688,7 +1713,7 @@ export function AccountCenter() { } throw new Error("payment pending timeout"); }, - [loadPaymentSnapshot, loadSnapshot], + [loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code], ); const signBindMessage = async ( @@ -2062,6 +2087,13 @@ export function AccountCenter() { const txPayload = created.tx_payload; if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error("intent payload invalid"); + trackAppEvent("checkout_started", { + entry: "account_center", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + use_points: billing.canRedeem && usePoints, + pay_amount_usd: billing.payAmount, + }); const intentReceiver = String(txPayload.to || "").toLowerCase(); if (intentReceiver !== expectedReceiver) { throw new Error( @@ -2197,6 +2229,12 @@ export function AccountCenter() { } setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`); + trackAppEvent("checkout_succeeded", { + entry: "account_center", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + tx_hash: txHashNorm, + }); await loadSnapshot(); await loadPaymentSnapshot(); } catch (error) { diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index 57dbaa03..9244f384 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -11,6 +11,7 @@ import { useI18n } from "@/hooks/useI18n"; import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources"; import { getCityScenery } from "@/lib/dashboard-scenery"; import { CityDetail } from "@/lib/dashboard-types"; +import { trackAppEvent } from "@/lib/app-analytics"; import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links"; import { getCityProfileStats, @@ -183,6 +184,15 @@ export function DetailPanel() { const handleFeatureAccess = (feature: "today" | "history") => { blurActiveElement(); + if (!isPro) { + trackAppEvent("paywall_feature_clicked", { + entry: "detail_panel", + feature, + city: store.selectedCity, + user_state: isAuthenticated ? "logged_in" : "guest", + }); + } + if (isPro) { if (feature === "today") { void store.openTodayModal(); diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx index d8b6c2b3..5810d290 100644 --- a/frontend/components/dashboard/ProFeaturePaywall.tsx +++ b/frontend/components/dashboard/ProFeaturePaywall.tsx @@ -1,10 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useI18n } from "@/hooks/useI18n"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay"; +import { trackAppEvent } from "@/lib/app-analytics"; const TELEGRAM_GROUP_URL = String( process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL || @@ -63,6 +64,14 @@ export function ProFeaturePaywall({ ? "Sign In to Unlock Pro" : "先登录再开通 Pro"; + useEffect(() => { + trackAppEvent("paywall_viewed", { + entry: "feature_gate", + feature, + user_state: isAuthenticated ? "logged_in" : "guest", + }); + }, [feature, isAuthenticated]); + return (
{ + if (proAccess.loading || !proAccess.authenticated || !proAccess.userId) { + return; + } + if ( + markAnalyticsOnce(`dashboard-active:${proAccess.userId}`, "session") + ) { + trackAppEvent("dashboard_active", { + subscription_active: proAccess.subscriptionActive, + subscription_plan_code: proAccess.subscriptionPlanCode, + }); + } + + const isTrialPlan = /trial/i.test( + String(proAccess.subscriptionPlanCode || ""), + ); + if ( + isTrialPlan && + markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local") + ) { + trackAppEvent("signup_completed", { + source: "auth_me_trial", + subscription_plan_code: proAccess.subscriptionPlanCode, + }); + } + }, [ + proAccess.authenticated, + proAccess.loading, + proAccess.subscriptionActive, + proAccess.subscriptionPlanCode, + proAccess.userId, + ]); + useEffect(() => { if (!cities.length) return; diff --git a/frontend/lib/app-analytics.ts b/frontend/lib/app-analytics.ts new file mode 100644 index 00000000..9b27436d --- /dev/null +++ b/frontend/lib/app-analytics.ts @@ -0,0 +1,91 @@ +"use client"; + +type TrackableAnalyticsEvent = + | "signup_completed" + | "dashboard_active" + | "paywall_feature_clicked" + | "paywall_viewed" + | "checkout_started" + | "checkout_succeeded"; + +const CLIENT_ID_KEY = "polyweather:analytics:client-id"; +const SESSION_ID_KEY = "polyweather:analytics:session-id"; + +function isClient() { + return typeof window !== "undefined"; +} + +function randomId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function getStoredId(storage: Storage, key: string) { + let value = storage.getItem(key); + if (!value) { + value = randomId(); + storage.setItem(key, value); + } + return value; +} + +export function getAnalyticsClientId() { + if (!isClient()) return ""; + try { + return getStoredId(window.localStorage, CLIENT_ID_KEY); + } catch { + return ""; + } +} + +export function getAnalyticsSessionId() { + if (!isClient()) return ""; + try { + return getStoredId(window.sessionStorage, SESSION_ID_KEY); + } catch { + return ""; + } +} + +export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") { + if (!isClient()) return false; + const storage = scope === "local" ? window.localStorage : window.sessionStorage; + const normalizedKey = `polyweather:analytics:once:${key}`; + try { + if (storage.getItem(normalizedKey) === "1") { + return false; + } + storage.setItem(normalizedKey, "1"); + return true; + } catch { + return true; + } +} + +export function trackAppEvent( + eventType: TrackableAnalyticsEvent, + payload: Record = {}, +) { + if (!isClient()) return; + const body = { + event_type: eventType, + client_id: getAnalyticsClientId() || undefined, + session_id: getAnalyticsSessionId() || undefined, + payload: { + ...payload, + path: window.location.pathname, + href: window.location.href, + captured_at: new Date().toISOString(), + }, + }; + void fetch("/api/analytics/events", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + keepalive: true, + }).catch(() => {}); +} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 56f8d552..c3ccc282 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -435,6 +435,7 @@ export interface HistoryState { export interface ProAccessState { loading: boolean; authenticated: boolean; + userId: string | null; subscriptionActive: boolean; subscriptionPlanCode: string | null; subscriptionExpiresAt: string | null; diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 7f0dde9d..606802dd 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -50,6 +50,7 @@ function isPublicPage(pathname: string) { function isPublicApi(pathname: string) { return ( pathname === "/api/auth/me" || + pathname === "/api/analytics/events" || pathname === "/api/cities" || pathname === "/api/vitals" || /^\/api\/city\/[^/]+$/i.test(pathname) || diff --git a/src/database/db_manager.py b/src/database/db_manager.py index f69511aa..fa83663f 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -104,6 +104,23 @@ class DBManager: conn.execute( "CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)" ) + conn.execute(""" + CREATE TABLE IF NOT EXISTS app_analytics_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + user_id TEXT, + client_id TEXT, + session_id TEXT, + payload_json TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_app_analytics_events_created_at ON app_analytics_events(created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_app_analytics_events_type_created_at ON app_analytics_events(event_type, created_at DESC)" + ) conn.execute(""" CREATE TABLE IF NOT EXISTS supabase_bindings ( supabase_user_id TEXT PRIMARY KEY, @@ -197,6 +214,164 @@ class DBManager: ) conn.commit() + def append_app_analytics_event( + self, + event_type: str, + payload: Dict[str, Any], + *, + user_id: Optional[str] = None, + client_id: Optional[str] = None, + session_id: Optional[str] = None, + ) -> None: + kind = str(event_type or "").strip().lower() + if not kind: + return + body = payload if isinstance(payload, dict) else {} + normalized_user_id = str(user_id or "").strip().lower() or None + normalized_client_id = str(client_id or "").strip() or None + normalized_session_id = str(session_id or "").strip() or None + with self._get_connection() as conn: + conn.execute( + """ + INSERT INTO app_analytics_events ( + event_type, + user_id, + client_id, + session_id, + payload_json, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + kind, + normalized_user_id, + normalized_client_id, + normalized_session_id, + json.dumps(body, ensure_ascii=False), + datetime.now().isoformat(), + ), + ) + conn.commit() + + def list_app_analytics_events( + self, + *, + limit: int = 200, + event_type: Optional[str] = None, + since_iso: Optional[str] = None, + ) -> List[Dict[str, Any]]: + safe_limit = max(1, min(int(limit or 200), 2000)) + kind = str(event_type or "").strip().lower() + params: List[Any] = [] + clauses: List[str] = [] + if kind: + clauses.append("event_type = ?") + params.append(kind) + since_text = str(since_iso or "").strip() + if since_text: + clauses.append("created_at >= ?") + params.append(since_text) + where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.append(safe_limit) + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + f""" + SELECT id, event_type, user_id, client_id, session_id, payload_json, created_at + FROM app_analytics_events + {where_sql} + ORDER BY id DESC + LIMIT ? + """, + tuple(params), + ).fetchall() + out: List[Dict[str, Any]] = [] + for row in rows: + try: + payload = json.loads(str(row["payload_json"] or "{}")) + except Exception: + payload = {} + out.append( + { + "id": int(row["id"]), + "event_type": str(row["event_type"] or ""), + "user_id": str(row["user_id"] or "") or None, + "client_id": str(row["client_id"] or "") or None, + "session_id": str(row["session_id"] or "") or None, + "payload": payload if isinstance(payload, dict) else {}, + "created_at": row["created_at"], + } + ) + return out + + def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]: + safe_days = max(1, min(int(days or 30), 365)) + 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", + ] + summary: Dict[str, Dict[str, Any]] = { + name: { + "total": 0, + "unique_users": 0, + "unique_actors": 0, + } + for name in event_names + } + actor_sets: Dict[str, set[str]] = {name: set() for name in event_names} + 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: + continue + summary[event_type]["total"] += 1 + user_id = str(row.get("user_id") or "").strip().lower() + client_id = str(row.get("client_id") or "").strip() + session_id = str(row.get("session_id") or "").strip() + actor_key = "" + if user_id: + actor_key = f"user:{user_id}" + user_sets[event_type].add(user_id) + elif client_id: + actor_key = f"client:{client_id}" + elif session_id: + actor_key = f"session:{session_id}" + else: + actor_key = f"event:{row.get('id')}" + actor_sets[event_type].add(actor_key) + + for name in event_names: + summary[name]["unique_users"] = len(user_sets[name]) + summary[name]["unique_actors"] = len(actor_sets[name]) + + def _rate(numerator_key: str, denominator_key: str) -> Optional[float]: + denominator = int(summary[denominator_key]["unique_actors"] or 0) + numerator = int(summary[numerator_key]["unique_actors"] or 0) + if denominator <= 0: + return None + return round((numerator / denominator) * 100, 1) + + return { + "window_days": safe_days, + "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"), + }, + } + def list_payment_audit_events( self, limit: int = 50, diff --git a/web/core.py b/web/core.py index 51074f3a..977a60c5 100644 --- a/web/core.py +++ b/web/core.py @@ -341,6 +341,13 @@ class ConfirmPaymentTxRequest(BaseModel): tx_hash: Optional[str] = None +class AnalyticsEventRequest(BaseModel): + event_type: str = Field(..., min_length=3, max_length=64) + client_id: Optional[str] = Field(default=None, max_length=128) + session_id: Optional[str] = Field(default=None, max_length=128) + payload: Dict[str, Any] = Field(default_factory=dict) + + class GrantPointsRequest(BaseModel): email: str = Field(..., min_length=3) points: int = Field(..., gt=0, le=100000) diff --git a/web/routes.py b/web/routes.py index 18017b54..fddd236d 100644 --- a/web/routes.py +++ b/web/routes.py @@ -21,6 +21,7 @@ from web.analysis_service import ( _build_city_summary_payload, ) from web.core import ( + AnalyticsEventRequest, CITIES, CITY_REGISTRY, CITY_RISK_PROFILES, @@ -51,6 +52,15 @@ from web.core import ( router = APIRouter() +TRACKABLE_ANALYTICS_EVENTS = { + "signup_completed", + "dashboard_active", + "paywall_feature_clicked", + "paywall_viewed", + "checkout_started", + "checkout_succeeded", +} + def _parse_snapshot_dt(value: object) -> Optional[datetime]: raw = str(value or "").strip() @@ -377,6 +387,32 @@ async def auth_me(request: Request): } +@router.post("/api/analytics/events") +async def analytics_track(request: Request, body: AnalyticsEventRequest): + _bind_optional_supabase_identity(request) + event_type = str(body.event_type or "").strip().lower() + if event_type not in TRACKABLE_ANALYTICS_EVENTS: + raise HTTPException(status_code=400, detail="unsupported_event_type") + + payload = body.payload if isinstance(body.payload, dict) else {} + normalized_payload = { + key: value + for key, value in payload.items() + if isinstance(key, str) and len(key) <= 64 + } + from src.database.db_manager import DBManager + + db = DBManager() + db.append_app_analytics_event( + event_type, + normalized_payload, + user_id=getattr(request.state, "auth_user_id", None), + client_id=body.client_id, + session_id=body.session_id, + ) + return {"ok": True} + + @router.get("/api/ops/users") async def ops_search_users(request: Request, q: str = "", limit: int = 20): _assert_entitlement(request) @@ -509,6 +545,16 @@ async def ops_grant_points(request: Request, body: GrantPointsRequest): return result +@router.get("/api/ops/analytics/funnel") +async def ops_analytics_funnel(request: Request, days: int = 30): + _assert_entitlement(request) + _require_ops_admin(request) + from src.database.db_manager import DBManager + + db = DBManager() + return db.get_app_analytics_funnel_summary(days=days) + + @router.get("/api/payments/config") async def payment_config(request: Request): _assert_entitlement(request)