@myazahq/kyc-sdk-react-native 2.0.2 → 2.2.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 +152 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +70 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +96 -1
- package/src/config/keyPeople.ts +162 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +322 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +207 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +156 -132
- package/src/screens/IdInputStep.tsx +13 -5
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/KeyPersonForm.tsx +148 -0
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/LivenessStep.tsx +155 -247
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +2 -2
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -124
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- package/src/__tests__/validators.test.ts +0 -38
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Pressable, Share, View } from 'react-native';
|
|
3
|
+
import * as Clipboard from 'expo-clipboard';
|
|
4
|
+
|
|
5
|
+
import { radius, spacing } from '../config/theme';
|
|
6
|
+
import { useKyc, useTheme } from '../components/runtime';
|
|
7
|
+
import { MyazaText } from '../components/Typography';
|
|
8
|
+
import { Icon } from '../components/Icon';
|
|
9
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
10
|
+
import { regionCountryName } from '../config/regions';
|
|
11
|
+
import { KEY_PERSON_ROLE_LABELS } from '../config/keyPeople';
|
|
12
|
+
import type { KeyPersonInvite } from '../services/api-types';
|
|
13
|
+
import type { ApplicantRole, KeyPersonRole } from '../types/business';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// "Awaiting users" — the KYB success screen's people section. Every person the
|
|
17
|
+
// review is waiting on, grouped by role, each with a status pill; pending
|
|
18
|
+
// people carry a shareable verification link, and the APPLICANT (who verified
|
|
19
|
+
// in-flow) appears with a green Submitted pill so the picture is complete.
|
|
20
|
+
// Mirrors the web SDK's KeyPeopleInviteLinks 1:1 — mobile shares via the
|
|
21
|
+
// native sheet (reaches WhatsApp/SMS, still offers Copy inside).
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
type AwaitRole = ApplicantRole;
|
|
25
|
+
|
|
26
|
+
interface AwaitRow {
|
|
27
|
+
name: string;
|
|
28
|
+
role: AwaitRole;
|
|
29
|
+
pct: string | null;
|
|
30
|
+
country: string | null;
|
|
31
|
+
status: 'submitted' | 'pending';
|
|
32
|
+
inviteUrl?: string;
|
|
33
|
+
isApplicant?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ROLE_LABELS: Record<AwaitRole, string> = {
|
|
37
|
+
...KEY_PERSON_ROLE_LABELS,
|
|
38
|
+
authorized_representative: 'Authorized representative',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SECTIONS: Array<{ role: AwaitRole; label: string }> = [
|
|
42
|
+
{ role: 'beneficial_owner', label: 'UBOS' },
|
|
43
|
+
{ role: 'director', label: 'DIRECTORS' },
|
|
44
|
+
{ role: 'signatory', label: 'SIGNATORIES' },
|
|
45
|
+
{ role: 'shareholder', label: 'SHAREHOLDERS' },
|
|
46
|
+
{ role: 'authorized_representative', label: 'REPRESENTATIVES' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function KeyPeopleInviteLinks({
|
|
50
|
+
invites,
|
|
51
|
+
}: {
|
|
52
|
+
invites: KeyPersonInvite[];
|
|
53
|
+
}): React.ReactElement | null {
|
|
54
|
+
const { colors } = useTheme();
|
|
55
|
+
const app = useKyc((s) => s.businessApplication);
|
|
56
|
+
if (invites.length === 0) return null;
|
|
57
|
+
|
|
58
|
+
const rows: AwaitRow[] = [];
|
|
59
|
+
|
|
60
|
+
// The applicant themselves — verified in-flow, nothing more to do.
|
|
61
|
+
if (app.applicantRole) {
|
|
62
|
+
const self =
|
|
63
|
+
app.applicantKeyPersonIndex !== null ? app.keyPeople[app.applicantKeyPersonIndex] : null;
|
|
64
|
+
rows.push({
|
|
65
|
+
name: (self?.name ?? app.applicantName).trim() || 'You',
|
|
66
|
+
role: (self?.role ?? app.applicantRole) as AwaitRole,
|
|
67
|
+
pct: self?.ownershipPct.trim() || null,
|
|
68
|
+
country: self?.country.trim() ? self.country.trim().toUpperCase() : null,
|
|
69
|
+
status: 'submitted',
|
|
70
|
+
isApplicant: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Everyone the server minted a link for — enriched from the entered rows.
|
|
75
|
+
for (const invite of invites) {
|
|
76
|
+
const entered = app.keyPeople.find((p) => p.name.trim() === invite.name.trim());
|
|
77
|
+
rows.push({
|
|
78
|
+
name: invite.name,
|
|
79
|
+
role: (entered?.role ?? 'director') as AwaitRole,
|
|
80
|
+
pct: entered?.ownershipPct.trim() || null,
|
|
81
|
+
country: entered?.country.trim() ? entered.country.trim().toUpperCase() : null,
|
|
82
|
+
status: 'pending',
|
|
83
|
+
inviteUrl: invite.inviteUrl,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<View style={{ width: '100%', marginTop: spacing.lg }}>
|
|
89
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center', marginBottom: spacing.md }}>
|
|
90
|
+
To complete the review, the people below must verify their identity with a KYC check.
|
|
91
|
+
Anyone with an email on file has already been sent their link.
|
|
92
|
+
</MyazaText>
|
|
93
|
+
|
|
94
|
+
{SECTIONS.map(({ role, label }) => {
|
|
95
|
+
const group = rows.filter((r) => r.role === role);
|
|
96
|
+
if (group.length === 0) return null;
|
|
97
|
+
return (
|
|
98
|
+
<View key={role} style={{ marginBottom: spacing.sm }}>
|
|
99
|
+
<MyazaText
|
|
100
|
+
variant="bodySmall"
|
|
101
|
+
color={colors.textMuted}
|
|
102
|
+
style={{ fontWeight: '600', letterSpacing: 1.2, marginBottom: spacing.xs + 2 }}
|
|
103
|
+
>
|
|
104
|
+
{label}
|
|
105
|
+
</MyazaText>
|
|
106
|
+
{group.map((row, i) => (
|
|
107
|
+
<AwaitCard key={`${role}-${i}`} row={row} />
|
|
108
|
+
))}
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
|
|
113
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
|
|
114
|
+
Links are valid for 14 days.
|
|
115
|
+
</MyazaText>
|
|
116
|
+
</View>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function AwaitCard({ row }: { row: AwaitRow }): React.ReactElement {
|
|
121
|
+
const { colors } = useTheme();
|
|
122
|
+
const submitted = row.status === 'submitted';
|
|
123
|
+
const [copied, setCopied] = useState(false);
|
|
124
|
+
|
|
125
|
+
const share = (): void => {
|
|
126
|
+
if (!row.inviteUrl) return;
|
|
127
|
+
Share.share({ message: row.inviteUrl }).catch(() => undefined);
|
|
128
|
+
};
|
|
129
|
+
const copy = (): void => {
|
|
130
|
+
if (!row.inviteUrl) return;
|
|
131
|
+
Clipboard.setStringAsync(row.inviteUrl)
|
|
132
|
+
.then(() => {
|
|
133
|
+
setCopied(true);
|
|
134
|
+
setTimeout(() => setCopied(false), 2000);
|
|
135
|
+
})
|
|
136
|
+
.catch(() => undefined);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<View
|
|
141
|
+
style={{
|
|
142
|
+
backgroundColor: colors.backgroundSecondary,
|
|
143
|
+
borderRadius: radius.md,
|
|
144
|
+
padding: spacing.md,
|
|
145
|
+
marginBottom: spacing.sm,
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
|
149
|
+
<View style={{ flex: 1, minWidth: 0, marginRight: spacing.sm }}>
|
|
150
|
+
<MyazaText variant="bodyMedium" numberOfLines={1} style={{ fontWeight: '700' }}>
|
|
151
|
+
{row.name}
|
|
152
|
+
{row.isApplicant ? (
|
|
153
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
154
|
+
{' (you)'}
|
|
155
|
+
</MyazaText>
|
|
156
|
+
) : null}
|
|
157
|
+
</MyazaText>
|
|
158
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', marginTop: 2 }}>
|
|
159
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
160
|
+
{ROLE_LABELS[row.role]}
|
|
161
|
+
{row.pct ? ` · ${row.pct}%` : ''}
|
|
162
|
+
{row.country ? ' · ' : ''}
|
|
163
|
+
</MyazaText>
|
|
164
|
+
{row.country ? (
|
|
165
|
+
<>
|
|
166
|
+
<CountryFlag country={row.country} size={14} />
|
|
167
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
168
|
+
{` ${regionCountryName(row.country)}`}
|
|
169
|
+
</MyazaText>
|
|
170
|
+
</>
|
|
171
|
+
) : null}
|
|
172
|
+
</View>
|
|
173
|
+
</View>
|
|
174
|
+
<View
|
|
175
|
+
style={{
|
|
176
|
+
borderRadius: radius.full,
|
|
177
|
+
paddingHorizontal: spacing.sm + 4,
|
|
178
|
+
paddingVertical: spacing.xs,
|
|
179
|
+
backgroundColor: submitted ? colors.successBg : colors.primary100,
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<MyazaText
|
|
183
|
+
variant="bodySmall"
|
|
184
|
+
color={submitted ? colors.success : colors.primary}
|
|
185
|
+
style={{ fontWeight: '700', fontSize: 11, letterSpacing: 0.6 }}
|
|
186
|
+
>
|
|
187
|
+
{submitted ? 'SUBMITTED' : 'KYC PENDING'}
|
|
188
|
+
</MyazaText>
|
|
189
|
+
</View>
|
|
190
|
+
</View>
|
|
191
|
+
|
|
192
|
+
{row.status === 'pending' && row.inviteUrl ? (
|
|
193
|
+
// Both actions, since it's mobile: Copy for pasting anywhere, Share
|
|
194
|
+
// for the native sheet (WhatsApp/SMS directly).
|
|
195
|
+
<View style={{ flexDirection: 'row', marginTop: spacing.sm + 4 }}>
|
|
196
|
+
<Pressable
|
|
197
|
+
onPress={copy}
|
|
198
|
+
accessibilityRole="button"
|
|
199
|
+
accessibilityLabel={`Copy ${row.name}'s verification link`}
|
|
200
|
+
style={{
|
|
201
|
+
flex: 1,
|
|
202
|
+
flexDirection: 'row',
|
|
203
|
+
alignItems: 'center',
|
|
204
|
+
justifyContent: 'center',
|
|
205
|
+
backgroundColor: colors.primary100,
|
|
206
|
+
borderRadius: radius.full,
|
|
207
|
+
paddingVertical: spacing.sm + 2,
|
|
208
|
+
marginRight: spacing.sm,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<Icon name={copied ? 'check' : 'copy'} size={15} color={colors.primary} />
|
|
212
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700', marginLeft: spacing.xs + 2 }}>
|
|
213
|
+
{copied ? 'Copied' : 'Copy link'}
|
|
214
|
+
</MyazaText>
|
|
215
|
+
</Pressable>
|
|
216
|
+
<Pressable
|
|
217
|
+
onPress={share}
|
|
218
|
+
accessibilityRole="button"
|
|
219
|
+
accessibilityLabel={`Share ${row.name}'s verification link`}
|
|
220
|
+
style={{
|
|
221
|
+
flex: 1,
|
|
222
|
+
flexDirection: 'row',
|
|
223
|
+
alignItems: 'center',
|
|
224
|
+
justifyContent: 'center',
|
|
225
|
+
backgroundColor: colors.primary100,
|
|
226
|
+
borderRadius: radius.full,
|
|
227
|
+
paddingVertical: spacing.sm + 2,
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<Icon name="share" size={15} color={colors.primary} />
|
|
231
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700', marginLeft: spacing.xs + 2 }}>
|
|
232
|
+
Share
|
|
233
|
+
</MyazaText>
|
|
234
|
+
</Pressable>
|
|
235
|
+
</View>
|
|
236
|
+
) : null}
|
|
237
|
+
</View>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { Icon } from '../components/Icon';
|
|
8
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
+
import {
|
|
10
|
+
KEY_PERSON_ROLE_LABELS,
|
|
11
|
+
initialsOf,
|
|
12
|
+
isKeyPersonRowValid,
|
|
13
|
+
type KeyPersonEntry,
|
|
14
|
+
} from '../config/keyPeople';
|
|
15
|
+
import { regionCountryName } from '../config/regions';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// One saved key person, summarised — monogram avatar with their ID-issuing
|
|
19
|
+
// country flag badged on its corner, name, role · ownership meta, and the
|
|
20
|
+
// email their invite will go to. The whole card opens the edit sheet (the
|
|
21
|
+
// chevron is the affordance); removal lives INSIDE that sheet, so a stray tap
|
|
22
|
+
// can never delete a person.
|
|
23
|
+
//
|
|
24
|
+
// Same visual language as the applicant step's "this is me" cards, so the two
|
|
25
|
+
// screens read as one system.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function KeyPersonCard({
|
|
29
|
+
entry,
|
|
30
|
+
onPress,
|
|
31
|
+
}: {
|
|
32
|
+
entry: KeyPersonEntry;
|
|
33
|
+
onPress: () => void;
|
|
34
|
+
}): React.ReactElement {
|
|
35
|
+
const { colors } = useTheme();
|
|
36
|
+
const name = entry.name.trim() || 'Unnamed person';
|
|
37
|
+
const country = entry.country.trim() ? entry.country.trim().toUpperCase() : null;
|
|
38
|
+
const pct = entry.ownershipPct.trim();
|
|
39
|
+
// A row persisted by the old inline UI (or interrupted mid-edit) may be
|
|
40
|
+
// incomplete — the card says so instead of silently blocking Continue.
|
|
41
|
+
const incomplete = !isKeyPersonRowValid(entry);
|
|
42
|
+
|
|
43
|
+
const meta = [KEY_PERSON_ROLE_LABELS[entry.role], pct ? `${pct}% ownership` : null]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(' · ');
|
|
46
|
+
// The flag alone doesn't say WHICH country — spell it out, alongside the
|
|
47
|
+
// email their invite goes to.
|
|
48
|
+
const detail = [country ? regionCountryName(country) : null, entry.email.trim() || null]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(' · ');
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Pressable
|
|
54
|
+
onPress={onPress}
|
|
55
|
+
accessibilityRole="button"
|
|
56
|
+
accessibilityLabel={`Edit ${name}`}
|
|
57
|
+
style={({ pressed }) => ({
|
|
58
|
+
flexDirection: 'row',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
borderWidth: 1,
|
|
61
|
+
borderColor: incomplete ? colors.error : colors.border,
|
|
62
|
+
borderRadius: radius.sm,
|
|
63
|
+
backgroundColor: pressed ? colors.backgroundSecondary : colors.background,
|
|
64
|
+
padding: spacing.md - 2,
|
|
65
|
+
marginBottom: spacing.sm,
|
|
66
|
+
})}
|
|
67
|
+
>
|
|
68
|
+
<View
|
|
69
|
+
style={{
|
|
70
|
+
width: 40,
|
|
71
|
+
height: 40,
|
|
72
|
+
borderRadius: 20,
|
|
73
|
+
backgroundColor: colors.primary100,
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
justifyContent: 'center',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
|
|
79
|
+
{initialsOf(name)}
|
|
80
|
+
</MyazaText>
|
|
81
|
+
{country ? (
|
|
82
|
+
<View
|
|
83
|
+
style={{
|
|
84
|
+
position: 'absolute',
|
|
85
|
+
bottom: -2,
|
|
86
|
+
right: -4,
|
|
87
|
+
borderRadius: 10,
|
|
88
|
+
borderWidth: 2,
|
|
89
|
+
borderColor: colors.background,
|
|
90
|
+
overflow: 'hidden',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<CountryFlag country={country} size={16} />
|
|
94
|
+
</View>
|
|
95
|
+
) : null}
|
|
96
|
+
</View>
|
|
97
|
+
|
|
98
|
+
<View style={{ flex: 1, minWidth: 0, marginLeft: spacing.sm + 4 }}>
|
|
99
|
+
<MyazaText variant="bodyMedium" numberOfLines={1} style={{ fontWeight: '600' }}>
|
|
100
|
+
{name}
|
|
101
|
+
</MyazaText>
|
|
102
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
103
|
+
{meta}
|
|
104
|
+
</MyazaText>
|
|
105
|
+
{incomplete ? (
|
|
106
|
+
<MyazaText variant="bodySmall" color={colors.error} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
107
|
+
Incomplete — tap to finish
|
|
108
|
+
</MyazaText>
|
|
109
|
+
) : detail !== '' ? (
|
|
110
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
111
|
+
{detail}
|
|
112
|
+
</MyazaText>
|
|
113
|
+
) : null}
|
|
114
|
+
</View>
|
|
115
|
+
|
|
116
|
+
<Icon name="pencil" size={16} color={colors.textMuted} />
|
|
117
|
+
</Pressable>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaInput } from '../components/MyazaInput';
|
|
8
|
+
import { MyazaSelect } from '../components/MyazaSelect';
|
|
9
|
+
import { CountryField } from '../components/CountryField';
|
|
10
|
+
import type { DialCodeOption } from '../components/DialCodePicker';
|
|
11
|
+
import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
|
|
12
|
+
import { isValidContactEmail } from '../config/contact';
|
|
13
|
+
import {
|
|
14
|
+
KEY_PERSON_ROLES,
|
|
15
|
+
KEY_PERSON_ROLE_LABELS,
|
|
16
|
+
type KeyPersonEntry,
|
|
17
|
+
} from '../config/keyPeople';
|
|
18
|
+
import type { KeyPersonRole } from '../types/business';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The key-person FIELDS — full name → role → ownership % → country → email —
|
|
22
|
+
// with the same live per-field validation the old inline row had. No card
|
|
23
|
+
// chrome, no header: this is the body of the add/edit sheet (KeyPersonSheet),
|
|
24
|
+
// which owns the draft state and the save/remove actions.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Whether the role is an ownership one (% is only meaningful then). */
|
|
28
|
+
function isOwnerRole(role: KeyPersonRole): boolean {
|
|
29
|
+
return role === 'beneficial_owner' || role === 'shareholder';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every ISO-2 we can name, alphabetical — built once, shared by all mounts. */
|
|
33
|
+
let countryOptions: DialCodeOption[] | null = null;
|
|
34
|
+
function allCountryOptions(): DialCodeOption[] {
|
|
35
|
+
countryOptions ??= [...ALL_REGION_CODES]
|
|
36
|
+
.map((code) => ({ code, name: regionCountryName(code) }))
|
|
37
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
38
|
+
return countryOptions;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function KeyPersonForm({
|
|
42
|
+
entry,
|
|
43
|
+
onChange,
|
|
44
|
+
uboThreshold = 25,
|
|
45
|
+
combinedPctError = null,
|
|
46
|
+
}: {
|
|
47
|
+
entry: KeyPersonEntry;
|
|
48
|
+
onChange: (patch: Partial<KeyPersonEntry>) => void;
|
|
49
|
+
/** Ownership % at/above which the server treats a person as a beneficial
|
|
50
|
+
* owner (the workflow's `keyPeople.ownershipThreshold`, default 25). */
|
|
51
|
+
uboThreshold?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Set when this draft's % would push the COMBINED ownership across all
|
|
54
|
+
* people past 100% — shown on the % field as a warning. It never blocks
|
|
55
|
+
* saving (the fix may live on a different person); the list's summary and
|
|
56
|
+
* the disabled Continue enforce the total.
|
|
57
|
+
*/
|
|
58
|
+
combinedPctError?: string | null;
|
|
59
|
+
}): React.ReactElement {
|
|
60
|
+
const { colors } = useTheme();
|
|
61
|
+
const options = useMemo(allCountryOptions, []);
|
|
62
|
+
|
|
63
|
+
const nameInvalid = entry.name !== '' && entry.name.trim().length < 2;
|
|
64
|
+
const emailInvalid = entry.email.trim() !== '' && !isValidContactEmail(entry.email.trim());
|
|
65
|
+
const pct = entry.ownershipPct.trim();
|
|
66
|
+
const pctNum = Number(pct);
|
|
67
|
+
const pctInvalid = pct !== '' && (!Number.isFinite(pctNum) || pctNum < 0 || pctNum > 100);
|
|
68
|
+
// Surface the regulatory consequence as feedback: at/above the threshold the
|
|
69
|
+
// server escalates this person to a beneficial owner regardless of the role
|
|
70
|
+
// picked. Quiet when they already chose UBO — nothing new to say.
|
|
71
|
+
const uboHint =
|
|
72
|
+
!pctInvalid && pct !== '' && pctNum >= uboThreshold && entry.role !== 'beneficial_owner';
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View>
|
|
76
|
+
<MyazaInput
|
|
77
|
+
label="Full name"
|
|
78
|
+
value={entry.name}
|
|
79
|
+
onChangeText={(name) => onChange({ name })}
|
|
80
|
+
placeholder="e.g. Bola Owner"
|
|
81
|
+
// It's a person's name — start every word capitalized.
|
|
82
|
+
autoCapitalize="words"
|
|
83
|
+
error={nameInvalid ? 'Enter the person’s full name.' : null}
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
<View style={{ height: spacing.sm }} />
|
|
87
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
88
|
+
Role
|
|
89
|
+
</MyazaText>
|
|
90
|
+
<MyazaSelect
|
|
91
|
+
value={entry.role}
|
|
92
|
+
sheetTitle="Role"
|
|
93
|
+
options={KEY_PERSON_ROLES.map((role) => ({
|
|
94
|
+
value: role,
|
|
95
|
+
label: KEY_PERSON_ROLE_LABELS[role],
|
|
96
|
+
}))}
|
|
97
|
+
onChange={(role) => onChange({ role })}
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<View style={{ height: spacing.sm }} />
|
|
101
|
+
<MyazaInput
|
|
102
|
+
label="Ownership % (optional)"
|
|
103
|
+
value={entry.ownershipPct}
|
|
104
|
+
onChangeText={(ownershipPct) => onChange({ ownershipPct })}
|
|
105
|
+
placeholder={isOwnerRole(entry.role) ? 'e.g. 60' : '—'}
|
|
106
|
+
keyboardType="decimal-pad"
|
|
107
|
+
suffix={
|
|
108
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
109
|
+
%
|
|
110
|
+
</MyazaText>
|
|
111
|
+
}
|
|
112
|
+
error={pctInvalid ? 'Enter a value between 0 and 100.' : combinedPctError}
|
|
113
|
+
helper={
|
|
114
|
+
uboHint
|
|
115
|
+
? `At ${uboThreshold}% or more, this person counts as a beneficial owner.`
|
|
116
|
+
: undefined
|
|
117
|
+
}
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<View style={{ height: spacing.sm }} />
|
|
121
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
122
|
+
Country{' '}
|
|
123
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
124
|
+
(where their ID was issued)
|
|
125
|
+
</MyazaText>
|
|
126
|
+
</MyazaText>
|
|
127
|
+
{/* The SAME sheet as the phone field's dial-code picker (keyboard-aware,
|
|
128
|
+
autofocused search, results pinned above the keys) — minus the dial
|
|
129
|
+
codes. Two country pickers that feel different would read as a bug. */}
|
|
130
|
+
<CountryField
|
|
131
|
+
value={entry.country || null}
|
|
132
|
+
options={options}
|
|
133
|
+
onChange={(country) => onChange({ country })}
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<View style={{ height: spacing.sm }} />
|
|
137
|
+
<MyazaInput
|
|
138
|
+
label="Email (optional — used to send their verification link)"
|
|
139
|
+
value={entry.email}
|
|
140
|
+
onChangeText={(email) => onChange({ email })}
|
|
141
|
+
placeholder="name@company.com"
|
|
142
|
+
keyboardType="email-address"
|
|
143
|
+
autoCapitalize="none"
|
|
144
|
+
error={emailInvalid ? 'Enter a valid email address.' : null}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Keyboard,
|
|
4
|
+
Modal,
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
useWindowDimensions,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { radius, spacing } from '../config/theme';
|
|
13
|
+
import { useTheme } from '../components/runtime';
|
|
14
|
+
import { MyazaText } from '../components/Typography';
|
|
15
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
16
|
+
import { Icon } from '../components/Icon';
|
|
17
|
+
import { KeyPersonForm } from './KeyPersonForm';
|
|
18
|
+
import { isKeyPersonRowValid, type KeyPersonEntry } from '../config/keyPeople';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The add/edit key-person sheet. The list step stays a clean stack of summary
|
|
22
|
+
// cards; the FORM lives here — slide-up sheet, grab handle, the five fields,
|
|
23
|
+
// and a pinned primary action. Editing adds a visually separated destructive
|
|
24
|
+
// "Remove this person" beneath the save (never beside it — HIG destructive
|
|
25
|
+
// separation).
|
|
26
|
+
//
|
|
27
|
+
// Keyboard handling mirrors DialCodePicker: the sheet is lifted above the keys
|
|
28
|
+
// and sized against the space that remains, so the focused field is never
|
|
29
|
+
// underneath the keyboard.
|
|
30
|
+
//
|
|
31
|
+
// Save is enabled once the draft is valid. A combined-ownership overshoot
|
|
32
|
+
// WARNS here but never blocks saving — the fix may live on a different
|
|
33
|
+
// person's %, and trapping the user inside this sheet would force them to
|
|
34
|
+
// discard their work to go adjust it.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export function KeyPersonSheet({
|
|
38
|
+
mode,
|
|
39
|
+
initial,
|
|
40
|
+
uboThreshold,
|
|
41
|
+
otherPctTotal,
|
|
42
|
+
onSave,
|
|
43
|
+
onRemove,
|
|
44
|
+
onClose,
|
|
45
|
+
}: {
|
|
46
|
+
mode: 'add' | 'edit';
|
|
47
|
+
initial: KeyPersonEntry;
|
|
48
|
+
uboThreshold?: number;
|
|
49
|
+
/** Sum of every OTHER person's ownership % — for the combined warning. */
|
|
50
|
+
otherPctTotal: number;
|
|
51
|
+
onSave: (entry: KeyPersonEntry) => void;
|
|
52
|
+
/** Edit mode only — removes the person and closes. */
|
|
53
|
+
onRemove?: () => void;
|
|
54
|
+
onClose: () => void;
|
|
55
|
+
}): React.ReactElement {
|
|
56
|
+
const { colors } = useTheme();
|
|
57
|
+
const { height: screenHeight } = useWindowDimensions();
|
|
58
|
+
const [draft, setDraft] = useState<KeyPersonEntry>(initial);
|
|
59
|
+
const [keyboard, setKeyboard] = useState(0);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
// iOS reports 'will' events early enough to resize before the keys land;
|
|
63
|
+
// Android only emits 'did'.
|
|
64
|
+
const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
65
|
+
const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
66
|
+
const shown = Keyboard.addListener(show, (e) => setKeyboard(e.endCoordinates.height));
|
|
67
|
+
const hidden = Keyboard.addListener(hide, () => setKeyboard(0));
|
|
68
|
+
return () => {
|
|
69
|
+
shown.remove();
|
|
70
|
+
hidden.remove();
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const available = screenHeight - keyboard;
|
|
75
|
+
const maxHeight = Math.min(available * 0.92, screenHeight * 0.85);
|
|
76
|
+
|
|
77
|
+
const draftPct = Number(draft.ownershipPct);
|
|
78
|
+
const combinedTotal =
|
|
79
|
+
draft.ownershipPct.trim() !== '' && Number.isFinite(draftPct)
|
|
80
|
+
? otherPctTotal + draftPct
|
|
81
|
+
: otherPctTotal;
|
|
82
|
+
const fmtPct = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1));
|
|
83
|
+
const combinedPctError =
|
|
84
|
+
combinedTotal > 100
|
|
85
|
+
? `Combined ownership would be ${fmtPct(combinedTotal)}% — over by ${fmtPct(combinedTotal - 100)}%.`
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
const canSave = isKeyPersonRowValid(draft) && draft.name.trim().length >= 2;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
|
92
|
+
{/* Same wrapper as MyazaSelect's option sheet: a flex-end backdrop with
|
|
93
|
+
the sheet inside it, so the top corners round identically. Tapping
|
|
94
|
+
the backdrop dismisses; a tap inside the sheet does not. */}
|
|
95
|
+
<Pressable
|
|
96
|
+
onPress={onClose}
|
|
97
|
+
accessibilityLabel="Close"
|
|
98
|
+
style={{
|
|
99
|
+
flex: 1,
|
|
100
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
101
|
+
justifyContent: 'flex-end',
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<Pressable
|
|
105
|
+
onPress={() => {}}
|
|
106
|
+
style={{
|
|
107
|
+
maxHeight,
|
|
108
|
+
marginBottom: keyboard,
|
|
109
|
+
backgroundColor: colors.background,
|
|
110
|
+
borderTopLeftRadius: radius.lg,
|
|
111
|
+
borderTopRightRadius: radius.lg,
|
|
112
|
+
overflow: 'hidden',
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<View
|
|
116
|
+
style={{
|
|
117
|
+
alignSelf: 'center',
|
|
118
|
+
width: 36,
|
|
119
|
+
height: 4,
|
|
120
|
+
borderRadius: radius.full,
|
|
121
|
+
backgroundColor: colors.border,
|
|
122
|
+
marginTop: spacing.sm,
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
<View
|
|
126
|
+
style={{
|
|
127
|
+
flexDirection: 'row',
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
paddingHorizontal: spacing.md,
|
|
130
|
+
paddingTop: spacing.sm + 4,
|
|
131
|
+
paddingBottom: spacing.sm,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<MyazaText variant="body" style={{ flex: 1, fontWeight: '700' }}>
|
|
135
|
+
{mode === 'add' ? 'Add a person' : 'Edit person'}
|
|
136
|
+
</MyazaText>
|
|
137
|
+
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button" accessibilityLabel="Close">
|
|
138
|
+
<Icon name="x" size={20} color={colors.textSecondary} />
|
|
139
|
+
</Pressable>
|
|
140
|
+
</View>
|
|
141
|
+
|
|
142
|
+
<ScrollView
|
|
143
|
+
bounces={false}
|
|
144
|
+
keyboardShouldPersistTaps="handled"
|
|
145
|
+
contentContainerStyle={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}
|
|
146
|
+
>
|
|
147
|
+
<KeyPersonForm
|
|
148
|
+
entry={draft}
|
|
149
|
+
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
|
|
150
|
+
uboThreshold={uboThreshold}
|
|
151
|
+
combinedPctError={combinedPctError}
|
|
152
|
+
/>
|
|
153
|
+
</ScrollView>
|
|
154
|
+
|
|
155
|
+
<View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm, paddingBottom: spacing.lg }}>
|
|
156
|
+
<MyazaButton
|
|
157
|
+
label={mode === 'add' ? 'Add person' : 'Save changes'}
|
|
158
|
+
onPress={canSave ? () => onSave(draft) : undefined}
|
|
159
|
+
disabled={!canSave}
|
|
160
|
+
/>
|
|
161
|
+
{mode === 'edit' && onRemove ? (
|
|
162
|
+
<Pressable
|
|
163
|
+
onPress={onRemove}
|
|
164
|
+
accessibilityRole="button"
|
|
165
|
+
accessibilityLabel="Remove this person"
|
|
166
|
+
style={({ pressed }) => ({
|
|
167
|
+
height: 44,
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
justifyContent: 'center',
|
|
170
|
+
marginTop: spacing.xs,
|
|
171
|
+
opacity: pressed ? 0.7 : 1,
|
|
172
|
+
})}
|
|
173
|
+
>
|
|
174
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ fontWeight: '600' }}>
|
|
175
|
+
Remove this person
|
|
176
|
+
</MyazaText>
|
|
177
|
+
</Pressable>
|
|
178
|
+
) : null}
|
|
179
|
+
</View>
|
|
180
|
+
</Pressable>
|
|
181
|
+
</Pressable>
|
|
182
|
+
</Modal>
|
|
183
|
+
);
|
|
184
|
+
}
|