@wireai/activation 0.13.0 → 0.13.3

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 (82) hide show
  1. package/AGENTS.md +7 -4
  2. package/CHANGELOG.md +341 -4
  3. package/README.md +98 -2
  4. package/dist/analytics/index.d.mts +4 -4
  5. package/dist/analytics/index.d.ts +4 -4
  6. package/dist/analytics/index.js +164 -471
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +162 -469
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +14 -1
  11. package/dist/coachmarks/index.d.ts +14 -1
  12. package/dist/coachmarks/index.js +58 -14
  13. package/dist/coachmarks/index.js.map +1 -1
  14. package/dist/coachmarks/index.mjs +58 -14
  15. package/dist/coachmarks/index.mjs.map +1 -1
  16. package/dist/{currentSession-_GynvhzT.d.mts → currentSession-BoWtr3Jp.d.mts} +299 -14
  17. package/dist/{currentSession-D7zabMXK.d.ts → currentSession-CCOMlaQ2.d.ts} +299 -14
  18. package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
  19. package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
  20. package/dist/index.d.mts +218 -4
  21. package/dist/index.d.ts +218 -4
  22. package/dist/index.js +1104 -888
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +887 -692
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/questionnaire/index.d.mts +42 -10
  27. package/dist/questionnaire/index.d.ts +42 -10
  28. package/dist/questionnaire/index.js +45 -13
  29. package/dist/questionnaire/index.js.map +1 -1
  30. package/dist/questionnaire/index.mjs +45 -13
  31. package/dist/questionnaire/index.mjs.map +1 -1
  32. package/dist/reviews/index.d.mts +4 -4
  33. package/dist/reviews/index.d.ts +4 -4
  34. package/dist/reviews/index.js +39 -8
  35. package/dist/reviews/index.js.map +1 -1
  36. package/dist/reviews/index.mjs +39 -8
  37. package/dist/reviews/index.mjs.map +1 -1
  38. package/dist/showcase/index.js +7 -2
  39. package/dist/showcase/index.js.map +1 -1
  40. package/dist/showcase/index.mjs +7 -2
  41. package/dist/showcase/index.mjs.map +1 -1
  42. package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
  43. package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
  44. package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
  45. package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
  46. package/package.json +4 -1
  47. package/src/OnboardingFlow.tsx +175 -15
  48. package/src/WireOnboarding.tsx +70 -8
  49. package/src/activation/wireActivation.ts +3 -3
  50. package/src/analytics/analyticsEvent.ts +16 -1
  51. package/src/analytics/analyticsFacade.ts +2 -2
  52. package/src/analytics/eventQueue.ts +85 -20
  53. package/src/analytics/reportClientEvent.ts +68 -12
  54. package/src/cards/CardGridSelectCard.tsx +1 -1
  55. package/src/cards/ChipSelectCard.tsx +2 -2
  56. package/src/cards/PermissionCard.tsx +438 -0
  57. package/src/cards/SelectionCard.tsx +1 -1
  58. package/src/cards/index.ts +7 -0
  59. package/src/coachmarks/runtime.ts +62 -2
  60. package/src/coachmarks/useCoachmarkTour.ts +62 -14
  61. package/src/context/deviceId.ts +4 -4
  62. package/src/features/WireFeaturesProvider.tsx +4 -1
  63. package/src/identity/userIdentity.ts +1 -1
  64. package/src/illustrations/defaultIllustrations.tsx +44 -3
  65. package/src/index.ts +38 -0
  66. package/src/permissions/index.ts +64 -0
  67. package/src/permissions/permissionCopy.ts +87 -0
  68. package/src/permissions/permissionEvents.ts +76 -0
  69. package/src/permissions/permissionMemory.ts +88 -0
  70. package/src/permissions/placement.ts +88 -0
  71. package/src/permissions/types.ts +131 -0
  72. package/src/questionnaire/QuestionnaireGate.tsx +90 -10
  73. package/src/questionnaire/index.ts +9 -1
  74. package/src/questionnaire/transport.ts +46 -11
  75. package/src/reviews/ReviewGate.tsx +61 -6
  76. package/src/reviews/index.ts +10 -1
  77. package/src/reviews/transport.ts +58 -12
  78. package/src/session/persistedSession.ts +10 -3
  79. package/src/session-analytics/useLifecycleEvents.ts +1 -1
  80. package/src/theme/ThemeContext.tsx +42 -1
  81. package/src/types.ts +66 -4
  82. package/src/utils/submitResult.ts +39 -0
@@ -3,12 +3,53 @@
3
3
  * themed card via `useOnboardingTheme()`. WHY a context rather than prop-drilling:
4
4
  * the cards are mounted by the SDK's ComponentRenderer (not by us), so we can't
5
5
  * pass them props directly — they must read the theme from context.
