@myazahq/kyc-sdk-react-native 2.0.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/KycSdkReactNative.podspec +32 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +110 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/cpp/cpp-adapter.cpp +12 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
- package/app.plugin.js +55 -0
- package/expo-module.config.json +6 -0
- package/ios/HybridMyazaFaceDetector.swift +233 -0
- package/package.json +84 -0
- package/react-native.config.js +23 -0
- package/src/MyazaKYC.tsx +235 -0
- package/src/__tests__/cardCrop.test.ts +39 -0
- package/src/__tests__/deviceMetadata.test.ts +34 -0
- package/src/__tests__/errors.test.ts +34 -0
- package/src/__tests__/flow.test.ts +61 -0
- package/src/__tests__/gestureDetector.test.ts +37 -0
- package/src/__tests__/liveness.test.ts +112 -0
- package/src/__tests__/resolveUrl.test.ts +64 -0
- package/src/__tests__/validators.test.ts +38 -0
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/components/CameraPermissionView.tsx +116 -0
- package/src/components/CameraViewfinder.tsx +156 -0
- package/src/components/CountryFlag.tsx +52 -0
- package/src/components/DocumentCropper.tsx +325 -0
- package/src/components/GlassIconButton.tsx +92 -0
- package/src/components/Icon.tsx +125 -0
- package/src/components/KycFlow.tsx +205 -0
- package/src/components/KycSheet.tsx +224 -0
- package/src/components/MyazaAlert.tsx +57 -0
- package/src/components/MyazaButton.tsx +101 -0
- package/src/components/MyazaCard.tsx +48 -0
- package/src/components/MyazaInput.tsx +112 -0
- package/src/components/MyazaPulseLoader.tsx +71 -0
- package/src/components/StatusBarController.tsx +42 -0
- package/src/components/StepHeader.tsx +56 -0
- package/src/components/StepIndicator.tsx +74 -0
- package/src/components/Typography.tsx +69 -0
- package/src/components/fonts.ts +49 -0
- package/src/components/glass/GlassGroup.tsx +34 -0
- package/src/components/glass/GlassSurface.tsx +64 -0
- package/src/components/runtime.tsx +93 -0
- package/src/components/toast.tsx +154 -0
- package/src/components/useBranding.ts +27 -0
- package/src/components/useVideoRecorder.ts +122 -0
- package/src/config/captureSettings.ts +67 -0
- package/src/config/idTypes.ts +79 -0
- package/src/config/theme.ts +186 -0
- package/src/index.ts +54 -0
- package/src/liveness/challengeManager.ts +130 -0
- package/src/liveness/faceDetector.ts +79 -0
- package/src/liveness/gestureDetector.ts +80 -0
- package/src/liveness/speech.ts +66 -0
- package/src/liveness/types.ts +99 -0
- package/src/liveness/useLiveness.ts +484 -0
- package/src/liveness/visionCameraFaceDetector.ts +118 -0
- package/src/screens/ConsentStep.tsx +164 -0
- package/src/screens/DocumentCaptureStep.tsx +500 -0
- package/src/screens/IdInputStep.tsx +79 -0
- package/src/screens/IdTypeStep.tsx +142 -0
- package/src/screens/LivenessAvatar.tsx +69 -0
- package/src/screens/LivenessStep.tsx +615 -0
- package/src/screens/SubmittedStep.tsx +177 -0
- package/src/services/api.ts +291 -0
- package/src/services/cardCrop.ts +52 -0
- package/src/services/deviceMetadata.ts +185 -0
- package/src/services/errors.ts +92 -0
- package/src/services/mediaCompress.ts +129 -0
- package/src/services/resolveUrl.ts +98 -0
- package/src/services/retry.ts +70 -0
- package/src/services/validators.ts +103 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
- package/src/store/kycStore.ts +288 -0
- package/src/store/serverConfig.ts +78 -0
- package/src/types/config.ts +239 -0
- package/src/types/country-flag-icons.d.ts +6 -0
- package/src/types/verification.ts +79 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/tokens.ts +10 -0
- package/src/utils/uuid.ts +31 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { ID_TYPES, requiresDocumentCapture } from '../config/idTypes';
|
|
6
|
+
import { KYCError } from '../types/verification';
|
|
7
|
+
import { safeReportError } from '../services/errors';
|
|
8
|
+
import type { KYCStep, SupportedCountry } from '../types/config';
|
|
9
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from './runtime';
|
|
10
|
+
import { KycSheet } from './KycSheet';
|
|
11
|
+
import { ToastProvider } from './toast';
|
|
12
|
+
import { MyazaText } from './Typography';
|
|
13
|
+
import { MyazaButton } from './MyazaButton';
|
|
14
|
+
import { Icon } from './Icon';
|
|
15
|
+
import { ConsentStep } from '../screens/ConsentStep';
|
|
16
|
+
import { IdTypeStep } from '../screens/IdTypeStep';
|
|
17
|
+
import { IdInputStep } from '../screens/IdInputStep';
|
|
18
|
+
import { DocumentCaptureStep, documentCaptureMeta } from '../screens/DocumentCaptureStep';
|
|
19
|
+
import { LivenessStep } from '../screens/LivenessStep';
|
|
20
|
+
import { SubmittedStep } from '../screens/SubmittedStep';
|
|
21
|
+
|
|
22
|
+
// The step router — 1:1 with the Flutter SDK's _KycFlowWidget. Computes per-step
|
|
23
|
+
// header title/description, the 4-step indicator info, back/country, fetches the
|
|
24
|
+
// server config on mount, and gates the flow on fatal auth failures.
|
|
25
|
+
|
|
26
|
+
function labelFor(country: SupportedCountry, idType: string | null): string {
|
|
27
|
+
if (!idType) return 'Document';
|
|
28
|
+
return Object.values(ID_TYPES).flat().find((t) => t.key === idType)?.label ?? 'Document';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Result of a back request handled inside the flow:
|
|
33
|
+
* 'navigated' — went back a step; the caller should NOT close.
|
|
34
|
+
* 'blocked' — nowhere to go back AND close is disabled; swallow it.
|
|
35
|
+
* 'close' — at the first step with close allowed; the caller should close.
|
|
36
|
+
*/
|
|
37
|
+
export type BackResult = 'navigated' | 'blocked' | 'close';
|
|
38
|
+
|
|
39
|
+
export function KycFlow({
|
|
40
|
+
onClose,
|
|
41
|
+
backRef,
|
|
42
|
+
}: {
|
|
43
|
+
onClose: () => void;
|
|
44
|
+
/** Populated with the flow's back-aware handler so the Modal's onRequestClose
|
|
45
|
+
* (Android hardware back) can navigate a step instead of dismissing. */
|
|
46
|
+
backRef?: React.MutableRefObject<(() => BackResult) | null>;
|
|
47
|
+
}): React.ReactElement {
|
|
48
|
+
const { colors } = useTheme();
|
|
49
|
+
const config = useKycConfig();
|
|
50
|
+
const store = useKycStore();
|
|
51
|
+
const currentStep = useKyc((s) => s.currentStep);
|
|
52
|
+
const selectedIdType = useKyc((s) => s.selectedIdType);
|
|
53
|
+
const documentCapturePhase = useKyc((s) => s.documentCapturePhase);
|
|
54
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
55
|
+
|
|
56
|
+
const startedRef = useRef(false);
|
|
57
|
+
const reportedRef = useRef(false);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!startedRef.current) {
|
|
61
|
+
startedRef.current = true;
|
|
62
|
+
config.onStart?.();
|
|
63
|
+
void store.getState().loadServerConfig();
|
|
64
|
+
}
|
|
65
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const isFatal = serverConfig.status === 'error' && serverConfig.fatal;
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isFatal && !reportedRef.current) {
|
|
71
|
+
reportedRef.current = true;
|
|
72
|
+
const code = serverConfig.statusCode === 401 ? 'invalid_api_key' : 'feature_disabled';
|
|
73
|
+
safeReportError(config.onError, new KYCError(code, serverConfig.message ?? 'Unable to start verification.'));
|
|
74
|
+
}
|
|
75
|
+
}, [isFatal, serverConfig.statusCode, serverConfig.message, config.onError]);
|
|
76
|
+
|
|
77
|
+
// ── Per-step header meta ──────────────────────────────────────────────────
|
|
78
|
+
const meta = useMemo(() => {
|
|
79
|
+
const label = labelFor(config.country, selectedIdType);
|
|
80
|
+
switch (currentStep) {
|
|
81
|
+
case 'consent':
|
|
82
|
+
return { title: '', description: null as string | null };
|
|
83
|
+
case 'id-type':
|
|
84
|
+
return { title: 'Select ID Type', description: "Choose the type of identification document you'd like to use." };
|
|
85
|
+
case 'id-input':
|
|
86
|
+
return { title: 'Enter Your Details', description: `Provide your ${label} for verification.` };
|
|
87
|
+
case 'document-capture':
|
|
88
|
+
// Phase-aware title/description live in the header (synced from the
|
|
89
|
+
// capture screen via `documentCapturePhase`) — mirrors Flutter.
|
|
90
|
+
return documentCaptureMeta(documentCapturePhase, label);
|
|
91
|
+
case 'liveness':
|
|
92
|
+
return { title: 'Face Verification', description: 'Follow the on-screen instructions' };
|
|
93
|
+
case 'submitted':
|
|
94
|
+
default:
|
|
95
|
+
return { title: '', description: null };
|
|
96
|
+
}
|
|
97
|
+
}, [currentStep, config.country, selectedIdType, documentCapturePhase]);
|
|
98
|
+
|
|
99
|
+
// ── Step indicator info (consent, id-type, capture|input, liveness?) ───────
|
|
100
|
+
const stepInfo = useMemo(() => {
|
|
101
|
+
if (currentStep === 'submitted') return null;
|
|
102
|
+
const hasCapture =
|
|
103
|
+
config.enableDocumentCapture && (selectedIdType ? requiresDocumentCapture(selectedIdType) : true);
|
|
104
|
+
const hasLiveness = config.enableLiveness !== false && config.enableSelfie !== false;
|
|
105
|
+
const steps: KYCStep[] = ['consent', 'id-type', hasCapture ? 'document-capture' : 'id-input'];
|
|
106
|
+
if (hasLiveness) steps.push('liveness');
|
|
107
|
+
const idx = steps.indexOf(currentStep);
|
|
108
|
+
if (idx < 0) return null;
|
|
109
|
+
return { progress: (idx + 1) / steps.length, stepCount: steps.length };
|
|
110
|
+
}, [currentStep, config.enableDocumentCapture, config.enableLiveness, config.enableSelfie, selectedIdType]);
|
|
111
|
+
|
|
112
|
+
const country = currentStep === 'id-type' || currentStep === 'id-input' ? config.country : null;
|
|
113
|
+
const onBack = currentStep === 'consent' || currentStep === 'submitted' ? null : () => store.getState().previousStep();
|
|
114
|
+
|
|
115
|
+
// Android hardware back arrives via the <Modal>'s onRequestClose. Expose a
|
|
116
|
+
// back-aware handler through `backRef` so that handler navigates a step back
|
|
117
|
+
// when the flow can, instead of dismissing the whole SDK. It only reports
|
|
118
|
+
// 'close' from the first step (consent) with close allowed; with
|
|
119
|
+
// `disableClose` set it reports 'blocked' so back can never force the flow
|
|
120
|
+
// closed. (iOS has no hardware back; its swipe-down keeps the standard
|
|
121
|
+
// dismiss behaviour, handled by the Modal.)
|
|
122
|
+
const canGoBack = !isFatal && onBack != null;
|
|
123
|
+
const disableClose = config.disableClose === true;
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!backRef) return undefined;
|
|
126
|
+
backRef.current = (): BackResult => {
|
|
127
|
+
if (canGoBack) {
|
|
128
|
+
store.getState().previousStep();
|
|
129
|
+
return 'navigated';
|
|
130
|
+
}
|
|
131
|
+
return disableClose ? 'blocked' : 'close';
|
|
132
|
+
};
|
|
133
|
+
return () => {
|
|
134
|
+
backRef.current = null;
|
|
135
|
+
};
|
|
136
|
+
}, [backRef, canGoBack, disableClose, store]);
|
|
137
|
+
|
|
138
|
+
if (isFatal) {
|
|
139
|
+
return (
|
|
140
|
+
<KycSheet title="" onClose={onClose} hideBrand>
|
|
141
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
142
|
+
<View
|
|
143
|
+
style={{
|
|
144
|
+
width: 80,
|
|
145
|
+
height: 80,
|
|
146
|
+
borderRadius: radius.full,
|
|
147
|
+
backgroundColor: `${colors.error}1A`,
|
|
148
|
+
alignItems: 'center',
|
|
149
|
+
justifyContent: 'center',
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<Icon name="alert" size={40} color={colors.error} />
|
|
153
|
+
</View>
|
|
154
|
+
<View style={{ height: spacing.lg }} />
|
|
155
|
+
<MyazaText variant="heading2" style={{ textAlign: 'center' }}>
|
|
156
|
+
Unable to start verification
|
|
157
|
+
</MyazaText>
|
|
158
|
+
<View style={{ height: spacing.xs }} />
|
|
159
|
+
<MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
160
|
+
{serverConfig.message ?? 'Please check your API key and try again.'}
|
|
161
|
+
</MyazaText>
|
|
162
|
+
<View style={{ height: spacing.lg }} />
|
|
163
|
+
<View style={{ width: '100%' }}>
|
|
164
|
+
<MyazaButton label="Close" onPress={onClose} />
|
|
165
|
+
</View>
|
|
166
|
+
</View>
|
|
167
|
+
</KycSheet>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<ToastProvider>
|
|
173
|
+
<KycSheet
|
|
174
|
+
title={meta.title}
|
|
175
|
+
description={meta.description}
|
|
176
|
+
progress={stepInfo?.progress ?? null}
|
|
177
|
+
stepCount={stepInfo?.stepCount ?? null}
|
|
178
|
+
country={country}
|
|
179
|
+
onBack={onBack}
|
|
180
|
+
onClose={onClose}
|
|
181
|
+
>
|
|
182
|
+
<StepView step={currentStep} onClose={onClose} />
|
|
183
|
+
</KycSheet>
|
|
184
|
+
</ToastProvider>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function StepView({ step, onClose }: { step: KYCStep; onClose: () => void }): React.ReactElement {
|
|
189
|
+
switch (step) {
|
|
190
|
+
case 'consent':
|
|
191
|
+
return <ConsentStep />;
|
|
192
|
+
case 'id-type':
|
|
193
|
+
return <IdTypeStep />;
|
|
194
|
+
case 'id-input':
|
|
195
|
+
return <IdInputStep />;
|
|
196
|
+
case 'document-capture':
|
|
197
|
+
return <DocumentCaptureStep />;
|
|
198
|
+
case 'liveness':
|
|
199
|
+
return <LivenessStep />;
|
|
200
|
+
case 'submitted':
|
|
201
|
+
return <SubmittedStep onClose={onClose} />;
|
|
202
|
+
default:
|
|
203
|
+
return <ConsentStep />;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Image, Platform, ScrollView, StatusBar, View } from 'react-native';
|
|
3
|
+
import { StatusBarController } from './StatusBarController';
|
|
4
|
+
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
5
|
+
|
|
6
|
+
import { headerSurface, radius, spacing } from '../config/theme';
|
|
7
|
+
import type { SupportedCountry } from '../types/config';
|
|
8
|
+
import { useKycConfig, useTheme } from './runtime';
|
|
9
|
+
import { useBranding } from './useBranding';
|
|
10
|
+
import { MyazaText } from './Typography';
|
|
11
|
+
import { StepHeader } from './StepHeader';
|
|
12
|
+
import { StepIndicator } from './StepIndicator';
|
|
13
|
+
import { GlassIconButton } from './GlassIconButton';
|
|
14
|
+
import { GlassSurface } from './glass/GlassSurface';
|
|
15
|
+
import { GlassGroup } from './glass/GlassGroup';
|
|
16
|
+
|
|
17
|
+
// Full-screen sheet shell — 1:1 with the Flutter SDK's KycBottomSheet:
|
|
18
|
+
// • a tinted header block (Liquid Glass on iOS 26) with a bottom border:
|
|
19
|
+
// row 1 → brand bar (left) + theme toggle + close (glass icon buttons)
|
|
20
|
+
// row 2 → StepHeader (back + title + country flag, description)
|
|
21
|
+
// row 3 → segmented step indicator
|
|
22
|
+
// • scrollable step body below.
|
|
23
|
+
|
|
24
|
+
export interface KycSheetProps {
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
title: string;
|
|
27
|
+
description?: string | null;
|
|
28
|
+
/** 0–1 progress fraction; null hides the step indicator. */
|
|
29
|
+
progress?: number | null;
|
|
30
|
+
stepCount?: number | null;
|
|
31
|
+
country?: SupportedCountry | null;
|
|
32
|
+
onBack?: (() => void) | null;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
/** Hide the persistent brand bar (e.g. on a fatal config-error screen). */
|
|
35
|
+
hideBrand?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function KycSheet({
|
|
39
|
+
children,
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
progress,
|
|
43
|
+
stepCount,
|
|
44
|
+
country,
|
|
45
|
+
onBack,
|
|
46
|
+
onClose,
|
|
47
|
+
hideBrand,
|
|
48
|
+
}: KycSheetProps): React.ReactElement {
|
|
49
|
+
const { colors, mode, toggle } = useTheme();
|
|
50
|
+
const config = useKycConfig();
|
|
51
|
+
const { logoUri, companyName } = useBranding();
|
|
52
|
+
|
|
53
|
+
const tint = headerSurface(colors, mode);
|
|
54
|
+
const showIndicator = progress != null && stepCount != null;
|
|
55
|
+
const showBrand = !hideBrand && !!logoUri;
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
// On iOS the modal is a pageSheet by default (sits below the status bar), but
|
|
60
|
+
// `disableClose` presents it full-screen (to drop the swipe-to-dismiss), so it
|
|
61
|
+
// then extends under the notch + over the home indicator and needs its own
|
|
62
|
+
// safe-area insets — the pageSheet provides them for free.
|
|
63
|
+
const iosFullScreen = Platform.OS === 'ios' && config.disableClose === true;
|
|
64
|
+
|
|
65
|
+
// Static safe-area snapshot (captured at app launch — no SafeAreaProvider
|
|
66
|
+
// needed). On Android 15+/API 35+ the modal is drawn EDGE-TO-EDGE, and
|
|
67
|
+
// `StatusBar.currentHeight` is unreliable there, so the real top inset comes
|
|
68
|
+
// from here.
|
|
69
|
+
const sa = initialWindowMetrics?.insets;
|
|
70
|
+
|
|
71
|
+
// Keep the header controls clear of the status bar / notch.
|
|
72
|
+
// • Android: full-screen edge-to-edge modal → clear the status bar using the
|
|
73
|
+
// safe-area top inset (fall back to StatusBar.currentHeight, then 28dp).
|
|
74
|
+
// • iOS pageSheet: already below the status bar → no inset.
|
|
75
|
+
// • iOS full-screen (disableClose): 56 covers the status bar + notch.
|
|
76
|
+
const topInset =
|
|
77
|
+
Platform.OS === 'android'
|
|
78
|
+
? sa?.top || StatusBar.currentHeight || 28
|
|
79
|
+
: iosFullScreen
|
|
80
|
+
? 56
|
|
81
|
+
: 0;
|
|
82
|
+
|
|
83
|
+
// Clear the home indicator / nav bar at the bottom of a full-screen modal.
|
|
84
|
+
const bottomInset =
|
|
85
|
+
Platform.OS === 'android' ? sa?.bottom ?? 0 : iosFullScreen ? 34 : 0;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
|
89
|
+
{/* The SDK theme drives the status-bar glyph colour so it stays readable on
|
|
90
|
+
the modal background — light glyphs in dark mode, dark in light mode.
|
|
91
|
+
Android: JS StatusBar/SystemBars can't reach the <Modal>'s Dialog window,
|
|
92
|
+
so a native view (StatusBarController) themes it from inside the modal.
|
|
93
|
+
iOS: only the full-screen modal (disableClose) slides under the bar — the
|
|
94
|
+
default pageSheet keeps the host bar above the card. */}
|
|
95
|
+
{Platform.OS === 'android' ? (
|
|
96
|
+
<StatusBarController barStyle={mode === 'dark' ? 'light' : 'dark'} />
|
|
97
|
+
) : iosFullScreen ? (
|
|
98
|
+
<StatusBar barStyle={mode === 'dark' ? 'light-content' : 'dark-content'} animated />
|
|
99
|
+
) : null}
|
|
100
|
+
<View style={{ flex: 1 }}>
|
|
101
|
+
{/* Header block (glass on iOS 26, tinted surface otherwise) */}
|
|
102
|
+
<GlassSurface
|
|
103
|
+
glassStyle="regular"
|
|
104
|
+
fallbackColor={tint}
|
|
105
|
+
style={{ borderBottomWidth: 1, borderBottomColor: colors.border, paddingBottom: spacing.md }}
|
|
106
|
+
>
|
|
107
|
+
{/* Row 1 — brand + controls */}
|
|
108
|
+
<View
|
|
109
|
+
style={{
|
|
110
|
+
flexDirection: 'row',
|
|
111
|
+
alignItems: 'center',
|
|
112
|
+
paddingHorizontal: spacing.md,
|
|
113
|
+
paddingTop: topInset + spacing.sm,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
|
|
117
|
+
{showBrand ? <BrandBar logoUri={logoUri!} companyName={companyName} /> : null}
|
|
118
|
+
</View>
|
|
119
|
+
|
|
120
|
+
{/* Header controls. When BOTH the theme toggle and close show, they
|
|
121
|
+
share a single Liquid Glass capsule (each button `plain`). When
|
|
122
|
+
only one shows — the consumer disabled the other (`disableClose`
|
|
123
|
+
or `showThemeToggle: false`) — that button stands on its own
|
|
124
|
+
glass instead of a one-icon capsule. Neither → nothing. */}
|
|
125
|
+
{(() => {
|
|
126
|
+
const showToggle = config.showThemeToggle !== false;
|
|
127
|
+
const showClose = !config.disableClose;
|
|
128
|
+
const toggleBtn = showToggle ? (
|
|
129
|
+
<GlassIconButton
|
|
130
|
+
icon={mode === 'dark' ? 'sun' : 'moon'}
|
|
131
|
+
onPress={toggle}
|
|
132
|
+
solid
|
|
133
|
+
plain={showClose}
|
|
134
|
+
accessibilityLabel="Toggle theme"
|
|
135
|
+
/>
|
|
136
|
+
) : null;
|
|
137
|
+
const closeBtn = showClose ? (
|
|
138
|
+
<GlassIconButton
|
|
139
|
+
icon="close"
|
|
140
|
+
onPress={onClose}
|
|
141
|
+
plain={showToggle}
|
|
142
|
+
accessibilityLabel="Close"
|
|
143
|
+
/>
|
|
144
|
+
) : null;
|
|
145
|
+
|
|
146
|
+
if (showToggle && showClose) {
|
|
147
|
+
return (
|
|
148
|
+
<GlassGroup style={{ gap: 2 }}>
|
|
149
|
+
{toggleBtn}
|
|
150
|
+
{closeBtn}
|
|
151
|
+
</GlassGroup>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
// Exactly one (or zero) — render it standalone with its own glass.
|
|
155
|
+
return toggleBtn ?? closeBtn;
|
|
156
|
+
})()}
|
|
157
|
+
</View>
|
|
158
|
+
|
|
159
|
+
{/* Row 2 — title + back + flag */}
|
|
160
|
+
{title || onBack ? (
|
|
161
|
+
<View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm }}>
|
|
162
|
+
<StepHeader title={title} description={description} onBack={onBack} country={country} />
|
|
163
|
+
</View>
|
|
164
|
+
) : null}
|
|
165
|
+
|
|
166
|
+
{/* Row 3 — step indicator */}
|
|
167
|
+
{showIndicator ? (
|
|
168
|
+
<View style={{ marginTop: spacing.md }}>
|
|
169
|
+
<StepIndicator progress={progress!} stepCount={stepCount!} />
|
|
170
|
+
</View>
|
|
171
|
+
) : null}
|
|
172
|
+
</GlassSurface>
|
|
173
|
+
|
|
174
|
+
{/* Step body */}
|
|
175
|
+
<ScrollView
|
|
176
|
+
contentContainerStyle={{ padding: spacing.md, paddingTop: spacing.md, paddingBottom: spacing.xl + bottomInset, flexGrow: 1 }}
|
|
177
|
+
keyboardShouldPersistTaps="handled"
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
</ScrollView>
|
|
181
|
+
</View>
|
|
182
|
+
</View>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Persistent header brand — circular logo avatar + company name. A broken/missing
|
|
187
|
+
// logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
|
|
188
|
+
// Flutter's `errorBuilder`), so the header never shows a broken-image box.
|
|
189
|
+
function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: string }): React.ReactElement | null {
|
|
190
|
+
const { colors } = useTheme();
|
|
191
|
+
const [broken, setBroken] = useState(false);
|
|
192
|
+
if (broken) return null;
|
|
193
|
+
return (
|
|
194
|
+
<>
|
|
195
|
+
<View
|
|
196
|
+
style={{
|
|
197
|
+
width: 28,
|
|
198
|
+
height: 28,
|
|
199
|
+
borderRadius: radius.full,
|
|
200
|
+
backgroundColor: '#FFFFFF',
|
|
201
|
+
borderWidth: 1,
|
|
202
|
+
borderColor: 'rgba(0,0,0,0.05)',
|
|
203
|
+
overflow: 'hidden',
|
|
204
|
+
marginRight: spacing.sm,
|
|
205
|
+
alignItems: 'center',
|
|
206
|
+
justifyContent: 'center',
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
{/* Fill + cover-crop the circle. The borderRadius is repeated on the
|
|
210
|
+
Image itself because iOS doesn't reliably clip an Image child to a
|
|
211
|
+
parent's rounded corners (mirrors Flutter's ClipOval + cover). */}
|
|
212
|
+
<Image
|
|
213
|
+
source={{ uri: logoUri }}
|
|
214
|
+
style={{ width: '100%', height: '100%', borderRadius: radius.full }}
|
|
215
|
+
resizeMode="cover"
|
|
216
|
+
onError={() => setBroken(true)}
|
|
217
|
+
/>
|
|
218
|
+
</View>
|
|
219
|
+
<MyazaText variant="heading3" numberOfLines={1} color={colors.textDark} style={{ flexShrink: 1, fontSize: 14 }}>
|
|
220
|
+
{companyName}
|
|
221
|
+
</MyazaText>
|
|
222
|
+
</>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { MyazaText } from './Typography';
|
|
7
|
+
|
|
8
|
+
// Info / success / warning / error banner. Mirrors the Flutter SDK's MyazaAlert.
|
|
9
|
+
|
|
10
|
+
export type AlertVariant = 'info' | 'success' | 'warning' | 'error';
|
|
11
|
+
|
|
12
|
+
export interface MyazaAlertProps {
|
|
13
|
+
variant: AlertVariant;
|
|
14
|
+
title?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MyazaAlert({ variant, title, message }: MyazaAlertProps): React.ReactElement {
|
|
19
|
+
const { colors } = useTheme();
|
|
20
|
+
const bg =
|
|
21
|
+
variant === 'success'
|
|
22
|
+
? colors.successBg
|
|
23
|
+
: variant === 'warning'
|
|
24
|
+
? colors.warningBg
|
|
25
|
+
: variant === 'error'
|
|
26
|
+
? colors.errorBg
|
|
27
|
+
: colors.infoBg;
|
|
28
|
+
const accent =
|
|
29
|
+
variant === 'success'
|
|
30
|
+
? colors.success
|
|
31
|
+
: variant === 'warning'
|
|
32
|
+
? colors.warning
|
|
33
|
+
: variant === 'error'
|
|
34
|
+
? colors.error
|
|
35
|
+
: colors.info;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<View
|
|
39
|
+
style={{
|
|
40
|
+
backgroundColor: bg,
|
|
41
|
+
borderRadius: radius.sm,
|
|
42
|
+
padding: spacing.md,
|
|
43
|
+
borderLeftWidth: 3,
|
|
44
|
+
borderLeftColor: accent,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{title ? (
|
|
48
|
+
<MyazaText variant="label" color={accent} style={{ marginBottom: 2 }}>
|
|
49
|
+
{title}
|
|
50
|
+
</MyazaText>
|
|
51
|
+
) : null}
|
|
52
|
+
<MyazaText variant="bodyMedium" color={colors.textDark}>
|
|
53
|
+
{message}
|
|
54
|
+
</MyazaText>
|
|
55
|
+
</View>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, sizing, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from './runtime';
|
|
6
|
+
import { MyazaText } from './Typography';
|
|
7
|
+
import { Icon, type IconName } from './Icon';
|
|
8
|
+
|
|
9
|
+
// Branded button — 1:1 with the Flutter SDK's MyazaButton:
|
|
10
|
+
// • primary — solid primary fill, onPrimary label
|
|
11
|
+
// • outline — transparent, 1.5px primary border, primary label
|
|
12
|
+
// • ghost — transparent, primary label
|
|
13
|
+
// • destructive — error fill, white label
|
|
14
|
+
// Radius is `sm` (12px), height 48. No Liquid Glass on buttons (glass is reserved
|
|
15
|
+
// for the modal chrome / icon buttons, per the design).
|
|
16
|
+
|
|
17
|
+
export type ButtonVariant = 'primary' | 'outline' | 'ghost' | 'destructive';
|
|
18
|
+
|
|
19
|
+
export interface MyazaButtonProps {
|
|
20
|
+
label: string;
|
|
21
|
+
onPress?: () => void;
|
|
22
|
+
variant?: ButtonVariant;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
leadingIcon?: IconName;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
fullWidth?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function MyazaButton({
|
|
31
|
+
label,
|
|
32
|
+
onPress,
|
|
33
|
+
variant = 'primary',
|
|
34
|
+
disabled = false,
|
|
35
|
+
loading = false,
|
|
36
|
+
leadingIcon,
|
|
37
|
+
style,
|
|
38
|
+
fullWidth = true,
|
|
39
|
+
}: MyazaButtonProps): React.ReactElement {
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
const isDisabled = disabled || loading || !onPress;
|
|
42
|
+
|
|
43
|
+
const fg =
|
|
44
|
+
variant === 'primary'
|
|
45
|
+
? colors.onPrimary
|
|
46
|
+
: variant === 'destructive'
|
|
47
|
+
? '#FFFFFF'
|
|
48
|
+
: colors.primary;
|
|
49
|
+
const effectiveFg = isDisabled ? `${fg}80` : fg; // 50% alpha when disabled
|
|
50
|
+
|
|
51
|
+
const bg =
|
|
52
|
+
variant === 'primary'
|
|
53
|
+
? colors.primary
|
|
54
|
+
: variant === 'destructive'
|
|
55
|
+
? colors.error
|
|
56
|
+
: 'transparent';
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Pressable
|
|
60
|
+
onPress={isDisabled ? undefined : onPress}
|
|
61
|
+
disabled={isDisabled}
|
|
62
|
+
style={({ pressed }) => [
|
|
63
|
+
{
|
|
64
|
+
height: sizing.buttonHeight,
|
|
65
|
+
borderRadius: radius.sm,
|
|
66
|
+
flexDirection: 'row',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
paddingHorizontal: spacing.md,
|
|
70
|
+
width: fullWidth ? '100%' : undefined,
|
|
71
|
+
backgroundColor: isDisabled
|
|
72
|
+
? variant === 'primary'
|
|
73
|
+
? `${colors.primary}80`
|
|
74
|
+
: variant === 'destructive'
|
|
75
|
+
? `${colors.error}80`
|
|
76
|
+
: 'transparent'
|
|
77
|
+
: bg,
|
|
78
|
+
borderWidth: variant === 'outline' ? 1.5 : 0,
|
|
79
|
+
borderColor: variant === 'outline' ? (isDisabled ? `${colors.primary}66` : colors.primary) : undefined,
|
|
80
|
+
opacity: pressed ? 0.9 : 1,
|
|
81
|
+
},
|
|
82
|
+
style,
|
|
83
|
+
]}
|
|
84
|
+
>
|
|
85
|
+
{loading ? (
|
|
86
|
+
<ActivityIndicator color={effectiveFg} size="small" />
|
|
87
|
+
) : (
|
|
88
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
89
|
+
{leadingIcon ? (
|
|
90
|
+
<View style={{ marginRight: 8 }}>
|
|
91
|
+
<Icon name={leadingIcon} size={18} color={effectiveFg} />
|
|
92
|
+
</View>
|
|
93
|
+
) : null}
|
|
94
|
+
<MyazaText variant="button" color={effectiveFg}>
|
|
95
|
+
{label}
|
|
96
|
+
</MyazaText>
|
|
97
|
+
</View>
|
|
98
|
+
)}
|
|
99
|
+
</Pressable>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from './runtime';
|
|
6
|
+
import { GlassSurface, supportsLiquidGlass } from './glass/GlassSurface';
|
|
7
|
+
|
|
8
|
+
// Branded card. Tappable + selectable (selection draws a brand border + tint).
|
|
9
|
+
// On iOS 26 the surface is Liquid Glass; otherwise a token-filled panel.
|
|
10
|
+
|
|
11
|
+
export interface MyazaCardProps {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
onPress?: () => void;
|
|
14
|
+
selected?: boolean;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MyazaCard({ children, onPress, selected = false, style }: MyazaCardProps): React.ReactElement {
|
|
19
|
+
const { colors } = useTheme();
|
|
20
|
+
|
|
21
|
+
const container: ViewStyle = {
|
|
22
|
+
borderRadius: radius.md,
|
|
23
|
+
padding: spacing.md,
|
|
24
|
+
borderWidth: 1,
|
|
25
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
26
|
+
overflow: 'hidden',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const selectedTint: ViewStyle = selected ? { backgroundColor: colors.primary100 } : {};
|
|
30
|
+
|
|
31
|
+
const inner = supportsLiquidGlass() ? (
|
|
32
|
+
<GlassSurface glassStyle="regular" style={[container, selectedTint]} tintColor={selected ? colors.primary : undefined}>
|
|
33
|
+
{children}
|
|
34
|
+
</GlassSurface>
|
|
35
|
+
) : (
|
|
36
|
+
<View style={[container, { backgroundColor: selected ? colors.primary100 : colors.backgroundSecondary }]}>
|
|
37
|
+
{children}
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!onPress) return <View style={style}>{inner}</View>;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Pressable onPress={onPress} style={style}>
|
|
45
|
+
{inner}
|
|
46
|
+
</Pressable>
|
|
47
|
+
);
|
|
48
|
+
}
|