@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.
Files changed (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. 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
+ }