@myazahq/kyc-sdk-react-native 2.3.0 → 2.5.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 (129) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -2
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/BrandBar.tsx +137 -0
  18. package/src/components/DashedBorder.tsx +55 -0
  19. package/src/components/DialCodePicker.tsx +25 -24
  20. package/src/components/DocumentReviewSide.tsx +34 -14
  21. package/src/components/Icon.tsx +14 -0
  22. package/src/components/KycSheet.tsx +39 -140
  23. package/src/components/MediaSourceSheet.tsx +7 -37
  24. package/src/components/MultiIdProgress.tsx +107 -0
  25. package/src/components/MyazaDateField.tsx +6 -20
  26. package/src/components/MyazaInput.tsx +23 -4
  27. package/src/components/MyazaSelect.tsx +17 -40
  28. package/src/components/OwnershipSlider.tsx +102 -0
  29. package/src/components/PhoneNumberInput.tsx +37 -4
  30. package/src/components/SandboxBanner.tsx +92 -0
  31. package/src/components/StaggerIn.tsx +63 -0
  32. package/src/components/StepHeader.tsx +15 -2
  33. package/src/components/StepView.tsx +26 -0
  34. package/src/components/glass/ChromeGlass.tsx +65 -0
  35. package/src/components/glass/FloatingSheet.tsx +242 -0
  36. package/src/components/glass/GlassSheet.tsx +50 -0
  37. package/src/components/glass/GlassSurface.tsx +31 -3
  38. package/src/components/stepHeaderMeta.tsx +3 -1
  39. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  40. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  41. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  42. package/src/config/business.ts +6 -6
  43. package/src/config/businessDetailsValidity.ts +52 -0
  44. package/src/config/businessPrefill.ts +72 -0
  45. package/src/config/businessSteps.ts +14 -3
  46. package/src/config/keyPeople.ts +111 -7
  47. package/src/config/keyPeopleCorporate.ts +19 -0
  48. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  49. package/src/config/keyPeopleOwners.ts +26 -0
  50. package/src/config/keyPeoplePrefill.ts +138 -0
  51. package/src/config/keyPeopleSectionDefs.ts +65 -0
  52. package/src/config/keyPeopleSections.ts +167 -0
  53. package/src/config/registrationHint.ts +1 -1
  54. package/src/config/stepOrder.ts +24 -4
  55. package/src/config/theme.ts +8 -2
  56. package/src/config/website.ts +36 -0
  57. package/src/config/workflowMerge.ts +5 -0
  58. package/src/emrtd/activeAuth.ts +100 -0
  59. package/src/emrtd/extras.ts +76 -0
  60. package/src/emrtd/files.ts +5 -0
  61. package/src/emrtd/index.ts +1 -0
  62. package/src/emrtd/read.ts +8 -1
  63. package/src/emrtd/session.ts +41 -0
  64. package/src/emrtd/stages.ts +11 -0
  65. package/src/lib/contact-recovery.ts +44 -0
  66. package/src/lib/multi-id.ts +210 -0
  67. package/src/lib/resubmit.ts +130 -0
  68. package/src/lib/screen-corners.ts +50 -0
  69. package/src/lib/step-log.ts +24 -3
  70. package/src/lib/use-multi-id-plan.ts +29 -0
  71. package/src/liveness/useModelReady.ts +65 -0
  72. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  73. package/src/screens/ApplicantRoleStep.tsx +9 -5
  74. package/src/screens/BusinessCheckPanel.tsx +89 -0
  75. package/src/screens/BusinessDetailsFields.tsx +109 -0
  76. package/src/screens/BusinessDetailsStep.tsx +131 -145
  77. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  78. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  79. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  80. package/src/screens/BusinessPickedCard.tsx +86 -0
  81. package/src/screens/BusinessPickedSection.tsx +51 -0
  82. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  83. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  84. package/src/screens/BusinessSearch.tsx +199 -0
  85. package/src/screens/BusinessSearchResults.tsx +115 -0
  86. package/src/screens/CompanyInfoFields.tsx +86 -51
  87. package/src/screens/ConsentStep.tsx +31 -0
  88. package/src/screens/ContactDestinationField.tsx +1 -0
  89. package/src/screens/ContactVerificationStep.tsx +34 -3
  90. package/src/screens/IdTypeStep.tsx +12 -5
  91. package/src/screens/KeepLinksSheet.tsx +88 -0
  92. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  93. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  94. package/src/screens/KeyPeoplePending.tsx +154 -0
  95. package/src/screens/KeyPeopleSection.tsx +162 -0
  96. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  97. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  98. package/src/screens/KeyPersonCard.tsx +36 -4
  99. package/src/screens/KeyPersonForm.tsx +195 -36
  100. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  101. package/src/screens/KeyPersonOwners.tsx +109 -0
  102. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  103. package/src/screens/KeyPersonSheet.tsx +92 -108
  104. package/src/screens/LivenessStep.tsx +18 -0
  105. package/src/screens/NfcStep.tsx +14 -0
  106. package/src/screens/ProofOfAddressStep.tsx +6 -3
  107. package/src/screens/SubmittedStep.tsx +66 -10
  108. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  109. package/src/screens/useAwaitingPeople.ts +90 -0
  110. package/src/services/api-types.ts +158 -2
  111. package/src/services/api-verify-types.ts +53 -1
  112. package/src/services/api.ts +104 -0
  113. package/src/services/deviceMetadata.ts +1 -1
  114. package/src/services/errors.ts +10 -2
  115. package/src/services/workflowGate.ts +1 -0
  116. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  117. package/src/store/businessCheck.ts +113 -0
  118. package/src/store/derive.ts +15 -0
  119. package/src/store/kycStore.ts +241 -14
  120. package/src/store/serverConfig.ts +2 -0
  121. package/src/store/session.ts +208 -0
  122. package/src/store/state.ts +155 -2
  123. package/src/store/submit.ts +63 -13
  124. package/src/store/submitApplicant.ts +5 -11
  125. package/src/types/business.ts +23 -2
  126. package/src/types/config.ts +29 -3
  127. package/src/types/verification.ts +1 -1
  128. package/src/types/workflow.ts +15 -0
  129. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,55 @@
