@wireai/activation 0.3.0 → 0.7.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.
Files changed (75) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +57 -0
  3. package/dist/analytics/index.d.mts +15 -2
  4. package/dist/analytics/index.d.ts +15 -2
  5. package/dist/analytics/index.js +224 -14
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs +222 -15
  8. package/dist/analytics/index.mjs.map +1 -1
  9. package/dist/coachmarks/index.js +6 -1
  10. package/dist/coachmarks/index.js.map +1 -1
  11. package/dist/coachmarks/index.mjs +6 -1
  12. package/dist/coachmarks/index.mjs.map +1 -1
  13. package/dist/{eventQueue-CA1d8Fmn.d.mts → currentSession-BJBB7i4-.d.mts} +143 -15
  14. package/dist/{eventQueue-CrNB9gzH.d.ts → currentSession-CxnP7gAa.d.ts} +143 -15
  15. package/dist/index.d.mts +40 -39
  16. package/dist/index.d.ts +40 -39
  17. package/dist/index.js +378 -154
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +370 -156
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/questionnaire/index.d.mts +1 -1
  22. package/dist/questionnaire/index.d.ts +1 -1
  23. package/dist/questionnaire/index.js +29 -5
  24. package/dist/questionnaire/index.js.map +1 -1
  25. package/dist/questionnaire/index.mjs +30 -6
  26. package/dist/questionnaire/index.mjs.map +1 -1
  27. package/dist/reviews/index.d.mts +1 -1
  28. package/dist/reviews/index.d.ts +1 -1
  29. package/dist/reviews/index.js +40 -9
  30. package/dist/reviews/index.js.map +1 -1
  31. package/dist/reviews/index.mjs +42 -11
  32. package/dist/reviews/index.mjs.map +1 -1
  33. package/dist/showcase/index.d.mts +1 -14
  34. package/dist/showcase/index.d.ts +1 -14
  35. package/dist/showcase/index.js +8 -2
  36. package/dist/showcase/index.js.map +1 -1
  37. package/dist/showcase/index.mjs +8 -2
  38. package/dist/showcase/index.mjs.map +1 -1
  39. package/package.json +3 -2
  40. package/src/OnboardingFlow.tsx +8 -4
  41. package/src/WireOnboarding.tsx +12 -6
  42. package/src/analytics/analyticsFacade.ts +86 -10
  43. package/src/analytics/contextEnvelope.ts +13 -7
  44. package/src/analytics/currentSession.ts +35 -0
  45. package/src/analytics/eventQueue.ts +5 -0
  46. package/src/analytics/index.ts +3 -0
  47. package/src/analytics/useAnalytics.ts +7 -1
  48. package/src/analytics/useScreenTracking.ts +23 -1
  49. package/src/cards/InterstitialCard.tsx +1 -1
  50. package/src/cards/NumberStepperCard.tsx +12 -7
  51. package/src/cards/StatusCard.tsx +13 -8
  52. package/src/cards/TextInputCard.tsx +10 -5
  53. package/src/components/AnimatedSparkle.tsx +20 -3
  54. package/src/components/Button.tsx +10 -1
  55. package/src/components/CardHandoff.tsx +2 -6
  56. package/src/components/CardLayout.tsx +16 -10
  57. package/src/components/Illustration.tsx +9 -5
  58. package/src/components/LoadingBlock.tsx +44 -29
  59. package/src/components/LoadingScreen.tsx +3 -2
  60. package/src/components/OnboardingScaffold.tsx +21 -23
  61. package/src/context/userContext.ts +210 -0
  62. package/src/device/appVersion.ts +103 -0
  63. package/src/device/deviceContext.ts +17 -5
  64. package/src/features/WireFeaturesProvider.tsx +7 -2
  65. package/src/identity/userIdentity.ts +5 -0
  66. package/src/index.ts +23 -0
  67. package/src/questionnaire/QuestionnaireGate.tsx +15 -3
  68. package/src/reviews/ReviewGate.tsx +27 -7
  69. package/src/reviews/ReviewModal.tsx +4 -0
  70. package/src/session-analytics/reportSessionStart.ts +7 -0
  71. package/src/session-analytics/useLifecycleEvents.ts +4 -2
  72. package/src/session-analytics/useSessionStart.ts +2 -1
  73. package/src/showcase/FeatureShowcase.tsx +2 -1
  74. package/src/types.ts +6 -5
  75. package/src/components/loaderChrome.ts +0 -28
