@wireai/activation 0.1.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 (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. package/src/utils/readProgress.ts +33 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * WireOnboarding — the public host component. Drop it into a screen and it runs
3
+ * the whole AI onboarding: builds the A2A transport from `config`, wires the SDK
4
+ * provider with the themed cards, applies the theme, and renders the flow.
5
+ *
6
+ * <WireOnboarding
7
+ * config={{ apiKey, serverUrl, appId }}
8
+ * theme={myBrandTheme}
9
+ * validators={{ username: checkUsernameAvailable }}
10
+ * onComplete={persist}
11
+ * onSkip={skip}
12
+ * />
13
+ */
14
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { WireAIProvider, type LocalLLMConfig } from "wireai-rn";
16
+ import { OnboardingThemeProvider } from "./theme/ThemeContext";
17
+ import { IllustrationProvider } from "./components/Illustration";
18
+ import { defaultIllustrations } from "./illustrations/defaultIllustrations";
19
+ import { LoadingScreen } from "./components/LoadingScreen";
20
+ import { OnboardingFlow, DEFAULT_COPY } from "./OnboardingFlow";
21
+ import { onboardingComponents } from "./cards";
22
+ import { makeSessionId, reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
23
+ import { collectDeviceContext, type DeviceContext } from "./device/deviceContext";
24
+ import { sanitizeUserId } from "./identity/userIdentity";
25
+ import {
26
+ clearPersistedSession,
27
+ loadPersistedSession,
28
+ sessionStorageKey,
29
+ DEFAULT_SESSION_TTL_MS,
30
+ type LoadedSession,
31
+ } from "./session/persistedSession";
32
+ import type { OnboardingResult, WireOnboardingProps } from "./types";
33
+
34
+ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
35
+ config,
36
+ theme,
37
+ components,
38
+ illustrations,
39
+ validators,
40
+ onComplete,
41
+ onSkip,
42
+ onError,
43
+ onEvent,
44
+ copy,
45
+ approxScreens,
46
+ startMessage = "start",
47
+ startTimeoutMs = 15_000,
48
+ fallbackFlow,
49
+ maxRetries = 1,
50
+ storage,
51
+ sessionTtlMs = DEFAULT_SESSION_TTL_MS,
52
+ persistKey,
53
+ userContext,
54
+ userId,
55
+ }) => {
56
+ // The host's own opaque user id (trimmed + capped, NO PII) so onboarding sessions can be
57
+ // reconciled to real users later. `sanitizeUserId` is a pure string transform → the memoized
58
+ // result is a stable primitive, safe to feed the `llm` / `clientContext` memos directly (unlike
59
+ // the raw object props). Bound at three moments: session-start metadata (mount), an `identify`
60
+ // event on a mid-session change (late binding), and the `identifyOnboarding` helper post-flow.
61
+ const boundUserId = useMemo(() => sanitizeUserId(userId), [userId]);
62
+
63
+ // Privacy-label-neutral device snapshot, collected once per mount (no advertising IDs, no
64
+ // fingerprinting — see device/deviceContext.ts). `config.appVersion` is host-injected and
65
+ // merged in here (collectDeviceContext never sets it). Attached to BOTH the A2A session
66
+ // metadata and every client event so the backend can segment the funnel by device.
67
+ const device = useMemo<DeviceContext>(() => {
68
+ const collected = collectDeviceContext();
69
+ return config.appVersion ? { ...collected, appVersion: config.appVersion } : collected;
70
+ }, [config.appVersion]);
71
+
72
+ // Stable serialization of `userContext`. Hosts pass it INLINE (`userContext={{ … }}`, the
73
+ // documented usage), so the raw object identity changes every render. Feeding that raw object
74
+ // into the `llm` / `clientContext` memos below would make them unstable, and wireai-rn recreates
75
+ // its A2A adapter whenever the llmConfig identity changes — the adapter ctor resets `contextId`
76
+ // (node_modules/wireai-rn: `this.contextId = void 0`), so the server-learned session id is
77
+ // dropped and correlation breaks. We key both memos on the JSON string (compared by value) and
78
+ // reuse ONE normalized reference in the metadata + client-event paths.
79
+ const userContextKey = userContext ? JSON.stringify(userContext) : "";
80
+ const userContextStable = useMemo<Record<string, string | number | boolean> | undefined>(
81
+ () => (userContextKey ? (JSON.parse(userContextKey) as Record<string, string | number | boolean>) : undefined),
82
+ [userContextKey],
83
+ );
84
+
85
+ // The context payload shared by the A2A metadata + client-event paths. `userContext` +
86
+ // `userId` are host-injected and forwarded verbatim (the server sanitizes them). Omitted keys
87
+ // stay absent, so `dropped`/`client_fallback` events carry the user id when it's known.
88
+ const clientContext = useMemo(
89
+ () => ({ device, userContext: userContextStable, userId: boundUserId }),
90
+ [device, userContextStable, boundUserId],
91
+ );
92
+ // One stable session id per onboarding SESSION (not per mount). It's used as the
93
+ // client event `session_id` AND forwarded to the backend as `metadata.sessionId` so
94
+ // the server can adopt it as the A2A `contextId` — making client-reported events
95
+ // (`dropped`) correlate to the same session the server keys its own events by. See
96
+ // the SDK-correlation note in analytics/reportClientEvent.ts.
97
+ //
98
+ // Without `storage`: minted synchronously per mount (the original behavior).
99
+ // With `storage`: hydrated from the host-injected storage BEFORE the provider mounts
100
+ // (loader-gate below), so an app KILL mid-onboarding resumes the SAME backend session
101
+ // instead of minting a new one — which would count a phantom second `started` + a
102
+ // false "drop" in the analytics funnel. See session/persistedSession.ts.
103
+ const [session, setSession] = useState<LoadedSession | null>(() =>
104
+ storage ? null : { id: makeSessionId(), resumed: false },
105
+ );
106
+ const storageKey = persistKey ?? sessionStorageKey(config.appId);
107
+
108
+ useEffect(() => {
109
+ if (!storage || session) return;
110
+ // StrictMode/dev double-effect safe: the cancel flag drops the stale resolution.
111
+ // Dev-only caveat: on a brand-new first mint, StrictMode's double-invoke can persist
112
+ // then read the id back, surfacing a spurious `resumed: true`. Production is unaffected
113
+ // (double-invoke is dev-only) and it self-heals on the next mount.
114
+ let cancelled = false;
115
+ void loadPersistedSession(storage, storageKey, sessionTtlMs).then((loaded) => {
116
+ if (!cancelled) setSession(loaded);
117
+ });
118
+ return () => {
119
+ cancelled = true;
120
+ };
121
+ }, [storage, storageKey, sessionTtlMs, session]);
122
+
123
+ // Clear the cached id the moment the flow completes, so the next onboarding on this
124
+ // device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
125
+ // a dropped session must stay resumable within the TTL.
126
+ const handleComplete = useCallback(
127
+ (result: OnboardingResult) => {
128
+ if (storage) clearPersistedSession(storage, storageKey);
129
+ onComplete(result);
130
+ },
131
+ [storage, storageKey, onComplete],
132
+ );
133
+
134
+ const sessionId = session?.id ?? "";
135
+
136
+ // Session-start snapshot of the bound user id. It seeds the A2A `metadata.userId` so the
137
+ // adapter is built ONCE with whatever id was known at session start. A mid-session userId
138
+ // change must NOT flow through this memo — recreating `llm` recreates the A2A adapter,
139
+ // whose ctor resets `contextId` (see the userContext note above) and drops the server-
140
+ // learned session id. Later changes ride EXCLUSIVELY on the `identify` effect below, which
141
+ // mutates the live session in place. `useRef` captures the first-render value once.
142
+ const startupUserIdRef = useRef<string | undefined>(boundUserId);
143
+
144
+ // Build the A2A LocalLLMConfig from the friendlier kit config. The backend
145
+ // resolves the app from the API key, so `model` (appId) is informational.
146
+ const llm = useMemo<LocalLLMConfig>(() => {
147
+ const baseUrl = config.serverUrl.replace(/\/$/, "");
148
+ return {
149
+ provider: "a2a",
150
+ baseUrl: `${baseUrl}/a2a`,
151
+ model: config.appId,
152
+ apiKey: config.apiKey,
153
+ // Carry the kit session id alongside any host attribution so it reaches the A2A
154
+ // request's `params.metadata` (the correlation seed; see note above), plus the
155
+ // names of the components this device can actually render — so the backend only
156
+ // emits cards the kit knows how to draw.
157
+ metadata: {
158
+ ...config.metadata,
159
+ sessionId,
160
+ supportedComponents: (components ?? onboardingComponents).map((c) => c.name),
161
+ // Device snapshot + host-injected user context ride the session-start metadata so the
162
+ // backend can segment the funnel. Old servers ignore these unknown keys (backward compat).
163
+ device,
164
+ ...(userContextStable ? { userContext: userContextStable } : {}),
165
+ // The session-start user id (from the ref) rides the session-start metadata so the
166
+ // server binds the session to a real user at creation. Reading the ref — not
167
+ // `boundUserId` — keeps this memo off the userId dependency, so a mid-session change
168
+ // does NOT recreate the adapter; it's bound by the `identify` effect below instead.
169
+ ...(startupUserIdRef.current ? { userId: startupUserIdRef.current } : {}),
170
+ },
171
+ timeoutMs: 60_000,
172
+ };
173
+ }, [config.serverUrl, config.appId, config.apiKey, config.metadata, sessionId, components, device, userContextStable]);
174
+
175
+ // Where client-reported events are POSTed (`/v1/events`). Same tenant creds as the flow.
176
+ const reportTarget = useMemo<ClientEventTarget>(
177
+ () => ({ serverUrl: config.serverUrl, apiKey: config.apiKey }),
178
+ [config.serverUrl, config.apiKey],
179
+ );
180
+
181
+ // LATE BINDING (mid-session): the user often registers DURING onboarding. When `userId`
182
+ // becomes known (or changes) after the session already started, emit an `identify` client
183
+ // event that attaches the id to the LIVE session, so the backend can map this in-flight
184
+ // session to the real user. On a FRESH mount the initial id already rode the session-start
185
+ // metadata, so we seed the "already bound" ref with it and only react to later CHANGES. On a
186
+ // RESUMED session the server-side record predates this mount and won't re-read start metadata,
187
+ // so we leave the seed empty to force an identify when a userId is present. Fire-and-forget.
188
+ const identifiedRef = useRef<string | undefined>(undefined);
189
+ const identifySeededRef = useRef(false);
190
+ useEffect(() => {
191
+ if (!session || !sessionId) return;
192
+ if (!identifySeededRef.current) {
193
+ identifySeededRef.current = true;
194
+ identifiedRef.current = session.resumed ? undefined : boundUserId;
195
+ }
196
+ if (!boundUserId || boundUserId === identifiedRef.current) return;
197
+ identifiedRef.current = boundUserId;
198
+ reportClientEvent(reportTarget, {
199
+ event_type: "identify",
200
+ session_id: sessionId,
201
+ user_id: boundUserId,
202
+ });
203
+ }, [session, sessionId, boundUserId, reportTarget]);
204
+
205
+ const cards = components ?? onboardingComponents;
206
+
207
+ // Loader-gate (hydration approach A): hold the entire provider subtree until the
208
+ // stored id resolves. `sessionId` is a dependency of the `llm` memo above, so mounting
209
+ // the provider with a throwaway id and swapping later would re-init it mid-flight.
210
+ // The read is timeout-capped in loadPersistedSession, so this gate is one storage
211
+ // read (~a frame), never an indefinite hold.
212
+ if (!session) {
213
+ return (
214
+ <OnboardingThemeProvider theme={theme}>
215
+ <LoadingScreen
216
+ title={copy?.restoringTitle ?? DEFAULT_COPY.restoringTitle}
217
+ hint={copy?.restoringHint ?? DEFAULT_COPY.restoringHint}
218
+ />
219
+ </OnboardingThemeProvider>
220
+ );
221
+ }
222
+
223
+ return (
224
+ <OnboardingThemeProvider theme={theme}>
225
+ <IllustrationProvider registry={{ ...defaultIllustrations, ...illustrations }}>
226
+ <WireAIProvider llm={llm} components={cards}>
227
+ <OnboardingFlow
228
+ validators={validators}
229
+ onComplete={handleComplete}
230
+ onSkip={onSkip}
231
+ onError={onError}
232
+ onEvent={onEvent}
233
+ copy={copy}
234
+ approxScreens={approxScreens}
235
+ startMessage={startMessage}
236
+ startTimeoutMs={startTimeoutMs}
237
+ fallbackFlow={fallbackFlow}
238
+ maxRetries={maxRetries}
239
+ reportTarget={reportTarget}
240
+ sessionId={sessionId}
241
+ resumed={session.resumed}
242
+ clientContext={clientContext}
243
+ />
244
+ </WireAIProvider>
245
+ </IllustrationProvider>
246
+ </OnboardingThemeProvider>
247
+ );
248
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Canonical analytics names for the onboarding funnel. The kit already emits a typed
3
+ * `OnboardingEvent` (`started | turn | error | retry | fallback`) — but one app logged them as
4
+ * `onboarding_*` and another as `AI_ONBOARDING_*`, so the same funnel reads differently per app.
5
+ * This maps the kit event to ONE canonical `wire_onboarding_*` name + params, and the app logs
6
+ * it through whatever transport it already has (Firebase, Amplitude, console). The app still
7
+ * owns the logger; only the NAMES are standardized.
8
+ *
9
+ * <WireOnboarding
10
+ * onEvent={(e) => { const a = toAnalyticsEvent(e); logEvent(a.name, a.params); }}
11
+ * onComplete={(r) => { logEvent(WIRE_ONBOARDING_EVENTS.completed, { answers: Object.keys(r.answers).length }); persist(r); }}
12
+ * />
13
+ *
14
+ * `completed` has no kit `OnboardingEvent` (the kit signals completion via `onComplete`, not
15
+ * `onEvent`) — the app logs it explicitly on `onComplete` using the constant below, so the
16
+ * funnel name stays canonical.
17
+ */
18
+ import type { OnboardingEvent } from "../types";
19
+
20
+ export const WIRE_ONBOARDING_EVENTS = {
21
+ started: "wire_onboarding_started",
22
+ /** A persisted session was restored after an app kill (fires instead of `started`). */
23
+ resumed: "wire_onboarding_resumed",
24
+ turn: "wire_onboarding_turn",
25
+ error: "wire_onboarding_error",
26
+ retry: "wire_onboarding_retry",
27
+ fallback: "wire_onboarding_fallback",
28
+ /** Logged by the host on `onComplete` (no matching kit `OnboardingEvent`). */
29
+ completed: "wire_onboarding_completed",
30
+ } as const;
31
+
32
+ export type WireOnboardingEventName =
33
+ (typeof WIRE_ONBOARDING_EVENTS)[keyof typeof WIRE_ONBOARDING_EVENTS];
34
+
35
+ export type AnalyticsEvent = {
36
+ name: WireOnboardingEventName;
37
+ params?: Record<string, unknown>;
38
+ };
39
+
40
+ /**
41
+ * Map a kit `OnboardingEvent` to its canonical `{ name, params }`. Exhaustive over the union
42
+ * (the `never` default makes a new event type a compile error here — intentional).
43
+ */
44
+ export const toAnalyticsEvent = (event: OnboardingEvent): AnalyticsEvent => {
45
+ switch (event.type) {
46
+ case "started":
47
+ return { name: WIRE_ONBOARDING_EVENTS.started };
48
+ case "resumed":
49
+ return { name: WIRE_ONBOARDING_EVENTS.resumed };
50
+ case "turn":
51
+ return {
52
+ name: WIRE_ONBOARDING_EVENTS.turn,
53
+ params: { step: event.step, component: event.component },
54
+ };
55
+ case "error":
56
+ return { name: WIRE_ONBOARDING_EVENTS.error, params: { reason: event.reason } };
57
+ case "retry":
58
+ return {
59
+ name: WIRE_ONBOARDING_EVENTS.retry,
60
+ params: { reason: event.reason, attempt: event.attempt },
61
+ };
62
+ case "fallback":
63
+ return { name: WIRE_ONBOARDING_EVENTS.fallback, params: { reason: event.reason } };
64
+ default: {
65
+ const _exhaustive: never = event;
66
+ return _exhaustive;
67
+ }
68
+ }
69
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * reportClientEvent — forward DEVICE-ONLY onboarding events to the Wire AI analytics
3
+ * backend (`POST {serverUrl}/v1/events`), completing the funnel for events the server
4
+ * can't observe on its own.
5
+ *
6
+ * The backend already records the server-observable funnel during the A2A flow
7
+ * (`session_started`, `screen_shown`, `answer_submitted`, `completed`, `llm_fallback`,
8
+ * and even `screen_skipped` — it derives that from the kit's skip sentinel). The one
9
+ * event no server request can capture is `dropped`: the user closing the app / unmounting
10
+ * the flow without finishing. That's what this reporter is for.
11
+ *
12
+ * Contract (server: routers/onboarding.py → analytics/events.py):
13
+ * POST {serverUrl}/v1/events
14
+ * Authorization: Bearer {apiKey}
15
+ * { "events": [ { event_type, session_id, screen_index?, component?, question_key?,
16
+ * latency_ms?, meta?, device?, user_context? } ] }
17
+ * The server fills `app_id` + `environment` from the resolving key (never send app_id),
18
+ * and silently skips malformed events — one bad payload never fails the batch.
19
+ *
20
+ * ⚠️ Correlation: `session_id` MUST equal the A2A `contextId` the server uses to key the
21
+ * server-side events, or the funnel report (which groups by `session_id`) treats this as a
22
+ * phantom session. See `makeSessionId` + WireOnboarding for how the kit seeds it.
23
+ *
24
+ * Fire-and-forget: this never throws into the UI and never awaits — analytics must never
25
+ * be able to break onboarding.
26
+ */
27
+ import type { DeviceContext } from "../device/deviceContext";
28
+
29
+ /** Event types a CLIENT may report. The rest of the funnel is server-side; sending those
30
+ * here would double-count. `screen_skipped` is included for completeness, but the kit does
31
+ * NOT emit it — the backend already derives it from the skip sentinel (see OnboardingFlow).
32
+ * `client_fallback` is emitted by the kit when the AI flow degrades to the static fallback,
33
+ * so the dashboard's fallback-rate counts the whole-flow case (distinct from the server's
34
+ * per-turn `llm_fallback`). The server back-fills a `session_started` for it if unseen.
35
+ * This is the SINGLE fallback signal — hosts must NOT also report their own.
36
+ * `identify` binds the host's opaque `user_id` to this `session_id` (late binding — the user
37
+ * registered during/after onboarding). It carries no funnel weight; the server maps the
38
+ * session to the user and back-fills a `session_started` if it never saw the session. */
39
+ export type ClientEventType = "screen_skipped" | "dropped" | "client_fallback" | "identify";
40
+
41
+ /** One client-reported event. Mirrors the server's `OnboardingEvent` (client-settable fields). */
42
+ export type ClientEvent = {
43
+ event_type: ClientEventType;
44
+ /** Must match the server-side A2A contextId for this onboarding (see makeSessionId). */
45
+ session_id: string;
46
+ /** 0-based index of the screen the event refers to (matches server `screen_shown`). */
47
+ screen_index?: number;
48
+ component?: string;
49
+ question_key?: string;
50
+ latency_ms?: number;
51
+ /** JSON-stringified extras; the server stores it verbatim. */
52
+ meta?: string;
53
+ /**
54
+ * Privacy-label-neutral device snapshot (platform / form factor / locale / host appVersion).
55
+ * Sent as an object; the server sanitizes + persists it and derives a coarse country. Old
56
+ * servers ignore this unknown field — fully backward compatible. See device/deviceContext.ts.
57
+ */
58
+ device?: DeviceContext;
59
+ /**
60
+ * Host-injected, non-PII context (signup method, referral, plan, hashed user id). Old servers
61
+ * ignore it. MUST NOT contain PII like raw emails — see the README `userContext` section.
62
+ */
63
+ user_context?: Record<string, string | number | boolean>;
64
+ /**
65
+ * The host's OPAQUE PSEUDONYMOUS user id (their internal id, NOT an email/name). Required on
66
+ * `identify`, optional (rides along) on other events. Trimmed + capped at 128 chars host-side.
67
+ * Lets the backend reconcile onboarding sessions to real users. Old servers ignore it.
68
+ */
69
+ user_id?: string;
70
+ };
71
+
72
+ /** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
73
+ export type ClientEventTarget = {
74
+ /** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
75
+ serverUrl: string;
76
+ /** Tenant API key; sent as `Authorization: Bearer`. */
77
+ apiKey: string;
78
+ };
79
+
80
+ /**
81
+ * A unique-per-onboarding session id. Used both as the client event `session_id` AND as the
82
+ * seed the kit forwards to the backend so the SERVER adopts it as the A2A `contextId` — making
83
+ * client and server agree (see WireOnboarding + the SDK-correlation note in the kit docs).
84
+ * No crypto dependency: timestamp + random is collision-safe for a single device's onboarding.
85
+ */
86
+ export const makeSessionId = (): string =>
87
+ `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
88
+
89
+ /**
90
+ * POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
91
+ * a missing `fetch`, or a network failure is swallowed — the call returns immediately and the
92
+ * request (if any) runs in the background.
93
+ */
94
+ export const reportClientEvents = (
95
+ target: ClientEventTarget | undefined,
96
+ events: ClientEvent[],
97
+ ): void => {
98
+ if (!target?.serverUrl || events.length === 0) return;
99
+ try {
100
+ const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
101
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
102
+ if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
103
+ void fetch(url, {
104
+ method: "POST",
105
+ headers,
106
+ body: JSON.stringify({ events }),
107
+ }).catch(() => {
108
+ // Network/transport error — analytics is best-effort, swallow.
109
+ });
110
+ } catch {
111
+ // URL construction, JSON serialization, or a missing fetch — swallow.
112
+ }
113
+ };
114
+
115
+ /** Convenience single-event wrapper around {@link reportClientEvents}. */
116
+ export const reportClientEvent = (
117
+ target: ClientEventTarget | undefined,
118
+ event: ClientEvent,
119
+ ): void => reportClientEvents(target, [event]);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * sendPreview — fire-and-forget PREFETCH of the next A2A turn, so the backend can warm
3
+ * its generation cache during a moment the user isn't waiting.
4
+ *
5
+ * Two callers, both firing the SAME text the real commit will send (the backend keys the
6
+ * preview cache on the raw message string, so a mismatch just misses harmlessly):
7
+ * 1. InterstitialCard — its answer is always "continue" (SDK-wrapped `My answer is: "continue"`),
8
+ * warmed while the user reads the momentum screen.
9
+ * 2. Choice cards (Selection/ChipSelect) — warmed on selection-change with the exact
10
+ * `I selected: ${JSON.stringify(value)}` the Continue tap will send, so the perceptibly-slow
11
+ * first LLM question returns from cache. See OnboardingFlow.previewOnSelect.
12
+ *
13
+ * Contract (mirrors the A2A `message/send` shape, but a distinct `method`):
14
+ * POST {serverUrl}/a2a
15
+ * Authorization: Bearer {apiKey}
16
+ * { jsonrpc:"2.0", id:<rand>, method:"message/preview",
17
+ * params:{ message:{ role:"user", parts:[{ text }] }, contextId:<sessionId>,
18
+ * metadata:{ sessionId } } }
19
+ *
20
+ * Fire-and-forget, exactly like reportClientEvent: never awaits, never throws into the UI —
21
+ * a prefetch that fails just means the real turn generates normally.
22
+ */
23
+
24
+ /** Where to POST the preview. Same tenant creds as the flow (`serverUrl` + `apiKey`). */
25
+ export type PreviewTarget = {
26
+ /** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/a2a` is appended. */
27
+ serverUrl: string;
28
+ /** Tenant API key; sent as `Authorization: Bearer`. */
29
+ apiKey: string;
30
+ };
31
+
32
+ /** What to preview: the session to warm and the message the user is about to send. */
33
+ export type PreviewArgs = {
34
+ /** A2A contextId for this onboarding (the kit session id). */
35
+ sessionId: string;
36
+ /** The message that will be committed next (for interstitials, always "continue"). */
37
+ userMessage: string;
38
+ };
39
+
40
+ /**
41
+ * Warm the next turn's cache, fire-and-forget. A missing/invalid target, a build error,
42
+ * a missing `fetch`, or a network failure is swallowed — the call returns immediately and
43
+ * the request (if any) runs in the background.
44
+ */
45
+ export const sendPreview = (
46
+ target: PreviewTarget | undefined,
47
+ { sessionId, userMessage }: PreviewArgs,
48
+ ): void => {
49
+ if (!target?.serverUrl) return;
50
+ try {
51
+ const url = `${target.serverUrl.replace(/\/$/, "")}/a2a`;
52
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
53
+ if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
54
+ void fetch(url, {
55
+ method: "POST",
56
+ headers,
57
+ body: JSON.stringify({
58
+ jsonrpc: "2.0",
59
+ id: Math.floor(Math.random() * 1e9),
60
+ method: "message/preview",
61
+ params: {
62
+ message: { role: "user", parts: [{ text: userMessage }] },
63
+ contextId: sessionId,
64
+ metadata: { sessionId },
65
+ },
66
+ }),
67
+ }).catch(() => {
68
+ // Network/transport error — prefetch is best-effort, swallow.
69
+ });
70
+ } catch {
71
+ // URL construction, JSON serialization, or a missing fetch — swallow.
72
+ }
73
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * attribution — forward install/ad attribution to the onboarding agent so it can tailor the
3
+ * first question by acquisition source (e.g. a paid-campaign install vs an organic one). The
4
+ * channel already exists: `WireOnboardingConfig.metadata` is merged into every A2A request by
5
+ * `WireOnboarding`. No production app populated it before; this gives every app a standard shape.
6
+ *
7
+ * The app reads its raw attribution (AppsFlyer / Branch / deferred deep link — an app concern,
8
+ * not the kit's) and passes the normalized result as `metadata`:
9
+ *
10
+ * const config = wireConfigFromEnv({
11
+ * appId: "morrow-self",
12
+ * metadata: attributionMetadata({ source: conv.mediaSource, campaign: conv.campaign, isOrganic: conv.isOrganic }),
13
+ * });
14
+ *
15
+ * The backend sees `metadata.attribution = { source, campaign, ... }`.
16
+ */
17
+
18
+ /** Normalized acquisition attribution. All fields optional — pass what the SDK gives you. */
19
+ export type OnboardingAttribution = {
20
+ /** Media source / network, e.g. "appsflyer", "branch", "organic". */
21
+ source?: string;
22
+ campaign?: string;
23
+ adset?: string;
24
+ ad?: string;
25
+ /** Deferred deep-link slug / path that routed the install. */
26
+ deepLink?: string;
27
+ isFirstLaunch?: boolean;
28
+ isOrganic?: boolean;
29
+ /** Escape hatch for SDK-specific fields not covered above. */
30
+ raw?: Record<string, unknown>;
31
+ };
32
+
33
+ /**
34
+ * Wrap attribution into the `{ attribution: {...} }` metadata the agent reads. Drops `undefined`
35
+ * fields so the request payload stays compact. Returns `{ attribution: {} }` for empty input
36
+ * (still a valid, harmless metadata object).
37
+ */
38
+ export const attributionMetadata = (
39
+ a: OnboardingAttribution,
40
+ ): { attribution: Record<string, unknown> } => {
41
+ const attribution: Record<string, unknown> = {};
42
+ for (const [key, value] of Object.entries(a)) {
43
+ if (value !== undefined) attribution[key] = value;
44
+ }
45
+ return { attribution };
46
+ };