@myazahq/kyc-sdk-react-native 2.2.0 → 2.3.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.
@@ -0,0 +1,43 @@
1
+ // Session step log — records each SDK step the user reaches, with a
2
+ // timestamp, so the server can reconstruct the journey on the verification
3
+ // timeline ("consent opened → ID type chosen → document captured → …").
4
+ // Rides the verify submission as metadata.device.stepLog — the same free-form
5
+ // channel the Device Intelligence fingerprint uses: no extra network calls,
6
+ // no new endpoint, and old SDKs simply never send it. `sentAt` is stamped at
7
+ // collect time so the server can correct client-clock skew against its own
8
+ // receipt time. Step names only — never PII. Mirrors the web SDK's
9
+ // lib/step-log.ts; keep the two in lockstep.
10
+
11
+ export interface StepLogEntry {
12
+ step: string;
13
+ at: string;
14
+ }
15
+
16
+ export interface StepLog {
17
+ steps: StepLogEntry[];
18
+ sentAt: string;
19
+ }
20
+
21
+ const MAX_ENTRIES = 40;
22
+
23
+ let entries: StepLogEntry[] = [];
24
+
25
+ /** Fresh slate per session (called from the store's reset — each modal open). */
26
+ export function resetStepLog(): void {
27
+ entries = [];
28
+ }
29
+
30
+ /** Records a step visit. Consecutive duplicates are collapsed; back-and-forth
31
+ * navigation is kept — repeat visits are honest journey data. */
32
+ export function recordStep(step: string): void {
33
+ if (entries.length >= MAX_ENTRIES) return;
34
+ if (entries[entries.length - 1]?.step === step) return;
35
+ entries.push({ step, at: new Date().toISOString() });
36
+ }
37
+
38
+ /** Snapshot attached to the verify submission. Null when nothing was recorded
39
+ * so the field is simply absent. */
40
+ export function getStepLog(): StepLog | null {
41
+ if (entries.length === 0) return null;
42
+ return { steps: [...entries], sentAt: new Date().toISOString() };
43
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Which step circles to draw, and when to start collapsing them.
3
+ *
4
+ * A KYB flow can reach FOURTEEN steps (consent → email → phone →
5
+ * business-details → key-people → documents → applicant-role → country-select →
6
+ * id-type → document-capture → nfc → liveness → questionnaire → submitted), and
7
+ * an individual flow eleven. The circles are a fixed size, so they cannot shrink
8
+ * to fit: on a 360dp Android with 16dp padding, ten steps leave 14dp TOTAL for
9
+ * nine connectors, and twelve steps overflow the row outright.
10
+ *
11
+ * COLLAPSING IS A LAST RESORT. The row fits as many real circles as the measured
12
+ * width allows and only windows once they would stop looking like a connected
13
+ * chain — which is what the minimum connector length below decides. A fixed cap
14
+ * would collapse a 9-step flow on a large phone that had room for all of it.
15
+ *
16
+ * When it does window, two rules keep the elision honest:
17
+ *
18
+ * • The LAST step is always shown. Without it the ellipsis hides how much is
19
+ * left, which is the one thing a progress indicator exists to answer.
20
+ * • The FIRST step is always shown, so the row still reads as a whole journey
21
+ * rather than a fragment floating in the middle.
22
+ *
23
+ * 1 ··· 5 6 7 ··· 10
24
+ */
25
+
26
+ /** A rendered slot: a step index, or a collapsed run of them. */
27
+ export type StepSlot = number | 'ellipsis';
28
+
29
+ /**
30
+ * Shortest connector that still reads as a line joining two circles rather than
31
+ * a stray dash. Below this the "chain" metaphor is gone and the row just looks
32
+ * cramped — which is the state the 10-step screenshot was already in.
33
+ */
34
+ export const MIN_CONNECTOR = 10;
35
+
36
+ /** Horizontal margin a connector carries on each side (matches the component). */
37
+ const CONNECTOR_MARGIN = 6;
38
+
39
+ /**
40
+ * Never collapse below this many circles. At 5 the pattern still reads as
41
+ * first · gap · current · gap · last; below it the row says nothing useful.
42
+ */
43
+ const MIN_CIRCLES = 5;
44
+
45
+ const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
46
+
47
+ /**
48
+ * How many circles fit across `width` before they stop looking like a chain.
49
+ *
50
+ * n·size + (n−1)·(margins + minConnector) ≤ width
51
+ *
52
+ * Returns 0 when the width is not known yet, which the caller reads as "render
53
+ * them all" — an un-measured first frame should not flash a collapsed row.
54
+ */
55
+ export function fitStepCircles(width: number, circleSize: number): number {
56
+ if (!Number.isFinite(width) || width <= 0) return 0;
57
+ const gap = CONNECTOR_MARGIN + MIN_CONNECTOR;
58
+ return Math.max(1, Math.floor((width + gap) / (circleSize + gap)));
59
+ }
60
+
61
+ /**
62
+ * @param total how many steps the flow has
63
+ * @param active 0-based index of the current step
64
+ * @param maxCircles how many circles fit (from `fitStepCircles`); 0 = unknown,
65
+ * render everything
66
+ */
67
+ export function windowedSteps(total: number, active: number, maxCircles = 0): StepSlot[] {
68
+ if (total <= 0) return [];
69
+ const all = Array.from({ length: total }, (_, i) => i);
70
+ if (maxCircles <= 0 || total <= maxCircles) return all;
71
+
72
+ // A collapsed row also pays for up to TWO ellipses, each about as wide as a
73
+ // circle once its padding and connectors are counted. Reserving only one
74
+ // between them under-counted, and the row overflowed — which is invisible
75
+ // until you notice every `flex: 1` connector has collapsed to zero width.
76
+ const budget = Math.max(MIN_CIRCLES, maxCircles - 2);
77
+ if (total <= budget) return all;
78
+
79
+ const first = 0;
80
+ const last = total - 1;
81
+ const current = clamp(active, first, last);
82
+
83
+ // First and last are drawn unconditionally; the rest of the budget is a
84
+ // window centred on the current step.
85
+ const windowSize = Math.max(1, budget - 2);
86
+ const half = Math.floor((windowSize - 1) / 2);
87
+ const start = clamp(current - half, first + 1, Math.max(first + 1, last - windowSize));
88
+ const end = Math.min(start + windowSize - 1, last - 1);
89
+
90
+ const slots: StepSlot[] = [first];
91
+ if (start > first + 1) slots.push('ellipsis');
92
+ for (let i = start; i <= end; i += 1) slots.push(i);
93
+ if (end < last - 1) slots.push('ellipsis');
94
+ slots.push(last);
95
+ return slots;
96
+ }
@@ -562,7 +562,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
562
562
  setState((s) => ({
563
563
  ...s,
564
564
  phase: 'complete',
565
- instruction: 'Verification complete',
565
+ instruction: 'Capture complete',
566
566
  activeChallenge: null,
567
567
  positionGuidance: null,
568
568
  }));
@@ -99,6 +99,19 @@ export function IdTypeStep(): React.ReactElement {
99
99
  return (table[country.toUpperCase()] ?? []).filter((t) => allowed(t.key));
100
100
  }, [country, config.idTypes, config.countries, serverConfig]);