1
+ import React, { useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import Svg, { Rect } from 'react-native-svg';
4
+
5
+ // ─── A dashed card border the platform cannot restyle ─────────────────────────
6
+ //
7
+ // Native `borderStyle: 'dashed'` is drawn by the OS, and iOS draws it with
8
+ // long dashes and wide gaps that nothing can tune — beside the web SDK's
9
+ // tight CSS pattern the same card looked hand-drawn. This draws the same
10
+ // rectangle with SVG, where the dash pattern is ours to set; the defaults
11
+ // match the web look (and the Flutter SDK's DashedRoundedBorder). Overlay it
12
+ // inside a relatively-positioned card and drop the native border props.
13
+
14
+ export function DashedBorder({
15
+ color,
16
+ radius,
17
+ strokeWidth = 2,
18
+ dash = 4,
19
+ gap = 3,
20
+ }: {
21
+ color: string;
22
+ radius: number;
23
+ strokeWidth?: number;
24
+ dash?: number;
25
+ gap?: number;
26
+ }): React.ReactElement {
27
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
28
+ return (
29
+ <View
30
+ pointerEvents="none"
31
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
32
+ onLayout={(e) =>
33
+ setSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })
34
+ }
35
+ >
36
+ {size && size.w > strokeWidth && size.h > strokeWidth ? (
37
+ <Svg width={size.w} height={size.h}>
38
+ {/* Inset by half the stroke so the line sits fully inside the card,
39
+ where a native border would have been. */}
40
+ <Rect
41
+ x={strokeWidth / 2}
42
+ y={strokeWidth / 2}
43
+ width={size.w - strokeWidth}
44
+ height={size.h - strokeWidth}
45
+ rx={radius}
46
+ fill="none"
47
+ stroke={color}
48
+ strokeWidth={strokeWidth}
49
+ strokeDasharray={[dash, gap]}
50
+ />
51
+ </Svg>
52
+ ) : null}
53
+ </View>
54
+ );
55
+ }
@@ -2,19 +2,19 @@ import React, { useEffect, useMemo, useState } from 'react';
2
2
  import {
3
3
  FlatList,
4
4
  Keyboard,
5
- Modal,
6
5
  Platform,
7
6
  Pressable,
8
7
  useWindowDimensions,
9
8
  View,
10
9
  } from 'react-native';
