@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.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.
Files changed (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -0,0 +1,179 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Image, PanResponder, View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MapPinMarker } from './MapPinMarker';
7
+ import { MapAttribution, MapZoomControls } from './MapChrome';
8
+ import {
9
+ MAX_ZOOM,
10
+ MIN_ZOOM,
11
+ panCenter,
12
+ visibleTiles,
13
+ type LatLng,
14
+ } from '../lib/map-tiles';
15
+
16
+ interface MapPinPickerProps {
17
+ /** The confirmed pin, when one exists — the map centres on it. */
18
+ value: LatLng | null;
19
+ /** Fired when the user settles the map (drag end / zoom / recentre). */
20
+ onChange: (pin: LatLng) => void;
21
+ defaultCenter: LatLng;
22
+ defaultZoom: number;
23
+ /** Taller on the pin step, where there has to be room to actually find the
24
+ * building; short on the review step's read-only summary. */
25
+ height?: number;
26
+ /**
27
+ * A read-only summary map: no gestures, no zoom controls, no onChange. The
28
+ * review step draws the pin as a picture of the decision, and a map that can
29
+ * be dragged there invites an edit the step is not offering.
30
+ */
31
+ interactive?: boolean;
32
+ /** Corner rounding — squared off when the map is clipped by a card. */
33
+ cornerRadius?: number;
34
+ /** Zoom when the map has a pin. The pin step goes closer than the default so
35
+ * "is the pin on YOUR building?" is answerable without pinching first. */
36
+ pinZoom?: number;
37
+ }
38
+
39
+ const MAP_HEIGHT = 256;
40
+
41
+ /**
42
+ * A dependency-free OSM slippy map with a FIXED CENTRE PIN — the user moves
43
+ * the map under the pin (the pattern address pickers use on phones), so the
44
+ * pin is always exactly the centre and there is no marker to fumble. Maths in
45
+ * lib/map-tiles.ts; this is only the gesture shell. Mirrors the web SDK's
46
+ * MapPinPicker: tiles for the committed centre, a live translate during the
47
+ * drag, and a commit (+ onChange) on release.
48
+ */
49
+ export function MapPinPicker({
50
+ value,
51
+ onChange,
52
+ defaultCenter,
53
+ defaultZoom,
54
+ height = MAP_HEIGHT,
55
+ interactive = true,
56
+ cornerRadius,
57
+ pinZoom = 17,
58
+ }: MapPinPickerProps) {
59
+ const { colors } = useTheme();
60
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
61
+ const [center, setCenter] = useState<LatLng>(value ?? defaultCenter);
62
+ const [zoom, setZoom] = useState(value ? pinZoom : defaultZoom);
63
+ // Live drag offset — applied as a transform so panning stays smooth; the
64
+ // centre (and tile set) commits on release.
65
+ const [drag, setDrag] = useState<{ dx: number; dy: number } | null>(null);
66
+
67
+ // The responder callbacks close over state, so they read it via a ref — a
68
+ // PanResponder is created once and would otherwise pan against the centre
69
+ // and zoom from the first render forever.
70
+ const live = useRef({ center, zoom, onChange });
71
+ live.current = { center, zoom, onChange };
72
+
73
+ // An external recentre (a search pick, or Use my location) moves the map AND
74
+ // zooms in: "is the pin on your building?" cannot be answered at country
75
+ // zoom, and asking someone to pinch first is asking them to do our work.
76
+ // SELF-caused moves round-trip through the same prop (drag commits via
77
+ // onChange → store → new `value`), so a genuinely-external move is one that
78
+ // differs from the centre the map is already showing — without that check,
79
+ // an applicant who zoomed out to find their area was yanked back to pin
80
+ // zoom on every drag. Flutter's picker documents the same hazard.
81
+ useEffect(() => {
82
+ if (!value) return;
83
+ const c = live.current.center;
84
+ if (Math.abs(c.lat - value.lat) < 1e-9 && Math.abs(c.lng - value.lng) < 1e-9) return;
85
+ setCenter(value);
86
+ setZoom((z) => Math.max(z, pinZoom));
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [value?.lat, value?.lng]);
89
+
90
+ const pan = useRef(
91
+ PanResponder.create({
92
+ onStartShouldSetPanResponder: () => true,
93
+ onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) + Math.abs(g.dy) > 4,
94
+ onPanResponderMove: (_e, g) => setDrag({ dx: g.dx, dy: g.dy }),
95
+ onPanResponderRelease: (_e, g) => {
96
+ setDrag(null);
97
+ if (g.dx === 0 && g.dy === 0) return;
98
+ const { center: c, zoom: z, onChange: fire } = live.current;
99
+ const next = panCenter(c, z, g.dx, g.dy);
100
+ setCenter(next);
101
+ fire(next);
102
+ },
103
+ onPanResponderTerminate: () => setDrag(null),
104
+ // Android: a parent ScrollView (StickyActions) asks to take a vertical
105
+ // drag over once it passes the scroll slop; refuse, and block the native
106
+ // responder, or the map stops dead a few pixels in while the page moves.
107
+ onPanResponderTerminationRequest: () => false,
108
+ onShouldBlockNativeResponder: () => true,
109
+ }),
110
+ ).current;
111
+
112
+ const zoomBy = (delta: number) => {
113
+ const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + delta));
114
+ if (next === zoom) return;
115
+ setZoom(next);
116
+ onChange(center);
117
+ };
118
+
119
+ const tiles = size ? visibleTiles(center, zoom, size.w, size.h) : [];
120
+
121
+ return (
122
+ <View
123
+ accessibilityLabel={
124
+ interactive ? 'Map. Drag to position the pin on your address.' : 'Map showing your address'
125
+ }
126
+ onLayout={(e) =>
127
+ setSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })
128
+ }
129
+ style={{
130
+ height,
131
+ borderRadius: cornerRadius ?? radius.md,
132
+ borderWidth: cornerRadius === 0 ? 0 : 1,
133
+ borderColor: colors.border,
134
+ backgroundColor: colors.backgroundSecondary,
135
+ overflow: 'hidden',
136
+ }}
137
+ {...(interactive ? pan.panHandlers : {})}
138
+ >
139
+ <View
140
+ style={{
141
+ position: 'absolute',
142
+ top: 0,
143
+ left: 0,
144
+ right: 0,
145
+ bottom: 0,
146
+ transform: drag ? [{ translateX: drag.dx }, { translateY: drag.dy }] : [],
147
+ }}
148
+ >
149
+ {tiles.map((tile) => (
150
+ <Image
151
+ key={tile.key}
152
+ source={{ uri: tile.url }}
153
+ style={{ position: 'absolute', left: tile.left, top: tile.top, width: 256, height: 256 }}
154
+ />
155
+ ))}
156
+ </View>
157
+
158
+ {/* The fixed centre pin — its stem tip on the exact centre, lifted
159
+ with the landing dot beneath it while the map pans. */}
160
+ <View
161
+ pointerEvents="none"
162
+ style={{
163
+ position: 'absolute',
164
+ left: 0,
165
+ right: 0,
166
+ top: 0,
167
+ bottom: height / 2,
168
+ alignItems: 'center',
169
+ justifyContent: 'flex-end',
170
+ }}
171
+ >
172
+ <MapPinMarker lifted={drag != null} color={colors.primary} />
173
+ </View>
174
+
175
+ {interactive ? <MapZoomControls onZoom={zoomBy} /> : null}
176
+ <MapAttribution />
177
+ </View>
178
+ );
179
+ }
@@ -0,0 +1,155 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { Icon, type IconName } from './Icon';
7
+ import { MyazaText } from './Typography';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // The presence story's three-node track, shared by the intro gate (the promise)
11
+ // and the success card (the same promise, now running) so the two read as two
12
+ // frames of ONE story rather than two unrelated notices.
13
+ //
14
+ // Nodes ahead of the active one are TINTED, not greyed out: they are the plan,
15
+ // not disabled controls. Flat fills only.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface Milestone {
19
+ icon: IconName;
20
+ /** The when: "Your part", "Today", "Then". */
21
+ stage: string;
22
+ title: string;
23
+ caption: string;
24
+ state: 'active' | 'ahead';
25
+ }
26
+
27
+ export function MilestoneTrack({
28
+ milestones,
29
+ numbered = false,
30
+ nodeSize = 40,
31
+ }: {
32
+ milestones: readonly Milestone[];
33
+ /** Wear the step number on each node — the sequence is the point on the
34
+ * intro screen, where nothing has happened yet. */
35
+ numbered?: boolean;
36
+ /** Node diameter, and therefore where the connecting rail has to sit: 40 on
37
+ * the primer (web h-10), 32 on the success card (web h-8). */
38
+ nodeSize?: number;
39
+ }): React.ReactElement {
40
+ const { colors } = useTheme();
41
+ const NODE = nodeSize;
42
+ // The active node's halo: web's `shadow-[0_0_0_5px] shadow-primary/15`
43
+ // (4px on the smaller success-card node), drawn as a ring behind it.
44
+ const halo = NODE >= 40 ? 5 : 4;
45
+ return (
46
+ <View style={{ paddingHorizontal: spacing.md, paddingVertical: spacing.md }}>
47
+ {milestones.map((m, i) => {
48
+ const active = m.state === 'active';
49
+ const last = i === milestones.length - 1;
50
+ return (
51
+ <View
52
+ key={m.title}
53
+ style={{ flexDirection: 'row', gap: spacing.md, paddingBottom: last ? 0 : spacing.md }}
54
+ >
55
+ {!last ? (
56
+ <View
57
+ pointerEvents="none"
58
+ style={{
59
+ position: 'absolute',
60
+ left: NODE / 2 - 0.5,
61
+ top: NODE + 4,
62
+ bottom: 0,
63
+ width: 1,
64
+ backgroundColor: `${colors.primary}33`,
65
+ }}
66
+ />
67
+ ) : null}
68
+
69
+ <View style={{ width: NODE }}>
70
+ {active ? (
71
+ <View
72
+ pointerEvents="none"
73
+ style={{
74
+ position: 'absolute',
75
+ left: -halo,
76
+ top: -halo,
77
+ width: NODE + halo * 2,
78
+ height: NODE + halo * 2,
79
+ borderRadius: radius.full,
80
+ backgroundColor: `${colors.primary}26`,
81
+ }}
82
+ />
83
+ ) : null}
84
+ <View
85
+ style={{
86
+ width: NODE,
87
+ height: NODE,
88
+ borderRadius: radius.full,
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ backgroundColor: active ? colors.primary : `${colors.primary}1A`,
92
+ ...(active ? {} : { borderWidth: 1, borderColor: `${colors.primary}33` }),
93
+ }}
94
+ >
95
+ <Icon name={m.icon} size={NODE * 0.45} color={active ? colors.onPrimary : colors.primary} />
96
+ </View>
97
+ {numbered ? (
98
+ <View
99
+ style={{
100
+ position: 'absolute',
101
+ right: -4,
102
+ top: -4,
103
+ width: 18,
104
+ height: 18,
105
+ borderRadius: radius.full,
106
+ borderWidth: 1,
107
+ borderColor: active ? colors.primary : colors.border,
108
+ backgroundColor: colors.background,
109
+ alignItems: 'center',
110
+ justifyContent: 'center',
111
+ }}
112
+ >
113
+ <MyazaText
114
+ variant="bodySmall"
115
+ color={active ? colors.primary : colors.textSecondary}
116
+ style={{ fontSize: 9, fontWeight: '700', lineHeight: 11 }}
117
+ >
118
+ {i + 1}
119
+ </MyazaText>
120
+ </View>
121
+ ) : null}
122
+ </View>
123
+
124
+ <View style={{ flex: 1, paddingTop: 2 }}>
125
+ {/* Uppercased by STYLE, not by the string: a screen reader
126
+ should hear the words, not shouted initials. */}
127
+ <MyazaText
128
+ variant="bodySmall"
129
+ color={active ? colors.primary : colors.textSecondary}
130
+ style={{
131
+ fontSize: 10,
132
+ fontWeight: '600',
133
+ letterSpacing: 0.8,
134
+ textTransform: 'uppercase',
135
+ }}
136
+ >
137
+ {m.stage}
138
+ </MyazaText>
139
+ <MyazaText variant="bodyMedium" style={{ fontWeight: '600', marginTop: 1 }}>
140
+ {m.title}
141
+ </MyazaText>
142
+ <MyazaText
143
+ variant="bodySmall"
144
+ color={colors.textSecondary}
145
+ style={{ marginTop: 2 }}
146
+ >
147
+ {m.caption}
148
+ </MyazaText>
149
+ </View>
150
+ </View>
151
+ );
152
+ })}
153
+ </View>
154
+ );
155
+ }
@@ -21,6 +21,9 @@ export interface MyazaInputProps {
21
21
  value: string;
22
22
  onChangeText: (text: string) => void;
23
23
  label?: string;
24
+ /** Marks the label with an asterisk in the error colour: a field the flow
25
+ * will not continue without. */
26
+ required?: boolean;
24
27
  placeholder?: string;
25
28
  error?: string | null;
26
29
  helper?: string;
@@ -51,6 +54,9 @@ export interface MyazaInputProps {
51
54
  * keyboard preferred. Defaults on, like the platform.
52
55
  */
53
56
  autoCorrect?: boolean;
57
+ /** Multi-line entry (directions, notes): three visible lines, text anchored
58
+ * to the top on Android (which centres multiline text by default). */
59
+ multiline?: boolean;
54
60
  onFocus?: FocusHandler;
55
61
  onBlur?: BlurHandler;
56
62
  }
@@ -59,6 +65,7 @@ export function MyazaInput({
59
65
  value,
60
66
  onChangeText,
61
67
  label,
68
+ required = false,
62
69
  placeholder,
63
70
  error,
64
71
  helper,
@@ -66,6 +73,7 @@ export function MyazaInput({
66
73
  autoCapitalize = 'none',
67
74
  maxLength,
68
75
  editable = true,
76
+ multiline = false,
69
77
  autoFocus = false,
70
78
  prefix,
71
79
  suffix,
@@ -101,6 +109,11 @@ export function MyazaInput({
101
109
  {label ? (
102
110
  <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
103
111
  {label}
112
+ {required ? (
113
+ <MyazaText variant="label" color={colors.error}>
114
+ {' *'}
115
+ </MyazaText>
116
+ ) : null}
104
117
  </MyazaText>
105
118
  ) : null}
106
119
  {prefix || suffix ? (
@@ -152,10 +165,13 @@ export function MyazaInput({
152
165
  maxLength={maxLength}
153
166
  editable={editable}
154
167
  autoFocus={autoFocus}
168
+ multiline={multiline}
169
+ numberOfLines={multiline ? 3 : 1}
170
+ textAlignVertical={multiline ? 'top' : 'center'}
155
171
  onFocus={handleFocus}
156
172
  onBlur={handleBlur}
157
173
  style={{
158
- height: height ?? sizing.inputHeight,
174
+ height: height ?? (multiline ? sizing.inputHeight * 2 : sizing.inputHeight),
159
175
  borderWidth,
160
176
  borderColor,
161
177
  borderRadius: radius.sm,
@@ -22,27 +22,40 @@ export function OptionRow({
22
22
  label,
23
23
  selected,
24
24
  multi,
25
+ role,
26
+ icon,
25
27
  leading,
26
28
  trailing,
29
+ disabled,
27
30
  onPress,
28
31
  }: {
29
32
  label: string;
30
33
  selected: boolean;
31
34
  /** Renders a square check instead of a radio dot. */
32
35
  multi?: boolean;
36
+ /**
37
+ * What the row IS to a screen reader. Defaults from `multi`; a single-pick
38
+ * list wearing the square check (the PoA document kind) passes 'radio'.
39
+ */
40
+ role?: 'checkbox' | 'radio';
41
+ /** A glyph between the mark and the label — the mark stays. */
42
+ icon?: React.ReactNode;
33
43
  /** Something before the label — a flag, an icon. Replaces the radio mark. */
34
44
  leading?: React.ReactNode;
35
45
  trailing?: React.ReactNode;
46
+ /** Greyed and inert — a choice that is locked, not one that is gone. */
47
+ disabled?: boolean;
36
48
  onPress: () => void;
37
49
  }): React.ReactElement {
38
50
  const { colors } = useTheme();
39
51
  return (
40
52
  <Pressable
41
- onPress={onPress}
42
- accessibilityRole={multi ? 'checkbox' : 'radio'}
43
- accessibilityState={{ checked: selected }}
53
+ onPress={disabled ? undefined : onPress}
54
+ accessibilityRole={role ?? (multi ? 'checkbox' : 'radio')}
55
+ accessibilityState={{ checked: selected, disabled: disabled === true }}
44
56
  accessibilityLabel={label}
45
57
  style={{
58
+ opacity: disabled ? 0.5 : 1,
46
59
  flexDirection: 'row',
47
60
  alignItems: 'center',
48
61
  // The checkbox variant matches the web SDK's multi-select card exactly
@@ -59,6 +72,12 @@ export function OptionRow({
59
72
  >
60
73
  {leading ?? <SelectionMark selected={selected} multi={multi} />}
61
74
  <View style={{ width: multi ? 10 : spacing.sm }} />
75
+ {icon ? (
76
+ <>
77
+ {icon}
78
+ <View style={{ width: 10 }} />
79
+ </>
80
+ ) : null}
62
81
  <MyazaText variant="body" style={{ flex: 1 }}>
63
82
  {label}
64
83
  </MyazaText>
@@ -36,6 +36,7 @@ import { COUNTRY_NAMES } from '../config/countryNames.g';
36
36
  export function PhoneNumberInput({
37
37
  value,
38
38
  defaultCountry,
39
+ geoCountry,
39
40
  disabled,
40
41
  autoFocus = false,
41
42
  onChange,
@@ -49,6 +50,12 @@ export function PhoneNumberInput({
49
50
  value?: string;
50
51
  /** Seeds the picker (ISO-2). Falls back to NG. */
51
52
  defaultCountry?: string;
53
+ /**
54
+ * The visitor's IP country. Pinned to the top of the picker and tagged, so
55
+ * a guess we made on their behalf is visible AS a guess and one tap away
56
+ * rather than buried at its alphabetical position. Mirrors the web SDK.
57
+ */
58
+ geoCountry?: string | null;
52
59
  disabled?: boolean;
53
60
  /** Focus + keyboard on mount. Opt-in: right for a single-field OTP screen,
54
61
  * wrong for a row in a company-profile form. */
@@ -174,6 +181,7 @@ export function PhoneNumberInput({
174
181
  visible={open}
175
182
  options={options}
176
183
  selected={country}
184
+ pinned={geoCountry}
177
185
  onPick={pickCountry}
178
186
  onClose={() => setOpen(false)}
179
187
  />
@@ -36,12 +36,12 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
36
36
  return (
37
37
  <View
38
38
  style={{
39
- paddingTop: spacing.md,
39
+ paddingTop: 10,
40
40
  // Symmetric with the top. It was `lg`, which read as a heavy band on the
41
41
  // one screen where vertical space is contested — the searchable country
42
42
  // list, where the footer costs a visible row. The home-indicator inset
43
43
  // rides on top, so notched devices still clear it comfortably.
44
- paddingBottom: spacing.md + bottomInset,
44
+ paddingBottom: 12 + bottomInset,
45
45
  alignItems: 'center',
46
46
  }}
47
47
  >
@@ -70,9 +70,9 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
70
70
  hitSlop={8}
71
71
  style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}
72
72
  >
73
- <MyazaWordmark height={28} wordmark={markColor} />
73
+ <MyazaWordmark height={24} wordmark={markColor} />
74
74
 
75
- <View style={{ width: 1, height: 24, backgroundColor: markColor, opacity: 0.35 }} />
75
+ <View style={{ width: 1, height: 20, backgroundColor: markColor, opacity: 0.35 }} />
76
76
 
77
77
  {/* ~half the wordmark's height — the ratio the dashboard lockup uses. */}
78
78
  {/* Pinned to the brand face rather than inheriting: this word is part
@@ -83,7 +83,7 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
83
83
  brandMark
84
84
  variant="body"
85
85
  color={markColor}
86
- style={{ flexShrink: 1, fontSize: 14, fontWeight: '600', letterSpacing: 2 }}
86
+ style={{ flexShrink: 1, fontSize: 12, fontWeight: '600', letterSpacing: 1.68 }}
87
87
  >
88
88
  TRUST
89
89
  </MyazaText>
@@ -0,0 +1,153 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Animated, View } from 'react-native';
3
+
4
+ import { MyazaText } from './Typography';
5
+ import { MilestoneTrack, type Milestone } from './MilestoneTrack';
6
+ import { useReduceMotion } from './StaggerIn';
7
+ import { radius, spacing } from '../config/theme';
8
+ import { useTheme } from './theme-provider';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // The success screen's presence card, drawn as a LIVE PROCESS rather than a
12
+ // notice: the check began the moment they submitted, so the card should feel
13
+ // like something is already quietly working, because it is.
14
+ //
15
+ // Its milestones are the same three the intro gate promises, one frame later —
16
+ // keep the two, and the web and Flutter SDKs, in lockstep.
17
+ //
18
+ // The standalone "presence consent notice" that used to live here is gone: the
19
+ // disclosures belong on the intro gate, and a second copy on the address step
20
+ // was one more place for the consent wording to drift.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const MILESTONES: readonly Milestone[] = [
24
+ {
25
+ icon: 'map-pin-check',
26
+ stage: 'Today',
27
+ title: 'Check started',
28
+ caption: 'Your pin is saved. Keep location on.',
29
+ state: 'active',
30
+ },
31
+ {
32
+ icon: 'radar',
33
+ stage: 'Next few days',
34
+ title: 'Quiet check-ins',
35
+ caption: 'Your phone confirms it is at your address now and then.',
36
+ state: 'ahead',
37
+ },
38
+ {
39
+ icon: 'bell-ring',
40
+ stage: 'Then',
41
+ title: 'Confirmed',
42
+ caption: 'You get a notification. That is it.',
43
+ state: 'ahead',
44
+ },
45
+ ];
46
+
47
+ export function PresenceExpectations(): React.ReactElement {
48
+ const { colors } = useTheme();
49
+ return (
50
+ <View
51
+ style={{
52
+ alignSelf: 'stretch',
53
+ marginTop: spacing.lg,
54
+ borderWidth: 1,
55
+ borderColor: `${colors.primary}26`,
56
+ borderRadius: radius.md,
57
+ overflow: 'hidden',
58
+ }}
59
+ >
60
+ <View
61
+ style={{
62
+ backgroundColor: `${colors.primary}0F`,
63
+ paddingHorizontal: spacing.md,
64
+ paddingTop: spacing.md,
65
+ paddingBottom: spacing.sm + 4,
66
+ }}
67
+ >
68
+ <View
69
+ style={{
70
+ flexDirection: 'row',
71
+ alignItems: 'center',
72
+ alignSelf: 'flex-start',
73
+ gap: spacing.sm,
74
+ paddingHorizontal: spacing.sm + 2,
75
+ paddingVertical: 4,
76
+ borderRadius: radius.full,
77
+ backgroundColor: `${colors.primary}1A`,
78
+ }}
79
+ >
80
+ <LiveDot />
81
+ <MyazaText
82
+ variant="bodySmall"
83
+ color={colors.primary}
84
+ style={{
85
+ fontSize: 11,
86
+ fontWeight: '600',
87
+ letterSpacing: 0.6,
88
+ textTransform: 'uppercase',
89
+ }}
90
+ >
91
+ Address check active
92
+ </MyazaText>
93
+ </View>
94
+ <MyazaText variant="bodyMedium" style={{ fontWeight: '600', marginTop: spacing.sm }}>
95
+ Your address confirms itself from here
96
+ </MyazaText>
97
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
98
+ Nothing else for you to do. Carry on as normal.
99
+ </MyazaText>
100
+ </View>
101
+
102
+ {/* Web's h-8 nodes here, against the primer's h-10. */}
103
+ <MilestoneTrack milestones={MILESTONES} nodeSize={32} />
104
+ </View>
105
+ );
106
+ }
107
+
108
+ /** The badge's pulse — the one thing on the card that says "running now". */
109
+ function LiveDot(): React.ReactElement {
110
+ const { colors } = useTheme();
111
+ const reduceMotion = useReduceMotion();
112
+ const pulse = useRef(new Animated.Value(0)).current;
113
+
114
+ useEffect(() => {
115
+ if (reduceMotion) return undefined;
116
+ const loop = Animated.loop(
117
+ Animated.sequence([
118
+ Animated.timing(pulse, { toValue: 1, duration: 900, useNativeDriver: true }),
119
+ Animated.timing(pulse, { toValue: 0, duration: 0, useNativeDriver: true }),
120
+ ]),
121
+ );
122
+ loop.start();
123
+ return () => loop.stop();
124
+ }, [pulse, reduceMotion]);
125
+
126
+ return (
127
+ <View style={{ width: 8, height: 8, alignItems: 'center', justifyContent: 'center' }}>
128
+ {reduceMotion ? null : (
129
+ <Animated.View
130
+ style={{
131
+ position: 'absolute',
132
+ width: 8,
133
+ height: 8,
134
+ borderRadius: radius.full,
135
+ backgroundColor: colors.primary,
136
+ opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.6, 0] }),
137
+ transform: [
138
+ { scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 2.2] }) },
139
+ ],
140
+ }}
141
+ />
142
+ )}
143
+ <View
144
+ style={{
145
+ width: 8,
146
+ height: 8,
147
+ borderRadius: radius.full,
148
+ backgroundColor: colors.primary,
149
+ }}
150
+ />
151
+ </View>
152
+ );
153
+ }