@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +57 -0
- package/dist/analytics/index.d.mts +15 -2
- package/dist/analytics/index.d.ts +15 -2
- package/dist/analytics/index.js +224 -14
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +222 -15
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.js +6 -1
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +6 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{eventQueue-CA1d8Fmn.d.mts → currentSession-BJBB7i4-.d.mts} +143 -15
- package/dist/{eventQueue-CrNB9gzH.d.ts → currentSession-CxnP7gAa.d.ts} +143 -15
- package/dist/index.d.mts +40 -39
- package/dist/index.d.ts +40 -39
- package/dist/index.js +378 -154
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +370 -156
- 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 +29 -5
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +30 -6
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +1 -1
- package/dist/reviews/index.d.ts +1 -1
- package/dist/reviews/index.js +40 -9
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +42 -11
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -14
- package/dist/showcase/index.d.ts +1 -14
- package/dist/showcase/index.js +8 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +8 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/OnboardingFlow.tsx +8 -4
- package/src/WireOnboarding.tsx +12 -6
- package/src/analytics/analyticsFacade.ts +86 -10
- package/src/analytics/contextEnvelope.ts +13 -7
- package/src/analytics/currentSession.ts +35 -0
- package/src/analytics/eventQueue.ts +5 -0
- package/src/analytics/index.ts +3 -0
- package/src/analytics/useAnalytics.ts +7 -1
- package/src/analytics/useScreenTracking.ts +23 -1
- package/src/cards/InterstitialCard.tsx +1 -1
- package/src/cards/NumberStepperCard.tsx +12 -7
- package/src/cards/StatusCard.tsx +13 -8
- package/src/cards/TextInputCard.tsx +10 -5
- package/src/components/AnimatedSparkle.tsx +20 -3
- package/src/components/Button.tsx +10 -1
- package/src/components/CardHandoff.tsx +2 -6
- package/src/components/CardLayout.tsx +16 -10
- package/src/components/Illustration.tsx +9 -5
- package/src/components/LoadingBlock.tsx +44 -29
- package/src/components/LoadingScreen.tsx +3 -2
- package/src/components/OnboardingScaffold.tsx +21 -23
- package/src/context/userContext.ts +210 -0
- package/src/device/appVersion.ts +103 -0
- package/src/device/deviceContext.ts +17 -5
- package/src/features/WireFeaturesProvider.tsx +7 -2
- package/src/identity/userIdentity.ts +5 -0
- package/src/index.ts +23 -0
- package/src/questionnaire/QuestionnaireGate.tsx +15 -3
- package/src/reviews/ReviewGate.tsx +27 -7
- package/src/reviews/ReviewModal.tsx +4 -0
- package/src/session-analytics/reportSessionStart.ts +7 -0
- package/src/session-analytics/useLifecycleEvents.ts +4 -2
- package/src/session-analytics/useSessionStart.ts +2 -1
- package/src/showcase/FeatureShowcase.tsx +2 -1
- package/src/types.ts +6 -5
- 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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
+
resolveWithDelay();
|
|
110
129
|
} else {
|
|
111
130
|
setPhase("feedback");
|
|
112
131
|
}
|
|
113
132
|
},
|
|
114
|
-
[onEvent, id, store,
|
|
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
|
-
|
|
131
|
-
}, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id,
|
|
149
|
+
resolveWithDelay();
|
|
150
|
+
}, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolveWithDelay]);
|
|
132
151
|
|
|
133
152
|
const dismiss = useCallback(() => {
|
|
134
153
|
setPhase("thanks");
|
|
135
|
-
|
|
136
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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").
|
|
28
|
-
*
|
|
29
|
-
* (`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* Host app version string (e.g. "1.4.2"). OPTIONAL — when 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);
|