6
+ *
7
+ * ── WHY THE CONTEXT LIVES ON A `Symbol.for` SLOT ───────────────────────────────────────────────
8
+ *
9
+ * It was a plain `createContext(...)`, and tsup inlines this module into FIVE dist bundles
10
+ * (`.`, `./reviews`, `./questionnaire`, `./coachmarks`, `./showcase`) — so each one carried its own
11
+ * context instance. `OnboardingThemeProvider` is exported ONLY from the root barrel, so on any
12
+ * `dist` resolution path (plain RN CLI, `unstable_enablePackageExports: false`, node, jest, SSR) a
13
+ * host that wrapped its tree in `<OnboardingThemeProvider>` and rendered `<ReviewGate>` from
14
+ * `@wireai/activation/reviews` was writing one context and reading another. Nothing crashed and
15
+ * nothing warned: `useOnboardingTheme`'s `ctx ?? mergeTheme()` fallback quietly served the NEUTRAL
16
+ * default theme, so the tenant's brand simply did not apply to the gate. On-device this was masked
17
+ * only because Metro's `react-native` export condition collapses every subpath back to this one
18
+ * `src/` file — the same "bundler accident, not a guarantee" the `currentSession` header names, and
19
+ * the same root cause as the optional-peers bug 0.13.3 fixed.
20
+ *
21
+ * The bundler-agnostic fix, exactly as `coachmarks/runtime`, `analytics/currentSession`,
22
+ * `context/deviceId` and `features/WireFeaturesProvider` already do it: park the ONE context on a
23
+ * well-known `globalThis` slot keyed by `Symbol.for(...)`. `Symbol.for` uses the runtime-global
24
+ * symbol registry, so every inlined copy of this module resolves the SAME symbol, finds the slot
25
+ * already filled, and reuses that instance — one context identity however many copies exist.
26
+ * `globalThis` is present and identical in Hermes/React Native, Node and SSR (we never touch
27
+ * `window`), so this is safe on every host. `test/canary/themeContextIdentity.test.tsx` measures it
28
+ * in the BUILT artifact, because the defect is invisible in source.
6
29
  */
7
30
  import React, { createContext, useContext, useMemo } from "react";
8
31
  import { mergeTheme } from "./defaultTheme";
9
32
  import type { OnboardingTheme } from "./types";
10
33
 
11
- const OnboardingThemeContext = createContext<OnboardingTheme | null>(null);
34
+ /**
35
+ * Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
36
+ * what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
37
+ * this module, so duplicated inlined copies all address one slot.
38
+ */
39
+ const THEME_CONTEXT_SLOT: unique symbol = Symbol.for("@wireai/activation:themeContext");
40
+
41
+ type GlobalWithThemeContext = typeof globalThis & {
42
+ [THEME_CONTEXT_SLOT]?: React.Context<OnboardingTheme | null>;
43
+ };
44
+
45
+ const themeGlobal = globalThis as GlobalWithThemeContext;
46
+
47
+ // First copy to load creates the context; every later copy adopts it. Never overwrite an existing
48
+ // slot — a second `createContext` here would re-split the identity this guard exists to keep whole.
49
+ if (!themeGlobal[THEME_CONTEXT_SLOT]) {
50
+ themeGlobal[THEME_CONTEXT_SLOT] = createContext<OnboardingTheme | null>(null);
51
+ }
52
+ const OnboardingThemeContext = themeGlobal[THEME_CONTEXT_SLOT] as React.Context<OnboardingTheme | null>;
12
53
 
13
54
  export type OnboardingThemeProviderProps = {
14
55
  /** Partial overrides merged over the neutral default theme. */
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
  /**
@@ -281,9 +340,12 @@ export type OnboardingProgress = {
281
340
  * `answers.interests` silently becomes `answers.what_are_you_into_v2`, with no error anywhere. A
282
341
  * slot is the question's identity independent of its wording.
283
342
  *
284
- * FULLY ADDITIVE AND CURRENTLY INERT: no server emits it yet. Every fallback is PER-CARD, so a
285
- * thread that mixes slotted and unslotted cards (the real shape during a rollout) keys each one
286
- * correctly, and a backend that never sends it produces byte-identical behaviour to 0.12.2.
343
+ * FULLY ADDITIVE, AND LIVE SINCE 2026-07-28. 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.
287
349
  */
288
350
  slot_id?: string;
289
351
  /** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * submitResult — the ONE delivery verdict every user-payload POST in this kit reports back.
3
+ *
4
+ * WHY IT IS SHARED, AND WHY IT IS NOT A BOOLEAN. Two surfaces post a payload the user typed —
5
+ * `submitReview` (`reviews/transport.ts`) and `submitQuestionnaireResponse`
6
+ * (`questionnaire/transport.ts`) — and NEITHER has a persisted queue behind it (unlike
7
+ * `analytics/eventQueue`, with its `maxSize`/`maxRetries`/backoff). Both sit behind a permanent
8
+ * once-gate: the host writes `wire_review_<id>_seen` / `wire_questionnaire_<id>_<ver>_seen` when the
9
+ * gate resolves, so a payload that dies in the socket is lost AND the user is never asked again.
10
+ * Reading the fate of the request is the only thing that lets a caller keep an undelivered payload
11
+ * recoverable, so the two surfaces must answer the same question in the same words.
12
+ *
13
+ * • `accepted` — the server answered 2xx. Stored.
14
+ * • `rejected` — the server ANSWERED, non-2xx. It was reached; it either stored the payload and
15
+ * lost the ack (a gateway 502 after the write commits) or deliberately refused it.
16
+ * Either way the server owns the outcome and a re-post risks a SECOND row.
17
+ * • `unsent` — nothing came back at all: no target, a missing/throwing `fetch`, a rejected
18
+ * request. **This is the ONLY outcome a caller may retry on.**
19
+ *
20
+ * ── WHY THREE VALUES AND NOT TWO (pinned here so the defect cannot move surface) ───────────────
21
+ *
22
+ * `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
23
+ * `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
24
+ * treats `false` as "still owed" and re-posts. But the server mints its own row id
25
+ * (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
26
+ * body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
27
+ * insert commits means the re-post writes a SECOND row. A response of any status proves the server
28
+ * was reached, and that is a different question from whether it liked the payload.
29
+ *
30
+ * ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
31
+ *
32
+ * `unsent` is not proof the server never got it. A connection dropped after the request was written
33
+ * — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
34
+ * device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
35
+ * CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
36
+ * the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
37
+ * gap in the data, a duplicated one is a lie in the data.
38
+ */
39
+ export type SubmitResult = "accepted" | "rejected" | "unsent";