Add app analytics tracking for paywall and checkout events
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10 z-30 p-4">
|
||||
<UnlockProOverlay
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCityRevision,
|
||||
toCitySummary,
|
||||
} from "@/lib/dashboard-client";
|
||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
@@ -71,6 +72,7 @@ function getInitialProAccessState(): ProAccessState {
|
||||
return {
|
||||
loading: true,
|
||||
authenticated: false,
|
||||
userId: null,
|
||||
subscriptionActive: false,
|
||||
subscriptionPlanCode: null,
|
||||
subscriptionExpiresAt: null,
|
||||
@@ -408,6 +410,7 @@ export function DashboardStoreProvider({
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
authenticated?: boolean;
|
||||
user_id?: string | null;
|
||||
subscription_active?: boolean | null;
|
||||
subscription_plan_code?: string | null;
|
||||
subscription_expires_at?: string | null;
|
||||
@@ -416,6 +419,7 @@ export function DashboardStoreProvider({
|
||||
setProAccess({
|
||||
loading: false,
|
||||
authenticated: Boolean(payload.authenticated),
|
||||
userId: payload.user_id ?? null,
|
||||
subscriptionActive: payload.subscription_active === true,
|
||||
subscriptionPlanCode: payload.subscription_plan_code ?? null,
|
||||
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
|
||||
@@ -426,6 +430,7 @@ export function DashboardStoreProvider({
|
||||
setProAccess({
|
||||
loading: false,
|
||||
authenticated: false,
|
||||
userId: null,
|
||||
subscriptionActive: false,
|
||||
subscriptionPlanCode: null,
|
||||
subscriptionExpiresAt: null,
|
||||
@@ -443,6 +448,39 @@ export function DashboardStoreProvider({
|
||||
void refreshProAccess();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
) {
|
||||
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(() => {});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user