@wireai/activation 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +51 -0
- package/CHANGELOG.md +58 -3
- package/INTEGRATION_PROMPT.md +13 -1
- package/README.md +73 -0
- package/dist/analytics/index.d.mts +17 -6
- package/dist/analytics/index.d.ts +17 -6
- package/dist/analytics/index.js +130 -35
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +126 -36
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-D6RiVtc8.d.ts} +88 -30
- package/dist/{currentSession-C0_odnIW.d.mts → currentSession-DsSDHqor.d.mts} +88 -30
- package/dist/index.d.mts +236 -36
- package/dist/index.d.ts +236 -36
- package/dist/index.js +281 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +269 -21
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +1 -1
- package/dist/questionnaire/index.d.ts +1 -1
- package/dist/questionnaire/index.js +49 -6
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +49 -6
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +2 -2
- package/dist/reviews/index.d.ts +2 -2
- package/dist/reviews/index.js +73 -8
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +73 -8
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
- package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/activation/useWireActivation.ts +12 -1
- package/src/activation/wireActivation.ts +36 -24
- package/src/analytics/analyticsFacade.ts +41 -20
- package/src/analytics/currentSession.ts +83 -0
- package/src/analytics/eventQueue.ts +9 -1
- package/src/analytics/index.ts +20 -1
- package/src/analytics/reportClientEvent.ts +38 -0
- package/src/analytics/screenTracking.ts +6 -1
- package/src/analytics/useAnalytics.ts +22 -1
- package/src/context/deviceId.ts +109 -0
- package/src/context/userContext.ts +18 -0
- package/src/index.ts +45 -1
- package/src/questionnaire/runtime.ts +12 -2
- package/src/questionnaire/useQuestionnaireGate.ts +9 -7
- package/src/revenuecat/index.ts +55 -0
- package/src/revenuecat/purchaseEvents.ts +167 -0
- package/src/revenuecat/revenueCatBridge.ts +221 -0
- package/src/revenuecat/types.ts +95 -0
- package/src/reviews/runtime.ts +92 -1
- package/src/reviews/transport.ts +21 -2
- package/src/reviews/useReviewGate.ts +12 -7
- package/src/session-analytics/lifecycle.ts +9 -2
- package/src/session-analytics/reportSessionStart.ts +15 -4
- package/src/session-analytics/useLifecycleEvents.ts +28 -2
package/src/context/deviceId.ts
CHANGED
|
@@ -41,3 +41,112 @@ export const mintDeviceId = (): string => {
|
|
|
41
41
|
const time = Date.now().toString(36);
|
|
42
42
|
return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
|
|
43
43
|
};
|
|
44
|
+
|
|
45
|
+
// ── The ONE auto device key per install ──────────────────────────────────────────────────────
|
|
46
|
+
//
|
|
47
|
+
// WHY A REGISTRY AND NOT A `let` PER FACTORY: `createAnalytics` and `createWireActivation` each
|
|
48
|
+
// used to mint their OWN id synchronously and then race a storage read to overwrite it. A host that
|
|
49
|
+
// creates BOTH (the documented wiring: a façade for `track`/`screen`, an activation instance for the
|
|
50
|
+
// gate-firing `wire.track`) therefore had TWO auto ids for ONE install. Every event carried whichever
|
|
51
|
+
// id its own surface minted, so the `device_key` the server groups a device's sessions under — the
|
|
52
|
+
// key `min_sessions`, A/B arm stickiness, and the purchase↔onboarding join all read — SPLIT in two.
|
|
53
|
+
// On a first run both also wrote their own id to the same storage slot, so which one survived was a
|
|
54
|
+
// coin flip. Same failure class as joining two event families on disjoint id spaces: no error, just
|
|
55
|
+
// halved counts and a join that misses.
|
|
56
|
+
//
|
|
57
|
+
// The fix is the pattern this repo already uses for `currentSession` and `activation revalidation`:
|
|
58
|
+
// ONE value in a `globalThis` slot keyed by `Symbol.for(...)`, so every inlined copy of this module
|
|
59
|
+
// (tsup duplicates modules across the `.` / `./analytics` bundles) addresses the SAME registry.
|
|
60
|
+
// Keyed by `appId` so two tenants in one process never share an id.
|
|
61
|
+
//
|
|
62
|
+
// RESIDUAL WINDOW (documented, not fixed here): the storage read is async, so events emitted in the
|
|
63
|
+
// milliseconds before hydration completes still carry the freshly minted id rather than the persisted
|
|
64
|
+
// one. The registry makes every surface agree on WHICH id that is; it does not make the read sync.
|
|
65
|
+
|
|
66
|
+
/** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
|
|
67
|
+
const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
|
|
68
|
+
|
|
69
|
+
/** The shared registry: the live id per `appId`, plus the set of appIds whose hydration already ran. */
|
|
70
|
+
type AutoDeviceKeyRegistry = { keys: Map<string, string>; hydrating: Set<string> };
|
|
71
|
+
|
|
72
|
+
type GlobalWithDeviceKeys = typeof globalThis & {
|
|
73
|
+
[AUTO_DEVICE_KEY_SLOT]?: AutoDeviceKeyRegistry;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const deviceKeyGlobal = globalThis as GlobalWithDeviceKeys;
|
|
77
|
+
|
|
78
|
+
const autoDeviceKeyRegistry = (): AutoDeviceKeyRegistry => {
|
|
79
|
+
const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
|
|
80
|
+
if (existing) return existing;
|
|
81
|
+
const created: AutoDeviceKeyRegistry = { keys: new Map(), hydrating: new Set() };
|
|
82
|
+
deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
|
|
83
|
+
return created;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The persistence subset {@link resolveAutoDeviceKey} needs (a strict subset of `WireOnboardingStorage`). */
|
|
87
|
+
export type DeviceKeyStorage = {
|
|
88
|
+
getItem(key: string): Promise<string | null>;
|
|
89
|
+
setItem(key: string, value: string): Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Options for {@link resolveAutoDeviceKey}. Omitting `storage` gives a PROCESS-scoped id, not a
|
|
93
|
+
* per-install one — see the caller notes: a caller with no persistence must decide whether a
|
|
94
|
+
* per-launch id is better or worse than no id for its metric. */
|
|
95
|
+
export interface ResolveAutoDeviceKeyOptions {
|
|
96
|
+
/** Tenant/app id — namespaces both the registry entry and the storage slot. */
|
|
97
|
+
appId?: string;
|
|
98
|
+
/** Host persistence. Present → the id survives launches. Absent → process-scoped only. */
|
|
99
|
+
storage?: DeviceKeyStorage;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ONE auto-minted `device_key` for an install, shared by every kit surface.
|
|
104
|
+
*
|
|
105
|
+
* SYNCHRONOUS by contract (a fire-and-forget event path cannot await): returns the current live id
|
|
106
|
+
* immediately, minting one on first call. When `storage` is supplied it also kicks off a SINGLE
|
|
107
|
+
* hydration per `appId` that adopts the persisted id (or persists the freshly minted one). Callers
|
|
108
|
+
* should call this per EVENT rather than caching the return value, so an event built after hydration
|
|
109
|
+
* carries the persisted id.
|
|
110
|
+
*
|
|
111
|
+
* A host-supplied `deviceKey` always wins — callers must short-circuit before reaching this.
|
|
112
|
+
* Never throws: a missing, hung, or rejecting storage adapter degrades to the in-memory id.
|
|
113
|
+
*/
|
|
114
|
+
export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): string => {
|
|
115
|
+
const registry = autoDeviceKeyRegistry();
|
|
116
|
+
const appId = opts.appId ?? "default";
|
|
117
|
+
|
|
118
|
+
let id = registry.keys.get(appId);
|
|
119
|
+
if (!id) {
|
|
120
|
+
id = mintDeviceId();
|
|
121
|
+
registry.keys.set(appId, id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const storage = opts.storage;
|
|
125
|
+
// Single-flight: the FIRST caller with storage owns hydration for this appId; later callers just
|
|
126
|
+
// read whatever the registry currently holds.
|
|
127
|
+
if (storage && !registry.hydrating.has(appId)) {
|
|
128
|
+
registry.hydrating.add(appId);
|
|
129
|
+
const slot = deviceIdStorageKey(appId);
|
|
130
|
+
const minted = id;
|
|
131
|
+
try {
|
|
132
|
+
void Promise.resolve(storage.getItem(slot))
|
|
133
|
+
.then((saved) => {
|
|
134
|
+
const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
|
|
135
|
+
if (persisted) registry.keys.set(appId, persisted);
|
|
136
|
+
else void Promise.resolve(storage.setItem(slot, minted)).catch(() => {});
|
|
137
|
+
})
|
|
138
|
+
.catch(() => {});
|
|
139
|
+
} catch {
|
|
140
|
+
// A storage adapter that throws synchronously — degrade to the in-memory id.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return registry.keys.get(appId) ?? id;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
|
|
148
|
+
export const resetAutoDeviceKeys = (): void => {
|
|
149
|
+
const registry = autoDeviceKeyRegistry();
|
|
150
|
+
registry.keys.clear();
|
|
151
|
+
registry.hydrating.clear();
|
|
152
|
+
};
|
|
@@ -207,6 +207,24 @@ export const clearUserContext = async (opts: ClearUserContextOptions = {}): Prom
|
|
|
207
207
|
}
|
|
208
208
|
};
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The `userContext` value to hand `<WireOnboarding userContext={...} />` so an onboarding session
|
|
212
|
+
* and the app's later events (purchases, actions, screens) share ONE join key.
|
|
213
|
+
*
|
|
214
|
+
* WHY it exists as a named function instead of an inline object literal: the wire key is
|
|
215
|
+
* `device_key`, the prop-facing name is `deviceKey`, and the analytics surfaces auto-mint the value
|
|
216
|
+
* for you. A host that hand-writes `userContext={{ deviceKey }}` produces a bucket the server's
|
|
217
|
+
* device lookup does not read, and the resulting funnel is silently EMPTY rather than wrong. This is
|
|
218
|
+
* the one place that spelling is decided.
|
|
219
|
+
*
|
|
220
|
+
* Pass the SAME `deviceKey` you gave `createAnalytics` / `createWireActivation`. `session_id` is not
|
|
221
|
+
* a join key across those two families: an onboarding session id is the A2A `contextId` and an
|
|
222
|
+
* app-event session id is the per-open id, so intersecting them returns nothing.
|
|
223
|
+
*/
|
|
224
|
+
export const activationJoinContext = (
|
|
225
|
+
deviceKey: string,
|
|
226
|
+
): Record<string, string | number | boolean> => resolveUserContext({ deviceKey }).userContext ?? {};
|
|
227
|
+
|
|
210
228
|
/** Options for {@link resolveUserContext}. */
|
|
211
229
|
export interface ResolveUserContextOptions {
|
|
212
230
|
/**
|
package/src/index.ts
CHANGED
|
@@ -153,6 +153,7 @@ export {
|
|
|
153
153
|
clearUserContext,
|
|
154
154
|
clearPiiFromContext,
|
|
155
155
|
analyticsUserIdStorageKey,
|
|
156
|
+
activationJoinContext,
|
|
156
157
|
RESERVED_USER_CONTEXT_KEYS,
|
|
157
158
|
EXTRA_KEY_PREFIX,
|
|
158
159
|
} from "./context/userContext";
|
|
@@ -162,11 +163,27 @@ export type {
|
|
|
162
163
|
ResolveUserContextOptions,
|
|
163
164
|
ClearUserContextOptions,
|
|
164
165
|
} from "./context/userContext";
|
|
165
|
-
export {
|
|
166
|
+
export {
|
|
167
|
+
mintDeviceId,
|
|
168
|
+
deviceIdStorageKey,
|
|
169
|
+
AUTO_DEVICE_ID_PREFIX,
|
|
170
|
+
// `resolveAutoDeviceKey` is the id `createAnalytics` / `createWireActivation` auto-mint and persist.
|
|
171
|
+
// It MUST be public: the documented purchase↔onboarding join is
|
|
172
|
+
// `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, and a host that owns NO device
|
|
173
|
+
// id of its own had no way to obtain the key the analytics side was already stamping — so its
|
|
174
|
+
// purchase events carried `wdev_*` while its onboarding session carried no `device_key` at all, and
|
|
175
|
+
// the join returned the silent zero the README warns about.
|
|
176
|
+
resolveAutoDeviceKey,
|
|
177
|
+
resetAutoDeviceKeys,
|
|
178
|
+
} from "./context/deviceId";
|
|
179
|
+
export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
|
|
166
180
|
|
|
167
181
|
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
168
182
|
export {
|
|
169
183
|
getCurrentSessionId,
|
|
184
|
+
// Returns the registered per-open id, minting + registering one when no app-open has been
|
|
185
|
+
// registered yet — so no wire path can emit the empty `session_id` the server drops behind a 200.
|
|
186
|
+
ensureCurrentSessionId,
|
|
170
187
|
setCurrentSessionId,
|
|
171
188
|
resetCurrentSessionId,
|
|
172
189
|
} from "./analytics/currentSession";
|
|
@@ -189,6 +206,33 @@ export {
|
|
|
189
206
|
resetActivationRevalidation,
|
|
190
207
|
} from "./activation";
|
|
191
208
|
|
|
209
|
+
// ─── RevenueCat (purchase funnel → the same events stream, joined on device_key) ──
|
|
210
|
+
export {
|
|
211
|
+
createRevenueCatBridge,
|
|
212
|
+
WIRE_PURCHASE_EVENTS,
|
|
213
|
+
PLAN_TIER_CONTEXT_KEY,
|
|
214
|
+
activeEntitlement,
|
|
215
|
+
describeEntitlement,
|
|
216
|
+
describeFailure,
|
|
217
|
+
describePackage,
|
|
218
|
+
isUserCancelled,
|
|
219
|
+
resolvePlanTier,
|
|
220
|
+
} from "./revenuecat";
|
|
221
|
+
export type {
|
|
222
|
+
RevenueCatBridge,
|
|
223
|
+
RevenueCatBridgeConfig,
|
|
224
|
+
PurchaseProps,
|
|
225
|
+
WirePurchaseEventName,
|
|
226
|
+
PlanTier,
|
|
227
|
+
RevenueCatCustomerInfoLike,
|
|
228
|
+
RevenueCatEntitlementLike,
|
|
229
|
+
RevenueCatErrorLike,
|
|
230
|
+
RevenueCatOfferingLike,
|
|
231
|
+
RevenueCatPackageLike,
|
|
232
|
+
RevenueCatProductLike,
|
|
233
|
+
RevenueCatSink,
|
|
234
|
+
} from "./revenuecat";
|
|
235
|
+
|
|
192
236
|
// ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
|
|
193
237
|
export {
|
|
194
238
|
reportSessionStart,
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* host that mounted CoachmarkProvider gets questionnaire gating for free - no second storage
|
|
6
6
|
* to wire. Once-per-user is keyed on the questionnaire `id`.
|
|
7
7
|
*/
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
resolveStorage,
|
|
10
|
+
readInt,
|
|
11
|
+
writeInt,
|
|
12
|
+
bumpSessionCount,
|
|
13
|
+
currentOpenId,
|
|
14
|
+
} from "../reviews/runtime";
|
|
9
15
|
|
|
10
16
|
/** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
|
|
11
17
|
export const questionnaireSeenKey = (id: string, version?: string): string =>
|
|
@@ -15,6 +21,10 @@ export const questionnaireSeenKey = (id: string, version?: string): string =>
|
|
|
15
21
|
export const questionnaireLastShownKey = (id: string): string =>
|
|
16
22
|
`wire_questionnaire_${id}_last`;
|
|
17
23
|
|
|
18
|
-
/** Session-count key, incremented once per
|
|
24
|
+
/** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
|
|
19
25
|
export const questionnaireSessionsKey = (id: string): string =>
|
|
20
26
|
`wire_questionnaire_${id}_sessions`;
|
|
27
|
+
|
|
28
|
+
/** Companion key holding the open id the counter was LAST incremented for (see `bumpSessionCount`). */
|
|
29
|
+
export const questionnaireSessionOpenKey = (id: string): string =>
|
|
30
|
+
`wire_questionnaire_${id}_open`;
|
|
@@ -20,8 +20,10 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
|
20
20
|
import { sameDecision, shallowEqual } from "../reviews/equality";
|
|
21
21
|
import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
|
|
22
22
|
import {
|
|
23
|
+
bumpSessionCount,
|
|
23
24
|
questionnaireLastShownKey,
|
|
24
25
|
questionnaireSeenKey,
|
|
26
|
+
questionnaireSessionOpenKey,
|
|
25
27
|
questionnaireSessionsKey,
|
|
26
28
|
readInt,
|
|
27
29
|
resolveStorage,
|
|
@@ -71,14 +73,14 @@ export const useQuestionnaireGate = ({
|
|
|
71
73
|
);
|
|
72
74
|
const lastKey = questionnaireLastShownKey(config.id);
|
|
73
75
|
const sessionsKey = questionnaireSessionsKey(config.id);
|
|
76
|
+
const sessionOpenKey = questionnaireSessionOpenKey(config.id);
|
|
74
77
|
|
|
75
|
-
// Read (and bump) the
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
})[0];
|
|
78
|
+
// Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount — see the same note
|
|
79
|
+
// in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
|
|
80
|
+
// StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
|
|
81
|
+
const sessions = useState(() =>
|
|
82
|
+
bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
|
|
83
|
+
)[0];
|
|
82
84
|
|
|
83
85
|
// Gate the local rules behind an optional client-side timeout, so a reachable server gets a
|
|
84
86
|
// window to answer first. A present `decision` bypasses the wait entirely.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revenuecat - the drop-in RevenueCat to Wire activation path.
|
|
3
|
+
*
|
|
4
|
+
* Re-exported from the main `@wireai/activation` barrel (no separate subpath): it is a pure,
|
|
5
|
+
* dependency-free mapping layer with no UI, so it costs an analytics-only consumer nothing.
|
|
6
|
+
*
|
|
7
|
+
* Adopting it is a constructor plus your existing paywall call sites:
|
|
8
|
+
*
|
|
9
|
+
* import { createAnalytics, createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
|
|
10
|
+
*
|
|
11
|
+
* const analytics = createAnalytics({ serverUrl, apiKey, storage, userContext: { deviceKey } });
|
|
12
|
+
* const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
|
|
13
|
+
*
|
|
14
|
+
* revenuecat.paywallShown(offering, { source: variant });
|
|
15
|
+
* revenuecat.checkoutStarted(pkg, { source: variant });
|
|
16
|
+
* const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
|
|
17
|
+
*
|
|
18
|
+
* And the join that makes the numbers real, on the onboarding side:
|
|
19
|
+
*
|
|
20
|
+
* <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
|
|
21
|
+
*
|
|
22
|
+
* See `revenueCatBridge.ts` for why that key is `device_key` and never `session_id`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ─── The bridge (the thing you wire) ──────────────────────────────────────────
|
|
26
|
+
export { createRevenueCatBridge } from "./revenueCatBridge";
|
|
27
|
+
export type {
|
|
28
|
+
RevenueCatBridge,
|
|
29
|
+
RevenueCatBridgeConfig,
|
|
30
|
+
PurchaseProps,
|
|
31
|
+
} from "./revenueCatBridge";
|
|
32
|
+
|
|
33
|
+
// ─── Canonical purchase-funnel names + the pure mappers behind the bridge ─────
|
|
34
|
+
export {
|
|
35
|
+
WIRE_PURCHASE_EVENTS,
|
|
36
|
+
PLAN_TIER_CONTEXT_KEY,
|
|
37
|
+
activeEntitlement,
|
|
38
|
+
describeEntitlement,
|
|
39
|
+
describeFailure,
|
|
40
|
+
describePackage,
|
|
41
|
+
isUserCancelled,
|
|
42
|
+
resolvePlanTier,
|
|
43
|
+
} from "./purchaseEvents";
|
|
44
|
+
export type { WirePurchaseEventName, PlanTier } from "./purchaseEvents";
|
|
45
|
+
|
|
46
|
+
// ─── Structural mirrors of the react-native-purchases shapes (no native dep) ──
|
|
47
|
+
export type {
|
|
48
|
+
RevenueCatCustomerInfoLike,
|
|
49
|
+
RevenueCatEntitlementLike,
|
|
50
|
+
RevenueCatErrorLike,
|
|
51
|
+
RevenueCatOfferingLike,
|
|
52
|
+
RevenueCatPackageLike,
|
|
53
|
+
RevenueCatProductLike,
|
|
54
|
+
RevenueCatSink,
|
|
55
|
+
} from "./types";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* purchaseEvents - the canonical Wire names for a subscription funnel, plus the PURE mappers that
|
|
3
|
+
* turn a RevenueCat snapshot into small, non-PII event props.
|
|
4
|
+
*
|
|
5
|
+
* Same job `analyticsEvent.ts` does for the onboarding funnel: every app was naming these events
|
|
6
|
+
* itself (`PURCHASE`, `purchase_success`, `AI_PAYWALL_VIEW`), so the same funnel read differently
|
|
7
|
+
* per tenant and no cross-app report was possible. These are the ONE set of names; the mappers are
|
|
8
|
+
* the ONE definition of which RevenueCat fields are safe to send.
|
|
9
|
+
*
|
|
10
|
+
* PURE + dependency-free: no React, no React Native, no `react-native-purchases`, no transport.
|
|
11
|
+
* Every function tolerates a malformed or absent snapshot and returns a defined value instead of
|
|
12
|
+
* throwing, because a paywall must never break on an analytics mapping.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
RevenueCatCustomerInfoLike,
|
|
16
|
+
RevenueCatEntitlementLike,
|
|
17
|
+
RevenueCatPackageLike,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The canonical purchase-funnel event names. These are `app_event` `question_key` values on the
|
|
22
|
+
* wire, so they are ALSO the exact strings a review / questionnaire firing trigger matches on.
|
|
23
|
+
* Never rename one: a rename silently unfires every trigger configured against the old string.
|
|
24
|
+
*/
|
|
25
|
+
export const WIRE_PURCHASE_EVENTS = {
|
|
26
|
+
/** The paywall became visible (offerings loaded). */
|
|
27
|
+
paywallShown: "wire_paywall_shown",
|
|
28
|
+
/** The user tapped buy; the store sheet is about to open. */
|
|
29
|
+
checkoutStarted: "wire_checkout_started",
|
|
30
|
+
/** The store confirmed the purchase AND the entitlement is now active. */
|
|
31
|
+
purchased: "wire_purchase_completed",
|
|
32
|
+
/** The purchase did not land: a user cancel or a real store/network error (see `reason`). */
|
|
33
|
+
purchaseFailed: "wire_purchase_failed",
|
|
34
|
+
/** A restore ran (whether or not it produced an entitlement; see `plan_tier`). */
|
|
35
|
+
restored: "wire_purchase_restored",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type WirePurchaseEventName =
|
|
39
|
+
(typeof WIRE_PURCHASE_EVENTS)[keyof typeof WIRE_PURCHASE_EVENTS];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The user-context key the bridge writes the tier to. It lands NAMESPACED as
|
|
43
|
+
* `user_context["custom.plan_tier"]` (host extras are always prefixed), which is the flattened
|
|
44
|
+
* dimension the server report can break the funnel down by.
|
|
45
|
+
*/
|
|
46
|
+
export const PLAN_TIER_CONTEXT_KEY = "plan_tier";
|
|
47
|
+
|
|
48
|
+
/** The three tiers the kit models. Anything richer belongs in the host's own analytics. */
|
|
49
|
+
export type PlanTier = "paid" | "trial" | "free";
|
|
50
|
+
|
|
51
|
+
/** Read a plain record field defensively (the snapshot comes from a native bridge). */
|
|
52
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
53
|
+
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The ACTIVE entitlement matching `entitlementId`, or `undefined`. Returns `undefined` for a
|
|
57
|
+
* missing key, an explicitly inactive entry, and any malformed snapshot.
|
|
58
|
+
*/
|
|
59
|
+
export const activeEntitlement = (
|
|
60
|
+
info: RevenueCatCustomerInfoLike | undefined,
|
|
61
|
+
entitlementId: string,
|
|
62
|
+
): RevenueCatEntitlementLike | undefined => {
|
|
63
|
+
const active = asRecord(asRecord(asRecord(info)?.entitlements)?.active);
|
|
64
|
+
const found = active?.[entitlementId];
|
|
65
|
+
const entitlement = asRecord(found) as RevenueCatEntitlementLike | undefined;
|
|
66
|
+
if (!entitlement) return undefined;
|
|
67
|
+
// `isActive` is always present on the real type; treat an explicit `false` as not entitled and a
|
|
68
|
+
// missing value as entitled, because the key only appears in `entitlements.active` at all when
|
|
69
|
+
// RevenueCat considers it granted.
|
|
70
|
+
return entitlement.isActive === false ? undefined : entitlement;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** True when RevenueCat says this entitlement is a free trial rather than a paid period. */
|
|
74
|
+
const isTrial = (entitlement: RevenueCatEntitlementLike): boolean =>
|
|
75
|
+
typeof entitlement.periodType === "string" && entitlement.periodType.toUpperCase() === "TRIAL";
|
|
76
|
+
|
|
77
|
+
/** `paid` / `trial` / `free` for the configured entitlement. Never throws. */
|
|
78
|
+
export const resolvePlanTier = (
|
|
79
|
+
info: RevenueCatCustomerInfoLike | undefined,
|
|
80
|
+
entitlementId: string,
|
|
81
|
+
): PlanTier => {
|
|
82
|
+
const entitlement = activeEntitlement(info, entitlementId);
|
|
83
|
+
if (!entitlement) return "free";
|
|
84
|
+
return isTrial(entitlement) ? "trial" : "paid";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Non-PII props for an offering package: which package, which product, what it costs. Every field
|
|
89
|
+
* is optional on the wire, so a missing one is OMITTED rather than sent empty or invented.
|
|
90
|
+
*/
|
|
91
|
+
export const describePackage = (
|
|
92
|
+
pkg: RevenueCatPackageLike | undefined,
|
|
93
|
+
): Record<string, string | number> => {
|
|
94
|
+
const props: Record<string, string | number> = {};
|
|
95
|
+
if (!pkg) return props;
|
|
96
|
+
if (typeof pkg.identifier === "string") props.package_id = pkg.identifier;
|
|
97
|
+
if (typeof pkg.packageType === "string") props.package_type = pkg.packageType;
|
|
98
|
+
if (typeof pkg.offeringIdentifier === "string") props.offering_id = pkg.offeringIdentifier;
|
|
99
|
+
const product = asRecord(pkg.product) as RevenueCatProductFields | undefined;
|
|
100
|
+
if (product) {
|
|
101
|
+
if (typeof product.identifier === "string") props.product_id = product.identifier;
|
|
102
|
+
if (typeof product.price === "number" && Number.isFinite(product.price)) {
|
|
103
|
+
props.price = product.price;
|
|
104
|
+
}
|
|
105
|
+
if (typeof product.currencyCode === "string") props.currency = product.currencyCode;
|
|
106
|
+
}
|
|
107
|
+
return props;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The product fields `describePackage` reads, narrowed after the defensive record check. */
|
|
111
|
+
type RevenueCatProductFields = {
|
|
112
|
+
identifier?: unknown;
|
|
113
|
+
price?: unknown;
|
|
114
|
+
currencyCode?: unknown;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Non-PII props for the entitlement state after a purchase or a restore. Always carries
|
|
119
|
+
* `entitlement` + `plan_tier` so a "restore that granted nothing" is still a queryable row; the
|
|
120
|
+
* rest of the fields only appear when the entitlement is actually active.
|
|
121
|
+
*/
|
|
122
|
+
export const describeEntitlement = (
|
|
123
|
+
info: RevenueCatCustomerInfoLike | undefined,
|
|
124
|
+
entitlementId: string,
|
|
125
|
+
): Record<string, string | number | boolean> => {
|
|
126
|
+
const entitlement = activeEntitlement(info, entitlementId);
|
|
127
|
+
const props: Record<string, string | number | boolean> = {
|
|
128
|
+
entitlement: entitlementId,
|
|
129
|
+
plan_tier: entitlement ? (isTrial(entitlement) ? "trial" : "paid") : "free",
|
|
130
|
+
};
|
|
131
|
+
if (!entitlement) return props;
|
|
132
|
+
if (typeof entitlement.periodType === "string") props.period_type = entitlement.periodType;
|
|
133
|
+
props.is_trial = isTrial(entitlement);
|
|
134
|
+
if (typeof entitlement.willRenew === "boolean") props.will_renew = entitlement.willRenew;
|
|
135
|
+
if (typeof entitlement.store === "string") props.store = entitlement.store;
|
|
136
|
+
if (typeof entitlement.productIdentifier === "string") {
|
|
137
|
+
props.product_id = entitlement.productIdentifier;
|
|
138
|
+
}
|
|
139
|
+
if (typeof entitlement.isSandbox === "boolean") props.is_sandbox = entitlement.isSandbox;
|
|
140
|
+
return props;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** RevenueCat's `PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR`. A STRING enum, value `"1"`. */
|
|
144
|
+
const PURCHASE_CANCELLED_CODE = "1";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* True when the rejection is the user backing out of the store sheet rather than a real failure.
|
|
148
|
+
* Reads BOTH signals the SDK exposes: the current `code` and the deprecated-but-still-populated
|
|
149
|
+
* `userCancelled` flag. Anything else (including a plain `Error`) is a genuine failure.
|
|
150
|
+
*/
|
|
151
|
+
export const isUserCancelled = (error: unknown): boolean => {
|
|
152
|
+
const record = asRecord(error);
|
|
153
|
+
if (!record) return false;
|
|
154
|
+
if (record.userCancelled === true) return true;
|
|
155
|
+
return record.code === PURCHASE_CANCELLED_CODE;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A short, stable, non-PII failure descriptor. The store's `message` is DELIBERATELY dropped: it is
|
|
160
|
+
* localized, unbounded, and occasionally echoes the account it failed for, so it is not something
|
|
161
|
+
* the kit forwards to a shared analytics backend. The RevenueCat `code` is the stable handle.
|
|
162
|
+
*/
|
|
163
|
+
export const describeFailure = (error: unknown): { reason: string; code: string } => {
|
|
164
|
+
const record = asRecord(error);
|
|
165
|
+
const code = typeof record?.code === "string" ? record.code : "unknown";
|
|
166
|
+
return { reason: isUserCancelled(error) ? "cancelled" : "error", code };
|
|
167
|
+
};
|