package/src/index.ts CHANGED
@@ -117,11 +117,34 @@ export type { OnboardingAttribution } from "./attribution/attribution";
117
117
  // ─── Device context (privacy-label-neutral, dependency-free) ──────────────────
118
118
  export { collectDeviceContext } from "./device/deviceContext";
119
119
  export type { DeviceContext, DeviceFormFactor } from "./device/deviceContext";
120
+ export { detectAppVersion } from "./device/appVersion";
120
121
 
121
122
  // ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
122
123
  export { identifyOnboarding, sanitizeUserId, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
123
124
  export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
124
125
 
126
+ // ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
127
+ export {
128
+ resolveUserContext,
129
+ namespaceExtra,
130
+ hashEmailFnv1a,
131
+ isWireScalar,
132
+ RESERVED_USER_CONTEXT_KEYS,
133
+ EXTRA_KEY_PREFIX,
134
+ } from "./context/userContext";
135
+ export type {
136
+ WireUserContext,
137
+ ResolvedUserContext,
138
+ ResolveUserContextOptions,
139
+ } from "./context/userContext";
140
+
141
+ // ─── Current per-open session registry (identify/app-events reuse the live session) ───
142
+ export {
143
+ getCurrentSessionId,
144
+ setCurrentSessionId,
145
+ resetCurrentSessionId,
146
+ } from "./analytics/currentSession";
147
+
125
148
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
126
149
  export {
127
150
  reportSessionStart,
@@ -43,7 +43,7 @@ const mergeThemeOver = (
43
43
 
44
44
  type Phase = "form" | "thanks";
45
45
 
46
- export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
46
+ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
47
47
  questionnaire,
48
48
  target,
49
49
  sessionId,
@@ -81,6 +81,14 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
81
81
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
82
  }, []);
83
83
 
84
+ const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
85
+
86
+ React.useEffect(() => {
87
+ return () => {
88
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
89
+ };
90
+ }, []);
91
+
84
92
  const resolve = useCallback(() => {
85
93
  onResolved?.();
86
94
  }, [onResolved]);
@@ -100,8 +108,11 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
100
108
  submitQuestionnaireResponse(target, id, submission);
101
109
  onEvent?.({ name: "questionnaire_submitted", id });
102
110
  setPhase("thanks");
103
- resolve();
104
- }, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent, resolve]);
111
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
112
+ resolveTimer.current = setTimeout(() => {
113
+ modalRef.current?.close();
114
+ }, 1500);
115
+ }, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent]);
105
116
 
106
117
  // The in-card "Not now"/"Skip" link plays the popup's exit animation (which resolves on
107
118
  // completion, the same path a backdrop tap takes). Emit the dismissed moment first.
@@ -225,6 +236,7 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
225
236
  );
226
237
  };
227
238
 
239
+ export const QuestionnaireGate = React.memo(_QuestionnaireGate);
228
240
  QuestionnaireGate.displayName = "QuestionnaireGate";
229
241
 
230
242
  const styles = StyleSheet.create({
@@ -52,7 +52,7 @@ const mergeThemeOver = (
52
52
 
53
53
  type Phase = "rating" | "feedback" | "thanks";
54
54
 
55
- export const ReviewGate: React.FC<ReviewGateProps> = ({
55
+ const _ReviewGate: React.FC<ReviewGateProps> = ({
56
56
  appName,
57
57
  store,
58
58
  target,
@@ -94,10 +94,29 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
94
94
  // eslint-disable-next-line react-hooks/exhaustive-deps
95
95
  }, []);
96
96
 
97
+ const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
98
+
99
+ React.useEffect(() => {
100
+ return () => {
101
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
102
+ };
103
+ }, []);
104
+
97
105
  const resolve = useCallback(() => {
98
106
  onResolved?.();
99
107
  }, [onResolved]);
100
108
 
109
+ const resolveWithDelay = useCallback(() => {
110
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
111
+ resolveTimer.current = setTimeout(() => {
112
+ if (mode === "modal") {
113
+ modalRef.current?.close();
114
+ } else {
115
+ onResolved?.();
116
+ }
117
+ }, 1500);
118
+ }, [mode, onResolved]);
119
+
101
120
  const pickRating = useCallback(
102
121
  async (value: number) => {
103
122
  setStars(value);
@@ -106,12 +125,12 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
106
125
  onEvent?.({ name: "store_review_requested", id, stars: value });
107
126
  await requestStoreReview(store);
108
127
  setPhase("thanks");
109
- resolve();
128
+ resolveWithDelay();
110
129
  } else {
111
130
  setPhase("feedback");
112
131
  }
113
132
  },
114
- [onEvent, id, store, resolve],
133
+ [onEvent, id, store, resolveWithDelay],
115
134
  );
116
135
 
117
136
  const sendFeedback = useCallback(() => {
@@ -127,13 +146,13 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
127
146
  submitReview(target, body);
128
147
  onEvent?.({ name: "review_feedback_submitted", id, stars });
129
148
  setPhase("thanks");
130
- resolve();
131
- }, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolve]);
149
+ resolveWithDelay();
150
+ }, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolveWithDelay]);
132
151
 
