@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.
- package/AGENTS.md +73 -0
- package/CHANGELOG.md +572 -0
- package/INTEGRATION_PROMPT.md +55 -0
- package/LICENSE +21 -0
- package/README.md +923 -0
- package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
- package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
- package/dist/coachmarks/index.d.mts +352 -0
- package/dist/coachmarks/index.d.ts +352 -0
- package/dist/coachmarks/index.js +944 -0
- package/dist/coachmarks/index.js.map +1 -0
- package/dist/coachmarks/index.mjs +918 -0
- package/dist/coachmarks/index.mjs.map +1 -0
- package/dist/decision-CFvGY6nP.d.ts +228 -0
- package/dist/decision-Cl8OFYzu.d.mts +228 -0
- package/dist/index.d.mts +1390 -0
- package/dist/index.d.ts +1390 -0
- package/dist/index.js +3260 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3186 -0
- package/dist/index.mjs.map +1 -0
- package/dist/questionnaire/index.d.mts +249 -0
- package/dist/questionnaire/index.d.ts +249 -0
- package/dist/questionnaire/index.js +922 -0
- package/dist/questionnaire/index.js.map +1 -0
- package/dist/questionnaire/index.mjs +906 -0
- package/dist/questionnaire/index.mjs.map +1 -0
- package/dist/reviews/index.d.mts +213 -0
- package/dist/reviews/index.d.ts +213 -0
- package/dist/reviews/index.js +1001 -0
- package/dist/reviews/index.js.map +1 -0
- package/dist/reviews/index.mjs +971 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/showcase/index.d.mts +119 -0
- package/dist/showcase/index.d.ts +119 -0
- package/dist/showcase/index.js +660 -0
- package/dist/showcase/index.js.map +1 -0
- package/dist/showcase/index.mjs +651 -0
- package/dist/showcase/index.mjs.map +1 -0
- package/dist/types-BKfpdZzX.d.mts +84 -0
- package/dist/types-BKfpdZzX.d.ts +84 -0
- package/dist/types-CMuOexw0.d.mts +116 -0
- package/dist/types-CMuOexw0.d.ts +116 -0
- package/dist/types-GL_hQ0TN.d.mts +46 -0
- package/dist/types-GL_hQ0TN.d.ts +46 -0
- package/llms.txt +36 -0
- package/metro/index.d.ts +32 -0
- package/metro/index.js +83 -0
- package/package.json +171 -0
- package/src/DemoOnboarding.tsx +133 -0
- package/src/OnboardingFlow.tsx +504 -0
- package/src/WireOnboarding.tsx +248 -0
- package/src/analytics/analyticsEvent.ts +69 -0
- package/src/analytics/reportClientEvent.ts +119 -0
- package/src/analytics/sendPreview.ts +73 -0
- package/src/attribution/attribution.ts +46 -0
- package/src/cards/ChipSelectCard.tsx +265 -0
- package/src/cards/InterstitialCard.tsx +299 -0
- package/src/cards/NumberStepperCard.tsx +121 -0
- package/src/cards/SelectionCard.tsx +270 -0
- package/src/cards/StatusCard.tsx +170 -0
- package/src/cards/TextInputCard.tsx +147 -0
- package/src/cards/index.ts +23 -0
- package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
- package/src/coachmarks/CoachmarkProvider.tsx +108 -0
- package/src/coachmarks/GestureHint.tsx +331 -0
- package/src/coachmarks/SpotlightOverlay.tsx +254 -0
- package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
- package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
- package/src/coachmarks/index.ts +62 -0
- package/src/coachmarks/runtime.ts +103 -0
- package/src/coachmarks/selectTourSteps.ts +45 -0
- package/src/coachmarks/types.ts +58 -0
- package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
- package/src/coachmarks/useCoachmarkTour.ts +200 -0
- package/src/components/AnimatedSparkle.tsx +101 -0
- package/src/components/Button.tsx +129 -0
- package/src/components/CardHandoff.tsx +212 -0
- package/src/components/CardLayout.tsx +103 -0
- package/src/components/CenteredModal.tsx +184 -0
- package/src/components/CompletionView.tsx +186 -0
- package/src/components/DoneBlock.tsx +37 -0
- package/src/components/ErrorBlock.tsx +42 -0
- package/src/components/Illustration.tsx +36 -0
- package/src/components/LoadingBlock.tsx +182 -0
- package/src/components/LoadingScreen.tsx +35 -0
- package/src/components/OnboardingScaffold.tsx +105 -0
- package/src/components/StepProgress.tsx +85 -0
- package/src/components/loaderChrome.ts +28 -0
- package/src/config/onboardingFlag.ts +39 -0
- package/src/config/wireConfigFromEnv.ts +52 -0
- package/src/device/deviceContext.ts +158 -0
- package/src/features/WireFeaturesProvider.tsx +81 -0
- package/src/features/cache.ts +92 -0
- package/src/features/defaults.ts +67 -0
- package/src/features/fetchWireFeatures.ts +86 -0
- package/src/features/index.ts +39 -0
- package/src/features/types.ts +61 -0
- package/src/features/useWireFeatures.ts +57 -0
- package/src/identity/userIdentity.ts +100 -0
- package/src/illustrations/defaultIllustrations.tsx +99 -0
- package/src/index.ts +159 -0
- package/src/motion/centeredModalMotion.ts +45 -0
- package/src/motion/motionSpec.ts +133 -0
- package/src/motion/useReducedMotion.ts +43 -0
- package/src/questionnaire/QuestionnaireGate.tsx +235 -0
- package/src/questionnaire/decision.ts +63 -0
- package/src/questionnaire/index.ts +54 -0
- package/src/questionnaire/runtime.ts +20 -0
- package/src/questionnaire/transport.ts +83 -0
- package/src/questionnaire/types.ts +182 -0
- package/src/questionnaire/useQuestionnaireGate.ts +159 -0
- package/src/reviews/ReviewGate.tsx +316 -0
- package/src/reviews/ReviewModal.tsx +13 -0
- package/src/reviews/decision.ts +120 -0
- package/src/reviews/equality.ts +54 -0
- package/src/reviews/expo-store-review.d.ts +16 -0
- package/src/reviews/feedbackForm.ts +41 -0
- package/src/reviews/index.ts +78 -0
- package/src/reviews/presentation.ts +29 -0
- package/src/reviews/runtime.ts +45 -0
- package/src/reviews/storeReview.ts +62 -0
- package/src/reviews/transport.ts +89 -0
- package/src/reviews/types.ts +172 -0
- package/src/reviews/useReviewGate.ts +137 -0
- package/src/session/persistedSession.ts +143 -0
- package/src/session-analytics/index.ts +18 -0
- package/src/session-analytics/reportSessionStart.ts +144 -0
- package/src/session-analytics/useSessionStart.ts +114 -0
- package/src/showcase/FeatureShowcase.tsx +233 -0
- package/src/showcase/index.ts +19 -0
- package/src/showcase/selectShowcaseSlides.ts +45 -0
- package/src/showcase/showcaseColors.ts +91 -0
- package/src/showcase/types.ts +74 -0
- package/src/theme/ThemeContext.tsx +38 -0
- package/src/theme/defaultTheme.ts +70 -0
- package/src/theme/themeFromBrand.ts +93 -0
- package/src/theme/types.ts +88 -0
- package/src/theme/typography.ts +46 -0
- package/src/types.ts +215 -0
- package/src/utils/deriveAnswers.ts +43 -0
- 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
|
+
};
|