@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.
- package/package.json +1 -1
- package/src/components/KycSheet.tsx +209 -127
- package/src/components/MyazaInput.tsx +6 -1
- package/src/components/MyazaSelect.tsx +5 -0
- package/src/components/PoweredBy.tsx +20 -15
- package/src/components/ProgressBar.tsx +91 -0
- package/src/components/StepIndicator.tsx +145 -31
- package/src/components/fonts.ts +23 -0
- package/src/config/questionnaire.ts +45 -4
- package/src/config/workflowMerge.ts +1 -0
- package/src/emrtd/crypto.ts +1 -1
- package/src/emrtd/dg1.ts +55 -0
- package/src/emrtd/ec-curves.ts +142 -0
- package/src/emrtd/ec.ts +196 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/mrzKey.ts +21 -1
- package/src/emrtd/open.ts +169 -0
- package/src/emrtd/pace-params.ts +169 -0
- package/src/emrtd/pace.ts +295 -0
- package/src/emrtd/secureMessaging.ts +32 -14
- package/src/emrtd/session.ts +124 -20
- package/src/emrtd/suites.ts +99 -0
- package/src/index.ts +1 -0
- package/src/lib/step-log.ts +43 -0
- package/src/lib/step-window.ts +96 -0
- package/src/liveness/useLiveness.ts +1 -1
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/NfcStep.tsx +12 -0
- package/src/screens/QuestionnaireField.tsx +30 -0
- package/src/screens/QuestionnaireStep.tsx +3 -1
- package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
- package/src/services/deviceMetadata.ts +9 -1
- package/src/store/derive.ts +7 -0
- package/src/store/kycStore.ts +25 -1
- package/src/types/config.ts +17 -0
- package/src/types/workflow.ts +10 -0
package/package.json
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import React, { useEffect, useState } from
|
|
2
|
-
import { Image, Platform, ScrollView, StatusBar, View } from
|
|
3
|
-
import { SvgXml } from
|
|
4
|
-
import { StatusBarController } from
|
|
5
|
-
import { initialWindowMetrics } from
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Image, Platform, ScrollView, StatusBar, View } from "react-native";
|
|
3
|
+
import { SvgXml } from "react-native-svg";
|
|
4
|
+
import { StatusBarController } from "./StatusBarController";
|
|
5
|
+
import { initialWindowMetrics } from "react-native-safe-area-context";
|
|
6
6
|
|
|
7
|
-
import { headerSurface, radius, spacing } from
|
|
8
|
-
import { useKeyboardInset } from
|
|
9
|
-
import type { SupportedCountry } from
|
|
10
|
-
import { useKycConfig, useTheme } from
|
|
11
|
-
import { useBranding } from
|
|
12
|
-
import { MyazaText } from
|
|
13
|
-
import { PoweredBy } from
|
|
14
|
-
import { StepHeader } from
|
|
15
|
-
import { StepIndicator } from
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
7
|
+
import { headerSurface, radius, spacing } from "../config/theme";
|
|
8
|
+
import { useKeyboardInset } from "../lib/use-keyboard-inset";
|
|
9
|
+
import type { SupportedCountry } from "../types/config";
|
|
10
|
+
import { useKycConfig, useTheme } from "./runtime";
|
|
11
|
+
import { useBranding } from "./useBranding";
|
|
12
|
+
import { MyazaText } from "./Typography";
|
|
13
|
+
import { PoweredBy } from "./PoweredBy";
|
|
14
|
+
import { StepHeader } from "./StepHeader";
|
|
15
|
+
import { StepIndicator } from "./StepIndicator";
|
|
16
|
+
import { ProgressBar } from "./ProgressBar";
|
|
17
|
+
import { GlassIconButton } from "./GlassIconButton";
|
|
18
|
+
import { GlassSurface } from "./glass/GlassSurface";
|
|
19
|
+
import { GlassGroup } from "./glass/GlassGroup";
|
|
20
|
+
import { FlashOverlay } from "./FlashOverlay";
|
|
20
21
|
|
|
21
22
|
// Full-screen sheet shell — 1:1 with the Flutter SDK's KycBottomSheet:
|
|
22
23
|
// • a tinted header block (Liquid Glass on iOS 26) with a bottom border:
|
|
@@ -83,16 +84,20 @@ export function KycSheet({
|
|
|
83
84
|
const keyboardInset = useKeyboardInset();
|
|
84
85
|
|
|
85
86
|
const tint = headerSurface(colors, mode);
|
|
86
|
-
const
|
|
87
|
+
const hasProgress = progress != null && stepCount != null;
|
|
88
|
+
// The bar REPLACES the step row rather than joining it: two progress
|
|
89
|
+
// indicators on one header would be noise, and the point of the bar is that
|
|
90
|
+
// it costs no height.
|
|
91
|
+
const asBar = config.progressStyle === "bar";
|
|
92
|
+
const showIndicator = hasProgress && !asBar;
|
|
93
|
+
const showBar = hasProgress && asBar;
|
|
87
94
|
const showBrand = !hideBrand && !!logoUri;
|
|
88
95
|
|
|
89
|
-
|
|
90
|
-
|
|
91
96
|
// On iOS the modal is a pageSheet by default (sits below the status bar), but
|
|
92
97
|
// `disableClose` presents it full-screen (to drop the swipe-to-dismiss), so it
|
|
93
98
|
// then extends under the notch + over the home indicator and needs its own
|
|
94
99
|
// safe-area insets — the pageSheet provides them for free.
|
|
95
|
-
const iosFullScreen = Platform.OS ===
|
|
100
|
+
const iosFullScreen = Platform.OS === "ios" && config.disableClose === true;
|
|
96
101
|
|
|
97
102
|
// Static safe-area snapshot (captured at app launch — no SafeAreaProvider
|
|
98
103
|
// needed). On Android 15+/API 35+ the modal is drawn EDGE-TO-EDGE, and
|
|
@@ -106,7 +111,7 @@ export function KycSheet({
|
|
|
106
111
|
// • iOS pageSheet: already below the status bar → no inset.
|
|
107
112
|
// • iOS full-screen (disableClose): 56 covers the status bar + notch.
|
|
108
113
|
const topInset =
|
|
109
|
-
Platform.OS ===
|
|
114
|
+
Platform.OS === "android"
|
|
110
115
|
? sa?.top || StatusBar.currentHeight || 28
|
|
111
116
|
: iosFullScreen
|
|
112
117
|
? 56
|
|
@@ -114,95 +119,131 @@ export function KycSheet({
|
|
|
114
119
|
|
|
115
120
|
// Clear the home indicator / nav bar at the bottom of a full-screen modal.
|
|
116
121
|
const bottomInset =
|
|
117
|
-
Platform.OS ===
|
|
122
|
+
Platform.OS === "android" ? (sa?.bottom ?? 0) : iosFullScreen ? 34 : 0;
|
|
118
123
|
|
|
119
124
|
return (
|
|
120
|
-
<View
|
|
125
|
+
<View
|
|
126
|
+
style={{
|
|
127
|
+
flex: 1,
|
|
128
|
+
backgroundColor: immersive ? "#000000" : colors.background,
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
121
131
|
{/* The SDK theme drives the status-bar glyph colour so it stays readable on
|
|
122
132
|
the modal background — light glyphs in dark mode, dark in light mode.
|
|
123
133
|
Android: JS StatusBar/SystemBars can't reach the <Modal>'s Dialog window,
|
|
124
134
|
so a native view (StatusBarController) themes it from inside the modal.
|
|
125
135
|
iOS: only the full-screen modal (disableClose) slides under the bar — the
|
|
126
136
|
default pageSheet keeps the host bar above the card. */}
|
|
127
|
-
{Platform.OS ===
|
|
128
|
-
<StatusBarController barStyle={mode ===
|
|
137
|
+
{Platform.OS === "android" ? (
|
|
138
|
+
<StatusBarController barStyle={mode === "dark" ? "light" : "dark"} />
|
|
129
139
|
) : iosFullScreen ? (
|
|
130
|
-
<StatusBar
|
|
140
|
+
<StatusBar
|
|
141
|
+
barStyle={mode === "dark" ? "light-content" : "dark-content"}
|
|
142
|
+
animated
|
|
143
|
+
/>
|
|
131
144
|
) : null}
|
|
132
145
|
<View style={{ flex: 1 }}>
|
|
133
146
|
{/* Header block (glass on iOS 26, tinted surface otherwise) — dropped
|
|
134
147
|
entirely when the step owns the display. */}
|
|
135
|
-
{immersive ? null :
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
>
|
|
140
|
-
{/* Row 1 — brand + controls */}
|
|
141
|
-
<View
|
|
148
|
+
{immersive ? null : (
|
|
149
|
+
<GlassSurface
|
|
150
|
+
glassStyle="regular"
|
|
151
|
+
fallbackColor={tint}
|
|
142
152
|
style={{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
// The bar sits ON this edge and paints its own track, so the
|
|
154
|
+
// border would double it.
|
|
155
|
+
borderBottomWidth: showBar ? 0 : 1,
|
|
156
|
+
borderBottomColor: colors.border,
|
|
157
|
+
paddingBottom: spacing.md,
|
|
147
158
|
}}
|
|
148
159
|
>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
{/* Row 1 — brand + controls */}
|
|
161
|
+
<View
|
|
162
|
+
style={{
|
|
163
|
+
flexDirection: "row",
|
|
164
|
+
alignItems: "center",
|
|
165
|
+
paddingHorizontal: spacing.md,
|
|
166
|
+
paddingTop: topInset + spacing.sm,
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<View
|
|
170
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center" }}
|
|
171
|
+
>
|
|
172
|
+
{showBrand ? (
|
|
173
|
+
<BrandBar logoUri={logoUri!} companyName={companyName} />
|
|
174
|
+
) : null}
|
|
175
|
+
</View>
|
|
152
176
|
|
|
153
|
-
|
|
177
|
+
{/* Header controls. When BOTH the theme toggle and close show, they
|
|
154
178
|
share a single Liquid Glass capsule (each button `plain`). When
|
|
155
179
|
only one shows — the consumer disabled the other (`disableClose`
|
|
156
180
|
or `showThemeToggle: false`) — that button stands on its own
|
|
157
181
|
glass instead of a one-icon capsule. Neither → nothing. */}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
{(() => {
|
|
183
|
+
const showToggle = config.showThemeToggle !== false;
|
|
184
|
+
const showClose = !config.disableClose;
|
|
185
|
+
const toggleBtn = showToggle ? (
|
|
186
|
+
<GlassIconButton
|
|
187
|
+
icon={mode === "dark" ? "sun" : "moon"}
|
|
188
|
+
onPress={toggle}
|
|
189
|
+
solid
|
|
190
|
+
plain={showClose}
|
|
191
|
+
accessibilityLabel="Toggle theme"
|
|
192
|
+
/>
|
|
193
|
+
) : null;
|
|
194
|
+
const closeBtn = showClose ? (
|
|
195
|
+
<GlassIconButton
|
|
196
|
+
icon="close"
|
|
197
|
+
onPress={onClose}
|
|
198
|
+
plain={showToggle}
|
|
199
|
+
accessibilityLabel="Close"
|
|
200
|
+
/>
|
|
201
|
+
) : null;
|
|
178
202
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
</View>
|
|
191
|
-
|
|
192
|
-
{/* Row 2 — title + back + flag */}
|
|
193
|
-
{title || onBack ? (
|
|
194
|
-
<View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm }}>
|
|
195
|
-
<StepHeader title={title} description={description} onBack={onBack} country={country} />
|
|
203
|
+
if (showToggle && showClose) {
|
|
204
|
+
return (
|
|
205
|
+
<GlassGroup style={{ gap: 2 }}>
|
|
206
|
+
{toggleBtn}
|
|
207
|
+
{closeBtn}
|
|
208
|
+
</GlassGroup>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
// Exactly one (or zero) — render it standalone with its own glass.
|
|
212
|
+
return toggleBtn ?? closeBtn;
|
|
213
|
+
})()}
|
|
196
214
|
</View>
|
|
197
|
-
) : null}
|
|
198
215
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
216
|
+
{/* Row 2 — title + back + flag */}
|
|
217
|
+
{title || onBack ? (
|
|
218
|
+
<View
|
|
219
|
+
style={{
|
|
220
|
+
paddingHorizontal: spacing.md,
|
|
221
|
+
paddingTop: spacing.sm,
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<StepHeader
|
|
225
|
+
title={title}
|
|
226
|
+
description={description}
|
|
227
|
+
onBack={onBack}
|
|
228
|
+
country={country}
|
|
229
|
+
/>
|
|
230
|
+
</View>
|
|
231
|
+
) : null}
|
|
232
|
+
|
|
233
|
+
{/* Row 3 — step indicator */}
|
|
234
|
+
{showIndicator ? (
|
|
235
|
+
<View style={{ marginTop: spacing.md }}>
|
|
236
|
+
<StepIndicator progress={progress!} stepCount={stepCount!} />
|
|
237
|
+
</View>
|
|
238
|
+
) : null}
|
|
239
|
+
|
|
240
|
+
{/* Absolutely positioned, so it overlays the header's bottom edge
|
|
241
|
+
instead of adding a row — the whole reason to prefer it. */}
|
|
242
|
+
{showBar ? (
|
|
243
|
+
<ProgressBar progress={progress!} stepCount={stepCount!} />
|
|
244
|
+
) : null}
|
|
245
|
+
</GlassSurface>
|
|
246
|
+
)}
|
|
206
247
|
|
|
207
248
|
{/* Step body */}
|
|
208
249
|
{fillsViewport ? (
|
|
@@ -210,7 +251,9 @@ export function KycSheet({
|
|
|
210
251
|
// padding exists so scrolled content clears the home indicator, but
|
|
211
252
|
// in a fixed-height body it just lifts the list off the footer and
|
|
212
253
|
// reads as a dead band. PoweredBy owns the safe-area inset already.
|
|
213
|
-
<View
|
|
254
|
+
<View
|
|
255
|
+
style={{ flex: 1, padding: spacing.md, paddingBottom: spacing.md }}
|
|
256
|
+
>
|
|
214
257
|
{children}
|
|
215
258
|
</View>
|
|
216
259
|
) : (
|
|
@@ -236,7 +279,9 @@ export function KycSheet({
|
|
|
236
279
|
// input into view — the platform's own handling, which a
|
|
237
280
|
// KeyboardAvoidingView only approximates. Without this the
|
|
238
281
|
// keyboard simply covered the bottom of every input step.
|
|
239
|
-
automaticallyAdjustKeyboardInsets={
|
|
282
|
+
automaticallyAdjustKeyboardInsets={
|
|
283
|
+
Platform.OS === "ios" && !immersive
|
|
284
|
+
}
|
|
240
285
|
>
|
|
241
286
|
{children}
|
|
242
287
|
</ScrollView>
|
|
@@ -265,14 +310,20 @@ export function KycSheet({
|
|
|
265
310
|
|
|
266
311
|
/** Whether a logo URI points at an SVG (extension check, query-safe). */
|
|
267
312
|
function isSvgUri(uri: string): boolean {
|
|
268
|
-
const path = uri.split(
|
|
269
|
-
return path.toLowerCase().endsWith(
|
|
313
|
+
const path = uri.split("?")[0] ?? uri;
|
|
314
|
+
return path.toLowerCase().endsWith(".svg");
|
|
270
315
|
}
|
|
271
316
|
|
|
272
317
|
// Persistent header brand — circular logo avatar + company name. A broken/missing
|
|
273
318
|
// logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
|
|
274
319
|
// Flutter's `errorBuilder`), so the header never shows a broken-image box.
|
|
275
|
-
function BrandBar({
|
|
320
|
+
function BrandBar({
|
|
321
|
+
logoUri,
|
|
322
|
+
companyName,
|
|
323
|
+
}: {
|
|
324
|
+
logoUri: string;
|
|
325
|
+
companyName: string;
|
|
326
|
+
}): React.ReactElement | null {
|
|
276
327
|
const { colors } = useTheme();
|
|
277
328
|
const [broken, setBroken] = useState(false);
|
|
278
329
|
const svg = isSvgUri(logoUri);
|
|
@@ -285,7 +336,9 @@ function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: stri
|
|
|
285
336
|
if (!svg) return;
|
|
286
337
|
let cancelled = false;
|
|
287
338
|
fetch(logoUri)
|
|
288
|
-
.then((r) =>
|
|
339
|
+
.then((r) =>
|
|
340
|
+
r.ok ? r.text() : Promise.reject(new Error(String(r.status))),
|
|
341
|
+
)
|
|
289
342
|
.then((text) => {
|
|
290
343
|
if (!cancelled) setXml(text);
|
|
291
344
|
})
|
|
@@ -299,54 +352,83 @@ function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: stri
|
|
|
299
352
|
if (broken) return null;
|
|
300
353
|
return (
|
|
301
354
|
<>
|
|
355
|
+
{/* TWO layers, deliberately.
|
|
356
|
+
|
|
357
|
+
A shadow and `overflow: 'hidden'` CANNOT live on the same view on iOS:
|
|
358
|
+
overflow-hidden compiles to `masksToBounds`, which clips the shadow
|
|
359
|
+
away with everything else outside the bounds. Android draws `elevation`
|
|
360
|
+
outside the view, so it survived there — which is why the chip lost its
|
|
361
|
+
ring on iPhone only.
|
|
362
|
+
|
|
363
|
+
So the outer view owns the border and shadow and does NOT clip, and the
|
|
364
|
+
inner one clips the logo. Same split Flutter gets for free by putting
|
|
365
|
+
the border on the Container's decoration and the crop in a ClipOval. */}
|
|
302
366
|
<View
|
|
303
367
|
style={{
|
|
304
368
|
width: 28,
|
|
305
369
|
height: 28,
|
|
306
370
|
borderRadius: radius.full,
|
|
307
|
-
backgroundColor:
|
|
308
|
-
// Light
|
|
309
|
-
//
|
|
310
|
-
//
|
|
371
|
+
backgroundColor: "#FFFFFF",
|
|
372
|
+
// Light ring + soft shadow — the same treatment as the Flutter SDK's
|
|
373
|
+
// brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and the web
|
|
374
|
+
// SDK's ring-1 ring-black/5.
|
|
311
375
|
borderWidth: 1,
|
|
312
|
-
borderColor:
|
|
313
|
-
shadowColor:
|
|
376
|
+
borderColor: "rgba(0,0,0,0.05)",
|
|
377
|
+
shadowColor: "#000000",
|
|
314
378
|
shadowOpacity: 0.06,
|
|
315
379
|
shadowRadius: 4,
|
|
316
380
|
shadowOffset: { width: 0, height: 1 },
|
|
317
381
|
elevation: 1,
|
|
318
|
-
overflow: 'hidden',
|
|
319
382
|
marginRight: spacing.sm,
|
|
320
|
-
alignItems:
|
|
321
|
-
justifyContent:
|
|
383
|
+
alignItems: "center",
|
|
384
|
+
justifyContent: "center",
|
|
322
385
|
}}
|
|
323
386
|
>
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
parent's
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
387
|
+
<View
|
|
388
|
+
style={{
|
|
389
|
+
// Fills the parent's CONTENT box (inside its 1px border) and is the
|
|
390
|
+
// only thing that clips, so the logo reaches the ring on every side.
|
|
391
|
+
alignSelf: "stretch",
|
|
392
|
+
flex: 1,
|
|
393
|
+
borderRadius: radius.full,
|
|
394
|
+
overflow: "hidden",
|
|
395
|
+
}}
|
|
396
|
+
>
|
|
397
|
+
{/* Fill + cover-crop, matching Flutter's SizedBox.expand + BoxFit.cover.
|
|
398
|
+
|
|
399
|
+
Sized in PERCENTAGES, not the literal 26 this used to hardcode: that
|
|
400
|
+
number was "28 minus the 1px border on each side", so it silently
|
|
401
|
+
went wrong the moment either value changed, and it left the logo a
|
|
402
|
+
pixel short of the ring. The clipping parent now owns the geometry.
|
|
403
|
+
|
|
404
|
+
SVG logos are common here because the brand import picks up site
|
|
405
|
+
favicons, and <Image> cannot decode SVG at all — hence the fetched
|
|
406
|
+
markup through SvgXml. `slice` is the SVG spelling of cover-crop. */}
|
|
407
|
+
{svg ? (
|
|
408
|
+
xml ? (
|
|
409
|
+
<SvgXml
|
|
410
|
+
xml={xml}
|
|
411
|
+
width="100%"
|
|
412
|
+
height="100%"
|
|
413
|
+
preserveAspectRatio="xMidYMid slice"
|
|
414
|
+
/>
|
|
415
|
+
) : null
|
|
416
|
+
) : (
|
|
417
|
+
<Image
|
|
418
|
+
source={{ uri: logoUri }}
|
|
419
|
+
style={{ width: "100%", height: "100%" }}
|
|
420
|
+
resizeMode="cover"
|
|
421
|
+
onError={() => setBroken(true)}
|
|
338
422
|
/>
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
<Image
|
|
342
|
-
source={{ uri: logoUri }}
|
|
343
|
-
style={{ width: '100%', height: '100%', borderRadius: radius.full }}
|
|
344
|
-
resizeMode="cover"
|
|
345
|
-
onError={() => setBroken(true)}
|
|
346
|
-
/>
|
|
347
|
-
)}
|
|
423
|
+
)}
|
|
424
|
+
</View>
|
|
348
425
|
</View>
|
|
349
|
-
<MyazaText
|
|
426
|
+
<MyazaText
|
|
427
|
+
variant="heading3"
|
|
428
|
+
numberOfLines={1}
|
|
429
|
+
color={colors.textDark}
|
|
430
|
+
style={{ flexShrink: 1, fontSize: 14 }}
|
|
431
|
+
>
|
|
350
432
|
{companyName}
|
|
351
433
|
</MyazaText>
|
|
352
434
|
</>
|
|
@@ -11,6 +11,7 @@ type BlurHandler = NonNullable<TextInputProps['onBlur']>;
|
|
|
11
11
|
|
|
12
12
|
import { radius, sizing, spacing } from '../config/theme';
|
|
13
13
|
import { useTheme } from './runtime';
|
|
14
|
+
import { useInputFontFamily } from './fonts';
|
|
14
15
|
import { MyazaText } from './Typography';
|
|
15
16
|
|
|
16
17
|
// Branded text input with label, helper, and error states. Mirrors the Flutter
|
|
@@ -60,6 +61,9 @@ export function MyazaInput({
|
|
|
60
61
|
onBlur,
|
|
61
62
|
}: MyazaInputProps): React.ReactElement {
|
|
62
63
|
const { colors } = useTheme();
|
|
64
|
+
// TextInput does not inherit fontFamily in RN — set it or the field (and its
|
|
65
|
+
// placeholder) renders in the system face beside brand-font text.
|
|
66
|
+
const fontFamily = useInputFontFamily();
|
|
63
67
|
const [focused, setFocused] = useState(false);
|
|
64
68
|
|
|
65
69
|
// Mirror the Flutter SDK's MyazaInput border states:
|
|
@@ -113,7 +117,7 @@ export function MyazaInput({
|
|
|
113
117
|
autoFocus={autoFocus}
|
|
114
118
|
onFocus={handleFocus}
|
|
115
119
|
onBlur={handleBlur}
|
|
116
|
-
style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: 16 }}
|
|
120
|
+
style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: 16, fontFamily }}
|
|
117
121
|
/>
|
|
118
122
|
{suffix ? <View style={{ width: spacing.sm }} /> : null}
|
|
119
123
|
{suffix}
|
|
@@ -142,6 +146,7 @@ export function MyazaInput({
|
|
|
142
146
|
color: colors.textDark,
|
|
143
147
|
backgroundColor: colors.background,
|
|
144
148
|
fontSize: 16,
|
|
149
|
+
fontFamily,
|
|
145
150
|
}}
|
|
146
151
|
/>
|
|
147
152
|
)}
|
|
@@ -3,6 +3,7 @@ import { Modal, Pressable, ScrollView, TextInput, useWindowDimensions, View } fr
|
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import { useTheme } from './runtime';
|
|
6
|
+
import { useInputFontFamily } from './fonts';
|
|
6
7
|
import { MyazaText } from './Typography';
|
|
7
8
|
import { Icon } from './Icon';
|
|
8
9
|
|
|
@@ -72,6 +73,9 @@ export function MyazaSelect<T extends string | number>({
|
|
|
72
73
|
searchable?: boolean;
|
|
73
74
|
}): React.ReactElement {
|
|
74
75
|
const { colors } = useTheme();
|
|
76
|
+
// TextInput does not inherit fontFamily in RN — set it or the search field
|
|
77
|
+
// (and its placeholder) renders in the system face.
|
|
78
|
+
const fontFamily = useInputFontFamily();
|
|
75
79
|
const { height: windowHeight } = useWindowDimensions();
|
|
76
80
|
const [open, setOpen] = useState(false);
|
|
77
81
|
const [query, setQuery] = useState('');
|
|
@@ -207,6 +211,7 @@ export function MyazaSelect<T extends string | number>({
|
|
|
207
211
|
paddingHorizontal: spacing.sm,
|
|
208
212
|
color: colors.textDark,
|
|
209
213
|
fontSize: 15,
|
|
214
|
+
fontFamily,
|
|
210
215
|
}}
|
|
211
216
|
/>
|
|
212
217
|
{query !== '' ? (
|
|
@@ -45,17 +45,9 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
|
|
|
45
45
|
alignItems: 'center',
|
|
46
46
|
}}
|
|
47
47
|
>
|
|
48
|
-
{/*
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
onPress={() => {
|
|
52
|
-
void Linking.openURL(PRODUCT_URL).catch(() => undefined);
|
|
53
|
-
}}
|
|
54
|
-
accessibilityRole="link"
|
|
55
|
-
accessibilityLabel="Powered by Myaza Trust"
|
|
56
|
-
hitSlop={8}
|
|
57
|
-
style={{ flexDirection: 'row', alignItems: 'center', gap: 10, opacity: 0.9 }}
|
|
58
|
-
>
|
|
48
|
+
{/* The ROW is not the link — only the mark is. "Powered by" is a label,
|
|
49
|
+
not a destination, so it does not carry the tap. */}
|
|
50
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, opacity: 0.9 }}>
|
|
59
51
|
{/* Small and muted on purpose — "Powered by" is connective tissue, not
|
|
60
52
|
the message. The BRAND carries the weight. */}
|
|
61
53
|
<MyazaText variant="body" color={markColor} style={{ flexShrink: 1, fontSize: 12 }}>
|
|
@@ -63,8 +55,21 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
|
|
|
63
55
|
</MyazaText>
|
|
64
56
|
|
|
65
57
|
{/* The lockup, spaced TIGHTER than the gap before it so it reads as one
|
|
66
|
-
mark rather than three evenly-spaced items.
|
|
67
|
-
|
|
58
|
+
mark rather than three evenly-spaced items. This is the link:
|
|
59
|
+
wordmark, rule and TRUST are one brand, so the whole lockup is the
|
|
60
|
+
target — but nothing beyond it is.
|
|
61
|
+
|
|
62
|
+
openURL can reject (no browser / a locked-down device). The mark
|
|
63
|
+
itself is the point, so a failed open is swallowed. */}
|
|
64
|
+
<Pressable
|
|
65
|
+
onPress={() => {
|
|
66
|
+
void Linking.openURL(PRODUCT_URL).catch(() => undefined);
|
|
67
|
+
}}
|
|
68
|
+
accessibilityRole="link"
|
|
69
|
+
accessibilityLabel="Myaza Trust"
|
|
70
|
+
hitSlop={8}
|
|
71
|
+
style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}
|
|
72
|
+
>
|
|
68
73
|
<MyazaWordmark height={28} wordmark={markColor} />
|
|
69
74
|
|
|
70
75
|
<View style={{ width: 1, height: 24, backgroundColor: markColor, opacity: 0.35 }} />
|
|
@@ -82,8 +87,8 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
|
|
|
82
87
|
>
|
|
83
88
|
TRUST
|
|
84
89
|
</MyazaText>
|
|
85
|
-
</
|
|
86
|
-
</
|
|
90
|
+
</Pressable>
|
|
91
|
+
</View>
|
|
87
92
|
</View>
|
|
88
93
|
);
|
|
89
94
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing, useAnimatedValue, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from './runtime';
|
|
5
|
+
|
|
6
|
+
// The quiet alternative to StepIndicator: a single thin bar sitting ON the
|
|
7
|
+
// header's bottom edge, replacing its border rather than adding a row beneath
|
|
8
|
+
// it — so choosing it costs the header no height at all.
|
|
9
|
+
//
|
|
10
|
+
// Unlike the step circles it does not say WHICH step you are on or how many
|
|
11
|
+
// there are, which is the trade: it is unaffected by step count, so a 14-step
|
|
12
|
+
// KYB flow draws exactly like a 4-step one. Hosts who would rather the chrome
|
|
13
|
+
// said less opt in with `progressStyle: 'bar'`.
|
|
14
|
+
|
|
15
|
+
export interface ProgressBarProps {
|
|
16
|
+
/** 0.0–1.0 progress fraction. */
|
|
17
|
+
progress: number;
|
|
18
|
+
/** Steps in the flow — announced, not drawn. */
|
|
19
|
+
stepCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thickness of the bar.
|
|
24
|
+
*
|
|
25
|
+
* 5, not the 1px border it replaces: at hairline weight it read as a rendering
|
|
26
|
+
* artefact rather than a deliberate indicator, and the filled portion needs
|
|
27
|
+
* enough body for its colour to register against the track at a glance.
|
|
28
|
+
*/
|
|
29
|
+
const HEIGHT = 5;
|
|
30
|
+
|
|
31
|
+
export function ProgressBar({ progress, stepCount }: ProgressBarProps): React.ReactElement {
|
|
32
|
+
const { colors } = useTheme();
|
|
33
|
+
const fraction = Math.min(Math.max(progress, 0), 1);
|
|
34
|
+
const step = Math.min(Math.max(Math.round(fraction * stepCount), 1), stepCount);
|
|
35
|
+
|
|
36
|
+
// Animated so advancing a step reads as movement rather than a jump — the
|
|
37
|
+
// motion IS the feedback that the step was accepted. 250ms sits inside the
|
|
38
|
+
// 150–300ms micro-interaction band; ease-out because it is entering.
|
|
39
|
+
const width = useAnimatedValue(fraction);
|
|
40
|
+
const first = useRef(true);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (first.current) {
|
|
43
|
+
// Don't animate the initial mount from 0 — the flow may be resumed
|
|
44
|
+
// mid-way, and a bar sweeping in from empty would misreport where the
|
|
45
|
+
// user actually is.
|
|
46
|
+
first.current = false;
|
|
47
|
+
width.setValue(fraction);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
Animated.timing(width, {
|
|
51
|
+
toValue: fraction,
|
|
52
|
+
duration: 250,
|
|
53
|
+
easing: Easing.out(Easing.cubic),
|
|
54
|
+
// Width cannot be driven natively, and a scaleX transform would squash
|
|
55
|
+
// the rounded cap. The bar is one small view, so the JS driver is fine.
|
|
56
|
+
useNativeDriver: false,
|
|
57
|
+
}).start();
|
|
58
|
+
}, [fraction, width]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
style={{
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
left: 0,
|
|
65
|
+
right: 0,
|
|
66
|
+
bottom: 0,
|
|
67
|
+
height: HEIGHT,
|
|
68
|
+
// The track doubles as the header's bottom border, which is why the
|
|
69
|
+
// header drops its own when this is shown.
|
|
70
|
+
backgroundColor: colors.border,
|
|
71
|
+
}}
|
|
72
|
+
accessible
|
|
73
|
+
accessibilityRole="progressbar"
|
|
74
|
+
accessibilityLabel={`Step ${step} of ${stepCount}`}
|
|
75
|
+
accessibilityValue={{ min: 1, max: stepCount, now: step }}
|
|
76
|
+
>
|
|
77
|
+
<Animated.View
|
|
78
|
+
style={{
|
|
79
|
+
height: HEIGHT,
|
|
80
|
+
backgroundColor: colors.primary,
|
|
81
|
+
borderTopRightRadius: HEIGHT / 2,
|
|
82
|
+
borderBottomRightRadius: HEIGHT / 2,
|
|
83
|
+
width: width.interpolate({
|
|
84
|
+
inputRange: [0, 1],
|
|
85
|
+
outputRange: ['0%', '100%'],
|
|
86
|
+
}),
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|