133
152
  const dismiss = useCallback(() => {
134
153
  setPhase("thanks");
135
- resolve();
136
- }, [resolve]);
154
+ resolveWithDelay();
155
+ }, [resolveWithDelay]);
137
156
 
138
157
  // The in-card "Not now"/"Skip" link. In modal mode it plays the popup's exit animation
139
158
  // (which resolves on completion, the same path a backdrop tap takes); inline keeps the
@@ -303,6 +322,7 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
303
322
  return <View style={[styles.card, surface]}>{content}</View>;
304
323
  };
305
324
 
325
+ export const ReviewGate = React.memo(_ReviewGate);
306
326
  ReviewGate.displayName = "ReviewGate";
307
327
 
308
328
  const styles = StyleSheet.create({
@@ -5,6 +5,10 @@
5
5
  * questionnaire module can reuse the same shell without importing reviews/. This file keeps the
6
6
  * reviews public API (`ReviewModal`, `ReviewModalHandle`, `ReviewModalProps`) pointing at the
7
7
  * exact same runtime object, so the reviews canary and every existing import stay valid.
8
+ *
9
+ * The exported shell is already `React.memo`-wrapped at its source (`components/CenteredModal.tsx`
10
+ * wraps `_CenteredModal` in `React.memo`), so `ReviewModal` satisfies the shell-memoization rule
11
+ * without a second wrapper here. Re-wrapping would change the runtime identity the canary asserts.
8
12
  */
9
13
  export { CenteredModal as ReviewModal, default } from "../components/CenteredModal";
10
14
  export type {
@@ -31,6 +31,7 @@
31
31
  * awaits, and swallows a missing target / bad URL / missing fetch / network error. Analytics must
32
32
  * never be able to break the app.
33
33
  */
34
+ import { setCurrentSessionId } from "../analytics/currentSession";
34
35
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
36
  import type { DeviceContext } from "../device/deviceContext";
36
37
  import { sanitizeUserId } from "../identity/userIdentity";
@@ -104,6 +105,12 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
104
105
 
105
106
  const sessionId = opts.sessionId ?? makeSessionId();
106
107
 
108
+ // Register this open as the CURRENT per-open session so the analytics façade's `identify` /
109
+ // app-events reuse the id the server sees here (via `app.session_started`) instead of minting a
110
+ // fresh one it would back-fill into a phantom session. Idempotent; set before the once-guard so
111
+ // even a guard-deduped repeat keeps the current id pointing at this open.
112
+ setCurrentSessionId(sessionId);
113
+
107
114
  // Once-per-open guard: skip a repeat emit for a session id we already sent.
108
115
  if (opts.once !== false) {
109
116
  if (_emitted.has(sessionId)) return;
@@ -119,7 +119,8 @@ export const useLifecycleEvents = (
119
119
  const { config: cfg, options: opts } = latest.current;
120
120
  if (opts.enabled !== false) {
121
121
  const device = collectDeviceContext();
122
- if (cfg?.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
122
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
123
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
123
124
  reportFirstOpen({
124
125
  target: targetOf(cfg),
125
126
  sink: resolveSink(),
@@ -142,7 +143,8 @@ export const useLifecycleEvents = (
142
143
  if (opts.enabled === false) return;
143
144
  if (!cfg?.serverUrl && !opts.sink) return;
144
145
  const device = collectDeviceContext();
145
- if (cfg?.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
146
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
147
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
146
148
  reportSessionStart({
147
149
  target: targetOf(cfg),
148
150
  sink: resolveSink(),
@@ -72,7 +72,8 @@ export const useSessionStart = (
72
72
  if (opts.enabled === false) return;
73
73
  const target: ClientEventTarget = { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" };
74
74
  const device = collectDeviceContext();
75
- if (cfg.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
75
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
76
+ if (cfg.appVersion) device.appVersion = cfg.appVersion;
76
77
  reportSessionStart({
77
78
  target,
78
79
  // A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
@@ -48,7 +48,7 @@ const mergeThemeOver = (
48
48
  * When the gate says "seen", it renders nothing and calls `onDone` from an
49
49
  * effect (never during render).
50
50
  */
51
- export const FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
51
+ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
52
52
  config,
53
53
  onDone,
54
54
  theme: themeOverride,
@@ -211,6 +211,7 @@ export const FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
211
211
  );
212
212
  };
213
213
 
214
+ export const FeatureShowcase = React.memo(_FeatureShowcase);
214
215
  FeatureShowcase.displayName = "FeatureShowcase";
215
216
 
216
217
  const styles = StyleSheet.create({
package/src/types.ts CHANGED
@@ -24,11 +24,12 @@ export type WireOnboardingConfig = {
24
24
  */
25
25
  metadata?: Record<string, unknown>;
26
26
  /**
27
- * Host app version string (e.g. "1.4.2"). HOST-INJECTED — the kit adds no dependency to
28
- * read it; hosts typically pass it from `expo-constants`
29
- * (`Constants.expoConfig?.version`). Forwarded to the backend on the session metadata and
30
- * on client events (merged into the `device` snapshot as `device.appVersion`) so analytics
31
- * can segment the funnel by app version. Optional; omit if unknown.
27
+ * Host app version string (e.g. "1.4.2"). OPTIONALwhen omitted, the kit makes a best-effort
28
+ * auto-detection from `expo-constants` (`Constants.expoConfig?.version` / `nativeAppVersion`) or
29
+ * `expo-application` (`nativeApplicationVersion`) WITHOUT adding a dependency (a host that lacks
30
+ * those modules just gets no version see device/appVersion.ts). Pass this to override the
31
+ * auto-detected value (it always wins). Forwarded to the backend on the session metadata and on
32
+ * client events (as `device.appVersion`) so analytics can segment the funnel by app version.
32
33
  */
33
34
  appVersion?: string;
34
35
  };
@@ -1,28 +0,0 @@
1
- /**
2
- * loaderChrome — the seam that lets a loader center in the FULL flow area instead of
3
- * the header-offset body it happens to render into.
4
- *
5
- * The between-turns loader lives inside the OnboardingScaffold body (so the card↔loader
6
- * handoff stays one coordinated CardHandoff — see OnboardingFlow). But the body sits
7
- * BELOW the header (the StepProgress bar), so a loader that only fills the body centers
8
- * a little low — the "not centralized in the screen" device finding. The scaffold knows
9
- * its own top chrome, so it publishes a vertical offset here; LoadingBlock reads it and
10
- * shifts its centered content up by that much, landing it at the true screen center
11
- * WITHOUT moving the body (the card underneath is untouched, so the handoff never jumps).
12
- *
13
- * Default 0: a LoadingBlock rendered OUTSIDE a scaffold (the standalone LoadingScreen used
14
- * for cold-start / restoring / error) has no header above it, so it already centers in the
15
- * full area and needs no shift.
16
- */
17
- import { createContext, useContext } from "react";
18
-
19
- export type LoaderChrome = {
20
- /** Vertical shift (px, usually negative = up) that re-centers a loader against the full flow area. */
21
- offsetY: number;
22
- };
23
-
24
- const LoaderChromeContext = createContext<LoaderChrome>({ offsetY: 0 });
25
-
26
- export const LoaderChromeProvider = LoaderChromeContext.Provider;
27
-
28
- export const useLoaderChrome = (): LoaderChrome => useContext(LoaderChromeContext);