@wireai/activation 0.15.0 → 0.16.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 +95 -20
- package/CHANGELOG.md +707 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +100 -25
- package/dist/analytics/index.d.mts +32 -10
- package/dist/analytics/index.d.ts +32 -10
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +14 -0
- package/dist/coachmarks/index.d.ts +14 -0
- package/dist/coachmarks/index.js +73 -20
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +73 -20
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-orZy5p1e.d.mts → currentSession-Bz7G6lno.d.mts} +25 -35
- package/dist/{currentSession-CFSRZ2wg.d.ts → currentSession-z-CZ55ad.d.ts} +25 -35
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +0 -13
- package/dist/questionnaire/index.d.ts +0 -13
- package/dist/questionnaire/index.js +154 -43
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +155 -44
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +159 -91
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +160 -92
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +59 -18
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +60 -19
- package/dist/showcase/index.mjs.map +1 -1
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +53 -0
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +49 -15
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +39 -0
- package/src/reviews/idempotency.ts +38 -0
- package/src/reviews/runtime.ts +39 -10
- package/src/reviews/transport.ts +22 -8
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +5 -4
- package/src/utils/withDeadline.ts +70 -0
package/AGENTS.md
CHANGED
|
@@ -32,7 +32,7 @@ Everything an integration needs lives on the ROOT import (`@wireai/activation`).
|
|
|
32
32
|
subpaths are optional secondary feature modules; import one only if you use it.
|
|
33
33
|
|
|
34
34
|
- `@wireai/activation` (root): `WireOnboarding`, the helpers/types below, and the device-only event reporters (`reportClientEvent`, `makeSessionId`, `WIRE_ONBOARDING_EVENTS`, see "App events" below).
|
|
35
|
-
- `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`, `wireDoctor`. Except for `clearUserContext`, these are NOT root exports; importing them from the root does not compile. `clearUserContext` IS also a root export (`import { clearUserContext } from "@wireai/activation"`), which is the form the README teaches for the sign-out purge. `wireDoctor({ target, storage })` is the DEV-ONLY integration self-check: it resolves `{ok, checks:[{name, ok, detail}]}`, never throws, and posts one `dry_run` probe event so a broken integration is caught on the first run instead of in a funnel report. It is dev-gated on `__DEV__` (a release build makes no network call), it reports the apiKey's SHAPE and never its value, and its verdict is read from the ack body, never from the status code. See the README "Verify your integration" section.
|
|
35
|
+
- `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`, `wireDoctor`. Except for `clearUserContext`, these are NOT root exports; importing them from the root does not compile. `clearUserContext` IS also a root export (`import { clearUserContext } from "@wireai/activation"`), which is the form the README teaches for the sign-out purge. `wireDoctor({ target, storage, join })` is the DEV-ONLY integration self-check: it resolves `{ok, checks:[{name, ok, detail}]}`, never throws, and posts one `dry_run` probe event so a broken integration is caught on the first run instead of in a funnel report. It is dev-gated on `__DEV__` (a release build makes no network call), it reports the apiKey's SHAPE and never its value, and its verdict is read from the ack body, never from the status code. **Read the scope of a green report.** The five checks are `target`, `reachability`, `storage`, `round_trip`, `join_key`. `join_key` needs the `join: { appId, userContext, autoJoinKey }` argument, the same values you pass `<WireOnboarding>` (and OMITTING it fails the check rather than skipping it, because a report that never looked at the join has not earned a green). It is strictly read-only: it mints no key, writes no identity registry and persists nothing. None of the five checks that the lifecycle hook is mounted, and none checks `min_sessions`, so an integration that skipped step 8 reports all-green while its `activated` funnel stays permanently at zero. A green doctor means "events reach the server and can be joined", not "the funnel works". See the README "Verify your integration" section.
|
|
36
36
|
- `@wireai/activation/coachmarks`: in-app coachmark tour (`CoachmarkProvider`, `useCoachmarkTour`).
|
|
37
37
|
- `@wireai/activation/showcase`: post-onboarding feature showcase (`FeatureShowcase`).
|
|
38
38
|
- `@wireai/activation/reviews`: store-review gating (`ReviewGate`, `ReviewModal`, `useReviewGate`, `fetchReviewDecision`).
|
|
@@ -44,7 +44,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
|
|
|
44
44
|
- `WireOnboarding`: the component (props below).
|
|
45
45
|
- `DemoOnboarding`: dev/QA modal trigger to run the flow on demand (no account, re-runnable; place in a `__DEV__` Settings row). Props: `config` (or `null`), `theme`, `illustrations`, `label?`, `onComplete?`, `renderTrigger?`.
|
|
46
46
|
- `wireConfigFromEnv({ appId })`: build `config` from `EXPO_PUBLIC_WIREAI_*` env; returns `null` if keys missing (use as your gate).
|
|
47
|
-
- `isOnboardingEnabled({ remote? })`: the standard entry gate (transport present: `apiKey` + `serverUrl`, plus an optional host-passed kill switch).
|
|
47
|
+
- `isOnboardingEnabled({ remote?, config? })`: the standard entry gate (transport present: `apiKey` + `serverUrl`, plus an optional host-passed kill switch). `config` forwards `WireConfigOverrides` (a programmatic apiKey/serverUrl) to the transport-present check; usually omitted.
|
|
48
48
|
- `themeFromBrand({ primary })`: derive a full theme from one brand color.
|
|
49
49
|
- `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
|
|
50
50
|
- `defaultIllustrations`: dependency-free fallback art; spread your own over it.
|
|
@@ -52,8 +52,8 @@ subpaths are optional secondary feature modules; import one only if you use it.
|
|
|
52
52
|
- `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
|
|
53
53
|
- `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Every `<WireOnboarding>` needs it (see step 7).
|
|
54
54
|
- `resolveAutoDeviceKey({ appId, storage })`: the kit's own per-install `device_key`, read synchronously. Use it for a stamp on an ordinary event, never as the cross-launch join key on `<WireOnboarding>` (it cannot tell you whether the id persists, see the join-key section). `hydrateDeviceIdentity({ appId, storage })` is the awaited, durability-reporting sibling.
|
|
55
|
-
- `useLifecycleEvents(
|
|
56
|
-
- Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry
|
|
55
|
+
- `useLifecycleEvents(lifecycleConfig, options)`: the app-root lifecycle hook (see step 8). `useSessionStart` / `reportSessionStart` are the counter-owning alternatives.
|
|
56
|
+
- Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`, `WireOnboardingStorage` (the AsyncStorage-compatible subset the `storage` prop takes), `LifecycleConfig` (the first argument of `useLifecycleEvents`, step 8). Both of the last two are exported and you need them to type the props documented above; the review/questionnaire gates take a DIFFERENT, synchronous storage (`CoachmarkStorage`, from `@wireai/activation/coachmarks`).
|
|
57
57
|
|
|
58
58
|
## `<WireOnboarding>` props
|
|
59
59
|
|
|
@@ -88,7 +88,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
|
|
|
88
88
|
|
|
89
89
|
| Field | Type | Required | Notes |
|
|
90
90
|
|---|---|---|---|
|
|
91
|
-
| `apiKey` | `string` | yes | Tenant key (`wai_…`); resolves the app server-side. Get it from the
|
|
91
|
+
| `apiKey` | `string` | yes | Tenant key (`wai_…`); resolves the app server-side. Get it from the console: sign up at https://getwireai.com/signup (app name + email, then confirm by email and set a password, which is the step that actually creates the app and its key), then copy it from that app's Integrate tab. |
|
|
92
92
|
| `serverUrl` | `string` | yes | Base server URL; the kit appends `/a2a`. |
|
|
93
93
|
| `appId` | `string` | yes | Passed as the A2A `model` (informational; the key resolves the app). |
|
|
94
94
|
| `metadata` | `Record<string, unknown>` | no | Merged into every A2A request (e.g. install attribution). The kit reserves `sessionId` + `supportedComponents`; do not override them. |
|
|
@@ -106,22 +106,92 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
|
|
|
106
106
|
|
|
107
107
|
## Integration steps (ordered)
|
|
108
108
|
|
|
109
|
-
1. **Tenant key**:
|
|
110
|
-
2. **Install**: `npm install @wireai/activation wireai-rn` (react + react-native are peers). A tarball or `git+ssh://…` works for pre-publish/pinned builds.
|
|
109
|
+
1. **Tenant key**: get BOTH the `wai_…` key and the server URL from the console. Sign up at https://getwireai.com/signup (app name + email; the confirmation link is where you choose a password, and that one step creates your first app and its key and lands you in the console already signed in), then open that app's **Integrate** tab (`https://getwireai.com/console/<app-id>/integrate`): the key is a copy field, and the server URL is the `serverUrl` line of the init snippet on that same tab. (⚠️ the tab's **Add MCP** dialog also shows a copy field called "Server URL"; that one is the MCP endpoint on a different host, not this value.) The key stays readable there on every visit. Sign back in later at https://getwireai.com/login; register FURTHER apps at https://getwireai.com/console/register. Nothing renders without both. A key's label IS its environment (dev / prod / staging / custom), so use a different key in dev than in prod; the tab's **Keys and environments** section lists them and can revoke one, but issuing a new key is not self-serve yet, so ask support for an extra environment.
|
|
110
|
+
2. **Install**: `npm install @wireai/activation wireai-rn zod react-native-safe-area-context` (react + react-native are peers). A tarball or `git+ssh://…` works for pre-publish/pinned builds.
|
|
111
111
|
3. **Metro**: `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` (from `@wireai/activation/metro`); pins one React/RN/wireai-rn/zod copy.
|
|
112
112
|
4. **Config**: set `EXPO_PUBLIC_WIREAI_API_KEY` / `_SERVER_URL` / `_APP_ID`; build via `wireConfigFromEnv({ appId })`.
|
|
113
113
|
5. **Fonts**: host must load the font families named in `theme.fonts.regular/medium/bold` (e.g. `expo-font`), else text falls back to system font.
|
|
114
114
|
6. **Theme**: `themeFromBrand({ primary })` or a full `Partial<OnboardingTheme>`; for dark/light, pick the theme by the app's theme state.
|
|
115
115
|
7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate. Pass the join key: `userContext={activationJoinContext(deviceKey)}`, or, when the app owns no device id, pass a working `storage` and leave `userContext` alone so the kit injects its own (never hand-build it from `resolveAutoDeviceKey`, see the join-key section). Skipping both leaves the `activated` funnel permanently empty and reports no error.
|
|
116
|
-
8. **Lifecycle (do not skip)**: mount `useLifecycleEvents(
|
|
116
|
+
8. **Lifecycle (do not skip)**: mount `useLifecycleEvents({ serverUrl, apiKey, appId, storage }, { deviceKey, sessionCount, userId })` ONCE at the app root, before anything else touches analytics. Its first argument is a `LifecycleConfig`, not the `WireOnboardingConfig` from step 4: pass the fields explicitly (`wireConfigFromEnv` returns `null` when the env is unset, and `storage` is not a config field). It is the only path that emits `app.first_open`, it registers the per-open session id that `createAnalytics` / `wire.track` / the gates all correlate to, and with a `storage` adapter in that first argument it stamps the kit's persisted auto `device_key` on `app.session_started`, which is what the server's `min_sessions` rule counts. That last part needs storage that actually persists: since 0.14.0 an adapter that throws or rejects is treated exactly like no storage, so the events fire with no auto key rather than a per-launch one that corrupts the count. `sessionCount` in the second argument is YOURS to own: a counter you persist and increment once per app open, starting at 1 on the first launch. A hardcoded `1` compiles, ships, and pins every user at `returning: false` forever, silently. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and since 0.14.0 `useSessionStart` follows the same auto-key rule.
|
|
117
117
|
9. **Gate**: wrap behind a flag (env for dev, remote config for prod) **AND** `config != null`; fall through to existing onboarding when off/unconfigured. Decide who sees it (e.g. new signups only) in host nav logic.
|
|
118
118
|
|
|
119
119
|
## Review firing (do not hand-roll the decision fetch)
|
|
120
120
|
|
|
121
121
|
If you gate the review prompt on the server, call **`fetchReviewDecision(target, { deviceKey })`**
|
|
122
|
-
from `@wireai/activation/reviews` and pass the result to `useReviewGate
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
from `@wireai/activation/reviews` and pass the result to `useReviewGate`. Three things are
|
|
123
|
+
mandatory here and **every one of them fails silently** when you get it wrong: the code compiles
|
|
124
|
+
or runs, and the prompt simply never appears (or never stops appearing).
|
|
125
|
+
|
|
126
|
+
**1. The gate needs SYNCHRONOUS storage.** `useReviewGate` counts app opens through a
|
|
127
|
+
`CoachmarkStorage`, whose `getItem(key)` returns `string | null`, **not** a Promise. Pass it as the
|
|
128
|
+
hook's `storage` option, or mount `CoachmarkProvider` with one above the gate. This is a DIFFERENT
|
|
129
|
+
shape from `<WireOnboarding storage>`, which takes the AsyncStorage-style adapter. With no sync
|
|
130
|
+
storage, the kit's own source states the outcome: "No storage pins the counter at 1 forever, so the
|
|
131
|
+
fail-closed `minSessions` rule can never be met and the gate silently never fires"
|
|
132
|
+
(`src/reviews/runtime.ts`). Handing it an **async** adapter reaches the same dead end by another
|
|
133
|
+
route: the kit probes the adapter, sees the thenable, and substitutes a fail-closed store rather
|
|
134
|
+
than feed Promises to the counter. Both cases warn once under `__DEV__` and are invisible in a
|
|
135
|
+
release build. `minSessions` defaults to 2, so there is no version of this you can ignore.
|
|
136
|
+
|
|
137
|
+
**2. `config` is the GATE's config, not the kit transport config.** It needs an `id`. Handing over
|
|
138
|
+
`wireConfigFromEnv(...)`'s value does not compile (`Property 'id' is missing in type
|
|
139
|
+
'WireOnboardingConfig'`). Write `useReviewGate({ config: { id: "post_win" }, decision: decision ?? undefined, storage: gateStorage })`.
|
|
140
|
+
|
|
141
|
+
**3. The `?? undefined` is not optional.** The fetch resolves `ReviewDecisionResponse | null` and
|
|
142
|
+
the hook option is `decision?: ReviewDecision`, so handing the `null` straight over does not
|
|
143
|
+
compile (`Type 'ReviewDecisionResponse | null' is not assignable to type 'ReviewDecision | undefined'`).
|
|
144
|
+
|
|
145
|
+
### Wiring `<ReviewGate>`
|
|
146
|
+
|
|
147
|
+
`<ReviewGate>` has **no `gate` prop**, so the obvious first attempt (handing the controller
|
|
148
|
+
straight to the component) does not compile. The connection is the controller `useReviewGate`
|
|
149
|
+
returns: render on `visible`, and hand `markShown` / `markResolved` to `onShown` / `onResolved`.
|
|
150
|
+
That last pair is load-bearing and is the easiest thing in this package to leave out. They are what
|
|
151
|
+
writes the cooldown timestamp and the once-per-version seen gate.
|
|
152
|
+
Omit them and it compiles, the prompt shows, and it re-prompts the same user on every single open.
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { useEffect, useState } from "react";
|
|
156
|
+
import { useWireActivation } from "@wireai/activation";
|
|
157
|
+
import type { CoachmarkStorage } from "@wireai/activation/coachmarks";
|
|
158
|
+
import { ReviewGate, fetchReviewDecision, useReviewGate } from "@wireai/activation/reviews";
|
|
159
|
+
import type { ReviewDecisionResponse } from "@wireai/activation/reviews";
|
|
160
|
+
|
|
161
|
+
// SYNCHRONOUS by contract: getItem returns the value, never a Promise. MMKV, a memory map, or
|
|
162
|
+
// any store you can read without awaiting. AsyncStorage is NOT this shape.
|
|
163
|
+
const gateStorage: CoachmarkStorage = {
|
|
164
|
+
getItem: (key) => mmkv.getString(key) ?? null,
|
|
165
|
+
setItem: (key, value) => mmkv.set(key, value),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export function HomeScreen() {
|
|
169
|
+
// `track` comes off the same hook; call `await track("journal_done")` at your action site and it
|
|
170
|
+
// bumps `revalidation`, which re-runs the fetch below so the gate can fire off that action.
|
|
171
|
+
const { revalidation } = useWireActivation({ serverUrl, apiKey, appId, deviceKey });
|
|
172
|
+
const [served, setServed] = useState<ReviewDecisionResponse | null>(null);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
void fetchReviewDecision({ serverUrl, apiKey }, { deviceKey }).then(setServed);
|
|
176
|
+
}, [revalidation]);
|
|
177
|
+
|
|
178
|
+
const gate = useReviewGate({
|
|
179
|
+
config: { id: "post_win", minSessions: 2, appVersion },
|
|
180
|
+
decision: served ?? undefined,
|
|
181
|
+
storage: gateStorage,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return gate.visible ? (
|
|
185
|
+
<ReviewGate
|
|
186
|
+
appName="My App"
|
|
187
|
+
target={{ serverUrl, apiKey }}
|
|
188
|
+
meta={{ device_key: deviceKey, firing_arm: served?.arm }}
|
|
189
|
+
onShown={gate.markShown}
|
|
190
|
+
onResolved={gate.markResolved}
|
|
191
|
+
/>
|
|
192
|
+
) : null;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
125
195
|
|
|
126
196
|
- It returns the FULL `{ fire, reason, arm }` on 2xx, and `null` ONLY when the server is
|
|
127
197
|
unreachable / non-2xx / sends bad JSON. It never throws.
|
|
@@ -130,8 +200,10 @@ write your own fetch.
|
|
|
130
200
|
this and prompted a real user for a review 3 minutes into their first session (1 star).
|
|
131
201
|
- `sessionId` is OPTIONAL: the gate usually lives on the home feed, where there is no onboarding
|
|
132
202
|
session. `deviceKey` is the identity that matters. Pass `sessionId` only when you have one.
|
|
133
|
-
- Running a firing experiment? Echo the
|
|
134
|
-
submission so per-arm attribution survives a reweighting.
|
|
203
|
+
- Running a firing experiment? Echo the `arm` **from `fetchReviewDecision`'s response** back as
|
|
204
|
+
`meta.firing_arm` on the review submission, so per-arm attribution survives a reweighting. Read
|
|
205
|
+
it off the fetched `ReviewDecisionResponse`, not off `gate.decision`: the controller's `decision`
|
|
206
|
+
is the resolved `ReviewDecision` (`{ fire, reason }`) and carries no `arm`.
|
|
135
207
|
- **Fire the gate off an in-app action.** `useWireActivation({ serverUrl, apiKey, deviceKey })`
|
|
136
208
|
(ROOT export) returns `{ track, sessionId, revalidation }`. `await track(name, meta?)` POSTs an
|
|
137
209
|
`app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps
|
|
@@ -140,6 +212,7 @@ write your own fetch.
|
|
|
140
212
|
`createWireActivation(config)`. This is the kit-owned replacement for hand-rolling
|
|
141
213
|
session-id + await-POST + revalidate.
|
|
142
214
|
|
|
215
|
+
|
|
143
216
|
## RevenueCat (the purchase funnel)
|
|
144
217
|
|
|
145
218
|
The app sells subscriptions through `react-native-purchases`? Wire the paywall to the same event
|
|
@@ -185,9 +258,10 @@ import { activationJoinContext } from "@wireai/activation";
|
|
|
185
258
|
// The app owns a device id: pass it.
|
|
186
259
|
<WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
|
|
187
260
|
|
|
188
|
-
// The app owns none: pass `storage`
|
|
189
|
-
//
|
|
190
|
-
|
|
261
|
+
// The app owns none: pass `storage` (a PROP of <WireOnboarding>, never a field of the config) and
|
|
262
|
+
// leave `userContext` alone. The kit injects the same id the analytics side stamps, after awaiting
|
|
263
|
+
// the storage read AND confirming the id actually persists.
|
|
264
|
+
<WireOnboarding config={config} storage={storage} ... />
|
|
191
265
|
```
|
|
192
266
|
|
|
193
267
|
⛔ Do NOT hand-build that key with `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`.
|
|
@@ -200,7 +274,8 @@ should do the same rather than the sync call.
|
|
|
200
274
|
Always pass `storage`. Without it the id is per-LAUNCH, not per-install, and a per-launch key makes
|
|
201
275
|
every open look like a new device, which breaks `min_sessions` and A/B arm stickiness as surely as
|
|
202
276
|
no key breaks the join. The same rule governs the lifecycle wiring: `useLifecycleEvents` falls back
|
|
203
|
-
to this id for `app.session_started` / `app.first_open` ONLY when
|
|
277
|
+
to this id for `app.session_started` / `app.first_open` ONLY when its own first argument carries a
|
|
278
|
+
`storage` adapter.
|
|
204
279
|
|
|
205
280
|
## Gotchas (do not miss)
|
|
206
281
|
|
|
@@ -212,6 +287,6 @@ to this id for `app.session_started` / `app.first_open` ONLY when `config.storag
|
|
|
212
287
|
|
|
213
288
|
## Fastest path
|
|
214
289
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
scaffold screen → wire navigation + fallback → typecheck).
|
|
290
|
+
Paste [`INTEGRATION_PROMPT.md`](./INTEGRATION_PROMPT.md) into your coding agent. It ships in this
|
|
291
|
+
package and drives the same steps end to end (detect conventions → register tenant → install →
|
|
292
|
+
Metro → theme → scaffold screen → wire navigation + fallback → typecheck).
|