@wireai/activation 0.2.0 → 0.4.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/dist/analytics/index.d.mts +69 -2
- package/dist/analytics/index.d.ts +69 -2
- package/dist/analytics/index.js +123 -2
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +122 -3
- 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-CxKi7Qd5.d.mts → eventQueue-CA1d8Fmn.d.mts} +7 -3
- package/dist/{eventQueue-rV1dtJJR.d.ts → eventQueue-CrNB9gzH.d.ts} +7 -3
- package/dist/index.d.mts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +220 -151
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -152
- 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 +8 -3
- package/src/analytics/analyticsFacade.ts +184 -0
- package/src/analytics/eventQueue.ts +5 -0
- package/src/analytics/index.ts +12 -0
- package/src/analytics/reportClientEvent.ts +11 -2
- package/src/analytics/useAnalytics.ts +42 -0
- 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/features/WireFeaturesProvider.tsx +7 -2
- package/src/questionnaire/QuestionnaireGate.tsx +15 -3
- package/src/reviews/ReviewGate.tsx +27 -7
- package/src/reviews/ReviewModal.tsx +4 -0
- package/src/showcase/FeatureShowcase.tsx +2 -1
- package/src/components/loaderChrome.ts +0 -28
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analyticsFacade — a Segment/PostHog-shaped developer API (`track` / `screen` / `identify`)
|
|
3
|
+
* over the kit's OWN offline-first event queue. One package, one key, one line:
|
|
4
|
+
*
|
|
5
|
+
* const analytics = createAnalytics({ serverUrl, apiKey, storage });
|
|
6
|
+
* analytics.track("content_share", { source: "feed" });
|
|
7
|
+
* analytics.screen("Home", { tab: "explore" });
|
|
8
|
+
* analytics.identify("u_123", { plan: "pro" });
|
|
9
|
+
*
|
|
10
|
+
* WHY a façade: the primitives already exist (`createEventQueue` for durable offline-first
|
|
11
|
+
* transport, `buildContextEnvelope` for the non-PII device context, the `identify` client-event
|
|
12
|
+
* contract for user binding), but a host had to wire them together by hand. This composes them
|
|
13
|
+
* into the familiar analytics-SDK surface so a consumer gets the ergonomics with NO second
|
|
14
|
+
* install and NO second key — the same `{ serverUrl, apiKey }` creds as onboarding.
|
|
15
|
+
*
|
|
16
|
+
* ROUTING: every call builds a `ClientEvent` and `enqueue`s it. The queue stamps the context
|
|
17
|
+
* envelope (device + host scalars), persists offline, batches, retries, and dequeues on ack —
|
|
18
|
+
* so `track`/`screen`/`identify` inherit offline-first durability for free. `screen` routes
|
|
19
|
+
* through the SAME queue rather than the direct `reportAppEvent` POST (a deliberate upgrade:
|
|
20
|
+
* screen views survive being offline too).
|
|
21
|
+
*
|
|
22
|
+
* DEPENDENCY-FREE + TREE-SHAKEABLE: this module imports ONLY the queue, the envelope builder,
|
|
23
|
+
* the client-event types + session-id seed, and the identity sanitizer. It reaches NO
|
|
24
|
+
* onboarding / showcase / review UI, so an analytics-only consumer bundles none of it. React is
|
|
25
|
+
* absent here on purpose — the optional React glue is the thin `useAnalytics` hook.
|
|
26
|
+
*
|
|
27
|
+
* FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
|
|
28
|
+
*/
|
|
29
|
+
import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
|
|
30
|
+
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
31
|
+
import { makeSessionId, type ClientEvent } from "./reportClientEvent";
|
|
32
|
+
import { sanitizeUserId } from "../identity/userIdentity";
|
|
33
|
+
|
|
34
|
+
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
35
|
+
export type AnalyticsProps = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tenant transport + context inputs for {@link createAnalytics}. `serverUrl`/`apiKey` are the
|
|
39
|
+
* SAME creds as onboarding (never a second key). The rest feed the context envelope + the queue's
|
|
40
|
+
* offline persistence — all optional.
|
|
41
|
+
*/
|
|
42
|
+
export type CreateAnalyticsConfig = {
|
|
43
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
|
|
44
|
+
serverUrl: string;
|
|
45
|
+
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
46
|
+
apiKey: string;
|
|
47
|
+
/**
|
|
48
|
+
* Correlation id shared by every event from this instance (and the `identify` event). Defaults
|
|
49
|
+
* to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
|
|
50
|
+
*/
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
/** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
|
|
53
|
+
appId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Host persistence (AsyncStorage-compatible subset) for offline-first durability. When omitted,
|
|
56
|
+
* the queue runs in the documented in-memory mode (survives re-renders, not app kills).
|
|
57
|
+
*/
|
|
58
|
+
storage?: EventQueueOptions["storage"];
|
|
59
|
+
/** Host app version, e.g. "1.4.2" (host-injected; stamped onto every event's context). */
|
|
60
|
+
appVersion?: string;
|
|
61
|
+
/** Host native build number, e.g. "412" (host-injected). */
|
|
62
|
+
appBuild?: string;
|
|
63
|
+
/** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
|
|
64
|
+
networkType?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
68
|
+
export type AnalyticsOptions = Partial<
|
|
69
|
+
Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The developer-facing analytics surface. `track`/`screen`/`identify` are the Segment/PostHog-shaped
|
|
74
|
+
* API; `flush`/`notifyOnline`/`size` expose the underlying queue so a host can drive reconnect
|
|
75
|
+
* draining (load-bearing for offline-first) and inspect the pending buffer.
|
|
76
|
+
*/
|
|
77
|
+
export type Analytics = {
|
|
78
|
+
/** Record an in-app event: `event_type='app_event'`, `question_key=<event>`, `props`→`meta`. */
|
|
79
|
+
track(event: string, props?: AnalyticsProps): void;
|
|
80
|
+
/** Record a screen view: `event_type='app_event'`, `question_key='screen'`, `meta={ screen, ...props }`. */
|
|
81
|
+
screen(name: string, props?: AnalyticsProps): void;
|
|
82
|
+
/** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
|
|
83
|
+
identify(userId: string, traits?: AnalyticsProps): void;
|
|
84
|
+
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
85
|
+
flush(): void;
|
|
86
|
+
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
87
|
+
notifyOnline(): void;
|
|
88
|
+
/** Current pending (in-memory) count. */
|
|
89
|
+
size(): number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first
|
|
94
|
+
* queue over the tenant transport, and passes a fresh-per-event context envelope PROVIDER so the
|
|
95
|
+
* connectivity type is read at enqueue time. The bound user id starts unset (see `identify`).
|
|
96
|
+
*/
|
|
97
|
+
export const createAnalytics = (
|
|
98
|
+
config: CreateAnalyticsConfig,
|
|
99
|
+
options: AnalyticsOptions = {},
|
|
100
|
+
): Analytics => {
|
|
101
|
+
const sessionId = config.sessionId ?? makeSessionId();
|
|
102
|
+
|
|
103
|
+
// A provider (not a fixed value) so `networkType` is evaluated fresh on every enqueue.
|
|
104
|
+
const envelope = (): ContextEnvelope =>
|
|
105
|
+
buildContextEnvelope({
|
|
106
|
+
sessionId,
|
|
107
|
+
appVersion: config.appVersion,
|
|
108
|
+
appBuild: config.appBuild,
|
|
109
|
+
networkType: config.networkType,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const queue: EventQueue = createEventQueue({
|
|
113
|
+
target: { serverUrl: config.serverUrl, apiKey: config.apiKey },
|
|
114
|
+
storage: config.storage,
|
|
115
|
+
appId: config.appId,
|
|
116
|
+
envelope,
|
|
117
|
+
...options,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Per-session, in-memory user binding. Persisted across launches when storage is provided.
|
|
121
|
+
let boundUserId: string | undefined;
|
|
122
|
+
const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
|
|
123
|
+
|
|
124
|
+
if (config.storage) {
|
|
125
|
+
void config.storage
|
|
126
|
+
.getItem(storageKey)
|
|
127
|
+
.then((saved) => {
|
|
128
|
+
if (saved) boundUserId = saved;
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const track = (event: string, props?: AnalyticsProps): void => {
|
|
134
|
+
if (!event) return;
|
|
135
|
+
const clientEvent: ClientEvent = {
|
|
136
|
+
event_type: "app_event",
|
|
137
|
+
session_id: sessionId,
|
|
138
|
+
question_key: event,
|
|
139
|
+
};
|
|
140
|
+
if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
|
|
141
|
+
if (boundUserId) clientEvent.user_id = boundUserId;
|
|
142
|
+
queue.enqueue(clientEvent);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const screen = (name: string, props?: AnalyticsProps): void => {
|
|
146
|
+
if (!name) return;
|
|
147
|
+
// Reuse the K6 screen event shape (question_key='screen'); route through the queue for offline-first.
|
|
148
|
+
const meta = { screen: name, ...(props ?? {}) };
|
|
149
|
+
const clientEvent: ClientEvent = {
|
|
150
|
+
event_type: "app_event",
|
|
151
|
+
session_id: sessionId,
|
|
152
|
+
question_key: "screen",
|
|
153
|
+
meta: JSON.stringify(meta),
|
|
154
|
+
};
|
|
155
|
+
if (boundUserId) clientEvent.user_id = boundUserId;
|
|
156
|
+
queue.enqueue(clientEvent);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const identify = (userId: string, traits?: AnalyticsProps): void => {
|
|
160
|
+
const clean = sanitizeUserId(userId);
|
|
161
|
+
// Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
|
|
162
|
+
if (!clean) return;
|
|
163
|
+
boundUserId = clean;
|
|
164
|
+
if (config.storage) {
|
|
165
|
+
void config.storage.setItem(storageKey, clean).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
const clientEvent: ClientEvent = {
|
|
168
|
+
event_type: "identify",
|
|
169
|
+
session_id: sessionId,
|
|
170
|
+
user_id: clean,
|
|
171
|
+
};
|
|
172
|
+
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
173
|
+
queue.enqueue(clientEvent);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
track,
|
|
178
|
+
screen,
|
|
179
|
+
identify,
|
|
180
|
+
flush: queue.flush,
|
|
181
|
+
notifyOnline: queue.notifyOnline,
|
|
182
|
+
size: queue.size,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
@@ -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
|
@@ -45,3 +45,15 @@ export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
|
|
|
45
45
|
// ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
|
|
46
46
|
export { createEventQueue } from "./eventQueue";
|
|
47
47
|
export type { EventQueue, EventQueueOptions, EnvelopeSource } from "./eventQueue";
|
|
48
|
+
|
|
49
|
+
// ─── Developer-facing analytics façade (track / screen / identify) over the queue ─
|
|
50
|
+
export { createAnalytics } from "./analyticsFacade";
|
|
51
|
+
export type {
|
|
52
|
+
Analytics,
|
|
53
|
+
AnalyticsOptions,
|
|
54
|
+
AnalyticsProps,
|
|
55
|
+
CreateAnalyticsConfig,
|
|
56
|
+
} from "./analyticsFacade";
|
|
57
|
+
|
|
58
|
+
// ─── The thin optional React hook over the façade ─────────────────────────────
|
|
59
|
+
export { useAnalytics } from "./useAnalytics";
|
|
@@ -35,8 +35,17 @@ import type { DeviceContext } from "../device/deviceContext";
|
|
|
35
35
|
* This is the SINGLE fallback signal — hosts must NOT also report their own.
|
|
36
36
|
* `identify` binds the host's opaque `user_id` to this `session_id` (late binding — the user
|
|
37
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
|
-
|
|
38
|
+
* session to the user and back-fills a `session_started` if it never saw the session.
|
|
39
|
+
* `app_event` is the host's own in-app event (the `createAnalytics` façade's `track`/`screen`
|
|
40
|
+
* route through the offline queue as first-class `ClientEvent`s). The server already ingests
|
|
41
|
+
* it — `reportAppEvent` (reviews/transport) has fired `event_type='app_event'` all along; this
|
|
42
|
+
* widening just lets the same shape flow through the durable queue instead of a blind POST. */
|
|
43
|
+
export type ClientEventType =
|
|
44
|
+
| "screen_skipped"
|
|
45
|
+
| "dropped"
|
|
46
|
+
| "client_fallback"
|
|
47
|
+
| "identify"
|
|
48
|
+
| "app_event";
|
|
40
49
|
|
|
41
50
|
/** One client-reported event. Mirrors the server's `OnboardingEvent` (client-settable fields). */
|
|
42
51
|
export type ClientEvent = {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnalytics — a THIN optional React hook over the pure {@link createAnalytics} factory.
|
|
3
|
+
*
|
|
4
|
+
* It builds ONE analytics instance for the component's lifetime and returns it, so re-renders
|
|
5
|
+
* never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
|
|
6
|
+
* kit, so importing it here is allowed; the hook adds NO other dependency. Mirrors the existing
|
|
7
|
+
* `createScreenTracker` / `useScreenTracking` split — the factory stays React-free, this is the
|
|
8
|
+
* glue.
|
|
9
|
+
*
|
|
10
|
+
* const analytics = useAnalytics({ serverUrl, apiKey, storage, appId });
|
|
11
|
+
* analytics.track("content_share", { source: "feed" });
|
|
12
|
+
* // ...on reconnect: analytics.notifyOnline();
|
|
13
|
+
*/
|
|
14
|
+
import { useRef } from "react";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createAnalytics,
|
|
18
|
+
type Analytics,
|
|
19
|
+
type AnalyticsOptions,
|
|
20
|
+
type CreateAnalyticsConfig,
|
|
21
|
+
} from "./analyticsFacade";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a per-mount analytics instance. `config`/`options` are read once at first render (the
|
|
25
|
+
* instance is stable for the component's lifetime, held in a ref). Returns the {@link Analytics}
|
|
26
|
+
* surface so the component can `track` / `screen` / `identify` and drive `notifyOnline` on reconnect.
|
|
27
|
+
*/
|
|
28
|
+
export const useAnalytics = (
|
|
29
|
+
config: CreateAnalyticsConfig,
|
|
30
|
+
options: AnalyticsOptions = {},
|
|
31
|
+
): Analytics => {
|
|
32
|
+
// One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
|
|
33
|
+
const ref = useRef<Analytics | undefined>(undefined);
|
|
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
|
+
}
|
|
41
|
+
return ref.current;
|
|
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
|
+
}
|
|
@@ -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 => {
|