@wireai/activation 0.12.2 → 0.13.2

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.
Files changed (55) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +259 -1
  3. package/README.md +87 -3
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +174 -36
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +174 -37
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
  11. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
  12. package/dist/index.d.mts +197 -16
  13. package/dist/index.d.ts +197 -16
  14. package/dist/index.js +1056 -390
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +836 -193
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs.map +1 -1
  20. package/dist/reviews/index.js +20 -7
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +20 -7
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +141 -3
  26. package/src/WireOnboarding.tsx +178 -34
  27. package/src/activation/wireActivation.ts +13 -7
  28. package/src/analytics/analyticsEvent.ts +16 -1
  29. package/src/analytics/analyticsFacade.ts +11 -10
  30. package/src/analytics/currentSession.ts +6 -20
  31. package/src/analytics/eventQueue.ts +71 -1
  32. package/src/analytics/index.ts +1 -1
  33. package/src/analytics/reportClientEvent.ts +157 -38
  34. package/src/cards/PermissionCard.tsx +438 -0
  35. package/src/cards/index.ts +7 -0
  36. package/src/config/wireConfigFromEnv.ts +1 -10
  37. package/src/context/deviceId.ts +77 -16
  38. package/src/context/userContext.ts +4 -15
  39. package/src/identity/identityRecord.ts +123 -0
  40. package/src/identity/userIdentity.ts +45 -9
  41. package/src/illustrations/defaultIllustrations.tsx +44 -3
  42. package/src/index.ts +44 -4
  43. package/src/permissions/index.ts +64 -0
  44. package/src/permissions/permissionCopy.ts +87 -0
  45. package/src/permissions/permissionEvents.ts +76 -0
  46. package/src/permissions/permissionMemory.ts +88 -0
  47. package/src/permissions/placement.ts +88 -0
  48. package/src/permissions/types.ts +131 -0
  49. package/src/session/persistedSession.ts +10 -3
  50. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  51. package/src/types.ts +77 -1
  52. package/src/utils/deriveAnswers.ts +6 -2
  53. package/src/utils/readProgress.ts +4 -0
  54. package/src/utils/warnInDev.ts +33 -0
  55. package/src/components/DoneBlock.tsx +0 -37
