Repository navigation
Expand file tree
/
Copy pathactivation-events.ts
More file actions
130 lines (111 loc) · 4.62 KB
/
Copy pathactivation-events.ts
File metadata and controls
130 lines (111 loc) · 4.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import { captureProductEvent } from '@/lib/observability';
import { getActiveUserId } from '@/lib/supabase/client';
/**
* Activation events: sign-up, trial, paywall, first AI answer, welcome.
* They go through captureProductEvent, so its closed schema and its env-key
* gate apply. They carry no account id, email, or text; the once-per-account
* guards keep the id in this browser's storage only.
*/
const ONCE_PREFIX = 'debatelab-activation:';
// An account made this recently is a new sign-up, not a returning sign-in.
// Email codes and Google can take a while between creating and confirming.
const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
type OnceStorage = Pick<Storage, 'getItem' | 'setItem'>;
function browserStorage(): OnceStorage | null {
try {
return typeof localStorage === 'undefined' ? null : localStorage;
} catch {
return null;
}
}
/**
* Records `name` once per account in this browser. Returns whether it was sent.
* Blocked storage sends nothing: a guard that cannot remember would send the
* event on every page load and count one student many times.
*/
export function captureOncePerAccount(
name: string,
accountId: string | null | undefined,
properties?: Record<string, unknown>,
storage: OnceStorage | null = browserStorage(),
): boolean {
if (!accountId || !storage) return false;
const key = `${ONCE_PREFIX}${name}:${accountId}`;
try {
if (storage.getItem(key)) return false;
storage.setItem(key, '1');
} catch {
return false;
}
captureProductEvent(name, properties);
return true;
}
function signedInUserId(): string | null {
// Some unit tests replace the client module and omit this export.
try {
return typeof getActiveUserId === 'function' ? getActiveUserId() : null;
} catch {
return null;
}
}
export interface ActivationUser {
id: string;
created_at?: string;
app_metadata?: Record<string, unknown>;
}
// A first answer counts only for an account this young. Older accounts had
// answers before this event existed, so their next one is not their first.
const FIRST_SUCCESS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
let activationAccount: ActivationUser | null = null;
/** The signed-in account, so captureFirstAISuccess can tell a new account from an old one. */
export function rememberActivationAccount(user: ActivationUser | null): void {
activationAccount = user;
}
/** The first answer a new account gets from any tool. */
export function captureFirstAISuccess(tool: string, now = Date.now()): void {
const accountId = signedInUserId();
if (!accountId || activationAccount?.id !== accountId) return;
if (!isNewAccount(activationAccount, now, FIRST_SUCCESS_WINDOW_MS)) return;
captureOncePerAccount('first_ai_success', accountId, { tool });
}
export type SignupMethod = 'demo' | 'email' | 'google';
export function signupMethod(user: ActivationUser): SignupMethod | null {
const metadata = user.app_metadata ?? {};
if (metadata.demo_access_code_id) return 'demo';
if (metadata.provider === 'google') return 'google';
if (metadata.provider === 'email') return 'email';
return null;
}
export function isNewAccount(user: ActivationUser, now = Date.now(), windowMs = NEW_ACCOUNT_WINDOW_MS): boolean {
const created = user.created_at ? Date.parse(user.created_at) : NaN;
return Number.isFinite(created) && now - created >= 0 && now - created <= windowMs;
}
/** A first sign-in to an account created in the last day. Returns whether it was sent. */
export function captureSignupCompleted(user: ActivationUser, now = Date.now()): boolean {
const method = signupMethod(user);
if (!method || !isNewAccount(user, now)) return false;
return captureOncePerAccount('signup_completed', user.id, { method });
}
/**
* New accounts start on the trial (handle_new_user). An account that signed up
* earlier is not counted when this code ships: only new accounts send it.
*/
export function captureTrialStarted(user: ActivationUser, tier: string, now = Date.now()): boolean {
if (tier !== 'trial' || !isNewAccount(user, now)) return false;
return captureOncePerAccount('trial_started', user.id);
}
/** The welcome closed (Got it, Esc, or the backdrop). */
export function captureWelcomeCompleted(accountId: string | null | undefined): void {
captureOncePerAccount('welcome_completed', accountId);
}
export type PaywallReason =
| 'limit_reached'
| 'weekly_budget_reached'
| 'trial_ended'
| 'payment_failed'
| 'subscription_ended'
| 'model_restricted';
/** The limit or Pro-model dialog opened. Sent every time, not once. */
export function capturePaywallShown(reason: PaywallReason, tool: string): void {
captureProductEvent('paywall_shown', { reason, tool });
}