101
101
 
102
+ // Document Intelligence off ⇒ number-only IDs only (there is no document
103
+ // capture step), so drop every document-scanned ID from the picker. This is
104
+ // what makes the disabled step actually disappear from the live flow rather
105
+ // than still offering passports and licences that then have nowhere to be
106
+ // captured. Mirrors the web SDK's IdTypeStep.
107
+ const visible = useMemo<IdTypeDefinition[]>(
108
+ () =>
109
+ config.enableDocumentCapture === false
110
+ ? available.filter((t) => !t.requiresDocumentCapture)
111
+ : available,
112
+ [available, config.enableDocumentCapture],
113
+ );
114
+
102
115
  if (serverConfig.status === 'loading') {
103
116
  return (
104
117
  <View style={{ paddingVertical: spacing.xl, alignItems: 'center' }}>
@@ -107,7 +120,7 @@ export function IdTypeStep(): React.ReactElement {
107
120
  );
108
121
  }
109
122
 
110
- if (serverConfig.status === 'ready' && available.length === 0) {
123
+ if (serverConfig.status === 'ready' && visible.length === 0) {
111
124
  return (
112
125
  <View
113
126
  style={{
@@ -127,7 +140,7 @@ export function IdTypeStep(): React.ReactElement {
127
140
 
128
141
  return (
129
142
  <View>
130
- {available.map((t) => {
143
+ {visible.map((t) => {
131
144
  const selected = selectedIdType === t.key;
132
145
  return (
133
146
  <Pressable
@@ -151,6 +151,18 @@ export function NfcStep(): React.ReactElement {
151
151
  },
152
152
  );
153
153
  if (!liveRef.current || seq !== readSeqRef.current) return;
154
+ // Which access protocol opened the session, and why. PACE is new here and
155
+ // reads over BAC by default, so this line is what distinguishes "the chip
156
+ // never offered PACE" from "our PACE was attempted and failed" while it
157
+ // is being proven against real documents. Dev builds only — it is
158
+ // diagnostics, never user-facing.
159
+ if (__DEV__) {
160
+ console.log(
161
+ `[kyc.nfc] session opened over ${result.chipAuth}` +
162
+ (result.paceOutcome ? ` (pace: ${result.paceOutcome}` : '') +
163
+ (result.paceDetail ? ` — ${result.paceDetail})` : result.paceOutcome ? ')' : ''),
164
+ );
165
+ }
154
166
  store.getState().setChipData(result);
155
167
  setResult(result);
156
168
  setPhase('done');
@@ -33,22 +33,35 @@ export function QuestionnaireFieldView({
33
33
  field,
34
34
  value,
35
35
  currencyValue,
36
+ detailValue,
36
37
  error,
37
38
  onChange,
38
39
  onCurrencyChange,
40
+ onDetailChange,
39
41
  }: {
40
42
  field: FieldDef;
41
43
  value: QuestionnaireAnswerValue | undefined;
42
44
  /** money only: the `<key>_currency` companion answer. */
43
45
  currencyValue?: string;
46
+ /** choice fields only: the `<key>_other` companion answer. */
47
+ detailValue?: string;
44
48
  error?: string;
45
49
  onChange: (value: QuestionnaireAnswerValue | undefined) => void;
46
50
  onCurrencyChange: (currency: string) => void;
51
+ onDetailChange?: (detail: string | undefined) => void;
47
52
  }): React.ReactElement {
48
53
  const { colors } = useTheme();
49
54
 
50
55
  const isPlainInput = field.type === 'text' || field.type === 'number';
51
56
 
57
+ // The chosen option that is not an answer on its own ("Other"). Covers both
58
+ // select (a single value) and multiselect (a list).
59
+ const detailOption = (field.options ?? []).find(
60
+ (o) =>
61
+ o.requiresDetail &&
62
+ (Array.isArray(value) ? value.includes(o.value) : value === o.value),
63
+ );
64
+
52
65
  return (
53
66
  <View style={{ marginBottom: spacing.lg }}>
54
67
  <FieldLabel
@@ -143,6 +156,23 @@ export function QuestionnaireFieldView({
143
156
  })
144
157
  : null}
145
158
 
159
+ {/* Free text behind an "Other" choice. Always required once that option
160
+ is picked: an unexplained "Other" is the answer a compliance reviewer
161
+ most needs spelled out. */}
162
+ {detailOption ? (
163
+ <View style={{ marginTop: spacing.sm }}>
164
+ <MyazaInput
165
+ label={detailOption.detailLabel || 'Please specify'}
166
+ value={detailValue ?? ''}
167
+ maxLength={200}
168
+ placeholder={
169
+ detailOption.detailPlaceholder || `Tell us more about "${detailOption.label}"`
170
+ }
171
+ onChangeText={(text: string) => onDetailChange?.(text || undefined)}
172
+ />
173
+ </View>
174
+ ) : null}
175
+
146
176
  {/* Inputs draw their own error; the rest need one underneath. */}
147
177
  {error && !isPlainInput && field.type !== 'money' ? (
148
178
  <MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
@@ -4,7 +4,7 @@ import { View } from 'react-native';
4
4
  import { spacing } from '../config/theme';
5
5
  import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
6
6
  import { MyazaButton } from '../components/MyazaButton';
7
- import { currencyKeyFor, validateQuestionnaire } from '../config/questionnaire';
7
+ import { currencyKeyFor, otherKeyFor, validateQuestionnaire } from '../config/questionnaire';
8
8
  import { QuestionnaireFieldView } from './QuestionnaireField';
9
9
  import type { QuestionnaireAnswerValue } from '../types/workflow';
10
10
 
@@ -63,9 +63,11 @@ export function QuestionnaireStep(): React.ReactElement {
63
63
  field={field}
64
64
  value={answers[field.key]}
65
65
  currencyValue={answers[currencyKeyFor(field)] as string | undefined}
66
+ detailValue={answers[otherKeyFor(field)] as string | undefined}
66
67
  error={errors[field.key] || undefined}
67
68
  onChange={(value) => setAnswer(field.key, value)}
68
69
  onCurrencyChange={(currency) => setAnswer(currencyKeyFor(field), currency)}
70
+ onDetailChange={(detail) => setAnswer(otherKeyFor(field), detail)}
69
71
  />
70
72
  ))}
71
73
 
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useMemo, useState } from 'react';
2
2
  import { Image, View } from 'react-native';
3
3
 
4
4
  import { spacing } from '../../config/theme';
@@ -8,7 +8,7 @@ import { MyazaButton } from '../../components/MyazaButton';
8
8
  import { Icon } from '../../components/Icon';
9
9
  import { useChipPortrait } from './useChipPortrait';
10
10
  import { NfcScannedSummary } from './NfcScannedSummary';
11
- import type { EmrtdReadResult } from '../../emrtd';
11
+ import { parseDg1, type EmrtdReadResult } from '../../emrtd';
12
12
  import type { MrzScan } from '../../mrz/parse';
13
13
 
14
14
  // ---------------------------------------------------------------------------
@@ -41,7 +41,12 @@ export function NfcSuccessPanel({
41
41
  // component rejects) must degrade to the generic mark, not an empty circle.
42
42
  const [portraitBroken, setPortraitBroken] = useState(false);
43
43
  const portrait = portraitBroken ? null : portraitUri;
44
- const name = [mrz?.firstName, mrz?.lastName].filter(Boolean).join(' ');
44
+ // Once the chip has been read, IT is the source — see emrtd/dg1.ts. The
45
+ // camera scan stays as the fallback for a chip whose DG1 we could not parse,
46
+ // so a read that otherwise worked still shows something.
47
+ const chip = useMemo(() => parseDg1(result.dg1), [result.dg1]);
48
+ const holder = chip ?? mrz;
49
+ const name = [holder?.firstName, holder?.lastName].filter(Boolean).join(' ');
45
50
 
46
51
  return (
47
52
  <View style={{ alignItems: 'center' }}>
@@ -132,12 +137,14 @@ export function NfcSuccessPanel({
132
137
  ) : null}
133
138
 
134
139
  {/* What was read, shown back — the same card the pre-read screen uses, so
135
- the user checks the same two fields against the printed page. */}
136
- {mrz ? (
140
+ the user checks the same two fields against the printed page. Before
141
+ the read that card can only show the camera's guess; here it shows the
142
+ chip's own values, which is both correct and a stronger check. */}
143
+ {holder ? (
137
144
  <>
138
145
  <View style={{ height: spacing.lg }} />
139
146
  <View style={{ alignSelf: 'stretch' }}>
140
- <NfcScannedSummary scan={mrz} />
147
+ <NfcScannedSummary scan={holder} />
141
148
  </View>
142
149
  </>
143
150
  ) : null}
@@ -4,6 +4,7 @@
4
4
  // `utils/device-metadata.ts` and the Flutter SDK's `device_metadata_service.dart`.
5
5
 
6
6
  import { OS } from '../utils/platform';
7
+ import { getStepLog } from '../lib/step-log';
7
8
 
8
9
  export const SDK_TYPE = 'react-native' as const;
9
10
 
@@ -14,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
14
15
  * Single source of truth for the SDK version — also used by `services/api.ts`
15
16
  * for the `X-SDK-Version` header. Keep in sync with `package.json`.
16
17
  */
17
- export const SDK_VERSION = '2.2.0';
18
+ export const SDK_VERSION = '2.3.0';
18
19
 
19
20
  export interface ReactNativeDeviceMetadata {
20
21
  sdkType: 'react-native';
@@ -181,5 +182,12 @@ export function collectDeviceMetadata(): ReactNativeDeviceMetadata {
181
182
  meta.locale = locales[0]?.languageTag;
182
183
  }
183
184
 
185
+ // Step journey recorded during the session — powers the dashboard's
186
+ // verification timeline. See lib/step-log.
187
+ const stepLog = getStepLog();
188
+ if (stepLog) {
189
+ (meta as ReactNativeDeviceMetadata & { stepLog?: unknown }).stepLog = stepLog;
190
+ }
191
+
184
192
  return meta;
185
193
  }
@@ -29,6 +29,13 @@ import type { KYCStep, SupportedCountry } from '../types/config';
29
29
  import type { KycState } from './state';
30
30
 
31
31
  export function livenessEnabled(state: KycState): boolean {
32
+ // Presence Intelligence off ⇒ no selfie step at all. This is the builder's
33
+ // "Presence Intelligence step" switch (`enableSelfie`), and it outranks the
34
+ // liveness-gesture question below: with no selfie there is nothing to run
35
+ // gestures against. Checked first for that reason, and because omitting it
36
+ // was why turning the step off in the workflow changed nothing here while it
37
+ // worked on web.
38
+ if (state.config.enableSelfie === false) return false;
32
39
  // Consumer baseline: liveness is on unless explicitly disabled.
33
40
  if (state.config.enableLiveness === false) return false;
34
41
  const idType = state.selectedIdType;
@@ -27,6 +27,7 @@ import {
27
27
  type KycStore,
28
28
  } from './state';
29
29
  import { nextStepAfter, nfcDecision, previousStepBefore } from './derive';
30
+ import { recordStep, resetStepLog } from '../lib/step-log';
30
31
  import { buildVerifyRequest } from './submit';
31
32
  import { applicantMediaCaptured, buildApplicantVerifyRequest } from './submitApplicant';
32
33
 
@@ -54,7 +55,7 @@ export function createKycStore(
54
55
  const baseUrl = resolveBaseUrl(config.apiKey, config.devUrl);
55
56
  const api = createKYCApi(baseUrl, config.apiKey);
56
57
 
57
- return createStore<KycState>((set, get) => {
58
+ const store = createStore<KycState>((set, get) => {
58
59
  function emitStepChange(step: KYCStep): void {
59
60
  config.onStepChange?.(step);
60
61
  }
@@ -354,6 +355,11 @@ export function createKycStore(
354
355
  },
355
356
 
356
357
  reset() {
358
+ // Fresh step journey per session; the explicit record covers a store
359
+ // already sitting on 'consent' (the subscribe below only fires on
360
+ // change, and recordStep dedupes if it fires too).
361
+ resetStepLog();
362
+ recordStep('consent');
357
363
  set({
358
364
  currentStep: 'consent',
359
365
  selectedCountry: null,
@@ -383,6 +389,24 @@ export function createKycStore(
383
389
  },
384
390
  };
385
391
  });
392
+
393
+ // Step journey log — records every step the user reaches (the subscription
394
+ // catches ALL currentStep writes, whatever action made them; recordStep
395
+ // collapses consecutive duplicates). Rides the submission as
396
+ // metadata.device.stepLog for the dashboard timeline.
397
+ store.subscribe((s, prev) => {
398
+ if (s.currentStep !== prev.currentStep) recordStep(s.currentStep);
399
+ });
400
+
401
+ // A fresh store IS a fresh session: the runtime provider creates one per
402
+ // modal launch and reset() is NOT part of the open path, so the journey
403
+ // starts here — and the opening step must be recorded explicitly (the
404
+ // subscription above only fires on CHANGE, and a new store already sits on
405
+ // 'consent').
406
+ resetStepLog();
407
+ recordStep(store.getState().currentStep);
408
+
409
+ return store;
386
410
  }
387
411
 
388
412
  // Re-export the flow helpers for tests + screens that need to reason about
@@ -61,6 +61,9 @@ export type KYCStep =
61
61
  // Client-side SDK config (MyazaKYC.show() / useMyazaKYC options)
62
62
  // ---------------------------------------------------------------------------
63
63
 
64
+ /** How flow progress is drawn — see {@link MyazaKYCConfig.progressStyle}. */
65
+ export type ProgressStyle = 'steps' | 'bar';
66
+
64
67
  export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
65
68
  /**
66
69
  * Bearer token. The key prefix is the single source of truth for the
@@ -203,6 +206,20 @@ export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
203
206
  /** Show a light/dark mode toggle button inside the modal header. Default `true`. */
204
207
  showThemeToggle?: boolean;
205
208
 
209
+ /**
210
+ * How progress through the flow is drawn in the header.
211
+ *
212
+ * • `'steps'` (default) — numbered circles, one per step, connected. Shows
213
+ * WHICH step you are on and how many there are, and collapses to a window
214
+ * when they no longer fit.
215
+ * • `'bar'` — a single thin bar pinned to the bottom edge of the header.
216
+ * Quieter, and unaffected by step count, so it suits long flows and hosts
217
+ * who would rather the chrome said less.
218
+ *
219
+ * Both convey the same fraction; the choice is how much room it takes.
220
+ */
221
+ progressStyle?: ProgressStyle;
222
+
206
223
  /**
207
224
  * Hide the close (X) button and block all user-initiated dismissal of the
208
225
  * sheet — the X button, Android hardware back, and the iOS swipe-down drag.
@@ -82,6 +82,16 @@ export interface PhoneVerificationConfig {
82
82
  export interface QuestionnaireFieldOption {
83
83
  value: string;
84
84
  label: string;
85
+ /**
86
+ * Marks a choice that is not an answer on its own — an "Other". Selecting it
87
+ * reveals a required free-text input, stored as the `<key>_other` companion
88
+ * answer (the same shape as a money field's `<key>_currency`).
89
+ */
90
+ requiresDetail?: boolean;
91
+ /** Label for the detail input (default "Please specify"). */
92
+ detailLabel?: string;
93
+ /** Placeholder for the detail input (default `Tell us more about "<label>"`). */
94
+ detailPlaceholder?: string;
85
95
  }
86
96
 
87
97
  export interface QuestionnaireField {