@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
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* When the backend names an illustration the registry doesn't have, the
|
|
11
11
|
* InterstitialCard falls back to its `imageUrl` (a plain RN <Image>).
|
|
12
12
|
*/
|
|
13
|
-
import React, { createContext, useContext } from "react";
|
|
13
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
14
14
|
|
|
15
15
|
export type IllustrationRegistry = Record<string, React.ReactNode>;
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** One shared empty registry so an unconfigured provider never allocates a fresh {} per render. */
|
|
18
|
+
const EMPTY_REGISTRY: IllustrationRegistry = {};
|
|
19
|
+
|
|
20
|
+
const IllustrationContext = createContext<IllustrationRegistry>(EMPTY_REGISTRY);
|
|
18
21
|
|
|
19
22
|
export type IllustrationProviderProps = {
|
|
20
23
|
registry?: IllustrationRegistry;
|
|
@@ -24,9 +27,10 @@ export type IllustrationProviderProps = {
|
|
|
24
27
|
export const IllustrationProvider: React.FC<IllustrationProviderProps> = ({
|
|
25
28
|
registry,
|
|
26
29
|
children,
|
|
27
|
-
}) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
}) => {
|
|
31
|
+
const value = useMemo(() => registry ?? EMPTY_REGISTRY, [registry]);
|
|
32
|
+
return <IllustrationContext.Provider value={value}>{children}</IllustrationContext.Provider>;
|
|
33
|
+
};
|
|
30
34
|
|
|
31
35
|
/** Look up an app-supplied illustration node by name. Returns undefined if absent. */
|
|
32
36
|
export const useIllustration = (name?: string): React.ReactNode | undefined => {
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
scaledMs,
|
|
23
23
|
} from "../motion/motionSpec";
|
|
24
24
|
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
25
|
-
import { useLoaderChrome } from "./loaderChrome";
|
|
26
25
|
|
|
27
26
|
const STAGE_SIZE = 96;
|
|
28
27
|
const ORBIT_DOT = 9;
|
|
@@ -104,11 +103,12 @@ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
|
|
|
104
103
|
}) => {
|
|
105
104
|
const t = useOnboardingTheme();
|
|
106
105
|
const reduced = useReducedMotion();
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
106
|
+
// The loader fills its parent (flex:1) and centers in it. In the flow that parent is the
|
|
107
|
+
// scaffold BODY (the same content region the cards render into), so the loader lands at the
|
|
108
|
+
// content-area center — visually consistent with the cards it hands off to. Standalone
|
|
109
|
+
// (LoadingScreen / a playground CardFrame) it centers in whatever bounds it. No screen-inset
|
|
110
|
+
// math and no transform: it inherits the content area it is dropped into, so it is centered
|
|
111
|
+
// in every context by construction.
|
|
112
112
|
|
|
113
113
|
// Skeleton lines are gated behind the 300ms window: a fast fetch never shows them.
|
|
114
114
|
const [showSkeleton, setShowSkeleton] = useState(false);
|
|
@@ -118,34 +118,44 @@ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
|
|
|
118
118
|
}, []);
|
|
119
119
|
|
|
120
120
|
return (
|
|
121
|
-
<View
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
<View style={[styles.center, { padding: t.spacing.lg }]}>
|
|
122
|
+
{/*
|
|
123
|
+
* The stage is the ANCHOR: it's the only in-flow child, so `justifyContent:
|
|
124
|
+
* center` lands the icon at the true center of the flow area. Title, hint and
|
|
125
|
+
* the (300ms-gated) skeleton hang BELOW it in an absolutely-positioned column,
|
|
126
|
+
* so they never displace the icon — previously they lived in the same centered
|
|
127
|
+
* column, which re-balanced (and jumped the icon UP ~½ its height) the moment
|
|
128
|
+
* the skeleton lines mounted at 300ms.
|
|
129
|
+
*/}
|
|
128
130
|
<View style={styles.stage}>
|
|
129
131
|
<View style={[styles.ring, { borderColor: t.colors.border, borderRadius: t.radius.full }]} />
|
|
130
132
|
<OrbitDot color={t.colors.primary} reduced={reduced} />
|
|
131
133
|
<AnimatedSparkle size={40} variant="pulse" />
|
|
132
134
|
</View>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
135
|
+
<View
|
|
136
|
+
style={[
|
|
137
|
+
styles.labels,
|
|
138
|
+
{ marginTop: STAGE_SIZE / 2 + t.spacing.lg, gap: t.spacing.md, paddingHorizontal: t.spacing.lg },
|
|
139
|
+
]}
|
|
140
|
+
pointerEvents="none"
|
|
141
|
+
>
|
|
142
|
+
{title ? (
|
|
143
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
|
|
144
|
+
{title}
|
|
145
|
+
</Text>
|
|
146
|
+
) : null}
|
|
147
|
+
{hint ? (
|
|
148
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
149
|
+
{hint}
|
|
150
|
+
</Text>
|
|
151
|
+
) : null}
|
|
152
|
+
{showSkeleton ? (
|
|
153
|
+
<View style={[styles.skeleton, { gap: t.spacing.sm, marginTop: t.spacing.sm }]}>
|
|
154
|
+
<SkeletonLine width="80%" delay={0} color={t.colors.surface} reduced={reduced} />
|
|
155
|
+
<SkeletonLine width="60%" delay={200} color={t.colors.surface} reduced={reduced} />
|
|
156
|
+
</View>
|
|
157
|
+
) : null}
|
|
158
|
+
</View>
|
|
149
159
|
</View>
|
|
150
160
|
);
|
|
151
161
|
};
|
|
@@ -154,6 +164,11 @@ export const LoadingBlock = React.memo(_LoadingBlock);
|
|
|
154
164
|
|
|
155
165
|
const styles = StyleSheet.create({
|
|
156
166
|
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
167
|
+
// Absolutely-positioned label column, pinned just under the centered stage: its top
|
|
168
|
+
// edge starts at the flow center (top: 50%) and the inline marginTop (STAGE_SIZE/2 +
|
|
169
|
+
// a gap) drops it below the stage. Out of flow → it never moves the stage, so the
|
|
170
|
+
// 300ms skeleton reveal grows DOWNWARD only and the icon stays centered.
|
|
171
|
+
labels: { position: "absolute", top: "50%", left: 0, right: 0, alignItems: "center" },
|
|
157
172
|
stage: {
|
|
158
173
|
width: STAGE_SIZE,
|
|
159
174
|
height: STAGE_SIZE,
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* WHY a dedicated frame: those slots used to return a bare <LoadingBlock/>, whose flex:1 only
|
|
8
8
|
* fills whatever the host happened to give it — with no safe area and, if the host didn't
|
|
9
9
|
* bound it, no height at all (top-anchored). This wraps them in the same SafeAreaView the flow
|
|
10
|
-
* uses, so the standalone loaders center exactly like the between-turns loader (
|
|
11
|
-
*
|
|
10
|
+
* uses, so the standalone loaders center exactly like the between-turns loader (both fill their
|
|
11
|
+
* parent flex:1 region and center in it — here the SafeAreaView, in the flow the scaffold body).
|
|
12
|
+
* One consistent centered look.
|
|
12
13
|
*/
|
|
13
14
|
import React from "react";
|
|
14
15
|
import { StyleSheet } from "react-native";
|
|
@@ -15,16 +15,13 @@
|
|
|
15
15
|
* RN core SafeAreaView is deprecated and iOS-only, so the kit takes the standard
|
|
16
16
|
* Expo/RN peer instead of shipping cropped layouts on Android + notched devices.
|
|
17
17
|
*/
|
|
18
|
-
import React from "react";
|
|
18
|
+
import React, { useMemo } from "react";
|
|
19
19
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
20
20
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
21
21
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
22
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
22
23
|
import { bodyStyle } from "../theme/typography";
|
|
23
24
|
import { StepProgress } from "./StepProgress";
|
|
24
|
-
import { LoaderChromeProvider } from "./loaderChrome";
|
|
25
|
-
|
|
26
|
-
// The header's fixed StepProgress bar height (design bar = 6px; see StepProgress styles).
|
|
27
|
-
const PROGRESS_TRACK = 6;
|
|
28
25
|
|
|
29
26
|
export type OnboardingScaffoldProps = {
|
|
30
27
|
/** 1-based index of the current step; drives the (countless) progress bar. */
|
|
@@ -53,15 +50,9 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
|
53
50
|
children,
|
|
54
51
|
}) => {
|
|
55
52
|
const t = useOnboardingTheme();
|
|
53
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
56
54
|
const hit = { top: 12, bottom: 12, left: 12, right: 12 };
|
|
57
55
|
|
|
58
|
-
// Top chrome above the body content = header (progress bar + its paddingTop) + the body's
|
|
59
|
-
// own paddingTop. A loader that fills the body centers half that distance too low; it reads
|
|
60
|
-
// this offset (via LoaderChromeProvider) and shifts UP by half the chrome to land at the true
|
|
61
|
-
// screen center. Cards ignore it, so their layout — and the card↔loader handoff — is untouched.
|
|
62
|
-
const topChrome = t.spacing.md + PROGRESS_TRACK + t.spacing.md;
|
|
63
|
-
const loaderOffsetY = -Math.round(topChrome / 2);
|
|
64
|
-
|
|
65
56
|
return (
|
|
66
57
|
<SafeAreaView
|
|
67
58
|
edges={["top", "bottom"]}
|
|
@@ -72,15 +63,19 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
|
72
63
|
<StepProgress step={step} complete={complete} approxScreens={approxScreens} />
|
|
73
64
|
</View>
|
|
74
65
|
{onSkip ? (
|
|
75
|
-
<TouchableOpacity onPress={onSkip} hitSlop={hit} style={
|
|
66
|
+
<TouchableOpacity onPress={onSkip} hitSlop={hit} style={styles.skip}>
|
|
76
67
|
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>{skipLabel}</Text>
|
|
77
68
|
</TouchableOpacity>
|
|
78
69
|
) : null}
|
|
79
70
|
</View>
|
|
80
71
|
|
|
81
|
-
{/* Full-screen body: the card (via CardLayout) fills this and pins its own CTA
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
{/* Full-screen body (flex:1): the card (via CardLayout) fills this and pins its own CTA,
|
|
73
|
+
and the between-turns LoadingBlock fills+centers in this SAME region — so the loader
|
|
74
|
+
lands at the content-area center, consistent with the cards, not the full-screen center. */}
|
|
75
|
+
<View
|
|
76
|
+
style={[styles.body, { paddingHorizontal: t.spacing.md, paddingTop: t.spacing.md }]}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
84
79
|
</View>
|
|
85
80
|
|
|
86
81
|
{onBack ? (
|
|
@@ -96,10 +91,13 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
|
96
91
|
|
|
97
92
|
export const OnboardingScaffold = React.memo(_OnboardingScaffold);
|
|
98
93
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
94
|
+
function makeStyles(t: OnboardingTheme) {
|
|
95
|
+
return StyleSheet.create({
|
|
96
|
+
flex: { flex: 1 },
|
|
97
|
+
header: { flexDirection: "row", alignItems: "center" },
|
|
98
|
+
progressWrap: { flex: 1 },
|
|
99
|
+
body: { flex: 1 },
|
|
100
|
+
footer: { flexDirection: "row", justifyContent: "flex-start" },
|
|
101
|
+
skip: { marginLeft: t.spacing.md },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* userContext — the ONE extensible object a host passes once and the kit flows into every
|
|
3
|
+
* analytics event's `user_context` (plus the top-level opaque `user_id`).
|
|
4
|
+
*
|
|
5
|
+
* WHY it exists: hosts already hand the kit fragments of "who this user is" — `config.appVersion`,
|
|
6
|
+
* `useSessionStart({ deviceKey, userId })`, `<WireOnboarding userContext={…} />` — but there was no
|
|
7
|
+
* single object that carries app version + device key + user id + (opt-in) email + arbitrary extras
|
|
8
|
+
* together, with one precedence rule, into every event. `WireUserContext` is that object;
|
|
9
|
+
* `resolveUserContext` is the pure merge that turns it into the wire shape.
|
|
10
|
+
*
|
|
11
|
+
* PRECEDENCE (the one rule): an explicit `WireUserContext` field WINS over the #42 auto-detected
|
|
12
|
+
* `device`/`appVersion`. A missing field is OMITTED, never sent empty.
|
|
13
|
+
*
|
|
14
|
+
* WHERE EACH FIELD LANDS (deliberate separation so nothing leaks across buckets):
|
|
15
|
+
* • `userId` → the event's TOP-LEVEL opaque `user_id` (via `sanitizeUserId`). NEVER the bucket.
|
|
16
|
+
* • `userEmail` → its OWN key `user_context.user_email`. NEVER merged into `userId`. OPT-IN PII.
|
|
17
|
+
* • `deviceKey` → `user_context.device_key` (the server's `_event_device_key` reads it there).
|
|
18
|
+
* • `appVersion`→ `user_context.app_version` (and returned as `appVersion` for `device.appVersion`).
|
|
19
|
+
* • `extra` → NAMESPACED under a `custom.` key prefix, coerced to scalars, so a host extra can
|
|
20
|
+
* never collide with a reserved `user_context` key.
|
|
21
|
+
*
|
|
22
|
+
* DEPENDENCY-FREE: the only import is the kit's own `sanitizeUserId`. The optional email hash is a
|
|
23
|
+
* dependency-free FNV-1a fold (see {@link hashEmailFnv1a}) — no crypto library, no async.
|
|
24
|
+
*/
|
|
25
|
+
import { sanitizeUserId } from "../identity/userIdentity";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The single, extensible user-context object. A host passes it ONCE (at analytics init) and may
|
|
29
|
+
* update it post-mount (e.g. attach `userId`/`userEmail` at login) via `setUserContext(partial)`.
|
|
30
|
+
* Every field is optional; missing fields are omitted from the wire payload.
|
|
31
|
+
*/
|
|
32
|
+
export interface WireUserContext {
|
|
33
|
+
/**
|
|
34
|
+
* Host app version, e.g. "1.4.2". EXPLICIT — wins over the #42 auto-detected `device.appVersion`.
|
|
35
|
+
* Lands in `user_context.app_version`. Omitted when neither this nor auto-detect yields a version.
|
|
36
|
+
*/
|
|
37
|
+
appVersion?: string;
|
|
38
|
+
/**
|
|
39
|
+
* A stable, non-PII device id the host owns. Lands in `user_context.device_key` (NOT `session_id`),
|
|
40
|
+
* where the server groups a device's sessions. Host-supplied; the kit never mints or reads one.
|
|
41
|
+
*/
|
|
42
|
+
deviceKey?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The host's OPAQUE PSEUDONYMOUS user id (their internal id — NOT an email/name/phone). Sanitized +
|
|
45
|
+
* capped (see `sanitizeUserId`) and placed on the event's top-level `user_id`. NEVER the bucket.
|
|
46
|
+
*/
|
|
47
|
+
userId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* OPT-IN PII. The user's email, its OWN field (`user_context.user_email`) — NEVER merged into
|
|
50
|
+
* `userId`. The kit NEVER auto-collects this; a host passes it only WITH the user's consent (EU
|
|
51
|
+
* users: treat as personal data). For a non-reversible form, set {@link hashEmail} `true` (the kit
|
|
52
|
+
* folds it with a dependency-free hash and stamps `user_context.user_email_hashed: true`), OR
|
|
53
|
+
* pre-hash host-side with a cryptographic digest and pass that here with `hashEmail` falsy.
|
|
54
|
+
*/
|
|
55
|
+
userEmail?: string;
|
|
56
|
+
/**
|
|
57
|
+
* When `true`, {@link userEmail} is folded with the kit's dependency-free {@link hashEmailFnv1a}
|
|
58
|
+
* before it leaves the device, and `user_context.user_email_hashed` is set `true`. NOTE: FNV-1a is
|
|
59
|
+
* a lightweight NON-cryptographic fold (obfuscation, not a secure digest). For a cryptographic
|
|
60
|
+
* hash, compute it host-side (e.g. SHA-256 via `expo-crypto`) and pass the digest as `userEmail`
|
|
61
|
+
* with `hashEmail` falsy. Default: raw email is sent as-is (opt-in already gated it upstream).
|
|
62
|
+
*/
|
|
63
|
+
hashEmail?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Arbitrary host context (signup method, referral, plan tier…). Each value is coerced to a scalar
|
|
66
|
+
* (`string | number | boolean`; non-scalars and non-finite numbers are DROPPED) and NAMESPACED
|
|
67
|
+
* under a `custom.` key prefix in `user_context` (e.g. `user_context["custom.referral"]`) so it can
|
|
68
|
+
* never collide with a reserved key. No raw PII — use {@link userEmail} for email.
|
|
69
|
+
*/
|
|
70
|
+
extra?: Record<string, string | number | boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The wire-shaped result of {@link resolveUserContext}. `userContext` is the non-PII/opt-in-PII
|
|
75
|
+
* bucket stamped onto the event; `userId` is the top-level opaque id; `appVersion`/`deviceKey` are
|
|
76
|
+
* echoed for callers that also place them elsewhere (e.g. `device.appVersion`). Absent fields are
|
|
77
|
+
* omitted so a caller can spread this without sending empties.
|
|
78
|
+
*/
|
|
79
|
+
export interface ResolvedUserContext {
|
|
80
|
+
/** The opaque, sanitized user id → the event's top-level `user_id`. Omitted when unset/blank. */
|
|
81
|
+
userId?: string;
|
|
82
|
+
/** The stable device id → `user_context.device_key`. Omitted when unset. */
|
|
83
|
+
deviceKey?: string;
|
|
84
|
+
/** The effective app version (explicit > auto-detected) → `user_context.app_version`. */
|
|
85
|
+
appVersion?: string;
|
|
86
|
+
/** The `user_context` bucket (device_key, app_version, user_email[+ _hashed], custom.*). */
|
|
87
|
+
userContext?: Record<string, string | number | boolean>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
|
|
91
|
+
export const RESERVED_USER_CONTEXT_KEYS = [
|
|
92
|
+
"device_key",
|
|
93
|
+
"app_version",
|
|
94
|
+
"app_build",
|
|
95
|
+
"network_type",
|
|
96
|
+
"session_count",
|
|
97
|
+
"returning",
|
|
98
|
+
"platform",
|
|
99
|
+
"user_email",
|
|
100
|
+
"user_email_hashed",
|
|
101
|
+
] as const;
|
|
102
|
+
|
|
103
|
+
/** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
|
|
104
|
+
export const EXTRA_KEY_PREFIX = "custom." as const;
|
|
105
|
+
|
|
106
|
+
/** A finite scalar the wire accepts. Non-finite numbers (NaN/Infinity) are NOT scalars here. */
|
|
107
|
+
export const isWireScalar = (value: unknown): value is string | number | boolean => {
|
|
108
|
+
const t = typeof value;
|
|
109
|
+
if (t === "string" || t === "boolean") return true;
|
|
110
|
+
if (t === "number") return Number.isFinite(value as number);
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fold an email to a stable, dependency-free 32-bit FNV-1a hex token (lowercased + trimmed first so
|
|
116
|
+
* the same address always folds identically). This is OBFUSCATION, not a cryptographic digest — it
|
|
117
|
+
* is not collision-resistant. For a real hash, pre-hash host-side and pass the digest as `userEmail`.
|
|
118
|
+
*/
|
|
119
|
+
export const hashEmailFnv1a = (email: string): string => {
|
|
120
|
+
const normalized = email.trim().toLowerCase();
|
|
121
|
+
let hash = 0x811c9dc5; // FNV offset basis (32-bit)
|
|
122
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
123
|
+
hash ^= normalized.charCodeAt(i);
|
|
124
|
+
hash = Math.imul(hash, 0x01000193); // FNV prime (32-bit), kept in 32-bit via imul
|
|
125
|
+
}
|
|
126
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
|
|
130
|
+
const cleanString = (value: unknown): string | undefined => {
|
|
131
|
+
if (typeof value !== "string") return undefined;
|
|
132
|
+
const trimmed = value.trim();
|
|
133
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Coerce a host `extra` map into the namespaced, scalar-only bucket shape. Every kept value is
|
|
138
|
+
* placed under `custom.<key>`; non-scalar values (objects, arrays, null, functions, NaN/Infinity)
|
|
139
|
+
* are DROPPED. Returns an object (possibly empty).
|
|
140
|
+
*/
|
|
141
|
+
export const namespaceExtra = (
|
|
142
|
+
extra: Record<string, unknown> | undefined,
|
|
143
|
+
): Record<string, string | number | boolean> => {
|
|
144
|
+
const out: Record<string, string | number | boolean> = {};
|
|
145
|
+
if (!extra || typeof extra !== "object") return out;
|
|
146
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
147
|
+
const cleanKey = cleanString(key);
|
|
148
|
+
if (!cleanKey) continue;
|
|
149
|
+
if (!isWireScalar(value)) continue; // drop anything that isn't a finite scalar
|
|
150
|
+
out[`${EXTRA_KEY_PREFIX}${cleanKey}`] = value;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Options for {@link resolveUserContext}. */
|
|
156
|
+
export interface ResolveUserContextOptions {
|
|
157
|
+
/**
|
|
158
|
+
* The kit's best-effort auto-detected app version (#42; from `detectAppVersion()`/the device
|
|
159
|
+
* snapshot). Used ONLY when the explicit `WireUserContext.appVersion` is absent — explicit wins.
|
|
160
|
+
*/
|
|
161
|
+
autoAppVersion?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Merge a {@link WireUserContext} into the wire shape with the precedence rule (explicit field >
|
|
166
|
+
* auto-detected). Pure, never throws. Missing fields are omitted so the result can be spread onto an
|
|
167
|
+
* event without sending empties.
|
|
168
|
+
*/
|
|
169
|
+
export const resolveUserContext = (
|
|
170
|
+
ctx: WireUserContext = {},
|
|
171
|
+
opts: ResolveUserContextOptions = {},
|
|
172
|
+
): ResolvedUserContext => {
|
|
173
|
+
const result: ResolvedUserContext = {};
|
|
174
|
+
const bucket: Record<string, string | number | boolean> = {};
|
|
175
|
+
|
|
176
|
+
// userId → top-level opaque id (NEVER the bucket). Sanitized + capped host-side.
|
|
177
|
+
const userId = sanitizeUserId(ctx.userId);
|
|
178
|
+
if (userId) result.userId = userId;
|
|
179
|
+
|
|
180
|
+
// deviceKey → user_context.device_key (NOT session_id).
|
|
181
|
+
const deviceKey = cleanString(ctx.deviceKey);
|
|
182
|
+
if (deviceKey) {
|
|
183
|
+
result.deviceKey = deviceKey;
|
|
184
|
+
bucket.device_key = deviceKey;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// appVersion → explicit wins over auto-detected (#42); echoed for device.appVersion callers.
|
|
188
|
+
const appVersion = cleanString(ctx.appVersion) ?? cleanString(opts.autoAppVersion);
|
|
189
|
+
if (appVersion) {
|
|
190
|
+
result.appVersion = appVersion;
|
|
191
|
+
bucket.app_version = appVersion;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// userEmail → its OWN key. OPT-IN PII, optionally folded. NEVER touches userId.
|
|
195
|
+
const email = cleanString(ctx.userEmail);
|
|
196
|
+
if (email) {
|
|
197
|
+
if (ctx.hashEmail) {
|
|
198
|
+
bucket.user_email = hashEmailFnv1a(email);
|
|
199
|
+
bucket.user_email_hashed = true;
|
|
200
|
+
} else {
|
|
201
|
+
bucket.user_email = email;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// extra → namespaced + scalar-coerced.
|
|
206
|
+
Object.assign(bucket, namespaceExtra(ctx.extra));
|
|
207
|
+
|
|
208
|
+
if (Object.keys(bucket).length > 0) result.userContext = bucket;
|
|
209
|
+
return result;
|
|
210
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* appVersion — best-effort, DEPENDENCY-FREE auto-detection of the host app's version string.
|
|
3
|
+
*
|
|
4
|
+
* WHY this exists: analytics segments the funnel `by_app_version`, but that breakdown is only
|
|
5
|
+
* populated when a `device.appVersion` rides the event. `config.appVersion` (see types.ts) has
|
|
6
|
+
* always been the way to supply it — but it is easy for a host to forget, and then the release
|
|
7
|
+
* breakdown is silently empty. This module fills that gap: when the host does NOT pass a version,
|
|
8
|
+
* the kit makes a best-effort read of the app version the host already ships in its Expo config,
|
|
9
|
+
* so the breakdown works out of the box. An explicit `config.appVersion` always WINS over this.
|
|
10
|
+
*
|
|
11
|
+
* WHY it adds NO dependency (the kit's hard rule): `expo-constants` / `expo-application` are read
|
|
12
|
+
* through a GUARDED, VARIABLE-specifier `require`. Passing a variable (not a string literal) keeps
|
|
13
|
+
* Metro/esbuild from statically resolving the module, so a host that does NOT have it installed
|
|
14
|
+
* (e.g. bare React Native) never fails to bundle — the require simply throws at runtime and is
|
|
15
|
+
* swallowed. Nothing is added to `package.json`; nothing is forced on the host.
|
|
16
|
+
*
|
|
17
|
+
* PRIVACY: an app version string is not PII and identifies no user or device, so surfacing it
|
|
18
|
+
* changes no App Privacy / Data Safety declaration (same guarantee as the rest of deviceContext).
|
|
19
|
+
*
|
|
20
|
+
* NEVER THROWS: every read is guarded; a missing/odd value yields `undefined`, never an exception.
|
|
21
|
+
* Analytics must never be able to break onboarding.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime. Declared locally so
|
|
25
|
+
// this type-checks without ambient Node types; the `typeof` guard keeps the reference ESM-safe.
|
|
26
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
27
|
+
|
|
28
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded runtime require. */
|
|
29
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
30
|
+
|
|
31
|
+
/** Trim + reject non-strings/empties so we only ever emit a real version string. */
|
|
32
|
+
export const coerceVersion = (value: unknown): string | undefined => {
|
|
33
|
+
if (typeof value !== "string") return undefined;
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Guarded runtime require. `moduleName` is a VARIABLE (a parameter), so bundlers cannot statically
|
|
40
|
+
* resolve it — a host without the module never fails to build; the call just throws and is caught.
|
|
41
|
+
*/
|
|
42
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
43
|
+
try {
|
|
44
|
+
if (typeof require !== "function") return undefined;
|
|
45
|
+
return require(moduleName);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Read a module's `default` (Expo modules are consumed as default exports) or the namespace. */
|
|
52
|
+
const interop = (mod: unknown): Record<string, unknown> | undefined => {
|
|
53
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
54
|
+
const def = (mod as { default?: unknown }).default;
|
|
55
|
+
if (def && typeof def === "object") return def as Record<string, unknown>;
|
|
56
|
+
return mod as Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Resolve a module namespace, swallowing a throwing require (an uninstalled module throws). */
|
|
60
|
+
const safeInterop = (
|
|
61
|
+
requireModule: OptionalRequire,
|
|
62
|
+
moduleName: string,
|
|
63
|
+
): Record<string, unknown> | undefined => {
|
|
64
|
+
try {
|
|
65
|
+
return interop(requireModule(moduleName));
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect the host app version, preferring `expo-constants` (`expoConfig.version`, then
|
|
73
|
+
* `nativeAppVersion`) and finally `expo-application` (`nativeApplicationVersion`). Returns the
|
|
74
|
+
* first real string, or `undefined` when none of those are available. Pure and never throws.
|
|
75
|
+
*
|
|
76
|
+
* `requireModule` is injectable so tests can exercise the "found" path without the native modules;
|
|
77
|
+
* production defaults to the guarded runtime require above.
|
|
78
|
+
*/
|
|
79
|
+
export const detectAppVersion = (
|
|
80
|
+
requireModule: OptionalRequire = runtimeRequire,
|
|
81
|
+
): string | undefined => {
|
|
82
|
+
try {
|
|
83
|
+
const constants = safeInterop(requireModule, "expo-constants");
|
|
84
|
+
if (constants) {
|
|
85
|
+
const expoConfig = constants.expoConfig;
|
|
86
|
+
if (expoConfig && typeof expoConfig === "object") {
|
|
87
|
+
const fromExpoConfig = coerceVersion((expoConfig as { version?: unknown }).version);
|
|
88
|
+
if (fromExpoConfig) return fromExpoConfig;
|
|
89
|
+
}
|
|
90
|
+
const fromNative = coerceVersion(constants.nativeAppVersion);
|
|
91
|
+
if (fromNative) return fromNative;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const application = safeInterop(requireModule, "expo-application");
|
|
95
|
+
if (application) {
|
|
96
|
+
const fromApplication = coerceVersion(application.nativeApplicationVersion);
|
|
97
|
+
if (fromApplication) return fromApplication;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Any unexpected read error → "unknown"; analytics must never crash onboarding.
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* adding a single dependency to the kit or changing a host app's App Privacy / Data Safety
|
|
5
5
|
* declarations.
|
|
6
6
|
*
|
|
7
|
-
* HARD RULE (why this file
|
|
7
|
+
* HARD RULE (why this file adds no dependency):
|
|
8
8
|
* The kit stays dependency-free. Everything here comes from `Platform`, `Dimensions`,
|
|
9
|
-
* `I18nManager`, and the standard `Intl` global
|
|
9
|
+
* `I18nManager`, and the standard `Intl` global — plus a best-effort `appVersion` read via
|
|
10
|
+
* `detectAppVersion()`, which itself adds NO dependency (it reaches for `expo-constants` /
|
|
11
|
+
* `expo-application` through a guarded, variable-specifier require that a host without them
|
|
12
|
+
* simply never resolves — see device/appVersion.ts). There are NO advertising IDs, NO
|
|
10
13
|
* `getUniqueId`/IDFA/GAID/fingerprinting APIs, and nothing that would require a new
|
|
11
14
|
* privacy-label entry. A host can adopt this without touching its store declarations.
|
|
12
15
|
*
|
|
@@ -17,6 +20,8 @@
|
|
|
17
20
|
*/
|
|
18
21
|
import { Dimensions, I18nManager, Platform } from "react-native";
|
|
19
22
|
|
|
23
|
+
import { detectAppVersion } from "./appVersion";
|
|
24
|
+
|
|
20
25
|
/** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
|
|
21
26
|
export type DeviceFormFactor = "phone" | "tablet";
|
|
22
27
|
|
|
@@ -50,9 +55,11 @@ export type DeviceContext = {
|
|
|
50
55
|
/** IANA time zone (e.g. "Europe/Berlin"), from `Intl` when available. */
|
|
51
56
|
timeZone?: string;
|
|
52
57
|
/**
|
|
53
|
-
* Host app version (e.g. "1.4.2).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
58
|
+
* Host app version (e.g. "1.4.2"). BEST-EFFORT auto-detected here via `detectAppVersion()`
|
|
59
|
+
* (reads `expo-constants` / `expo-application` when present; adds no dependency — see
|
|
60
|
+
* device/appVersion.ts). An explicit host-injected `config.appVersion` always WINS: the merge
|
|
61
|
+
* sites (`WireOnboarding`, the session-analytics hooks, the context envelope) overwrite this
|
|
62
|
+
* with the host value when one is supplied. Omitted when neither source yields a version.
|
|
56
63
|
*/
|
|
57
64
|
appVersion?: string;
|
|
58
65
|
};
|
|
@@ -154,5 +161,10 @@ export const collectDeviceContext = (): DeviceContext => {
|
|
|
154
161
|
// Intl unavailable — omit locale/timeZone.
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
// Best-effort host app version (adds no dependency; omitted when unavailable). An explicit
|
|
165
|
+
// `config.appVersion` overrides this downstream at the merge sites.
|
|
166
|
+
const appVersion = detectAppVersion();
|
|
167
|
+
if (appVersion) ctx.appVersion = appVersion;
|
|
168
|
+
|
|
157
169
|
return ctx;
|
|
158
170
|
};
|
|
@@ -18,8 +18,13 @@ import { defaultWireFeatures } from "./defaults";
|
|
|
18
18
|
import { useWireFeatures } from "./useWireFeatures";
|
|
19
19
|
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
|
|
22
|
+
const globalObj = global as any;
|
|
23
|
+
|
|
24
|
+
if (!globalObj[CONTEXT_SYMBOL]) {
|
|
25
|
+
globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
|
|
26
|
+
}
|
|
27
|
+
const WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
|
|
23
28
|
|
|
24
29
|
export interface WireFeaturesProviderProps {
|
|
25
30
|
/** Tenant creds (+ optional storage) to fetch the flags once for the whole tree. */
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* ⚠️ NO PII. Pass an opaque id (or a hash), never a raw email/name/phone. The id is capped at
|
|
21
21
|
* {@link USER_ID_MAX_LENGTH} chars (longer ids are truncated, not rejected).
|
|
22
22
|
*/
|
|
23
|
+
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
23
24
|
import { reportClientEvent } from "../analytics/reportClientEvent";
|
|
24
25
|
import {
|
|
25
26
|
peekPersistedSession,
|
|
@@ -90,6 +91,10 @@ export const identifyOnboarding = async (
|
|
|
90
91
|
contextId = stored?.id;
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
// Last resort: bind to the LIVE per-open session (registered by `reportSessionStart`) so a
|
|
95
|
+
// post-flow identify with no captured contextId still attaches to a session the server saw,
|
|
96
|
+
// instead of no-oping. The onboarding contextId (above) is still preferred when available.
|
|
97
|
+
if (!contextId) contextId = getCurrentSessionId();
|
|
93
98
|
if (!contextId) return false;
|
|
94
99
|
|
|
95
100
|
reportClientEvent(
|