@myazahq/kyc-sdk-react-native 2.1.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/README.md +43 -2
- package/package.json +1 -1
- package/src/components/Icon.tsx +3 -0
- 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/keyPeople.ts +10 -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/ApplicantRoleStep.tsx +1 -10
- package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/{BusinessKeyPersonRow.tsx → KeyPersonForm.tsx} +35 -57
- package/src/screens/KeyPersonSheet.tsx +184 -0
- 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/README.md
CHANGED
|
@@ -133,6 +133,47 @@ autolinked via React Native / Expo autolinking — no manual linking required.
|
|
|
133
133
|
`children` (a string) to relabel it. For a fully custom trigger, use the
|
|
134
134
|
[`useMyazaKYC()` hook](#trigger-component--hook).
|
|
135
135
|
|
|
136
|
+
### Recommended — mount a workflow
|
|
137
|
+
|
|
138
|
+
Build the flow once in the Myaza dashboard as a **workflow**, then mount it by
|
|
139
|
+
id. The country, ID types, capture steps, add-ons, branding and copy all come
|
|
140
|
+
from the workflow, so changing the flow is a re-publish in the dashboard rather
|
|
141
|
+
than a new app build and an app-store review. See [Workflows](#workflows).
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
|
|
145
|
+
|
|
146
|
+
export default function VerifyScreen() {
|
|
147
|
+
return (
|
|
148
|
+
<MyazaKYC
|
|
149
|
+
apiKey="pk_live_xxx" // prefix selects the env: pk_test_ → sandbox
|
|
150
|
+
workflowId="wf_AbC123dEf456"
|
|
151
|
+
// Runtime data — a workflow is a shared template and cannot carry any of it.
|
|
152
|
+
userId="usr_123"
|
|
153
|
+
userData={{ firstName: 'Jane', lastName: 'Doe' }}
|
|
154
|
+
metadata={{ orderId: 'ord_456' }}
|
|
155
|
+
onSubmit={(submission) => console.log('Submitted!', submission.verificationId)}
|
|
156
|
+
onError={(err) => console.warn('SDK error:', err.code, err.message)}
|
|
157
|
+
onClose={() => console.log('Modal closed')}
|
|
158
|
+
>
|
|
159
|
+
Verify my identity
|
|
160
|
+
</MyazaKYC>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**`userData` is worth passing.** It is the name you believe the user has, and it
|
|
166
|
+
is compared against the name read off their document — that comparison is what
|
|
167
|
+
produces `dataMatch` on the verification. It cannot live on the workflow:
|
|
168
|
+
`userId`, `userData` and `metadata` are per-user runtime values, and a workflow
|
|
169
|
+
is a template shared by every visitor, so these stay in code even when
|
|
170
|
+
everything else moves to the dashboard.
|
|
171
|
+
|
|
172
|
+
### Or configure everything in code
|
|
173
|
+
|
|
174
|
+
Skip the workflow and pass the flow's shape as props. Useful for a quick start
|
|
175
|
+
or a single fixed flow; anything you'd change later means shipping a new build.
|
|
176
|
+
|
|
136
177
|
```tsx
|
|
137
178
|
import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
|
|
138
179
|
|
|
@@ -235,8 +276,8 @@ An unrecognized or malformed key throws at setup (it never silently defaults).
|
|
|
235
276
|
|
|
236
277
|
## Workflows
|
|
237
278
|
|
|
238
|
-
|
|
239
|
-
reference it by id
|
|
279
|
+
The recommended integration (see [Usage](#usage)): build the flow in the Myaza
|
|
280
|
+
dashboard and reference it by id —
|
|
240
281
|
|
|
241
282
|
```tsx
|
|
242
283
|
<MyazaKYC apiKey="pk_live_xxx" workflowId="wf_abc123" userId="usr_123" />
|
package/package.json
CHANGED
package/src/components/Icon.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
Moon,
|
|
31
31
|
MoveLeft,
|
|
32
32
|
Nfc,
|
|
33
|
+
Pencil,
|
|
33
34
|
Smartphone,
|
|
34
35
|
RefreshCw,
|
|
35
36
|
ScanFace,
|
|
@@ -84,6 +85,7 @@ export type IconName =
|
|
|
84
85
|
| 'scan-line'
|
|
85
86
|
| 'scan-face'
|
|
86
87
|
| 'nfc'
|
|
88
|
+
| 'pencil'
|
|
87
89
|
| 'search'
|
|
88
90
|
| 'zap'
|
|
89
91
|
| 'link'
|
|
@@ -145,6 +147,7 @@ const ICONS: Record<IconName, LucideIcon> = {
|
|
|
145
147
|
'scan-line': ScanLine,
|
|
146
148
|
'scan-face': ScanFace,
|
|
147
149
|
nfc: Nfc,
|
|
150
|
+
pencil: Pencil,
|
|
148
151
|
search: Search,
|
|
149
152
|
zap: Zap,
|
|
150
153
|
'zap-off': ZapOff,
|
|
@@ -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
|
}
|