@@ -0,0 +1,88 @@
1
+ /**
2
+ * permissionMemory - "shown exactly once per session", kept true across an app kill.
3
+ *
4
+ * The in-memory guard alone is not enough. The kit already resumes a killed onboarding into the
5
+ * SAME backend session (see `persistedSession.ts`), so without a persisted record the resumed mount
6
+ * would re-show a permission screen the user had already answered - and on the ask path that means
7
+ * a second dialog attempt against an OS that grants exactly one.
8
+ *
9
+ * The record is SCOPED TO THE SESSION ID, one entry, never a growing map: a record whose
10
+ * `sessionId` is not the session being resumed is stale by definition and reads as empty. That is
11
+ * also what makes a genuinely new onboarding start clean without anything having to expire it.
12
+ *
13
+ * Same host-injected `WireOnboardingStorage` and the same best-effort discipline as the session
14
+ * seed: reads race the shared ceiling and degrade to "nothing remembered", writes swallow every
15
+ * error. A broken storage adapter must never gate or break onboarding.
16
+ */
17
+ import {
18
+ READ_TIMEOUT_MS,
19
+ withTimeout,
20
+ type WireOnboardingStorage,
21
+ } from "../session/persistedSession";
22
+
23
+ /** Storage key for an app's settled permission screens, e.g. `wireai:permissions:acme`. */
24
+ export const permissionStorageKey = (appId: string): string => `wireai:permissions:${appId}`;
25
+
26
+ type PermissionRecord = { sessionId: string; ids: string[] };
27
+
28
+ /**
29
+ * PURE seam: which screen ids a stored blob contributes to THIS session. A corrupt entry, a
30
+ * malformed shape, or a record belonging to a different session all read as "nothing remembered",
31
+ * because re-showing a screen is a far cheaper failure than trusting another session's answers.
32
+ */
33
+ export const readSettledPermissions = (
34
+ raw: string | null | undefined,
35
+ sessionId: string,
36
+ ): string[] => {
37
+ if (!raw || !sessionId) return [];
38
+ try {
39
+ const parsed: unknown = JSON.parse(raw);
40
+ if (typeof parsed !== "object" || parsed === null) return [];
41
+ const record = parsed as Partial<PermissionRecord>;
42
+ if (record.sessionId !== sessionId || !Array.isArray(record.ids)) return [];
43
+ return record.ids.filter((id): id is string => typeof id === "string" && id.length > 0);
44
+ } catch {
45
+ // Corrupt entry: treat as absent. The next write overwrites it.
46
+ return [];
47
+ }
48
+ };
49
+
50
+ /** Read the settled ids for `sessionId`. Never throws, never hangs past the shared read ceiling. */
51
+ export const loadSettledPermissions = async (
52
+ storage: WireOnboardingStorage,
53
+ key: string,
54
+ sessionId: string,
55
+ ): Promise<string[]> => {
56
+ try {
57
+ return readSettledPermissions(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS), sessionId);
58
+ } catch {
59
+ return [];
60
+ }
61
+ };
62
+
63
+ /** Persist the settled ids for `sessionId` - fire-and-forget, all errors swallowed. */
64
+ export const saveSettledPermissions = (
65
+ storage: WireOnboardingStorage,
66
+ key: string,
67
+ sessionId: string,
68
+ ids: readonly string[],
69
+ ): void => {
70
+ try {
71
+ const record: PermissionRecord = { sessionId, ids: [...ids] };
72
+ void storage.setItem(key, JSON.stringify(record)).catch(() => {});
73
+ } catch {
74
+ // Best-effort: a failed write only means a resumed session may re-show the screen.
75
+ }
76
+ };
77
+
78
+ /** Drop the record (fired alongside the session seed on completion) - fire-and-forget. */
79
+ export const clearSettledPermissions = (
80
+ storage: WireOnboardingStorage,
81
+ key: string,
82
+ ): void => {
83
+ try {
84
+ void storage.removeItem(key).catch(() => {});
85
+ } catch {
86
+ // Best-effort.
87
+ }
88
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * placement - WHERE an injected permission screen lands in a stream nobody knows the length of.
3
+ *
4
+ * The card stream is server-driven: the backend decides how many questions this user gets, and the
5
+ * count differs per user and per run. So a placement is never an index into a known list; it is a
6
+ * predicate evaluated against the card the flow is ABOUT to render, on every turn.
7
+ *
8
+ * THE CLAMP is the whole reason this is a function and not a comparison. `{ afterCard: 4 }` against
9
+ * a stream that ended after 3 would simply never fire, and "the screen silently never showed" is
10
+ * the exact failure a host cannot see. So an unreached target degrades to `"beforeEnd"`: the
11
+ * terminal card is the last position where the screen can still exist, so that is where it goes.
12
+ *
13
+ * PURE, no React, no React Native - the placement math is the part worth unit-testing.
14
+ */
15
+ import type { PermissionPlacement, PermissionScreenConfig } from "./types";
16
+
17
+ /** Where a screen sits when the host does not say. High intent, and it can never be clamped away. */
18
+ export const DEFAULT_PERMISSION_PLACEMENT: PermissionPlacement = "beforeEnd";
19
+
20
+ /** The flow position a placement is resolved against. */
21
+ export type FlowPosition = {
22
+ /** 0-based index of the card about to render. `-1` when no card has arrived yet. */
23
+ cardIndex: number;
24
+ /** Whether that card is the terminal recap, i.e. the flow's last screen. */
25
+ isTerminal: boolean;
26
+ };
27
+
28
+ /** `afterCard` from a host is arbitrary input: floor it, floor it at 0, and never let NaN through. */
29
+ export const normalizeAfterCard = (value: number): number =>
30
+ Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
31
+
32
+ /** The 0-based card index a placement targets. `"beforeEnd"` has none, it rides `isTerminal`. */
33
+ const targetIndex = (placement: PermissionPlacement): number | undefined => {
34
+ if (placement === "beforeEnd") return undefined;
35
+ if (placement === "start") return 0;
36
+ return normalizeAfterCard(placement.afterCard);
37
+ };
38
+
39
+ /**
40
+ * Is a screen with this placement due at this position?
41
+ *
42
+ * `>=` rather than `===` on purpose: due-ness is re-evaluated every render and a screen is settled
43
+ * the moment it is answered, so a target the flow has already passed (because a screen ahead of it
44
+ * in the array was on screen for those turns) still fires instead of being skipped forever.
45
+ */
46
+ export const isPermissionDue = (
47
+ placement: PermissionPlacement,
48
+ position: FlowPosition,
49
+ ): boolean => {
50
+ if (position.cardIndex < 0) return false;
51
+ const target = targetIndex(placement);
52
+ // "beforeEnd", or an `afterCard` the stream ended before reaching: the terminal card is the
53
+ // last chance, so this is where the clamp lands.
54
+ if (target === undefined) return position.isTerminal;
55
+ return position.cardIndex >= target || position.isTerminal;
56
+ };
57
+
58
+ /**
59
+ * The stable id a screen is remembered by across a resume. Defaults to `<permission>:<index>`,
60
+ * which is right until the array is reordered - hence the documented `id` escape hatch.
61
+ */
62
+ export const permissionScreenId = (screen: PermissionScreenConfig, index: number): string =>
63
+ screen.id ?? `${screen.permission}:${index}`;
64
+
65
+ /** A due screen and the id it is remembered by. */
66
+ export type DuePermissionScreen = { screen: PermissionScreenConfig; id: string };
67
+
68
+ /**
69
+ * The FIRST configured screen that is due here and has not already been settled in this session.
70
+ * One at a time, in array order, so two screens due at the same position queue rather than collide.
71
+ */
72
+ export const selectDuePermissionScreen = (
73
+ screens: readonly PermissionScreenConfig[] | undefined,
74
+ settled: readonly string[],
75
+ position: FlowPosition,
76
+ ): DuePermissionScreen | undefined => {
77
+ if (!screens || screens.length === 0) return undefined;
78
+ for (let index = 0; index < screens.length; index++) {
79
+ const screen = screens[index];
80
+ if (!screen) continue;
81
+ const id = permissionScreenId(screen, index);
82
+ if (settled.includes(id)) continue;
83
+ if (isPermissionDue(screen.placement ?? DEFAULT_PERMISSION_PLACEMENT, position)) {
84
+ return { screen, id };
85
+ }
86
+ }
87
+ return undefined;
88
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Public types for the injectable mid-flow permission screens.
3
+ *
4
+ * THE ONE RULE THIS WHOLE MODULE EXISTS TO ENFORCE: the OS permission dialog is reached from the
5
+ * user's explicit primary tap and from nowhere else. iOS grants an app exactly ONE native
6
+ * notification prompt for its entire lifetime, so firing it on mount (the shape almost every app
7
+ * ships) spends the single ask on a user who has not been told why. A priming screen spends a
8
+ * cheap in-app screen first and only forwards the ones who said yes.
9
+ *
10
+ * DEPENDENCY-FREE, the RevenueCat-bridge idiom: the kit imports NO native permission module. The
11
+ * host passes `request` (and optionally `getStatus` / `openSettings`) in, exactly the way it hands
12
+ * the RevenueCat bridge real `CustomerInfo` objects. Wiring `expo-notifications` is five lines in
13
+ * the host and zero dependencies here.
14
+ *
15
+ * A PERMISSION SCREEN IS NOT A QUESTION. It mints no `key` and no `slot_id`, sends nothing to the
16
+ * backend, and never enters the thread, so `deriveAnswers` / `readProgress` and every completion
17
+ * semantic are byte-identical whether or not one is configured. Completion NEVER blocks on a grant.
18
+ */
19
+ import type { PermissionScreenCopy } from "./permissionCopy";
20
+
21
+ export type { PermissionScreenCopy } from "./permissionCopy";
22
+
23
+ /**
24
+ * Which OS permission a screen primes. `notifications` is the one the kit ships copy for; any other
25
+ * string is accepted (the host supplies the copy) so a host can prime tracking, health or location
26
+ * without waiting on a kit release.
27
+ */
28
+ export type WirePermissionKind = "notifications" | (string & {});
29
+
30
+ /**
31
+ * What the host's `request` / `getStatus` answers.
32
+ * - `granted`: the user allowed it.
33
+ * - `denied`: not allowed right now, but the OS would still show a dialog if asked again
34
+ * (Android, or an iOS provisional state).
35
+ * - `blocked`: permanently refused. Asking again shows NOTHING, so the only route left is Settings.
36
+ */
37
+ export type WirePermissionStatus = "granted" | "denied" | "blocked";
38
+
39
+ /** How a permission screen ENDED. `skipped` is the secondary tap, which never burns the OS prompt. */
40
+ export type WirePermissionOutcome = WirePermissionStatus | "skipped";
41
+
42
+ /**
43
+ * The moments a permission screen reports. One stage, one canonical event name (see
44
+ * `permissionEventName`), so the funnel reads the same across every tenant.
45
+ * - `shown`: the primer screen became visible. The denominator.
46
+ * - `accepted`: the user tapped the primary, so the OS dialog is ABOUT to open. The gap between
47
+ * `shown` and `accepted` is the only number that tells a host whether its rationale copy works.
48
+ * - `granted` / `denied`: what the OS answered (a `blocked` answer reports as `denied` and carries
49
+ * `status: "blocked"`, so the two are one funnel step and still distinguishable).
50
+ * - `skipped`: the secondary tap. The prompt was NOT spent.
51
+ * - `settings`: the user was redirected to the OS settings page (the `blocked` route).
52
+ */
53
+ export type PermissionStage =
54
+ | "shown"
55
+ | "accepted"
56
+ | "granted"
57
+ | "denied"
58
+ | "skipped"
59
+ | "settings";
60
+
61
+ /**
62
+ * WHERE a screen sits in the server-driven stream. The flow length is decided by the backend and
63
+ * varies per user, so every position is resolved against the card the flow is ABOUT to render:
64
+ * - `"start"`: before the first card.
65
+ * - `{ afterCard: n }`: after `n` cards have been shown (`{ afterCard: 2 }` sits between card 2
66
+ * and card 3). An `n` past the end of a shorter-than-expected stream degrades to `"beforeEnd"`
67
+ * rather than silently never showing.
68
+ * - `"beforeEnd"`: immediately before the terminal recap. The default.
69
+ */
70
+ export type PermissionPlacement = "start" | "beforeEnd" | { afterCard: number };
71
+
72
+ /**
73
+ * One injectable permission screen.
74
+ *
75
+ * ```tsx
76
+ * import * as Notifications from "expo-notifications";
77
+ *
78
+ * <WireOnboarding
79
+ * permissionScreens={[
80
+ * {
81
+ * permission: "notifications",
82
+ * placement: "beforeEnd",
83
+ * request: async () => {
84
+ * const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
85
+ * return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
86
+ * },
87
+ * },
88
+ * ]}
89
+ * />
90
+ * ```
91
+ */
92
+ export type PermissionScreenConfig = {
93
+ /** Which permission this screen primes. Drives the default copy and every event's `permission`. */
94
+ permission: WirePermissionKind;
95
+ /**
96
+ * Stable id for the once-only / resume record. Defaults to `<permission>:<index in the array>`,
97
+ * which is right until you reorder the array; set it explicitly if you configure two screens for
98
+ * the same permission or intend to reorder them between releases.
99
+ */
100
+ id?: string;
101
+ /** Where the screen sits in the stream. Default `"beforeEnd"`. */
102
+ placement?: PermissionPlacement;
103
+ /**
104
+ * THE ONLY FUNCTION THE KIT CALLS THAT CAN OPEN AN OS DIALOG, and it is called from the primary
105
+ * press handler alone. Never from mount, never from an effect, never from a status probe.
106
+ */
107
+ request: () => Promise<WirePermissionStatus>;
108
+ /**
109
+ * Optional NON-PROMPTING status read (`Notifications.getPermissionsAsync()`), used only to pick
110
+ * which primary action the screen offers: a `blocked` user gets "Open settings" instead of an
111
+ * "Enable" button that would open nothing, and an already-granted user gets a plain Continue.
112
+ * Leave it out and the screen simply always offers the ask.
113
+ */
114
+ getStatus?: () => Promise<WirePermissionStatus>;
115
+ /** Open the OS settings page (`Linking.openSettings()`). Only reachable on the `blocked` route. */
116
+ openSettings?: () => void | Promise<void>;
117
+ /** Rationale copy overrides. Anything left out keeps the kit default for this permission. */
118
+ copy?: Partial<PermissionScreenCopy>;
119
+ /**
120
+ * Name of a host illustration (the existing `illustrations` registry). Defaults to the
121
+ * permission name, so registering `illustrations={{ notifications: <MyBell/> }}` is enough. The
122
+ * kit ships a dependency-free default so the screen is never a blank box.
123
+ */
124
+ illustration?: string;
125
+ /**
126
+ * Fired once, with the outcome this screen produced. This is the seam for scheduling a local
127
+ * notification the moment a grant lands; the kit deliberately schedules nothing itself. It can
128
+ * never break the flow: a throw here is caught and the flow continues.
129
+ */
130
+ onResult?: (permission: WirePermissionKind, outcome: WirePermissionOutcome) => void;
131
+ };
@@ -37,7 +37,7 @@ export type WireOnboardingStorage = {
37
37
  export const DEFAULT_SESSION_TTL_MS = 3_600_000;
38
38
 
39
39
  /** Ceiling on the storage read — a hung adapter degrades to a fresh mint, never a stuck gate. */
40
- const READ_TIMEOUT_MS = 1_500;
40
+ export const READ_TIMEOUT_MS = 1_500;
41
41
 
42
42
  /** Storage key for an app's cached session, e.g. `wireai:session:acme`. */
43
43
  export const sessionStorageKey = (appId: string): string => `wireai:session:${appId}`;
@@ -51,7 +51,14 @@ export type LoadedSession = {
51
51
  resumed: boolean;
52
52
  };
53
53
 
54
- const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
54
+ /**
55
+ * Race a storage read against a ceiling, resolving `undefined` when the adapter did not answer.
56
+ * Exported so the permission memory reads through the same ceiling as the session seed rather than
57
+ * carrying a second copy of it. NOT the kit's only such helper: `features/cache.ts`,
58
+ * `session-analytics/lifecycle.ts` and `analytics/eventQueue.ts` each keep their own local timeout
59
+ * for their own transports. Consolidating those is a separate change, not this one.
60
+ */
61
+ export const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
55
62
  let timer: ReturnType<typeof setTimeout>;
56
63
  const timeout = new Promise<undefined>((resolve) => {
57
64
  timer = setTimeout(() => resolve(undefined), ms);
@@ -150,7 +157,7 @@ export const clearPersistedSession = (storage: WireOnboardingStorage, key: strin
150
157
  * not opt in.)
151
158
  *
152
159
  * - undefined / false (the default) → `true`: clear on completion, so the NEXT onboarding on this
153
- * device mints a fresh session. This is the legacy single-stage behavior (e.g. Myelino).
160
+ * device mints a fresh session. This is the legacy single-stage behavior.
154
161
  * - true → `false`: LEAVE the seed in place so a same-signup re-entry/remount within the TTL
155
162
  * resumes the SAME session (one funnel start) instead of minting a phantom second `started`.
156
163
  * Freshness for a genuinely new run is then governed by the TTL (past `sessionTtlMs` → fresh
@@ -33,6 +33,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
33
33
  import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
34
34
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
35
  import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
36
+ import { resolveIdentity } from "../identity/identityRecord";
36
37
  import { collectDeviceContext } from "../device/deviceContext";
37
38
  import type { WireOnboardingStorage } from "../session/persistedSession";
38
39
  import { reportFirstOpen } from "./lifecycle";
@@ -137,7 +138,15 @@ export const useLifecycleEvents = (
137
138
  cfg: LifecycleConfig | undefined,
138
139
  opts: UseLifecycleEventsOptions,
139
140
  ): string | undefined => {
140
- const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
141
+ // A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
142
+ // mount that was not given one can tell "this app owns no device id" from "this app owns one
143
+ // and forgot it there" — the silent third id space (K9). Recording only; nothing reads it here.
144
+ const host = resolveIdentity({
145
+ value: opts.deviceKey,
146
+ space: "device",
147
+ source: "host",
148
+ scope: cfg?.appId,
149
+ })?.value;
141
150
  if (host) return host;
142
151
  if (!cfg?.storage) return undefined;
143
152
  return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
package/src/types.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  import type { Message } from "wireai-rn";
5
5
  import type { OnboardingTheme } from "./theme/types";
6
6
  import type { WireOnboardingStorage } from "./session/persistedSession";
7
+ import type {
8
+ PermissionScreenConfig,
9
+ PermissionStage,
10
+ WirePermissionKind,
11
+ WirePermissionStatus,
12
+ } from "./permissions/types";
7
13
 
8
14
  export type { OnboardingTheme } from "./theme/types";
9
15
  export type { WireOnboardingStorage } from "./session/persistedSession";
@@ -65,6 +71,10 @@ export type OnboardingResult = {
65
71
  * - `fallback`: retries are exhausted; the kit degraded to the static `fallbackFlow`
66
72
  * (or handed off to `onError`). This is the client-side mirror of the
67
73
  * backend's `llm_fallback` reliability event.
74
+ * - `permission`: an injected permission screen moved (`shown` / `accepted` / `granted` /
75
+ * `denied` / `skipped` / `settings`). Carries NO funnel weight: a permission screen
76
+ * is not a question, so it never appears in `answers` and never gates completion.
77
+ * `toAnalyticsEvent` maps it to the canonical `wire_permission_*` name.
68
78
  */
69
79
  export type OnboardingEvent =
70
80
  | { type: "started"; contextId: string }
@@ -72,7 +82,14 @@ export type OnboardingEvent =
72
82
  | { type: "turn"; step: number; component?: string }
73
83
  | { type: "error"; reason: "backend" | "timeout" }
74
84
  | { type: "retry"; reason: "backend" | "timeout"; attempt: number }
75
- | { type: "fallback"; reason: "backend" | "timeout" };
85
+ | { type: "fallback"; reason: "backend" | "timeout" }
86
+ | {
87
+ type: "permission";
88
+ permission: WirePermissionKind;
89
+ stage: PermissionStage;
90
+ /** What the OS actually answered, when it answered. Absent on `shown` / `accepted`. */
91
+ status?: WirePermissionStatus;
92
+ };
76
93
 
77
94
  /**
78
95
  * Copy overrides for the kit's built-in (English) strings, so a host can localize
@@ -118,6 +135,48 @@ export type WireOnboardingProps = {
118
135
  icons?: Record<string, import("react").ReactNode>;
119
136
  /** Per-step validators keyed by base-question key, e.g. `{ username: checkUsername }`. */
120
137
  validators?: Record<string, StepValidator>;
138
+ /**
139
+ * PERMISSION SCREENS injected into the server-driven flow at a position you choose.
140
+ *
141
+ * The screen explains why the app wants the permission and asks the OS **only** on the primary
142
+ * tap. That priming pattern is not decoration: iOS grants an app exactly ONE native notification
143
+ * prompt for its whole lifetime, and firing it on mount spends it on a user who was told nothing.
144
+ * "Maybe later" advances the flow with the prompt still unspent.
145
+ *
146
+ * ```tsx
147
+ * import * as Notifications from "expo-notifications";
148
+ *
149
+ * <WireOnboarding
150
+ * permissionScreens={[
151
+ * {
152
+ * permission: "notifications",
153
+ * placement: "beforeEnd",
154
+ * request: async () => {
155
+ * const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
156
+ * return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
157
+ * },
158
+ * getStatus: async () => {
159
+ * const { status, canAskAgain } = await Notifications.getPermissionsAsync();
160
+ * return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
161
+ * },
162
+ * onResult: (_p, outcome) => { if (outcome === "granted") scheduleFirstReminder(); },
163
+ * },
164
+ * ]}
165
+ * />
166
+ * ```
167
+ *
168
+ * The kit adds NO dependency for this: it imports no `expo-notifications`, no
169
+ * `react-native-permissions`, nothing native. You inject `request`, exactly the way you hand the
170
+ * RevenueCat bridge real RevenueCat objects.
171
+ *
172
+ * A permission screen is NOT a question. It sends nothing to the backend, never enters the
173
+ * thread, mints no `key` / `slot_id`, and never appears in `onComplete`'s `answers`. Completion
174
+ * never blocks on a grant: grant, deny, skip and blocked all continue the flow. Each screen is
175
+ * shown at most once per session, and with `storage` that survives an app kill (a resumed session
176
+ * does not re-ask). Analytics ride `onEvent` (`type: "permission"`) and the canonical
177
+ * `wire_permission_*` events, stamped with the same `device_key` as the rest of the funnel.
178
+ */
179
+ permissionScreens?: PermissionScreenConfig[];
121
180
  /** Fired once the flow reaches its terminal StatusCard. */
122
181
  onComplete: (result: OnboardingResult) => void;
123
182
  /**
@@ -272,6 +331,23 @@ export type OnboardingProgress = {
272
331
  total: number;
273
332
  /** Base-question key for the CURRENT screen, when known (used to pick a validator). */
274
333
  key?: string;
334
+ /**
335
+ * STABLE per-slot identity for the CURRENT screen, when the backend sends one. Preferred over
336
+ * {@link key} for both the answer key and the validator lookup.
337
+ *
338
+ * WHY IT EXISTS: `key` is authored from the question's prompt text (the tenant flows slugify it and
339
+ * cut at 32 chars), so re-wording a question mints a NEW key — the answer a host reads as
340
+ * `answers.interests` silently becomes `answers.what_are_you_into_v2`, with no error anywhere. A
341
+ * slot is the question's identity independent of its wording.
342
+ *
343
+ * FULLY ADDITIVE, AND LIVE SINCE 2026-07-28 (server `47dae92`). The deployed server sends it on
344
+ * `progress` for every AI-GENERATED question, as `adaptive_<n>` 1-based over adaptive answers, and
345
+ * for a CONFIGURED question only when the tenant set one. Where the tenant set none the field is
346
+ * simply absent. Every fallback is PER-CARD, so a thread that mixes slotted and unslotted cards
347
+ * (the real shape during a rollout) keys each one correctly, and a backend or tenant that never
348
+ * sends it produces byte-identical behaviour to 0.12.2.
349
+ */
350
+ slot_id?: string;
275
351
  /** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */
276
352
  skippable?: boolean;
277
353
  };
@@ -29,10 +29,14 @@ export const deriveAnswers = (messages: Message[]): Record<string, unknown> => {
29
29
  if (!m || m.role !== "assistant" || !r || r.action !== "render") continue;
30
30
 
31
31
  const props = (r.props ?? {}) as Record<string, unknown>;
32
- const progress = props.progress as { key?: string } | undefined;
32
+ const progress = props.progress as { key?: string; slot_id?: string } | undefined;
33
33
  const questionText =
34
34
  (props.title as string) ?? (props.label as string) ?? (props.question as string);
35
- const key = progress?.key || questionText;
35
+ // slot_id > key > question text, decided PER CARD. A thread that mixes slotted and unslotted
36
+ // cards is the real shape during a server rollout, so caching a "this backend does slots" verdict
37
+ // off the first card would mis-key every later one. Against a backend that sends no `slot_id`
38
+ // this is byte-identical to 0.12.2.
39
+ const key = progress?.slot_id || progress?.key || questionText;
36
40
 
37
41
  const reply = messages[i + 1];
38
42
  if (key && reply?.role === "user") {
@@ -15,6 +15,8 @@ type PartialProgress = {
15
15
  step?: number;
16
16
  total?: number;
17
17
  key?: string;
18
+ /** The stable per-slot identity, when the backend sends one. See `OnboardingProgress.slot_id`. */
19
+ slot_id?: string;
18
20
  skippable?: boolean;
19
21
  };
20
22
 
@@ -28,6 +30,8 @@ export const readProgress = (response?: WireAIResponse): PartialProgress => {
28
30
  step: typeof p.step === "number" ? p.step : undefined,
29
31
  total: typeof p.total === "number" ? p.total : undefined,
30
32
  key: typeof p.key === "string" ? p.key : undefined,
33
+ // Whitelisted the same way as every other field: an old backend simply omits it.
34
+ slot_id: typeof p.slot_id === "string" ? p.slot_id : undefined,
31
35
  skippable: typeof p.skippable === "boolean" ? p.skippable : undefined,
32
36
  };
33
37
  };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * warnInDev — the ONE developer-warning primitive.
3
+ *
4
+ * The kit warns a developer in four places (the onboarding host, the env-config helper, the analytics
5
+ * façade, the current-session registry) and until 0.13.0 each carried its own copy of the same five
6
+ * lines. Three were byte-identical; the fourth differed only by returning whether it actually warned.
7
+ * Four copies of a guard is four chances for one of them to drift out of the `__DEV__` gate, which is
8
+ * the failure that matters: a warning that runs in production is a string built for nobody.
9
+ *
10
+ * ON THE TREE-SHAKING NOTE THIS REPLACES: `analytics/currentSession` used to justify its copy as
11
+ * keeping the module import-free for the tree-shaken `./analytics` bundle. That rationale had already
12
+ * lapsed — the module imports `makeSessionId` from `./reportClientEvent` — and this module has no
13
+ * imports of its own, so it adds one leaf to the graph and nothing to the bundle. The `treeShake`
14
+ * canary still holds the real guarantee (the analytics graph reaches no UI module).
15
+ */
16
+
17
+ /** RN sets this global; absent under node/SSR. Read defensively, never assumed. */
18
+ declare const __DEV__: boolean | undefined;
19
+
20
+ /**
21
+ * Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
22
+ *
23
+ * Returns whether it ACTUALLY warned, which a caller holding a once-flag must honour: marking
24
+ * "already warned" after a no-op would burn the single warning in production, and the one dev build
25
+ * that needed it would then run silent.
26
+ */
27
+ export const warnInDev = (message: string): boolean => {
28
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
29
+ console.warn(message);
30
+ return true;
31
+ }
32
+ return false;
33
+ };
@@ -1,37 +0,0 @@
1
- /**
2
- * DoneBlock — a brief themed "all set" state shown when the flow completes, while
3
- * the host persists results and navigates away. Purely cosmetic; the real
4
- * terminal signal is the SDK's StatusCard (handled in OnboardingFlow).
5
- */
6
- import React from "react";
7
- import { StyleSheet, Text, View } from "react-native";
8
- import { useOnboardingTheme } from "../theme/ThemeContext";
9
- import { bodyStyle, headingStyle } from "../theme/typography";
10
-
11
- export type DoneBlockProps = {
12
- title?: string;
13
- message?: string;
14
- };
15
-
16
- const _DoneBlock: React.FC<DoneBlockProps> = ({
17
- title = "You're all set",
18
- message = "Personalizing your experience…",
19
- }) => {
20
- const t = useOnboardingTheme();
21
- return (
22
- <View style={[styles.center, { gap: t.spacing.sm, padding: t.spacing.lg }]}>
23
- <Text style={[headingStyle(t.fonts), { color: t.colors.success, textAlign: "center" }]}>
24
- {title}
25
- </Text>
26
- <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
27
- {message}
28
- </Text>
29
- </View>
30
- );
31
- };
32
-
33
- export const DoneBlock = React.memo(_DoneBlock);
34
-
35
- const styles = StyleSheet.create({
36
- center: { flex: 1, alignItems: "center", justifyContent: "center" },
37
- });