# @onboarding-yak/react-native (prototype)

React Native renderer for OnboardingYak onboarding flows. It fetches a published flow, validates its schema, caches the last valid response with AsyncStorage, and falls back to an app-bundled flow when the network is unavailable.

## SDK 0.8.0

Adds photo input questions with host-app camera/library callbacks, optional app-owned AI analysis, completion photo metadata, and photo-variable rendering in later screens. The SDK keeps local photo URIs out of resumable progress and never calls an AI provider directly.

## SDK 0.7.2

This patch ensures interactive canvas answers receive the available section width in native layouts, fixing cases where the canvas collapsed to a thin line. It also aligns the canvas dimensions and iPhone/Android mockup proportions with the Studio preview.

## SDK 0.7.1

Adds interactive canvas styling controls and aligns native phone mockup proportions with Studio.

## SDK 0.7.0

This update adds automatic iOS/Android analytics labels, image motion and analytics environment labels, alongside sign-up in the middle of an onboarding. Events include the detected platform (`ios`, `android`, or `other`) and the configured analytics environment (`development`, `testing`, or `production`):

- Images can float, rock gently, pulse, or slowly zoom in the phone renderer.
- Development builds label analytics events as `development`. Set `analyticsEnvironment="testing"` for TestFlight builds and use `production` for App Store builds.
- A `signUp` action that hands off to your own account screen.
- `onAction` can return a Promise. The flow waits for it and shows a spinner on the button. If it resolves to `false` or rejects, the person stays on the screen.
- `onAction` gets the answers so far as its third argument.
- Progress is saved on the device. Someone who leaves the app to sign up, for example in a browser, comes back to the same screen with their answers.

Older SDKs pass `signUp` to `onAction` like any other action and move on without waiting.

## SDK 0.5.0

This update adds:

- New answer formats: a number wheel, a date picker, and a country picker.
- Loading screens with check marks, and benefit checklists.
- Image carousels.
- Custom header and body layouts.
- Graphs that draw in from zero.
- Light and dark mode.
- A keyboard-aware layout: the button stays above the keyboard, content scrolls, and tapping empty space in the content or the button dismisses it.

Everything is built from plain React Native views, so there are no new dependencies. Existing apps should reinstall the local package and restart Metro to pick up the JavaScript changes.

Flows built with these features still load on 0.4.0 and earlier, with simpler fallbacks:

- Pickers show as a plain text box.
- Loading screens show as normal screens with a button.
- Carousels show their first image.
- Custom layouts use the default order.
- Dark mode and animations are skipped.

## SDK 0.4.0

Adds full-screen background illustrations with solid or gradient overlays, and optional emoji icons on choice answers. It also adds runtime localization: translated copy follows the device locale by default, or you can pass `locale` to override it.

## Install in an Expo app

Install the SDK and its required peer dependencies:

```bash
npx expo install @onboarding-yak/react-native @react-native-async-storage/async-storage react-native-safe-area-context
```