11
10
 
12
- import { radius, spacing } from '../config/theme';
11
+ import { spacing } from '../config/theme';
13
12
  import { useTheme } from './runtime';
14
13
  import { MyazaText } from './Typography';
15
14
  import { MyazaInput } from './MyazaInput';
16
15
  import { Icon } from './Icon';
17
16
  import { CountryFlag } from './CountryFlag';
17
+ import { FloatingSheet } from './glass/FloatingSheet';
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // THE country sheet — the phone field's dial-code picker, generalised.
@@ -98,25 +98,24 @@ export function DialCodePicker({
98
98
  };
99
99
 
100
100
  return (
101
- <Modal visible={visible} animationType="slide" transparent onRequestClose={close}>
102
- <Pressable style={{ flex: 1, backgroundColor: '#00000066' }} onPress={close} />
103
- <View
104
- style={{
105
- maxHeight,
106
- marginBottom: keyboard,
107
- backgroundColor: colors.background,
108
- borderTopLeftRadius: radius.lg,
109
- borderTopRightRadius: radius.lg,
110
- paddingTop: spacing.md,
111
- paddingBottom: spacing.sm,
112
- }}
113
- >
114
- <View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}>
101
+ <FloatingSheet
102
+ visible={visible}
103
+ onClose={close}
104
+ maxHeight={maxHeight}
105
+ // Ride above a raised keyboard — this sheet's whole job is a search box.
106
+ bottomOffset={keyboard}
107
+ closeLabel="Close country picker"
108
+ >
109
+ {/* Clear air between the header row (handle + close) and the search
110
+ field — flush against the close button they read as one control. */}
111
+ <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.md, paddingBottom: spacing.sm }}>
112
+ {/* Deliberately NOT autofocused: springing the keyboard the instant
113
+ the sheet opens hides half the list before the person has even
114
+ seen it — search is one tap away for whoever wants it. */}
115
115
  <MyazaInput
116
116
  value={query}
117
117
  onChangeText={setQuery}
118
118
  placeholder={searchPlaceholder}
119
- autoFocus
120
119
  prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
121
120
  />
122
121
  </View>
@@ -141,8 +140,7 @@ export function DialCodePicker({
141
140
  />
142
141
  )}
143
142
  />
144
- </View>
145
- </Modal>
143
+ </FloatingSheet>
146
144
  );
147
145
  }
148
146
 
@@ -165,19 +163,22 @@ function CountryRow({
165
163
  flexDirection: 'row',
166
164
  alignItems: 'center',
167
165
  paddingHorizontal: spacing.md,
168
- paddingVertical: spacing.sm,
166
+ // Roomier rows: a country list is a long scan, and cramped rows make
167
+ // every tap a precision job. Matches the web dropdown's row height.
168
+ paddingVertical: 12,
169
169
  backgroundColor: isSelected ? colors.primary50 : 'transparent',
170
170
  }}
171
171
  >
172
- <CountryFlag country={option.code} size={24} />
172
+ <CountryFlag country={option.code} size={28} />
173
173
  <View style={{ width: spacing.md }} />
174
174
  {/* No line clamp: "Bosnia & Herzegovina" and the like must read in full,
175
- which is what Flutter's Expanded(Text) gives. */}
176
- <MyazaText variant="bodyMedium" style={{ flex: 1 }}>
175
+ which is what Flutter's Expanded(Text) gives. Full body size (16),
176
+ the web dropdown's reading size — 14 read small against the flags. */}
177
+ <MyazaText variant="body" style={{ flex: 1 }}>
177
178
  {option.name}
178
179
  </MyazaText>
179
180
  {option.dialCode != null ? (
180
- <MyazaText variant="label" color={colors.textSecondary}>
181
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
181
182
  {option.dialCode}
182
183
  </MyazaText>
183
184
  ) : null}
