@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
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
* FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
|
|
28
28
|
*/
|
|
29
29
|
import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
|
|
30
|
+
import { getCurrentSessionId } from "./currentSession";
|
|
30
31
|
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
31
32
|
import { makeSessionId, type ClientEvent } from "./reportClientEvent";
|
|
33
|
+
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
32
34
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
33
35
|
|
|
34
36
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
@@ -62,6 +64,13 @@ export type CreateAnalyticsConfig = {
|
|
|
62
64
|
appBuild?: string;
|
|
63
65
|
/** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
|
|
64
66
|
networkType?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
|
|
69
|
+
* user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
|
|
70
|
+
* {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional — omit it
|
|
71
|
+
* and events carry only the device context, exactly as before.
|
|
72
|
+
*/
|
|
73
|
+
userContext?: WireUserContext;
|
|
65
74
|
};
|
|
66
75
|
|
|
67
76
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
@@ -81,6 +90,12 @@ export type Analytics = {
|
|
|
81
90
|
screen(name: string, props?: AnalyticsProps): void;
|
|
82
91
|
/** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
|
|
83
92
|
identify(userId: string, traits?: AnalyticsProps): void;
|
|
93
|
+
/**
|
|
94
|
+
* Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
|
|
95
|
+
* merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
|
|
96
|
+
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
97
|
+
*/
|
|
98
|
+
setUserContext(partial: Partial<WireUserContext>): void;
|
|
84
99
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
85
100
|
flush(): void;
|
|
86
101
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
@@ -98,12 +113,22 @@ export const createAnalytics = (
|
|
|
98
113
|
config: CreateAnalyticsConfig,
|
|
99
114
|
options: AnalyticsOptions = {},
|
|
100
115
|
): Analytics => {
|
|
101
|
-
|
|
116
|
+
// A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
|
|
117
|
+
// no per-open session has been registered yet (see `resolveSessionId`).
|
|
118
|
+
const instanceSessionId = config.sessionId ?? makeSessionId();
|
|
119
|
+
|
|
120
|
+
// The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
|
|
121
|
+
// id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
|
|
122
|
+
// `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
|
|
123
|
+
// phantom session; finally fall back to the stable per-instance id when no open is registered yet.
|
|
124
|
+
const resolveSessionId = (): string =>
|
|
125
|
+
config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
|
|
102
126
|
|
|
103
|
-
// A provider (not a fixed value) so `networkType`
|
|
127
|
+
// A provider (not a fixed value) so `networkType` + the current session id are evaluated fresh
|
|
128
|
+
// on every enqueue.
|
|
104
129
|
const envelope = (): ContextEnvelope =>
|
|
105
130
|
buildContextEnvelope({
|
|
106
|
-
sessionId,
|
|
131
|
+
sessionId: resolveSessionId(),
|
|
107
132
|
appVersion: config.appVersion,
|
|
108
133
|
appBuild: config.appBuild,
|
|
109
134
|
networkType: config.networkType,
|
|
@@ -117,18 +142,62 @@ export const createAnalytics = (
|
|
|
117
142
|
...options,
|
|
118
143
|
});
|
|
119
144
|
|
|
120
|
-
//
|
|
121
|
-
|
|
145
|
+
// The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
|
|
146
|
+
// every event so a post-mount update (login) takes effect immediately.
|
|
147
|
+
let userContext: WireUserContext = { ...(config.userContext ?? {}) };
|
|
148
|
+
|
|
149
|
+
// Per-session, in-memory user binding. Seeded from the init context, then persisted across
|
|
150
|
+
// launches when storage is provided.
|
|
151
|
+
let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
|
|
152
|
+
const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
|
|
153
|
+
|
|
154
|
+
if (config.storage) {
|
|
155
|
+
void config.storage
|
|
156
|
+
.getItem(storageKey)
|
|
157
|
+
.then((saved) => {
|
|
158
|
+
// Don't clobber an explicit init-context user id with a stale persisted one.
|
|
159
|
+
if (saved && !boundUserId) boundUserId = saved;
|
|
160
|
+
})
|
|
161
|
+
.catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stamp the resolved rich context onto an event: the `user_context` bucket (device_key, app_version,
|
|
165
|
+
// opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
|
|
166
|
+
// key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
|
|
167
|
+
const applyContext = (event: ClientEvent): void => {
|
|
168
|
+
const resolved = resolveUserContext(userContext, { autoAppVersion: config.appVersion });
|
|
169
|
+
if (resolved.userContext) {
|
|
170
|
+
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
171
|
+
}
|
|
172
|
+
if (boundUserId && !event.user_id) event.user_id = boundUserId;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const setUserContext = (partial: Partial<WireUserContext>): void => {
|
|
176
|
+
if (!partial || typeof partial !== "object") return;
|
|
177
|
+
// Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
|
|
178
|
+
const mergedExtra =
|
|
179
|
+
partial.extra || userContext.extra
|
|
180
|
+
? { ...(userContext.extra ?? {}), ...(partial.extra ?? {}) }
|
|
181
|
+
: undefined;
|
|
182
|
+
userContext = { ...userContext, ...partial };
|
|
183
|
+
if (mergedExtra) userContext.extra = mergedExtra;
|
|
184
|
+
// A user id supplied here binds like `identify` so subsequent events carry `user_id`.
|
|
185
|
+
const uid = sanitizeUserId(partial.userId);
|
|
186
|
+
if (uid) {
|
|
187
|
+
boundUserId = uid;
|
|
188
|
+
if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
122
191
|
|
|
123
192
|
const track = (event: string, props?: AnalyticsProps): void => {
|
|
124
193
|
if (!event) return;
|
|
125
194
|
const clientEvent: ClientEvent = {
|
|
126
195
|
event_type: "app_event",
|
|
127
|
-
session_id:
|
|
196
|
+
session_id: resolveSessionId(),
|
|
128
197
|
question_key: event,
|
|
129
198
|
};
|
|
130
199
|
if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
|
|
131
|
-
|
|
200
|
+
applyContext(clientEvent);
|
|
132
201
|
queue.enqueue(clientEvent);
|
|
133
202
|
};
|
|
134
203
|
|
|
@@ -138,11 +207,11 @@ export const createAnalytics = (
|
|
|
138
207
|
const meta = { screen: name, ...(props ?? {}) };
|
|
139
208
|
const clientEvent: ClientEvent = {
|
|
140
209
|
event_type: "app_event",
|
|
141
|
-
session_id:
|
|
210
|
+
session_id: resolveSessionId(),
|
|
142
211
|
question_key: "screen",
|
|
143
212
|
meta: JSON.stringify(meta),
|
|
144
213
|
};
|
|
145
|
-
|
|
214
|
+
applyContext(clientEvent);
|
|
146
215
|
queue.enqueue(clientEvent);
|
|
147
216
|
};
|
|
148
217
|
|
|
@@ -151,12 +220,18 @@ export const createAnalytics = (
|
|
|
151
220
|
// Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
|
|
152
221
|
if (!clean) return;
|
|
153
222
|
boundUserId = clean;
|
|
223
|
+
if (config.storage) {
|
|
224
|
+
void config.storage.setItem(storageKey, clean).catch(() => {});
|
|
225
|
+
}
|
|
154
226
|
const clientEvent: ClientEvent = {
|
|
155
227
|
event_type: "identify",
|
|
156
|
-
|
|
228
|
+
// Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
|
|
229
|
+
// the session it already saw instead of back-filling a phantom `session_started`.
|
|
230
|
+
session_id: resolveSessionId(),
|
|
157
231
|
user_id: clean,
|
|
158
232
|
};
|
|
159
233
|
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
234
|
+
applyContext(clientEvent);
|
|
160
235
|
queue.enqueue(clientEvent);
|
|
161
236
|
};
|
|
162
237
|
|
|
@@ -164,6 +239,7 @@ export const createAnalytics = (
|
|
|
164
239
|
track,
|
|
165
240
|
screen,
|
|
166
241
|
identify,
|
|
242
|
+
setUserContext,
|
|
167
243
|
flush: queue.flush,
|
|
168
244
|
notifyOnline: queue.notifyOnline,
|
|
169
245
|
size: queue.size,
|
|
@@ -30,7 +30,7 @@ export type ContextEnvelope = {
|
|
|
30
30
|
device: DeviceContext;
|
|
31
31
|
/** Correlation id for this app-open / flow (caller-supplied). */
|
|
32
32
|
sessionId?: string;
|
|
33
|
-
/**
|
|
33
|
+
/** App version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected, else auto-detected). */
|
|
34
34
|
appVersion?: string;
|
|
35
35
|
/** Host native build number, e.g. "412" (from `expo-constants` `nativeBuildVersion`). */
|
|
36
36
|
appBuild?: string;
|
|
@@ -47,9 +47,10 @@ export type ContextEnvelopeInput = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block (which
|
|
51
|
+
* already carries a best-effort auto-detected `appVersion`) and layers the host-injected scalars
|
|
52
|
+
* on top. An explicit `input.appVersion` overrides the auto-detected `device.appVersion`, and the
|
|
53
|
+
* outer `appVersion` scalar mirrors whichever version is effective.
|
|
53
54
|
*
|
|
54
55
|
* Returns a NEW object on every call (no shared mutable reference), so a caller can hold or mutate
|
|
55
56
|
* the result without leaking into the next envelope. Never throws — `collectDeviceContext` is
|
|
@@ -59,12 +60,17 @@ export const buildContextEnvelope = (input: ContextEnvelopeInput = {}): ContextE
|
|
|
59
60
|
// Fresh copy so the returned envelope never aliases a cached device snapshot.
|
|
60
61
|
const device: DeviceContext = { ...collectDeviceContext() };
|
|
61
62
|
|
|
62
|
-
//
|
|
63
|
-
|
|
63
|
+
// `device.appVersion` is auto-detected best-effort by `collectDeviceContext`; an explicit
|
|
64
|
+
// host `input.appVersion` always wins.
|
|
65
|
+
if (input.appVersion) device.appVersion = input.appVersion;
|
|
66
|
+
|
|
67
|
+
// The outer scalar mirrors the effective version (host-supplied, else auto-detected) so the
|
|
68
|
+
// queue can stamp `user_context.app_version` even when the host never passed one.
|
|
69
|
+
const effectiveAppVersion = input.appVersion ?? device.appVersion;
|
|
64
70
|
|
|
65
71
|
const envelope: ContextEnvelope = { device };
|
|
66
72
|
if (input.sessionId) envelope.sessionId = input.sessionId;
|
|
67
|
-
if (
|
|
73
|
+
if (effectiveAppVersion) envelope.appVersion = effectiveAppVersion;
|
|
68
74
|
if (input.appBuild) envelope.appBuild = input.appBuild;
|
|
69
75
|
if (input.networkType) envelope.networkType = input.networkType;
|
|
70
76
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* currentSession — a tiny module-level registry of the CURRENT per-open `session_id`.
|
|
3
|
+
*
|
|
4
|
+
* WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
|
|
5
|
+
* `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
|
|
6
|
+
* post `app.session_started` with it — so the SERVER knows that id. But other client paths
|
|
7
|
+
* (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
|
|
8
|
+
* (a frozen per-instance id), which the server had never seen, so it back-filled a synthetic
|
|
9
|
+
* `session_started` — inflating session counts (the Morrow/Myelino "phantom-session" bug).
|
|
10
|
+
*
|
|
11
|
+
* This registry is the single seam that lets those paths reuse the LIVE per-open session id the
|
|
12
|
+
* server already ingested. `reportSessionStart` writes the current id here on every open; the façade
|
|
13
|
+
* reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
|
|
14
|
+
*
|
|
15
|
+
* DEPENDENCY-FREE + PROCESS-LOCAL: a plain module variable. It is intentionally NOT persisted — it
|
|
16
|
+
* tracks the CURRENT process's open, and a fresh open always overwrites it. No cross-launch state.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let _currentSessionId: string | undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
|
|
23
|
+
* app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
|
|
24
|
+
*/
|
|
25
|
+
export const setCurrentSessionId = (id: string | undefined): void => {
|
|
26
|
+
if (typeof id === "string" && id.length > 0) _currentSessionId = id;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
|
|
30
|
+
export const getCurrentSessionId = (): string | undefined => _currentSessionId;
|
|
31
|
+
|
|
32
|
+
/** Test-only: forget the current session id so a unit test starts from a clean registry. */
|
|
33
|
+
export const resetCurrentSessionId = (): void => {
|
|
34
|
+
_currentSessionId = undefined;
|
|
35
|
+
};
|
|
@@ -228,6 +228,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
228
228
|
// fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
|
|
229
229
|
const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
|
|
230
230
|
if (!target?.serverUrl || events.length === 0) return false;
|
|
231
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
232
|
+
const timer = setTimeout(() => controller?.abort(), 15_000);
|
|
231
233
|
try {
|
|
232
234
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
233
235
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
@@ -236,10 +238,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
236
238
|
method: "POST",
|
|
237
239
|
headers,
|
|
238
240
|
body: JSON.stringify({ events }),
|
|
241
|
+
signal: controller?.signal,
|
|
239
242
|
});
|
|
240
243
|
return !!(res && (res as { ok?: boolean }).ok);
|
|
241
244
|
} catch {
|
|
242
245
|
return false;
|
|
246
|
+
} finally {
|
|
247
|
+
clearTimeout(timer);
|
|
243
248
|
}
|
|
244
249
|
};
|
|
245
250
|
|
package/src/analytics/index.ts
CHANGED
|
@@ -57,3 +57,6 @@ export type {
|
|
|
57
57
|
|
|
58
58
|
// ─── The thin optional React hook over the façade ─────────────────────────────
|
|
59
59
|
export { useAnalytics } from "./useAnalytics";
|
|
60
|
+
|
|
61
|
+
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
62
|
+
export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
|
|
@@ -31,6 +31,12 @@ export const useAnalytics = (
|
|
|
31
31
|
): Analytics => {
|
|
32
32
|
// One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
|
|
33
33
|
const ref = useRef<Analytics | undefined>(undefined);
|
|
34
|
-
|
|
34
|
+
const prevKeys = useRef<string>("");
|
|
35
|
+
|
|
36
|
+
const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
|
|
37
|
+
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
38
|
+
prevKeys.current = currentKeys;
|
|
39
|
+
ref.current = createAnalytics(config, options);
|
|
40
|
+
}
|
|
35
41
|
return ref.current;
|
|
36
42
|
};
|
|
@@ -32,9 +32,31 @@ export const useScreenTracking = (
|
|
|
32
32
|
navigationRef: NavigationRefLike | undefined,
|
|
33
33
|
options: ScreenTrackerOptions = {},
|
|
34
34
|
): void => {
|
|
35
|
+
const optionsRef = useRef(options);
|
|
36
|
+
optionsRef.current = options;
|
|
37
|
+
|
|
35
38
|
// One tracker per mount; kept in a ref so re-renders never rebuild the de-dup memory.
|
|
36
39
|
const trackerRef = useRef<ScreenTracker | undefined>(undefined);
|
|
37
|
-
if (!trackerRef.current)
|
|
40
|
+
if (!trackerRef.current) {
|
|
41
|
+
const dynamicOptions: ScreenTrackerOptions = {
|
|
42
|
+
get target() {
|
|
43
|
+
return optionsRef.current.target;
|
|
44
|
+
},
|
|
45
|
+
get sessionId() {
|
|
46
|
+
return optionsRef.current.sessionId;
|
|
47
|
+
},
|
|
48
|
+
get deviceKey() {
|
|
49
|
+
return optionsRef.current.deviceKey;
|
|
50
|
+
},
|
|
51
|
+
get onScreen() {
|
|
52
|
+
return optionsRef.current.onScreen;
|
|
53
|
+
},
|
|
54
|
+
get shouldTrack() {
|
|
55
|
+
return optionsRef.current.shouldTrack;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
trackerRef.current = createScreenTracker(dynamicOptions);
|
|
59
|
+
}
|
|
38
60
|
|
|
39
61
|
useEffect(() => {
|
|
40
62
|
const tracker = trackerRef.current;
|
|
@@ -264,7 +264,7 @@ const _InterstitialCard: React.FC<Props> = ({
|
|
|
264
264
|
{items?.length ? (
|
|
265
265
|
<View style={[styles.items, { gap: t.spacing.md, marginTop: t.spacing.sm }]}>
|
|
266
266
|
{items.map((item, i) => (
|
|
267
|
-
<WorkItem key={item} text={item} index={i} reduced={reduced} />
|
|
267
|
+
<WorkItem key={`${item}-${i}`} text={item} index={i} reduced={reduced} />
|
|
268
268
|
))}
|
|
269
269
|
</View>
|
|
270
270
|
) : body ? (
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* Keeps the SDK's defensive numeric coercion so a malformed LLM payload can never
|
|
5
5
|
* feed NaN/Infinity into layout math.
|
|
6
6
|
*/
|
|
7
|
-
import React, { useCallback, useState } from "react";
|
|
7
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
8
8
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import type { InjectedProps, WireAIComponent } from "wireai-rn";
|
|
11
11
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
12
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
12
13
|
import { captionStyle, headingStyle } from "../theme/typography";
|
|
13
14
|
import { Button } from "../components/Button";
|
|
14
15
|
import { CardLayout } from "../components/CardLayout";
|
|
@@ -42,6 +43,7 @@ const _NumberStepperCard: React.FC<Props> = ({
|
|
|
42
43
|
onSubmit,
|
|
43
44
|
}) => {
|
|
44
45
|
const t = useOnboardingTheme();
|
|
46
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
45
47
|
|
|
46
48
|
const safeMin = safeNum(min, 1);
|
|
47
49
|
const safeMaxRaw = safeNum(max, 30);
|
|
@@ -84,7 +86,7 @@ const _NumberStepperCard: React.FC<Props> = ({
|
|
|
84
86
|
<Button title={submitLabel} onPress={handleSubmit} variant="primary" disabled={submitted} />
|
|
85
87
|
}
|
|
86
88
|
>
|
|
87
|
-
<View style={
|
|
89
|
+
<View style={styles.wrap}>
|
|
88
90
|
<View style={[styles.stepper, { gap: t.spacing.lg }]}>
|
|
89
91
|
<Pressable onPress={atMin || submitted ? undefined : handleDecrement} disabled={atMin || submitted} style={stepBtnStyle(atMin)}>
|
|
90
92
|
<Text style={[headingStyle(t.fonts), { color: atMin ? t.colors.textMuted : t.colors.primary }]}>{"−"}</Text>
|
|
@@ -114,8 +116,11 @@ export const NumberStepperCard: WireAIComponent = {
|
|
|
114
116
|
defaultProps: { min: 1, max: 30, defaultValue: 1, step: 1, submitLabel: "Confirm" },
|
|
115
117
|
};
|
|
116
118
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
119
|
+
function makeStyles(t: OnboardingTheme) {
|
|
120
|
+
return StyleSheet.create({
|
|
121
|
+
wrap: { gap: t.spacing.md, alignItems: "center" },
|
|
122
|
+
stepper: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
|
|
123
|
+
stepBtn: { width: 44, height: 44, borderWidth: 2, alignItems: "center", justifyContent: "center" },
|
|
124
|
+
valueBox: { alignItems: "center", minWidth: 80 },
|
|
125
|
+
});
|
|
126
|
+
}
|
package/src/cards/StatusCard.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Reduce motion: final frame instantly. (The terminal payoff with the burst is
|
|
10
10
|
* CompletionView; this card is the inline status treatment.)
|
|
11
11
|
*/
|
|
12
|
-
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
12
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { Animated, Easing, StyleSheet, Text, View } from "react-native";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import type { InjectedProps, WireAIComponent } from "wireai-rn";
|
|
@@ -60,13 +60,18 @@ const _StatusCard: React.FC<Props> = ({ status, title, message, ctaLabel, onCont
|
|
|
60
60
|
const reduced = useReducedMotion();
|
|
61
61
|
const [submitted, setSubmitted] = useState(false);
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const color = useMemo(() => {
|
|
64
|
+
switch (status) {
|
|
65
|
+
case "success":
|
|
66
|
+
return t.colors.success;
|
|
67
|
+
case "warning":
|
|
68
|
+
return t.colors.warning;
|
|
69
|
+
case "error":
|
|
70
|
+
return t.colors.error;
|
|
71
|
+
default:
|
|
72
|
+
return t.colors.info;
|
|
73
|
+
}
|
|
74
|
+
}, [status, t.colors]);
|
|
70
75
|
|
|
71
76
|
// Glyph pop + copy fade-up (final frame under reduce motion).
|
|
72
77
|
const popT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* multi-line box by default so the user has room to actually write. Short fields
|
|
10
10
|
* (a name, a username) set `multiline: false` for the compact single-line height.
|
|
11
11
|
*/
|
|
12
|
-
import React, { useCallback, useState } from "react";
|
|
12
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
13
13
|
import { StyleSheet, Text, TextInput, View, useWindowDimensions } from "react-native";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import type { InjectedProps, WireAIComponent } from "wireai-rn";
|
|
16
16
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
17
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
17
18
|
import { bodyStyle, captionStyle } from "../theme/typography";
|
|
18
19
|
import { Button } from "../components/Button";
|
|
19
20
|
import { CardLayout } from "../components/CardLayout";
|
|
@@ -66,6 +67,7 @@ const _TextInputCard: React.FC<Props> = ({
|
|
|
66
67
|
lines = 6,
|
|
67
68
|
}) => {
|
|
68
69
|
const t = useOnboardingTheme();
|
|
70
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
69
71
|
const { height: screenHeight } = useWindowDimensions();
|
|
70
72
|
const [value, setValue] = useState("");
|
|
71
73
|
const [submitted, setSubmitted] = useState(false);
|
|
@@ -102,7 +104,7 @@ const _TextInputCard: React.FC<Props> = ({
|
|
|
102
104
|
/>
|
|
103
105
|
}
|
|
104
106
|
>
|
|
105
|
-
<View style={
|
|
107
|
+
<View style={styles.row}>
|
|
106
108
|
<TextInput
|
|
107
109
|
value={value}
|
|
108
110
|
onChangeText={setValue}
|
|
@@ -142,6 +144,9 @@ export const TextInputCard: WireAIComponent = {
|
|
|
142
144
|
defaultProps: { submitLabel: "Submit", multiline: true, lines: 6 },
|
|
143
145
|
};
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
147
|
+
function makeStyles(t: OnboardingTheme) {
|
|
148
|
+
return StyleSheet.create({
|
|
149
|
+
row: { gap: t.spacing.sm },
|
|
150
|
+
input: { borderWidth: 1 },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -81,12 +81,24 @@ const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, va
|
|
|
81
81
|
? { opacity: pulse }
|
|
82
82
|
: { transform: [{ rotate }] };
|
|
83
83
|
|
|
84
|
+
// The glyph lives in a fixed size×size box (alignItems/justifyContent:center) so the
|
|
85
|
+
// font's side-bearing and Android includeFontPadding can't shove the visible mark off
|
|
86
|
+
// center. The box carries the transform too, so a spinning box still rotates around its
|
|
87
|
+
// own center → the ✦ spins in place, optically centered on both axes.
|
|
84
88
|
return (
|
|
85
|
-
<Animated.View style={animatedStyle}>
|
|
89
|
+
<Animated.View style={[styles.box, { width: size, height: size }, animatedStyle]}>
|
|
86
90
|
<Text
|
|
87
91
|
accessibilityElementsHidden
|
|
88
92
|
importantForAccessibility="no"
|
|
89
|
-
style={[
|
|
93
|
+
style={[
|
|
94
|
+
styles.glyph,
|
|
95
|
+
{
|
|
96
|
+
width: size,
|
|
97
|
+
fontSize: size,
|
|
98
|
+
lineHeight: size,
|
|
99
|
+
color: color ?? t.colors.primary,
|
|
100
|
+
},
|
|
101
|
+
]}
|
|
90
102
|
>
|
|
91
103
|
✦
|
|
92
104
|
</Text>
|
|
@@ -97,5 +109,10 @@ const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, va
|
|
|
97
109
|
export const AnimatedSparkle = React.memo(_AnimatedSparkle);
|
|
98
110
|
|
|
99
111
|
const styles = StyleSheet.create({
|
|
100
|
-
|
|
112
|
+
box: { alignItems: "center", justifyContent: "center" },
|
|
113
|
+
glyph: {
|
|
114
|
+
textAlign: "center",
|
|
115
|
+
textAlignVertical: "center",
|
|
116
|
+
includeFontPadding: false,
|
|
117
|
+
},
|
|
101
118
|
});
|
|
@@ -64,6 +64,15 @@ const _Button: React.FC<ButtonProps> = ({
|
|
|
64
64
|
},
|
|
65
65
|
[pressT, reduced],
|
|
66
66
|
);
|
|
67
|
+
const lastPress = useRef<number>(0);
|
|
68
|
+
const handlePress = useCallback(() => {
|
|
69
|
+
if (!onPress) return;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (now - lastPress.current < 500) return;
|
|
72
|
+
lastPress.current = now;
|
|
73
|
+
onPress();
|
|
74
|
+
}, [onPress]);
|
|
75
|
+
|
|
67
76
|
const handlePressIn = useCallback(() => pressTo(1), [pressTo]);
|
|
68
77
|
const handlePressOut = useCallback(() => pressTo(0), [pressTo]);
|
|
69
78
|
|
|
@@ -78,7 +87,7 @@ const _Button: React.FC<ButtonProps> = ({
|
|
|
78
87
|
|
|
79
88
|
return (
|
|
80
89
|
<Pressable
|
|
81
|
-
onPress={disabled ? undefined :
|
|
90
|
+
onPress={disabled ? undefined : handlePress}
|
|
82
91
|
onPressIn={disabled ? undefined : handlePressIn}
|
|
83
92
|
onPressOut={disabled ? undefined : handlePressOut}
|
|
84
93
|
disabled={disabled}
|
|
@@ -62,8 +62,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
62
62
|
const exitT = useRef(new Animated.Value(0)).current;
|
|
63
63
|
// The previous render's child, snapshotted so it can play the exit layer.
|
|
64
64
|
const prev = useRef<Snapshot>({ key: transitionKey, node: children, variant });
|
|
65
|
-
// Variant of the child currently entering (drives the enter interpolations).
|
|
66
|
-
const [enterVariant, setEnterVariant] = useState<CardHandoffVariant>(variant);
|
|
67
65
|
const running = useRef<{ enter?: Animated.CompositeAnimation; exit?: Animated.CompositeAnimation }>({});
|
|
68
66
|
const mounted = useRef(false);
|
|
69
67
|
|
|
@@ -78,7 +76,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
78
76
|
// First mount: enter only (the design's initial reveal), nothing exits.
|
|
79
77
|
if (!mounted.current) {
|
|
80
78
|
mounted.current = true;
|
|
81
|
-
setEnterVariant(variant);
|
|
82
79
|
running.current.enter = startEnter(enterT, variant, reduced, 0);
|
|
83
80
|
return;
|
|
84
81
|
}
|
|
@@ -87,7 +84,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
87
84
|
running.current.enter?.stop();
|
|
88
85
|
running.current.exit?.stop();
|
|
89
86
|
setLeaving(last);
|
|
90
|
-
setEnterVariant(variant);
|
|
91
87
|
enterT.setValue(0);
|
|
92
88
|
exitT.setValue(0);
|
|
93
89
|
|
|
@@ -122,7 +118,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
122
118
|
|
|
123
119
|
const enterStyle = {
|
|
124
120
|
opacity: enterT,
|
|
125
|
-
transform: transformFor(
|
|
121
|
+
transform: transformFor(variant, enterT, reduced, "enter"),
|
|
126
122
|
};
|
|
127
123
|
|
|
128
124
|
return (
|
|
@@ -141,7 +137,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
141
137
|
{leaving.node}
|
|
142
138
|
</Animated.View>
|
|
143
139
|
) : null}
|
|
144
|
-
<Animated.View style={[styles.fill, enterStyle]}>{children}</Animated.View>
|
|
140
|
+
<Animated.View key={variant} style={[styles.fill, enterStyle]}>{children}</Animated.View>
|
|
145
141
|
</>
|
|
146
142
|
);
|
|
147
143
|
};
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
* whose window already resizes for the keyboard the computed overlap is ~0, so the
|
|
23
23
|
* padding is a no-op — safe on both iOS and Android, edge-to-edge or not.
|
|
24
24
|
*/
|
|
25
|
-
import React from "react";
|
|
25
|
+
import React, { useMemo } from "react";
|
|
26
26
|
import { KeyboardAvoidingView, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
27
27
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
28
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
28
29
|
import { bodyStyle, labelStyle } from "../theme/typography";
|
|
29
30
|
|
|
30
31
|
export type CardLayoutProps = {
|
|
@@ -51,12 +52,13 @@ const _CardLayout: React.FC<CardLayoutProps> = ({
|
|
|
51
52
|
titleNode,
|
|
52
53
|
}) => {
|
|
53
54
|
const t = useOnboardingTheme();
|
|
55
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
54
56
|
const hasHeader = !!title || !!subtitle || !!titleNode;
|
|
55
57
|
|
|
56
58
|
return (
|
|
57
59
|
<KeyboardAvoidingView style={styles.fill} behavior="padding">
|
|
58
60
|
{hasHeader ? (
|
|
59
|
-
<View style={
|
|
61
|
+
<View style={styles.header}>
|
|
60
62
|
{titleNode ??
|
|
61
63
|
(title ? (
|
|
62
64
|
<Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
|
|
@@ -87,17 +89,21 @@ const _CardLayout: React.FC<CardLayoutProps> = ({
|
|
|
87
89
|
{children}
|
|
88
90
|
</ScrollView>
|
|
89
91
|
|
|
90
|
-
{footer ? <View style={
|
|
92
|
+
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
|
91
93
|
</KeyboardAvoidingView>
|
|
92
94
|
);
|
|
93
95
|
};
|
|
94
96
|
|
|
95
97
|
export const CardLayout = React.memo(_CardLayout);
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
function makeStyles(t: OnboardingTheme) {
|
|
100
|
+
return StyleSheet.create({
|
|
101
|
+
fill: { flex: 1 },
|
|
102
|
+
header: { marginBottom: t.spacing.lg },
|
|
103
|
+
footer: { paddingTop: t.spacing.lg },
|
|
104
|
+
// flexGrow lets a short content block grow to fill (pinning the footer) while a
|
|
105
|
+
// tall one scrolls past the visible area.
|
|
106
|
+
scrollContent: { flexGrow: 1 },
|
|
107
|
+
center: { justifyContent: "center" },
|
|
108
|
+
});
|
|
109
|
+
}
|