mirror of
https://github.com/selfxyz/self.git
synced 2026-02-19 02:24:25 -05:00
SELF-1889: Initiate Sumsub during onboarding error flows (#1662)
* integrate sumsub into error flows * formatting * fix test * format * clean up * udpate flows * agent feedback * updates * save wip updates * clean up design * updates * lint * agent feedback * formatting * fix
This commit is contained in:
@@ -72,6 +72,9 @@ const config = {
|
||||
new RegExp(
|
||||
'packages/mobile-sdk-alpha/node_modules/react-native-svg(/|$)',
|
||||
),
|
||||
new RegExp(
|
||||
'packages/mobile-sdk-alpha/node_modules/react-native-webview(/|$)',
|
||||
),
|
||||
new RegExp('packages/mobile-sdk-demo/node_modules/react(/|$)'),
|
||||
new RegExp('packages/mobile-sdk-demo/node_modules/react-dom(/|$)'),
|
||||
new RegExp('packages/mobile-sdk-demo/node_modules/react-native(/|$)'),
|
||||
|
||||
27
app/src/hooks/useErrorInjection.ts
Normal file
27
app/src/hooks/useErrorInjection.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
|
||||
import { useErrorInjectionStore } from '@/stores/errorInjectionStore';
|
||||
import { IS_DEV_MODE } from '@/utils/devUtils';
|
||||
|
||||
/**
|
||||
* Hook for checking if a specific error should be injected
|
||||
* Only active in dev mode
|
||||
*/
|
||||
export function useErrorInjection() {
|
||||
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
|
||||
|
||||
const shouldInjectError = useCallback(
|
||||
(errorType: InjectedErrorType): boolean => {
|
||||
if (!IS_DEV_MODE) return false;
|
||||
return injectedErrors.includes(errorType);
|
||||
},
|
||||
[injectedErrors],
|
||||
);
|
||||
|
||||
return { shouldInjectError };
|
||||
}
|
||||
127
app/src/hooks/useSumsubLauncher.ts
Normal file
127
app/src/hooks/useSumsubLauncher.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
|
||||
import type { SumsubResult } from '@/integrations/sumsub/types';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
|
||||
export type FallbackErrorSource =
|
||||
| 'mrz_scan_failed'
|
||||
| 'nfc_scan_failed'
|
||||
| 'sumsub_initialization'
|
||||
| 'sumsub_verification';
|
||||
|
||||
export interface UseSumsubLauncherOptions {
|
||||
/**
|
||||
* Country code for the user's document
|
||||
*/
|
||||
countryCode: string;
|
||||
/**
|
||||
* Error source to track where the Sumsub launch was initiated from
|
||||
*/
|
||||
errorSource: FallbackErrorSource;
|
||||
/**
|
||||
* Optional callback to handle successful verification
|
||||
*/
|
||||
onSuccess?: (result: SumsubResult) => void | Promise<void>;
|
||||
/**
|
||||
* Optional callback to handle user cancellation
|
||||
*/
|
||||
onCancel?: () => void | Promise<void>;
|
||||
/**
|
||||
* Optional callback to handle verification failure
|
||||
*/
|
||||
onError?: (error: unknown, result?: SumsubResult) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for launching Sumsub verification with consistent error handling.
|
||||
*
|
||||
* Abstracts the common pattern of:
|
||||
* 1. Fetching access token
|
||||
* 2. Launching Sumsub SDK
|
||||
* 3. Handling errors by navigating to fallback screen
|
||||
* 4. Managing loading state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
* countryCode: 'US',
|
||||
* errorSource: 'nfc_scan_failed',
|
||||
* });
|
||||
*
|
||||
* <Button onPress={launchSumsubVerification} disabled={isLoading}>
|
||||
* {isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
* </Button>
|
||||
* ```
|
||||
*/
|
||||
export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
|
||||
const { countryCode, errorSource, onSuccess, onCancel, onError } = options;
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const launchSumsubVerification = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accessToken = await fetchAccessToken();
|
||||
const result = await launchSumsub({ accessToken: accessToken.token });
|
||||
|
||||
// Handle user cancellation
|
||||
if (!result.success && result.status === 'Interrupted') {
|
||||
await onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle verification failure
|
||||
if (!result.success) {
|
||||
const error = result.errorMsg || result.errorType || 'Unknown error';
|
||||
const safeError = sanitizeErrorMessage(error);
|
||||
console.error('Sumsub verification failed:', safeError);
|
||||
|
||||
// Call custom error handler if provided, otherwise navigate to fallback screen
|
||||
if (onError) {
|
||||
await onError(safeError, result);
|
||||
} else {
|
||||
navigation.navigate('RegistrationFallback', {
|
||||
errorSource,
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle success
|
||||
await onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const safeError = sanitizeErrorMessage(errorMessage);
|
||||
console.error('Error launching alternative verification:', safeError);
|
||||
|
||||
// Call custom error handler if provided, otherwise navigate to fallback screen
|
||||
if (onError) {
|
||||
await onError(safeError);
|
||||
} else {
|
||||
navigation.navigate('RegistrationFallback', {
|
||||
errorSource,
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]);
|
||||
|
||||
return {
|
||||
launchSumsubVerification,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import DocumentCameraTroubleScreen from '@/screens/documents/scanning/DocumentCa
|
||||
import DocumentNFCMethodSelectionScreen from '@/screens/documents/scanning/DocumentNFCMethodSelectionScreen';
|
||||
import DocumentNFCScanScreen from '@/screens/documents/scanning/DocumentNFCScanScreen';
|
||||
import DocumentNFCTroubleScreen from '@/screens/documents/scanning/DocumentNFCTroubleScreen';
|
||||
import RegistrationFallbackScreen from '@/screens/documents/scanning/RegistrationFallbackScreen';
|
||||
import ConfirmBelongingScreen from '@/screens/documents/selection/ConfirmBelongingScreen';
|
||||
import CountryPickerScreen from '@/screens/documents/selection/CountryPickerScreen';
|
||||
import DocumentOnboardingScreen from '@/screens/documents/selection/DocumentOnboardingScreen';
|
||||
@@ -155,6 +156,17 @@ const documentsScreens = {
|
||||
errorType: 'general',
|
||||
},
|
||||
},
|
||||
RegistrationFallback: {
|
||||
screen: RegistrationFallbackScreen,
|
||||
options: {
|
||||
title: 'REGISTRATION',
|
||||
headerShown: false,
|
||||
} as NativeStackNavigationOptions,
|
||||
initialParams: {
|
||||
errorSource: 'sumsub_initialization',
|
||||
countryCode: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default documentsScreens;
|
||||
|
||||
@@ -79,6 +79,7 @@ export type RootStackParamList = Omit<
|
||||
| 'Home'
|
||||
| 'IDPicker'
|
||||
| 'IdDetails'
|
||||
| 'RegistrationFallback'
|
||||
| 'Loading'
|
||||
| 'Modal'
|
||||
| 'MockDataDeepLink'
|
||||
@@ -127,6 +128,16 @@ export type RootStackParamList = Omit<
|
||||
errorType: string;
|
||||
};
|
||||
|
||||
// Registration Fallback screens
|
||||
RegistrationFallback: {
|
||||
errorSource:
|
||||
| 'mrz_scan_failed'
|
||||
| 'nfc_scan_failed'
|
||||
| 'sumsub_initialization'
|
||||
| 'sumsub_verification';
|
||||
countryCode: string;
|
||||
};
|
||||
|
||||
// Account/Recovery screens
|
||||
AccountRecovery:
|
||||
| {
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
type LogLevel,
|
||||
type NFCScanContext,
|
||||
reactNativeScannerAdapter,
|
||||
sanitizeErrorMessage,
|
||||
SdkEvents,
|
||||
SelfClientProvider as SDKSelfClientProvider,
|
||||
type TrackEventParams,
|
||||
useMRZStore,
|
||||
webNFCScannerShim,
|
||||
type WsConn,
|
||||
} from '@selfxyz/mobile-sdk-alpha';
|
||||
@@ -33,7 +35,12 @@ import {
|
||||
setPassportKeychainErrorCallback,
|
||||
} from '@/providers/passportDataProvider';
|
||||
import { trackEvent, trackNfcEvent } from '@/services/analytics';
|
||||
import {
|
||||
type InjectedErrorType,
|
||||
useErrorInjectionStore,
|
||||
} from '@/stores/errorInjectionStore';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { IS_DEV_MODE } from '@/utils/devUtils';
|
||||
import {
|
||||
registerModalCallbacks,
|
||||
unregisterModalCallbacks,
|
||||
@@ -68,7 +75,20 @@ function navigateIfReady<RouteName extends keyof RootStackParamList>(
|
||||
}
|
||||
|
||||
export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
const config = useMemo(() => ({}), []);
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
devConfig: IS_DEV_MODE
|
||||
? {
|
||||
shouldTrigger: (errorType: string) => {
|
||||
return useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger(errorType as InjectedErrorType);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const adapters: Adapters = useMemo(
|
||||
() => ({
|
||||
scanner:
|
||||
@@ -167,6 +187,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
const appListeners = useMemo(() => {
|
||||
const { map, addListener } = createListenersMap();
|
||||
|
||||
// Track current countryCode for error navigation
|
||||
let currentCountryCode = '';
|
||||
|
||||
addListener(SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, () => {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('DocumentDataNotFound');
|
||||
@@ -261,7 +284,10 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
});
|
||||
|
||||
addListener(SdkEvents.DOCUMENT_MRZ_READ_FAILURE, () => {
|
||||
navigateIfReady('DocumentCameraTrouble');
|
||||
navigateIfReady('RegistrationFallback', {
|
||||
errorSource: 'mrz_scan_failed',
|
||||
countryCode: currentCountryCode,
|
||||
});
|
||||
});
|
||||
|
||||
addListener(SdkEvents.PROVING_AADHAAR_UPLOAD_SUCCESS, () => {
|
||||
@@ -280,6 +306,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
countryCode: string;
|
||||
documentTypes: string[];
|
||||
}) => {
|
||||
currentCountryCode = countryCode;
|
||||
// Store country code early so it's available for Sumsub fallback flows
|
||||
useMRZStore.getState().update({ countryCode });
|
||||
navigateIfReady('IDPicker', { countryCode, documentTypes });
|
||||
},
|
||||
);
|
||||
@@ -300,14 +329,68 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
break;
|
||||
case 'kyc':
|
||||
fetchAccessToken()
|
||||
.then(accessToken => {
|
||||
launchSumsub({ accessToken: accessToken.token });
|
||||
})
|
||||
// TODO: show sumsub error screen
|
||||
.catch(error => {
|
||||
console.error('Error launching Sumsub:', error);
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
// Dev-only: Check for injected initialization error
|
||||
if (
|
||||
useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger('sumsub_initialization')
|
||||
) {
|
||||
console.log('[DEV] Injecting Sumsub initialization error');
|
||||
throw new Error(
|
||||
'Injected Sumsub initialization error for testing',
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = await fetchAccessToken();
|
||||
const result = await launchSumsub({
|
||||
accessToken: accessToken.token,
|
||||
});
|
||||
|
||||
// User cancelled - return silently
|
||||
if (!result.success && result.status === 'Interrupted') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev-only: Check for injected verification error
|
||||
const shouldInjectVerificationError = useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger('sumsub_verification');
|
||||
|
||||
// Actual error from provider
|
||||
if (!result.success || shouldInjectVerificationError) {
|
||||
if (shouldInjectVerificationError) {
|
||||
console.log('[DEV] Injecting Sumsub verification error');
|
||||
} else {
|
||||
const safeError = sanitizeErrorMessage(
|
||||
result.errorMsg || result.errorType || 'unknown_error',
|
||||
);
|
||||
console.error('KYC provider failed:', safeError);
|
||||
}
|
||||
// Guard navigation call after async operations
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('RegistrationFallback', {
|
||||
errorSource: 'sumsub_verification',
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
// success case: provider handles its own success UI
|
||||
} catch (error) {
|
||||
const safeInitError = sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error('Error in KYC flow:', safeInitError);
|
||||
// Guard navigation call after async operations
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('RegistrationFallback', {
|
||||
errorSource: 'sumsub_initialization',
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
break;
|
||||
default:
|
||||
if (countryCode) {
|
||||
|
||||
@@ -107,8 +107,8 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({ route }) => {
|
||||
} else {
|
||||
await init(selfClient, 'dsc', true);
|
||||
}
|
||||
} catch {
|
||||
console.error('Error loading selected document:');
|
||||
} catch (error) {
|
||||
console.error('Error loading selected document:', error);
|
||||
await init(selfClient, 'dsc', true);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
subscribeToTopics,
|
||||
unsubscribeFromTopics,
|
||||
} from '@/services/notifications/notificationService';
|
||||
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
|
||||
import {
|
||||
ERROR_GROUPS,
|
||||
ERROR_LABELS,
|
||||
useErrorInjectionStore,
|
||||
} from '@/stores/errorInjectionStore';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { IS_DEV_MODE } from '@/utils/devUtils';
|
||||
@@ -390,6 +396,152 @@ const LogLevelSelector = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorInjectionSelector = () => {
|
||||
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
|
||||
const setInjectedErrors = useErrorInjectionStore(
|
||||
state => state.setInjectedErrors,
|
||||
);
|
||||
const clearAllErrors = useErrorInjectionStore(state => state.clearAllErrors);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Single error selection - replace instead of toggle
|
||||
const selectError = (errorType: InjectedErrorType) => {
|
||||
// If clicking the same error, clear it; otherwise set the new one
|
||||
if (injectedErrors.length === 1 && injectedErrors[0] === errorType) {
|
||||
clearAllErrors();
|
||||
} else {
|
||||
setInjectedErrors([errorType]);
|
||||
}
|
||||
// Close the sheet after selection
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const currentError = injectedErrors.length > 0 ? injectedErrors[0] : null;
|
||||
const currentErrorLabel = currentError ? ERROR_LABELS[currentError] : null;
|
||||
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<Button
|
||||
style={{ backgroundColor: 'white' }}
|
||||
borderColor={slate200}
|
||||
borderRadius="$2"
|
||||
height="$5"
|
||||
padding={0}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<XStack
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
paddingVertical="$3"
|
||||
paddingLeft="$4"
|
||||
paddingRight="$1.5"
|
||||
>
|
||||
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
|
||||
{currentErrorLabel || 'Select onboarding error to test'}
|
||||
</Text>
|
||||
<ChevronDown color={slate500} strokeWidth={2.5} />
|
||||
</XStack>
|
||||
</Button>
|
||||
|
||||
{currentError && (
|
||||
<Button
|
||||
backgroundColor={red500}
|
||||
borderRadius="$2"
|
||||
height="$5"
|
||||
onPress={clearAllErrors}
|
||||
pressStyle={{
|
||||
opacity: 0.8,
|
||||
scale: 0.98,
|
||||
}}
|
||||
>
|
||||
<Text color={white} fontSize="$5" fontFamily={dinot}>
|
||||
Clear
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
modal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
snapPoints={[85]}
|
||||
animation="medium"
|
||||
dismissOnSnapToBottom
|
||||
>
|
||||
<Sheet.Overlay />
|
||||
<Sheet.Frame
|
||||
backgroundColor={white}
|
||||
borderTopLeftRadius="$9"
|
||||
borderTopRightRadius="$9"
|
||||
>
|
||||
<YStack padding="$4">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
marginBottom="$4"
|
||||
>
|
||||
<Text fontSize="$8" fontFamily={dinot}>
|
||||
Onboarding Error Testing
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => setOpen(false)}
|
||||
padding="$2"
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
<ChevronDown
|
||||
color={slate500}
|
||||
strokeWidth={2.5}
|
||||
style={{ transform: [{ rotate: '180deg' }] }}
|
||||
/>
|
||||
</Button>
|
||||
</XStack>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
>
|
||||
{Object.entries(ERROR_GROUPS).map(([groupName, errors]) => (
|
||||
<YStack key={groupName} marginBottom="$4">
|
||||
<Text
|
||||
fontSize="$6"
|
||||
fontFamily={dinot}
|
||||
fontWeight="600"
|
||||
color={slate800}
|
||||
marginBottom="$2"
|
||||
>
|
||||
{groupName}
|
||||
</Text>
|
||||
{errors.map((errorType: InjectedErrorType) => (
|
||||
<TouchableOpacity
|
||||
key={errorType}
|
||||
onPress={() => selectError(errorType)}
|
||||
>
|
||||
<XStack
|
||||
paddingVertical="$3"
|
||||
paddingHorizontal="$2"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor={slate200}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
|
||||
{ERROR_LABELS[errorType]}
|
||||
</Text>
|
||||
{currentError === errorType && (
|
||||
<Check color={slate600} size={20} />
|
||||
)}
|
||||
</XStack>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</YStack>
|
||||
))}
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
const { clearDocumentCatalogForMigrationTesting } = usePassport();
|
||||
const clearPointEvents = usePointEventStore(state => state.clearEvents);
|
||||
@@ -779,6 +931,16 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
|
||||
/>
|
||||
</ParameterSection>
|
||||
|
||||
{IS_DEV_MODE && (
|
||||
<ParameterSection
|
||||
icon={<BugIcon />}
|
||||
title="Onboarding Error Testing"
|
||||
description="Test onboarding error flows"
|
||||
>
|
||||
<ErrorInjectionSelector />
|
||||
</ParameterSection>
|
||||
)}
|
||||
|
||||
{Platform.OS === 'android' && (
|
||||
<ParameterSection
|
||||
icon={<BugIcon />}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { YStack } from 'tamagui';
|
||||
|
||||
import { Caption } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import Activity from '@/assets/icons/activity.svg';
|
||||
import PassportCameraBulb from '@/assets/icons/passport_camera_bulb.svg';
|
||||
@@ -15,6 +17,7 @@ import Star from '@/assets/icons/star.svg';
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flush as flushAnalytics } from '@/services/analytics';
|
||||
|
||||
@@ -48,6 +51,13 @@ const tips: TipProps[] = [
|
||||
|
||||
const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
const go = useHapticNavigation('DocumentCamera', { action: 'cancel' });
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
countryCode,
|
||||
errorSource: 'sumsub_initialization',
|
||||
});
|
||||
|
||||
// error screen, flush analytics
|
||||
useEffect(() => {
|
||||
@@ -64,10 +74,28 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
</Caption>
|
||||
}
|
||||
footer={
|
||||
<Caption size="large" style={{ color: slate500 }}>
|
||||
Following these steps should help your phone's camera capture the ID
|
||||
page quickly and clearly!
|
||||
</Caption>
|
||||
<YStack gap="$3">
|
||||
<Caption size="large" style={{ color: slate500 }}>
|
||||
Following these steps should help your phone's camera capture the ID
|
||||
page quickly and clearly!
|
||||
</Caption>
|
||||
|
||||
<Caption
|
||||
size="large"
|
||||
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
|
||||
>
|
||||
Or try an alternative verification method:
|
||||
</Caption>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
}
|
||||
>
|
||||
<Tips items={tips} />
|
||||
|
||||
@@ -54,6 +54,7 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
import passportVerifyAnimation from '@/assets/animations/passport_verify.json';
|
||||
import NFC_IMAGE from '@/assets/images/nfc.png';
|
||||
import { logNFCEvent } from '@/config/sentry';
|
||||
import { useErrorInjection } from '@/hooks/useErrorInjection';
|
||||
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import {
|
||||
@@ -106,8 +107,9 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<DocumentNFCScanRoute>();
|
||||
const { showModal } = useFeedback();
|
||||
useFeedback();
|
||||
useFeedbackAutoHide();
|
||||
const { shouldInjectError } = useErrorInjection();
|
||||
const {
|
||||
passportNumber,
|
||||
dateOfBirth,
|
||||
@@ -189,18 +191,12 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
},
|
||||
{ message: sanitizeErrorMessage(message) },
|
||||
);
|
||||
showModal({
|
||||
titleText: 'NFC Scan Error',
|
||||
bodyText: message,
|
||||
buttonText: SUPPORT_FORM_BUTTON_TEXT,
|
||||
secondaryButtonText: 'Help',
|
||||
preventDismiss: false,
|
||||
onButtonPress: openSupportForm,
|
||||
onSecondaryButtonPress: goToNFCTrouble,
|
||||
onModalDismiss: () => {},
|
||||
navigation.navigate('RegistrationFallback', {
|
||||
errorSource: 'nfc_scan_failed',
|
||||
countryCode,
|
||||
});
|
||||
},
|
||||
[baseContext, showModal, goToNFCTrouble],
|
||||
[baseContext, navigation, countryCode],
|
||||
);
|
||||
|
||||
const checkNfcSupport = useCallback(async () => {
|
||||
@@ -324,6 +320,18 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
// Dev-only: Check for injected timeout error
|
||||
if (shouldInjectError('nfc_timeout')) {
|
||||
console.log('[DEV] Injecting NFC timeout error');
|
||||
throw new Error('Injected timeout error for testing');
|
||||
}
|
||||
|
||||
// Dev-only: Check for injected module unavailable error
|
||||
if (shouldInjectError('nfc_module_unavailable')) {
|
||||
console.log('[DEV] Injecting NFC module unavailable error');
|
||||
throw new Error('NFC scanning is currently unavailable');
|
||||
}
|
||||
|
||||
const {
|
||||
canNumber,
|
||||
useCan,
|
||||
@@ -376,6 +384,12 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
);
|
||||
let passportData: PassportData | null = null;
|
||||
try {
|
||||
// Dev-only: Check for injected parse failure error
|
||||
if (shouldInjectError('nfc_parse_failure')) {
|
||||
console.log('[DEV] Injecting NFC parse failure error');
|
||||
throw new Error('Failed to parse NFC response');
|
||||
}
|
||||
|
||||
passportData = parseScanResponse(scanResponse);
|
||||
} catch (e: unknown) {
|
||||
console.error('Parsing NFC Response Unsuccessful');
|
||||
@@ -452,6 +466,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
navigation,
|
||||
openErrorModal,
|
||||
trackEvent,
|
||||
shouldInjectError,
|
||||
]);
|
||||
|
||||
const navigateToHome = useHapticNavigation('Home', {
|
||||
|
||||
@@ -7,13 +7,15 @@ import { View } from 'react-native';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { YStack } from 'tamagui';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flushAllAnalytics } from '@/services/analytics';
|
||||
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
|
||||
@@ -50,6 +52,13 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
const goToNFCMethodSelection = useHapticNavigation(
|
||||
'DocumentNFCMethodSelection',
|
||||
);
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
countryCode,
|
||||
errorSource: 'sumsub_initialization',
|
||||
});
|
||||
useFeedbackAutoHide();
|
||||
|
||||
// error screen, flush analytics
|
||||
@@ -71,9 +80,24 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
secondaryButtonText="Open NFC Options"
|
||||
onSecondaryButtonPress={goToNFCMethodSelection}
|
||||
footer={
|
||||
<SecondaryButton onPress={openSupportForm} style={{ marginBottom: 0 }}>
|
||||
{SUPPORT_FORM_BUTTON_TEXT}
|
||||
</SecondaryButton>
|
||||
<YStack gap="$3">
|
||||
<SecondaryButton
|
||||
onPress={openSupportForm}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{SUPPORT_FORM_BUTTON_TEXT}
|
||||
</SecondaryButton>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
}
|
||||
>
|
||||
<YStack
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Button, XStack, YStack } from 'tamagui';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { HelpCircle, X } from '@tamagui/lucide-icons';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import {
|
||||
BodyText,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import {
|
||||
black,
|
||||
cyan300,
|
||||
slate100,
|
||||
slate200,
|
||||
slate300,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
type FallbackErrorSource =
|
||||
| 'mrz_scan_failed'
|
||||
| 'nfc_scan_failed'
|
||||
| 'sumsub_initialization'
|
||||
| 'sumsub_verification';
|
||||
|
||||
type RegistrationFallbackRouteParams = {
|
||||
errorSource: FallbackErrorSource;
|
||||
countryCode: string;
|
||||
};
|
||||
|
||||
type RegistrationFallbackRoute = RouteProp<
|
||||
Record<string, RegistrationFallbackRouteParams>,
|
||||
string
|
||||
>;
|
||||
|
||||
const getHeaderTitle = (errorSource: FallbackErrorSource): string => {
|
||||
switch (errorSource) {
|
||||
case 'mrz_scan_failed':
|
||||
return 'MRZ SCAN';
|
||||
case 'nfc_scan_failed':
|
||||
return 'NFC SCAN';
|
||||
default:
|
||||
return 'REGISTRATION';
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentStep = (errorSource: FallbackErrorSource): number => {
|
||||
switch (errorSource) {
|
||||
case 'mrz_scan_failed':
|
||||
return 1; // Step 1: MRZ scanning
|
||||
case 'nfc_scan_failed':
|
||||
return 2; // Step 2: NFC reading
|
||||
case 'sumsub_initialization':
|
||||
case 'sumsub_verification':
|
||||
return 3; // Step 3: Proving/verification
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const getRetryButtonText = (errorSource: FallbackErrorSource): string => {
|
||||
switch (errorSource) {
|
||||
case 'mrz_scan_failed':
|
||||
return 'Try scanning again';
|
||||
case 'nfc_scan_failed':
|
||||
return 'Try reading again';
|
||||
default:
|
||||
return 'Try again';
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorMessages = (
|
||||
errorSource: FallbackErrorSource,
|
||||
): { title: string; description: string; canRetryOriginal: boolean } => {
|
||||
switch (errorSource) {
|
||||
case 'mrz_scan_failed':
|
||||
return {
|
||||
title: 'There was a problem scanning your document',
|
||||
description: 'Make sure the document is clearly visible and try again',
|
||||
canRetryOriginal: true,
|
||||
};
|
||||
case 'nfc_scan_failed':
|
||||
return {
|
||||
title: 'There was a problem reading the chip',
|
||||
description: 'Make sure NFC is enabled and try again',
|
||||
canRetryOriginal: true,
|
||||
};
|
||||
case 'sumsub_initialization':
|
||||
return {
|
||||
title: 'Connection Error',
|
||||
description:
|
||||
'Unable to connect to verification service. Please check your internet connection and try again.',
|
||||
canRetryOriginal: false,
|
||||
};
|
||||
case 'sumsub_verification':
|
||||
return {
|
||||
title: 'Verification Error',
|
||||
description:
|
||||
'Something went wrong during the verification process. Please try again.',
|
||||
canRetryOriginal: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const RegistrationFallbackScreen: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<RegistrationFallbackRoute>();
|
||||
const selfClient = useSelfClient();
|
||||
const { trackEvent, useMRZStore } = selfClient;
|
||||
const storeCountryCode = useMRZStore(state => state.countryCode);
|
||||
|
||||
const errorSource = route.params?.errorSource || 'sumsub_initialization';
|
||||
// Use country code from route params, or fall back to MRZ store
|
||||
const countryCode = route.params?.countryCode || storeCountryCode || '';
|
||||
|
||||
const headerTitle = getHeaderTitle(errorSource);
|
||||
const retryButtonText = getRetryButtonText(errorSource);
|
||||
const currentStep = getCurrentStep(errorSource);
|
||||
const { title, description, canRetryOriginal } =
|
||||
getErrorMessages(errorSource);
|
||||
|
||||
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
|
||||
{
|
||||
countryCode,
|
||||
errorSource,
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
onError: (_error, _result) => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Success - provider handles its own success UI
|
||||
// The screen will be navigated away by the provider's flow
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
buttonTap();
|
||||
navigation.goBack();
|
||||
}, [navigation]);
|
||||
|
||||
const handleTryAlternative = useCallback(async () => {
|
||||
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', { errorSource });
|
||||
await launchSumsubVerification();
|
||||
}, [errorSource, launchSumsubVerification, trackEvent]);
|
||||
|
||||
const handleRetryOriginal = useCallback(() => {
|
||||
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', { errorSource });
|
||||
|
||||
// Navigate back to the appropriate screen based on error source
|
||||
if (errorSource === 'mrz_scan_failed') {
|
||||
navigation.navigate('DocumentCamera');
|
||||
} else if (errorSource === 'nfc_scan_failed') {
|
||||
navigation.navigate('DocumentNFCScan', {});
|
||||
} else if (errorSource === 'sumsub_initialization') {
|
||||
// Go back to ID Picker
|
||||
navigation.goBack();
|
||||
}
|
||||
// TODO: Handle 'sumsub_verification' case - currently falls through without action
|
||||
// which could leave users stuck when tapping "Try again" after Sumsub verification failure.
|
||||
// Consider: calling launchSumsubVerification() or navigating to appropriate retry screen.
|
||||
// Need to determine the correct retry behavior for failed Sumsub verifications.
|
||||
}, [errorSource, navigation, trackEvent]);
|
||||
|
||||
return (
|
||||
<YStack flex={1} backgroundColor={slate100}>
|
||||
{/* Header */}
|
||||
<YStack backgroundColor={slate100}>
|
||||
<XStack
|
||||
backgroundColor={slate100}
|
||||
padding={20}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
paddingTop={Math.max(insets.top, 15) + extraYPadding}
|
||||
paddingBottom={10}
|
||||
>
|
||||
<Button
|
||||
unstyled
|
||||
onPress={handleClose}
|
||||
padding={8}
|
||||
borderRadius={20}
|
||||
hitSlop={10}
|
||||
>
|
||||
<X size={24} color={black} />
|
||||
</Button>
|
||||
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: black,
|
||||
fontWeight: '600',
|
||||
fontFamily: dinot,
|
||||
}}
|
||||
>
|
||||
{headerTitle}
|
||||
</BodyText>
|
||||
|
||||
<Button
|
||||
unstyled
|
||||
padding={8}
|
||||
borderRadius={20}
|
||||
hitSlop={10}
|
||||
width={32}
|
||||
height={32}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
disabled
|
||||
>
|
||||
<HelpCircle size={20} color={black} opacity={0} />
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<YStack paddingHorizontal={40} paddingBottom={10}>
|
||||
<XStack gap={3} height={6}>
|
||||
{[1, 2, 3, 4].map(step => (
|
||||
<YStack
|
||||
key={step}
|
||||
flex={1}
|
||||
backgroundColor={step === currentStep ? cyan300 : slate300}
|
||||
borderRadius={10}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
{/* Warning Icon */}
|
||||
<YStack flex={1} paddingHorizontal={20} paddingTop={20}>
|
||||
<YStack
|
||||
flex={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
paddingVertical={20}
|
||||
>
|
||||
<WarningIcon width={120} height={120} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
{/* Error Message */}
|
||||
<YStack
|
||||
paddingHorizontal={20}
|
||||
paddingTop={20}
|
||||
alignItems="center"
|
||||
paddingVertical={25}
|
||||
>
|
||||
<BodyText style={{ fontSize: 19, textAlign: 'center', color: black }}>
|
||||
{title}
|
||||
</BodyText>
|
||||
<BodyText
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontSize: 17,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</BodyText>
|
||||
</YStack>
|
||||
|
||||
{/* Top Button - Retry */}
|
||||
{canRetryOriginal && (
|
||||
<YStack paddingHorizontal={25} paddingBottom={20}>
|
||||
<PrimaryButton onPress={handleRetryOriginal} disabled={isRetrying}>
|
||||
{retryButtonText}
|
||||
</PrimaryButton>
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{/* Bottom Section with Grey Line Separator */}
|
||||
<YStack
|
||||
paddingHorizontal={25}
|
||||
backgroundColor={white}
|
||||
paddingBottom={paddingBottom}
|
||||
paddingTop={25}
|
||||
gap="$3"
|
||||
borderTopWidth={1}
|
||||
borderTopColor={slate200}
|
||||
>
|
||||
<SecondaryButton onPress={handleTryAlternative} disabled={isRetrying}>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</SecondaryButton>
|
||||
|
||||
{/* Footer Text */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify your
|
||||
document.
|
||||
</BodyText>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistrationFallbackScreen;
|
||||
103
app/src/stores/errorInjectionStore.ts
Normal file
103
app/src/stores/errorInjectionStore.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { IS_DEV_MODE } from '@/utils/devUtils';
|
||||
|
||||
export type InjectedErrorType =
|
||||
| 'mrz_invalid_format'
|
||||
| 'mrz_unknown_error'
|
||||
| 'nfc_timeout'
|
||||
| 'nfc_module_unavailable'
|
||||
| 'nfc_parse_failure'
|
||||
| 'api_network_error'
|
||||
| 'api_timeout'
|
||||
| 'sumsub_initialization'
|
||||
| 'sumsub_verification';
|
||||
|
||||
export const ERROR_GROUPS = {
|
||||
MRZ: ['mrz_invalid_format', 'mrz_unknown_error'] as InjectedErrorType[],
|
||||
NFC: [
|
||||
'nfc_timeout',
|
||||
'nfc_module_unavailable',
|
||||
'nfc_parse_failure',
|
||||
] as InjectedErrorType[],
|
||||
API: ['api_network_error', 'api_timeout'] as InjectedErrorType[],
|
||||
Sumsub: [
|
||||
'sumsub_initialization',
|
||||
'sumsub_verification',
|
||||
] as InjectedErrorType[],
|
||||
};
|
||||
|
||||
export const ERROR_LABELS: Record<InjectedErrorType, string> = {
|
||||
mrz_invalid_format: 'MRZ: Invalid format',
|
||||
mrz_unknown_error: 'MRZ: Unknown error',
|
||||
nfc_timeout: 'NFC: Timeout',
|
||||
nfc_module_unavailable: 'NFC: Module unavailable',
|
||||
nfc_parse_failure: 'NFC: Parse failure',
|
||||
api_network_error: 'API: Network error',
|
||||
api_timeout: 'API: Timeout',
|
||||
sumsub_initialization: 'Sumsub: Initialization',
|
||||
sumsub_verification: 'Sumsub: Verification',
|
||||
};
|
||||
|
||||
interface ErrorInjectionState {
|
||||
injectedErrors: InjectedErrorType[];
|
||||
// Actions
|
||||
setInjectedErrors: (errors: InjectedErrorType[]) => void;
|
||||
toggleError: (error: InjectedErrorType) => void;
|
||||
clearError: (error: InjectedErrorType) => void;
|
||||
clearAllErrors: () => void;
|
||||
shouldTrigger: (error: InjectedErrorType) => boolean;
|
||||
}
|
||||
|
||||
export const useErrorInjectionStore = create<ErrorInjectionState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
injectedErrors: [],
|
||||
|
||||
setInjectedErrors: (errors: InjectedErrorType[]) => {
|
||||
if (!IS_DEV_MODE) return;
|
||||
set({ injectedErrors: errors });
|
||||
},
|
||||
|
||||
toggleError: (error: InjectedErrorType) => {
|
||||
if (!IS_DEV_MODE) return;
|
||||
set(state => {
|
||||
const hasError = state.injectedErrors.includes(error);
|
||||
return {
|
||||
injectedErrors: hasError
|
||||
? state.injectedErrors.filter(e => e !== error)
|
||||
: [...state.injectedErrors, error],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearError: (error: InjectedErrorType) => {
|
||||
if (!IS_DEV_MODE) return;
|
||||
set(state => ({
|
||||
injectedErrors: state.injectedErrors.filter(e => e !== error),
|
||||
}));
|
||||
},
|
||||
|
||||
clearAllErrors: () => {
|
||||
if (!IS_DEV_MODE) return;
|
||||
set({ injectedErrors: [] });
|
||||
},
|
||||
|
||||
shouldTrigger: (error: InjectedErrorType) => {
|
||||
if (!IS_DEV_MODE) return false;
|
||||
const state = get();
|
||||
return state.injectedErrors.includes(error);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'error-injection-storage',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -101,6 +101,7 @@ describe('navigation', () => {
|
||||
'QRCodeViewFinder',
|
||||
'RecoverWithPhrase',
|
||||
'Referral',
|
||||
'RegistrationFallback',
|
||||
'SaveRecoveryPhrase',
|
||||
'Settings',
|
||||
'ShowRecoveryPhrase',
|
||||
|
||||
@@ -224,5 +224,7 @@ export function createSelfClient({
|
||||
useSelfAppStore,
|
||||
useProtocolStore,
|
||||
useMRZStore,
|
||||
// Expose config for internal SDK use
|
||||
config: cfg,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { Config } from '../types/public';
|
||||
|
||||
export const defaultConfig: Required<Config> = {
|
||||
export const defaultConfig: Omit<Required<Config>, 'devConfig'> & Pick<Config, 'devConfig'> = {
|
||||
timeouts: { scanMs: 60000 },
|
||||
// in future this can be used to enable/disable experimental features
|
||||
features: {},
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
|
||||
import type { Config } from '../types/public';
|
||||
|
||||
export function mergeConfig(base: Required<Config>, override: Config): Required<Config> {
|
||||
type BaseConfig = Omit<Required<Config>, 'devConfig'> & Pick<Config, 'devConfig'>;
|
||||
|
||||
export function mergeConfig(base: BaseConfig, override: Config): BaseConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
timeouts: { ...base.timeouts, ...(override.timeouts ?? {}) },
|
||||
features: { ...base.features, ...(override.features ?? {}) },
|
||||
devConfig: override.devConfig ?? base.devConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { checkScannedInfo, formatDateToYYMMDD } from '../../processing/mrz';
|
||||
import { SdkEvents } from '../../types/events';
|
||||
import type { MRZInfo } from '../../types/public';
|
||||
|
||||
// Dev-only error injection - uses injected devConfig from SDK context
|
||||
// No cross-package requires needed
|
||||
|
||||
export type { MRZScannerViewProps } from '../../components/MRZScannerView';
|
||||
export { MRZScannerView } from '../../components/MRZScannerView';
|
||||
|
||||
@@ -28,12 +31,25 @@ const calculateScanDurationSeconds = (scanStartTimeRef: RefObject<number>) => {
|
||||
|
||||
export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
|
||||
const selfClient = useSelfClient();
|
||||
const shouldTrigger = selfClient.config?.devConfig?.shouldTrigger;
|
||||
|
||||
return {
|
||||
onPassportRead: useCallback(
|
||||
(error: Error | null, result?: MRZInfo) => {
|
||||
const scanDurationSeconds = calculateScanDurationSeconds(scanStartTimeRef);
|
||||
|
||||
// Dev-only: Check for injected unknown error
|
||||
if (shouldTrigger?.('mrz_unknown_error')) {
|
||||
console.log('[DEV] Injecting MRZ unknown error');
|
||||
selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, {
|
||||
reason: 'unknown_error',
|
||||
error: 'Injected error for testing',
|
||||
duration_seconds: parseFloat(scanDurationSeconds),
|
||||
});
|
||||
selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_FAILURE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -63,7 +79,16 @@ export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
|
||||
const formattedDateOfBirth = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfBirth) : dateOfBirth;
|
||||
const formattedDateOfExpiry = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfExpiry) : dateOfExpiry;
|
||||
|
||||
if (!checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry)) {
|
||||
// Dev-only: Check for injected invalid format error
|
||||
const shouldInjectInvalidFormat = shouldTrigger?.('mrz_invalid_format') || false;
|
||||
|
||||
if (
|
||||
shouldInjectInvalidFormat ||
|
||||
!checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry)
|
||||
) {
|
||||
if (shouldInjectInvalidFormat) {
|
||||
console.log('[DEV] Injecting MRZ invalid format error');
|
||||
}
|
||||
selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, {
|
||||
reason: 'invalid_format',
|
||||
passportNumberLength: documentNumber.length,
|
||||
@@ -90,7 +115,7 @@ export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
|
||||
|
||||
selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS);
|
||||
},
|
||||
[selfClient],
|
||||
[selfClient, shouldTrigger],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,5 +138,6 @@ export { parseNFCResponse, scanNFC } from './nfc';
|
||||
export { reactNativeScannerAdapter } from './adapters/react-native/nfc-scanner';
|
||||
export { sanitizeErrorMessage } from './utils/utils';
|
||||
export { useCountries } from './documents/useCountries';
|
||||
export { useMRZStore } from './stores/mrzStore';
|
||||
|
||||
export { webNFCScannerShim } from './adapters/web/shims';
|
||||
|
||||
@@ -35,6 +35,18 @@ export interface Config {
|
||||
* treated as `false` and the SDK will continue using legacy flows.
|
||||
*/
|
||||
features?: Record<string, boolean>;
|
||||
/**
|
||||
* Optional dev-mode configuration for error injection and testing. Should
|
||||
* only be provided in development builds. Production builds should omit this.
|
||||
*/
|
||||
devConfig?: {
|
||||
/**
|
||||
* Callback to check if a specific error type should be injected for testing.
|
||||
* @param errorType - The type of error to check (e.g., 'mrz_unknown_error')
|
||||
* @returns true if the error should be injected, false otherwise
|
||||
*/
|
||||
shouldTrigger?: (errorType: string) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,6 +387,9 @@ export interface SelfClient {
|
||||
useProtocolStore: ReturnType<typeof create<ProtocolState, []>>;
|
||||
/** Zustand store hook mirroring {@link MRZState}. */
|
||||
useMRZStore: ReturnType<typeof create<MRZState, []>>;
|
||||
|
||||
/** The merged configuration object passed to createSelfClient. */
|
||||
config: Config;
|
||||
}
|
||||
|
||||
/** Function returned by {@link SelfClient.on} to detach a listener. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mergeConfig } from '../src/config/merge';
|
||||
import type { Config } from '../src/types/public';
|
||||
|
||||
describe('mergeConfig', () => {
|
||||
const baseConfig: Required<Config> = {
|
||||
const baseConfig: Omit<Required<Config>, 'devConfig'> & Pick<Config, 'devConfig'> = {
|
||||
timeouts: {
|
||||
scanMs: 30000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user