@@ -1,10 +1,14 @@
1
1
  import React from 'react';
2
- import { Image, Pressable, View } from 'react-native';
2
+ import { Image, Pressable, View, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { useTheme } from './runtime';
5
5
  import { Icon } from './Icon';
6
6
  import { MyazaText } from './Typography';
7
7
  import { radius, spacing } from '../config/theme';
8
+ import { CHROME_SCRIM, ChromeGlass } from './glass/ChromeGlass';
9
+
10
+ /** The chip's flat backing, and still the fallback off iOS 26. */
11
+ const CHIP_SCRIM = CHROME_SCRIM;
8
12
 
9
13
  // ─── One captured side, and its enlarged view ─────────────────────────────────
10
14
  //
@@ -67,7 +71,7 @@ export function DocumentReviewThumb({
67
71
  {/* Enlarging is a tap on the image, which nothing announces — so the
68
72
  image says so itself. */}
69
73
  <View style={{ position: 'absolute', right: spacing.md, bottom: spacing.md }} pointerEvents="none">
70
- <Chip icon>
74
+ <Chip icon glass>
71
75
  <Icon name="maximize" size={18} color="#FFFFFF" />
72
76
  </Chip>
73
77
  </View>
@@ -116,20 +120,36 @@ export function Chip({
116
120
  children,
117
121
  /** Icon chips are square and need room around the glyph, not text padding. */
118
122
  icon = false,
123
+ /**
124
+ * Render on Liquid Glass instead of the flat scrim.
125
+ *
126
+ * Only for a chip that ANNOUNCES AN ACTION. It looks like an exception to the
127
+ * "glass is for controls" rule, since the chip itself is `pointerEvents:
128
+ * none` — but the photo behind it is the button, and the chip sits inside
129
+ * that target, so a tap on it really does fire. The promise is kept.
130
+ *
131
+ * A chip that only labels something (FRONT / BACK) stays flat: glass would
132
+ * offer a tap it cannot honour.
133
+ */
134
+ glass = false,
119
135
  }: {
120
136
  children: React.ReactNode;
121
137
  icon?: boolean;
138
+ glass?: boolean;
122
139
  }): React.ReactElement {
123
- return (
124
- <View
125
- style={{
126
- paddingHorizontal: icon ? 8 : 10,
127
- paddingVertical: icon ? 8 : 5,
128
- borderRadius: radius.full,
129
- backgroundColor: 'rgba(0,0,0,0.55)',
130
- }}
131
- >
132
- {children}
133
- </View>
134
- );
140
+ const shape: ViewStyle = {
141
+ paddingHorizontal: icon ? 8 : 10,
142
+ paddingVertical: icon ? 8 : 5,
143
+ borderRadius: radius.full,
144
+ };
145
+ // Not `interactive`: the chip never receives the touch (the image below it
146
+ // does), so asking the glass to react to one would be asking for nothing.
147
+ if (glass) {
148
+ return (
149
+ <ChromeGlass scrim={CHIP_SCRIM} style={shape}>
150
+ {children}
151
+ </ChromeGlass>
152
+ );
153
+ }
154
+ return <View style={[shape, { backgroundColor: CHIP_SCRIM }]}>{children}</View>;
135
155
  }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import Ionicons from '@expo/vector-icons/Ionicons';
3
3
  import {
4
+ Info,
4
5
  BadgeCheck,
5
6
  BookUser,
6
7
  Building2,
@@ -17,6 +18,7 @@ import {
17
18
  CreditCard,
18
19
  Fingerprint,
19
20
  FileText,
21
+ FlaskConical,
20
22
  IdCard,
21
23
  Image as ImageIcon,
22
24
  Landmark,
@@ -30,6 +32,7 @@ import {
30
32
  Moon,
31
33
  MoveLeft,
32
34
  Nfc,
35
+ Filter,
33
36
  Pencil,
34
37
  Smartphone,
35
38
  RefreshCw,
@@ -42,6 +45,7 @@ import {
42
45
  Sun,
43
46
  Timer,
44
47
  Upload,
48
+ Plus,
45
49
  UserRound,
46
50
  UserRoundPlus,
47
51
  Link2,
@@ -68,6 +72,7 @@ export type IconName =
68
72
  | 'check'
69
73
  | 'lock'
70
74
  | 'alert'
75
+ | 'flask'
71
76
  | 'refresh'
72
77
  | 'maximize'
73
78
  | 'calendar'
@@ -81,10 +86,13 @@ export type IconName =
81
86
  | 'building-2'
82
87
  | 'user'
83
88
  | 'user-plus'
89
+ | 'plus'
90
+ | 'info'
84
91
  | 'users'
85
92
  | 'scan-line'
86
93
  | 'scan-face'
87
94
  | 'nfc'
95
+ | 'filter'
88
96
  | 'pencil'
89
97
  | 'search'
90
98
  | 'zap'
@@ -128,6 +136,9 @@ const ICONS: Record<IconName, LucideIcon> = {
128
136
  check: Check,
129
137
  lock: Lock,
130
138
  alert: CircleAlert,
139
+ // The environment banner's mark, shared with the web SDK (same lucide glyph)
140
+ // and mirrored by Flutter's Icons.science_outlined.
141
+ flask: FlaskConical,
131
142
  refresh: RefreshCw,
132
143
  maximize: Maximize2,
133
144
  calendar: Calendar,
@@ -139,7 +150,9 @@ const ICONS: Record<IconName, LucideIcon> = {
139
150
  'badge-check': BadgeCheck,
140
151
  'building-2': Building2,
141
152
  user: UserRound,
153
+ info: Info,
142
154
  'user-plus': UserRoundPlus,
155
+ plus: Plus,
143
156
  link: Link2,
144
157
  copy: Copy,
145
158
  share: Share2,
@@ -147,6 +160,7 @@ const ICONS: Record<IconName, LucideIcon> = {
147
160
  'scan-line': ScanLine,
148
161
  'scan-face': ScanFace,
149
162
  nfc: Nfc,
163
+ filter: Filter,
150
164
  pencil: Pencil,
151
165
  search: Search,
152
166
  zap: Zap,
@@ -1,21 +1,21 @@
1
- import React, { useEffect, useState } from "react";
2
- import { Image, Platform, ScrollView, StatusBar, View } from "react-native";
3
- import { SvgXml } from "react-native-svg";
1
+ import React from "react";
2
+ import { Platform, ScrollView, StatusBar, View } from "react-native";
4
3
  import { StatusBarController } from "./StatusBarController";
5
4
  import { initialWindowMetrics } from "react-native-safe-area-context";
6
5
 
7
- import { headerSurface, radius, spacing } from "../config/theme";
6
+ import { headerSurface, spacing } from "../config/theme";
8
7
  import { useKeyboardInset } from "../lib/use-keyboard-inset";
9
8
  import type { SupportedCountry } from "../types/config";
10
9
  import { useKycConfig, useTheme } from "./runtime";
11
10
  import { useBranding } from "./useBranding";
12
- import { MyazaText } from "./Typography";
11
+ import { BrandBar } from "./BrandBar";
13
12
  import { PoweredBy } from "./PoweredBy";
14
13
  import { StepHeader } from "./StepHeader";
15
14
  import { StepIndicator } from "./StepIndicator";
16
15
  import { ProgressBar } from "./ProgressBar";
17
16
  import { GlassIconButton } from "./GlassIconButton";
18
17
  import { GlassSurface } from "./glass/GlassSurface";
18
+ import { SandboxBanner, useSandboxBannerVisible } from "./SandboxBanner";
19
19
  import { GlassGroup } from "./glass/GlassGroup";
20
20
  import { FlashOverlay } from "./FlashOverlay";
21
21
 
@@ -84,13 +84,31 @@ export function KycSheet({
84
84
  const keyboardInset = useKeyboardInset();
85
85
 
86
86
  const tint = headerSurface(colors, mode);
87
+ // Decides who absorbs the status-bar inset: the banner sits above the header,
88
+ // so when it renders it is the topmost element and takes it instead.
89
+ const bannerVisible = useSandboxBannerVisible();
87
90
  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;
91
+ // The three styles are mutually exclusive: the bar REPLACES the step row
92
+ // rather than joining it (two progress indicators on one header would be
93
+ // noise, and the point of the bar is that it costs no height), and 'none'
94
+ // drops both. Switching on the resolved style rather than negating flags
95
+ // keeps a future fourth style from silently falling into the 'steps' branch.
96
+ const progressStyle = config.progressStyle ?? "steps";
97
+ const showIndicator = hasProgress && progressStyle === "steps";
98
+ const showBar = hasProgress && progressStyle === "bar";
99
+
100
+ // The header's bottom padding is the GAP to whatever sits under the brand
101
+ // row — the title block or the step row. With neither (a step that owns its
102
+ // own title, and progress off or drawn as the edge bar), that padding is
103
+ // space to a row that never renders, and the brand + controls read as
104
+ // sitting high in the band rather than centred on one line. Fall back to the
105
+ // row's own top padding so the two edges match.
106
+ //
107
+ // The bar is exempt: it is absolutely positioned ON the bottom edge, so the
108
+ // wider padding is what keeps it clear of the controls.
109
+ const hasRowBelowBrand = !!(title || onBack) || showIndicator;
110
+ const headerPaddingBottom =
111
+ hasRowBelowBrand || showBar ? spacing.md : spacing.sm;
94
112
  const showBrand = !hideBrand && !!logoUri;
95
113
 
96
114
  // On iOS the modal is a pageSheet by default (sits below the status bar), but
@@ -143,6 +161,11 @@ export function KycSheet({
143
161
  />
144
162
  ) : null}
145
163
  <View style={{ flex: 1 }}>
164
+ {/* Above the header block (and outside the immersive branch) so a
165
+ capture step that hides the chrome still says it is not live. It is
166
+ then the topmost element, so it — not the header — absorbs the
167
+ status-bar inset. */}
168
+ <SandboxBanner topInset={topInset} />
146
169
  {/* Header block (glass on iOS 26, tinted surface otherwise) — dropped
147
170
  entirely when the step owns the display. */}
148
171
  {immersive ? null : (
@@ -154,7 +177,7 @@ export function KycSheet({
154
177
  // border would double it.
155
178
  borderBottomWidth: showBar ? 0 : 1,
156
179
  borderBottomColor: colors.border,
157
- paddingBottom: spacing.md,
180
+ paddingBottom: headerPaddingBottom,
158
181
  }}
159
182
  >
160
183
  {/* Row 1 — brand + controls */}
@@ -163,7 +186,10 @@ export function KycSheet({
163
186
  flexDirection: "row",
164
187
  alignItems: "center",
165
188
  paddingHorizontal: spacing.md,
166
- paddingTop: topInset + spacing.sm,
189
+ // The banner, when shown, already cleared the status bar above
190
+ // us — padding for it again would leave a gap the width of the
191
+ // notch between the strip and the brand row.
192
+ paddingTop: (bannerVisible ? 0 : topInset) + spacing.sm,
167
193
  }}
168
194
  >
169
195
  <View
@@ -307,130 +333,3 @@ export function KycSheet({
307
333
  </View>
308
334
  );
309
335
  }
310
-
311
- /** Whether a logo URI points at an SVG (extension check, query-safe). */
312
- function isSvgUri(uri: string): boolean {
313
- const path = uri.split("?")[0] ?? uri;
314
- return path.toLowerCase().endsWith(".svg");
315
- }
316
-
317
- // Persistent header brand — circular logo avatar + company name. A broken/missing
318
- // logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
319
- // Flutter's `errorBuilder`), so the header never shows a broken-image box.
320
- function BrandBar({
321
- logoUri,
322
- companyName,
323
- }: {
324
- logoUri: string;
325
- companyName: string;
326
- }): React.ReactElement | null {
327
- const { colors } = useTheme();
328
- const [broken, setBroken] = useState(false);
329
- const svg = isSvgUri(logoUri);
330
- // SVG logos render through SvgXml with SELF-FETCHED markup — the exact
331
- // pipeline the country flags use, which is proven on-device. SvgUri's own
332
- // fetch/sizing was flaky here (the favicon rendered at intrinsic size
333
- // instead of filling the chip).
334
- const [xml, setXml] = useState<string | null>(null);
335
- useEffect(() => {
336
- if (!svg) return;
337
- let cancelled = false;
338
- fetch(logoUri)
339
- .then((r) =>
340
- r.ok ? r.text() : Promise.reject(new Error(String(r.status))),
341
- )
342
- .then((text) => {
343
- if (!cancelled) setXml(text);
344
- })
345
- .catch(() => {
346
- if (!cancelled) setBroken(true);
347
- });
348
- return () => {
349
- cancelled = true;
350
- };
351
- }, [logoUri, svg]);
352
- if (broken) return null;
353
- return (
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. */}
366
- <View
367
- style={{
368
- width: 28,
369
- height: 28,
370
- borderRadius: radius.full,
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.
375
- borderWidth: 1,
376
- borderColor: "rgba(0,0,0,0.05)",
377
- shadowColor: "#000000",
378
- shadowOpacity: 0.06,
379
- shadowRadius: 4,
380
- shadowOffset: { width: 0, height: 1 },
381
- elevation: 1,
382
- marginRight: spacing.sm,
383
- alignItems: "center",
384
- justifyContent: "center",
385
- }}
386
- >
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)}
422
- />
423
- )}
424
- </View>
425
- </View>
426
- <MyazaText
427
- variant="heading3"
428
- numberOfLines={1}
429
- color={colors.textDark}
430
- style={{ flexShrink: 1, fontSize: 14 }}
431
- >
432
- {companyName}
433
- </MyazaText>
434
- </>
435
- );
436
- }
@@ -1,10 +1,11 @@
1
1
  import React, { useRef } from 'react';
2
- import { Modal, Platform, Pressable, View } from 'react-native';
2
+ import { Platform, Pressable, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { Icon, type IconName } from './Icon';
6
6
  import { useTheme } from './runtime';
7
7
  import { MyazaText } from './Typography';
8
+ import { FloatingSheet } from './glass/FloatingSheet';
8
9
 
9
10
  /**
10
11
  * The SDK's own "where is the document?" sheet.
@@ -61,46 +62,16 @@ export function MediaSourceSheet({
61
62
  };
62
63
 
63
64
  return (
64
- <Modal
65
- transparent
65
+ <FloatingSheet
66
66
  visible={open}
67
- animationType="fade"
68
- onRequestClose={onClose}
67
+ onClose={onClose}
69
68
  onDismiss={() => {
70
69
  const run = pendingRef.current;
71
70
  pendingRef.current = null;
72
71
  run?.();
73
72
  }}
74
73
  >
75
- <Pressable
76
- onPress={onClose}
77
- style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
78
- >
79
- {/* Swallow taps on the card so only the backdrop dismisses. */}
80
- <Pressable
81
- onPress={() => undefined}
82
- style={{
83
- backgroundColor: colors.background,
84
- borderTopLeftRadius: radius.xl,
85
- borderTopRightRadius: radius.xl,
86
- padding: spacing.md,
87
- paddingBottom: spacing.xl,
88
- }}
89
- >
90
- {/* Grab handle — the affordance that says "sheet", and therefore
91
- "swipe-away-able", without a close button competing with the
92
- options. */}
93
- <View
94
- style={{
95
- alignSelf: 'center',
96
- width: 36,
97
- height: 4,
98
- borderRadius: 2,
99
- backgroundColor: colors.border,
100
- marginBottom: spacing.md,
101
- }}
102
- />
103
-
74
+ <View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
104
75
  <MyazaText variant="heading3" style={{ textAlign: 'center', marginBottom: spacing.md }}>
105
76
  {title}
106
77
  </MyazaText>
@@ -156,8 +127,7 @@ export function MediaSourceSheet({
156
127
  Cancel
157
128
  </MyazaText>
158
129
  </Pressable>
159
- </Pressable>
160
- </Pressable>
161
- </Modal>
130
+ </View>
131
+ </FloatingSheet>
162
132
  );
163
133
  }