The `expo-*` peer dependencies are optional. Install one only when a flow uses that feature (the studio's install guide lists the ones your app needs).

Import `Onboarding` from `@onboarding-yak/react-native` and provide a `fallbackFlow` in the same schema as the dashboard. Pass the app's publishable key as `apiKey`; the studio shows it in the app's install guide. Like a Stripe publishable key, it's meant to ship inside the app: it only lets the holder read that app's published flow and send its analytics events. Without a valid key the API refuses both requests and the SDK shows the cached or fallback flow. The dashboard API must be reachable from the device or simulator; `localhost` on a physical device refers to the device itself, so use the development machine's LAN IP during local testing.

```tsx
<Onboarding
  flowId="onboarding"
  apiKey="oy_pub_…"
  baseUrl="http://192.168.1.10:3000"
  fallbackFlow={bundledFlow}
  locale="fr-CA"
  appearance="system"
  onComplete={() => navigation.replace('Home')}
  onAction={(action, screen) => {
    if (action === 'requestNotifications') requestPushPermission();
    if (action === 'showPaywall') openPaywall();
  }}
/>
```

Development builds are tagged as `development` automatically. Release builds default to `production`. To keep TestFlight and other pre-release builds separate, set `analyticsEnvironment="testing"` on `Onboarding`; switch it back to `production` for App Store builds. Every event also includes the runtime platform (`ios`, `android`, or `other`), which can be filtered separately in Studio. The studio overview excludes development and testing events by default, and its Test data toggle includes them.

Supported screen kinds are `Welcome`, `Question`, `Feature`, `Permission`, and `Paywall`. These currently share a starter renderer. Actions are identifiers handled by the app; the flow contains no executable code.

## Photo input and optional analysis

Photo question screens use the host app's camera and library integrations. Return a local URI so the SDK can preview the selection, and an optional durable HTTPS `reference` if later screens should render it. Use `onPhotoAnalyze` only when the Studio screen enables optional analysis; send the photo to your own backend and return short result text.

```tsx
<Onboarding
  {...onboardingProps}
  onPhotoRequest={async (_screen, source) => {
    const asset = await pickPhotoWithYourNativePicker(source);
    if (!asset) return null;
    const reference = await uploadPhotoIfNeeded(asset.uri);
    return { uri: asset.uri, reference };
  }}
  onPhotoAnalyze={async (_screen, photo) =>
    analyzePhotoWithYourBackend(photo.reference ?? photo.uri)
  }
  onComplete={(answers, { variables, photos }) => saveProfile({ answers, variables, photos })}
/>
```

The picker, permissions, upload, AI provider, and retention rules belong to your app. Local photo URIs are kept in memory for the current run and excluded from resume storage. `onComplete` receives the selected photos separately, keyed by question screen ID. For a later screen, set its `imageVariable` to the photo question's variable; the callback must provide a durable HTTPS reference for that photo to render there. An optional `photoInput.analysisVariable` saves the returned result as a separate text variable.

## Appearance

Each app picks Light, Dark, or Match device in OnboardingYak, and the choice is published with the flow. Pass `appearance` (`'light'`, `'dark'`, or `'system'`) to override it. With `'system'`, the onboarding follows the phone's setting and switches live.

Screens can save their own dark colors. Any screen without them gets a dark version of its light palette that keeps its hue, so existing flows work in dark mode without changes.

## Layout

`theme.arrangement` sets the order of parts in the header and body. `theme.headerAlign`, `theme.bodyAlign`, and `theme.footerAlign` (`'top'`, `'center'`, or `'bottom'`) place each section in a top, middle, or bottom zone, with free space split evenly between zones. Order is kept: a later section never sits above an earlier one. Defaults are header top, footer bottom, and body bottom on full-photo screens and top otherwise. A bottom footer stays pinned above the keyboard; a footer moved up scrolls with the content.

## Answer formats

Question screens with a written answer can set `input.format`. Every format arrives as a string in `onAnswer` and `onComplete`.

| Format | What people see | Answer value |
| --- | --- | --- |
| `text` (default) | A multi-line text box | The text, e.g. `Notifications and meetings` |
| `number` | A large number field with the number keypad | Digits as typed, e.g. `170` |
| `wheel` | A scrolling number wheel using `min`, `max`, `step`, `initial`, and `unit` | The selected number, e.g. `70.5` |
| `date` | Month, day, and year wheels; `min` and `max` are years | An ISO date, e.g. `1995-01-01` |
| `country` | A searchable list of countries with flags | The English country name, e.g. `Spain` |

The unit is not included in the answer. Read it from `screen.input.unit` in `onAnswer` if you need it.

**Interactive canvas.** Set `answerType: 'positionPicker'` and configure a data-only `positionPicker`. The canvas can use a raster background image or stay blank, and supports up to 24 movable labeled hotspots. Coordinates are percentages. Customize the canvas with `height` and `cornerRadius`, and hotspots with `hotspotShape` (`circle`, `rounded`, or `chip`) and `hotspotSize` (`small`, `medium`, or `large`). The SDK renders native buttons and never loads SVG or executable custom markup. `onAnswer` receives the selected labels as the answer string and stable hotspot IDs in its values list.

```ts
{
  kind: 'Question',
  answerType: 'positionPicker',
  positionPicker: {
    maxSelections: 3,
    background: 'https://cdn.example.com/map.jpg',
    height: 320,
    cornerRadius: 18,
    hotspotShape: 'chip',
    hotspotSize: 'medium',
    positions: [
      { id: 'spot-1', label: 'Coffee near me', x: 35, y: 40 },
      { id: 'spot-2', label: 'Park', x: 70, y: 65 }
    ]
  }
}
```

`wheel` and `number` inputs can offer up to two more units in `input.units`, shown as tabs above the wheel or field. Each option has a `unit` label and a `factor` from the base unit (other = base × factor), plus an optional `min`, `max`, and `step`. The range is derived from the base range when left out. Set `display: 'feet-inches'` to show inches as 5′ 9″. Answers are always converted back to the base unit: someone who picks 154 lb on a kg wheel sends `69.9`.

```ts
input: { format: 'wheel', unit: 'kg', min: 35, max: 200, step: 0.5, initial: 70, units: [{ unit: 'lb', factor: 2.20462, step: 1 }] }
``` Wheels and date pickers start with a value already selected, so people can continue without scrolling.

## Multiple choice, branching, and personalized copy

**Multiple choice.** A choice question with `multiple: { min?, max? }` lets people pick several options. The button stays disabled until `min` are picked. The answer joins the picks with commas (`"Keto, Vegan"`), and `onAnswer(screen, answer, values)` also receives them as a list. Older SDKs show the question as single choice.

**Branching.** `showIf: { screen, anyOf, not? }` shows a screen only when an earlier choice question's answer is one of `anyOf`, or none of them with `not: true`. Screens that don't match are skipped, Back returns to the screens that were actually shown, and a screen whose question hasn't been answered is skipped. Older SDKs show every screen.

**Variables.** A question with `variable: "name"` saves its answer as a variable. Headlines, supporting text, buttons, checklist items, and loading steps can recall it as `{name}`, or `{name|friend}` to show "friend" when there's no answer. The variable's type comes from the question, and recalled values are formatted:

| Question | Type | Recalled as |
| --- | --- | --- |
| Written answer | `text` | `Alex` |
| Number field or wheel | `number` | `70 kg`, or `154 lb` if the person switched units |
| Number with `variableFormat.currency` | `money` | `$3,000` |
| Date picker | `date` | `January 4, 1995`; set `variableFormat.dateStyle` to `medium`, `short`, or `numeric` |
| Single choice | `choice` | `Lose weight` |
| Multiple choice | `list` | `Dairy, Nuts and Gluten` |
| Country picker | `country` | `Spain` |

Built-in variables are always available: `{app_name}`, `{first_name}` (the first word of `{name}`), `{today}` (e.g. March 12), and `{weekday}`.

When the flow ends, `onComplete(answers, { variables })` receives every variable keyed by name, as `{ type, value, unit?, currency?, display }`: the raw value for your logic or storage, and the text the onboarding showed. `onAnswer` also receives the variable as its fourth argument. Store them, use them, or ignore them; apps that only read `answers` keep working. Older SDKs show tokens as written, so flows that use variables need this SDK version.

```tsx
onComplete={(answers, { variables }) => {
  // variables.weight → { type: 'number', value: 69.9, unit: 'kg', display: '154 lb' }
  saveProfile(variables);
}}
```

## Actions and permissions

A screen's `action` runs when its button is tapped. `next` and `complete` are handled by the SDK. Every other action reaches `onAction(action, screen)` so your app can make the native request, and then the flow moves on:

| Action | What your app does | Package |
| --- | --- | --- |
| `requestNotifications` | Ask for notification permission | your push setup |
| `requestTracking` | Ask for App Tracking Transparency (add `NSUserTrackingUsageDescription`) | `expo-tracking-transparency` |
| `requestLocation` | Ask for location while in use | `expo-location` |
| `requestCamera` | Ask for camera access | `expo-camera` |
| `requestPhotos` | Ask for photo library access | `expo-media-library` |
| `requestMicrophone` | Ask for microphone access | `expo-audio` |
| `requestContacts` | Ask for contacts access | `expo-contacts` |
| `requestHealth` | Ask for Apple Health / Health Connect access (needs a development build) | `react-native-health`, `react-native-health-connect` |
| `requestBiometrics` | Run a Face ID or fingerprint check and turn on biometric unlock | `expo-local-authentication` |
| `requestReview` | Show the store rating prompt (iOS decides whether it appears) | `expo-store-review` |
| `showPaywall` | Present your paywall | your paywall |
| `signUp` | Present your sign-up and resolve once the account exists | your auth |
| `custom:<name>` | Anything else: your app decides, e.g. `custom:requestBluetooth` or `custom:addWidget` | your code |

Custom actions reach `onAction` exactly as written in the studio, so match on the full string:

```tsx
onAction={(action) => {
  if (action === 'custom:requestBluetooth') return requestBluetooth(); // a Promise holds the flow until it resolves
}}
```

Turn on the matching capabilities in OnboardingYak and the setup guide includes the packages and the `onAction` code. Older SDKs pass these actions through the same way.

**Waiting for the app.** If `onAction` returns nothing or `true`, the flow moves on straight away. If it returns a Promise, the flow waits and the button shows a spinner. Resolve to `true` to move on. Resolve to `false`, or reject, to keep the person on the screen, for example when they cancel. `screen_completed` is sent only once the flow moves on, so the dashboard shows how many people stop at that screen.

## Sign-up during onboarding

Sign-up stays in your app. The flow never sees credentials or tokens. A screen with `action: 'signUp'` hands off to your own account screen (Sign in with Apple, Google, email, and so on), and the flow resumes when you resolve:

```tsx
onAction={async (action, screen, { variables }) => {
  if (action !== 'signUp') return;
  if (await isSignedIn()) return true;           // already has an account: move on
  const user = await presentSignUp();            // your sheet; null if the person cancels
  if (!user) return false;                       // stay on this screen
  await saveProfile(user.id, variables);         // the answers so far, typed
  return true;
}}
```

The third argument holds `answers` and `variables` up to this screen, so you can save them to the new account right away. Everything after it still arrives in `onComplete`.

**Leaving the app.** OAuth redirects and magic links send people out of the app, and the system may close it while they're gone. The SDK saves progress (screen, Back history, answers, and session) on the device after every step. On the next launch it picks up where the person left off and keeps the same analytics session. Saved progress is ignored if the published flow version changed or the screen moved, and it's cleared when the onboarding finishes. Pass `resume={false}` to always start from the first screen.

When the person comes back signed in, they land on the sign-up screen again. Have `onAction` check for an existing session and resolve `true` straight away, as in the example.

**Placement.** Ask after the questions and the personalized result, before the paywall. People have invested in the onboarding by then, and purchases can be tied to the account. Offer a way to continue without an account in your sign-up sheet unless the app needs one: App Store guideline 5.1.1 rejects required sign-up for apps without account-based features. Note that the screen's Skip ends the whole onboarding, so let people skip sign-up in your own sheet (resolve `true`) instead.

## A/B tests

**Flow tests.** A published flow can carry `experiment: { id, name, controlWeight, variants }`, where each variant has its own full `screens`. The flow's own `screens` are version A. Each install is assigned one version by a stable hash of an install id saved in AsyncStorage, so a person keeps the same version on later launches. Every event during that onboarding carries `variant: "flow-a"`, `"flow-b"`, and so on, and OnboardingYak compares started and finished per version. Screen tests are paused while a flow test runs. If a version's screens fail validation, the SDK runs version A. Older SDKs always run version A.

**Screen tests.** A screen with `experiment: { controlWeight, variants }` shows one of up to four versions: the original (`a`) or a variant that changes `title`, `subtitle`, `button`, or `illustration`. Weights split traffic. Each install is assigned by a stable hash, so a person keeps the same version across launches. `screen_viewed` and `screen_completed` events on that screen include `variant`, and OnboardingYak compares how many people continued and finished per version. Variant copy is shown as written, without translations. Older SDKs always show the original and send events without a variant, which count toward version A.

## Social proof

`proof` adds a star rating (`score` 1–5, `count`, `label`), up to three testimonials (`quote`, `author`, `detail`, `stars`), and a `stat` line, shown with a short staggered entrance. Hide it with `theme.showProof: false`. Use real reviews and numbers only. Older SDKs skip the block.

## Loading screens and checklists

A screen with a `loader` shows a ring that fills to 100% while each step gets a check mark. The button stays disabled until loading finishes. By default the flow then moves on by itself; set `autoAdvance: false` to wait for a tap. A `checklist` shows benefit rows with check marks, and is often used on paywalls. Set `theme.showLoader`, `theme.showChecklist`, or `theme.showCharts` to `false` to hide them without losing their settings. A hidden loader doesn't hold up the button.

Entrance animations, graph drawing, and carousel autoplay are skipped when the device's reduced-motion setting is on. The loading ring still fills, because it shows progress.

## Videos and phone mockups

Any image slot (the screen image, carousel slides, and full-screen backgrounds) can hold a video instead: a link ending in `.mp4`, `.m4v`, `.mov`, or `.webm`, or a video uploaded in the studio. The SDK has no video dependency of its own, so pass `renderVideo` to draw them. It should fill `style`, loop, and stay muted. With expo-video:

```tsx
import { useVideoPlayer, VideoView } from 'expo-video';
import { Onboarding, type OnboardingVideo } from '@onboarding-yak/react-native';

function FlowVideo({ uri, resizeMode, style }: OnboardingVideo) {
  const player = useVideoPlayer(uri, (player) => { player.loop = true; player.muted = true; player.play(); });
  return <VideoView player={player} style={style} contentFit={resizeMode === 'stretch' ? 'fill' : resizeMode} nativeControls={false} />;
}

<Onboarding {...props} renderVideo={(video) => <FlowVideo {...video} />} />
```

Without `renderVideo`, a video slot shows the screen's illustration color. Videos uploaded in the studio are embedded as `data:` URLs; if your player can't play those, write them to a file first (for example with expo-file-system) or link hosted videos instead.

`theme.imageFrame` set to `iphone` or `android` shows an inline image, carousel, or video as a screenshot inside a phone. The phone's height follows `imageHeight`; its width follows the phone's proportions. Background images are never framed. Older SDKs show the image without the phone.

## Current limitations

The package is an early prototype and has not yet been published to npm. Flows use the app-specific `flowId` created in OnboardingYak. When the SDK starts, it sends anonymous `flow_started` and `screen_viewed` events. Advancing sends `screen_completed`; finishing or skipping sends `flow_completed`. Events include app ID, screen ID, flow version, and an ephemeral session ID. Answer text and selected answers are never sent as analytics events.

Question screens require an answer: a single choice, or any of the answer formats above. `onAnswer(screen, answer)` receives each submitted response; `onComplete(answers)` receives all submitted values keyed by screen ID so the host app can use or persist them. Uploaded screen illustrations are included in the published flow and rendered by the native SDK.

## Developing and releasing the SDK

The package ships compiled JavaScript and type declarations from `lib/`, built from `src/` with `npm run build`. This folder has its own `node_modules` with React and React Native for type-checking, so don't link it into an app with `file:../path` or a symlink: Metro would load a second copy of React. Test a change in an app from a tarball instead, the same way npm delivers it:

```bash
npm run build && npm pack --pack-destination ../path/to/app/vendor
cd ../path/to/app && npm install ./vendor/onboardingyak-react-native-<version>.tgz
```

To release, bump `version` in `package.json`, then run `npm publish` while logged in to an npm account that's a member of the `onboarding-yak` org. `prepublishOnly` rebuilds `lib/` first, and `publishConfig` publishes the package as public.
