From 73ca37f3b96956fae8bc348a2f4a25d1f4dea068 Mon Sep 17 00:00:00 2001
From: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
Date: Fri, 6 Feb 2026 22:27:50 +1000
Subject: [PATCH] Feat/add logo confirmation screen (#1666)
* feat: initial logoConfirmation screen
* fix: center text in buttons
* fix: ensure biometric logo is not cropped
* fix: add spacing to logo confirm screen, consistent padding for buttons
* feat: add popup to logo confirmation screen
* fix: handle Sumsub cancellation correctly in Other ID flow
* feat: add pathway from logo confirmation -> sumsub -> success
* feat: add document type pre-selection for Sumsub SDK
* feat: add KycFailureScreen for when user clicks on failure notification
* feat: add KycConnectionErrorScreen for sumsub_initiation error
* refactor: edit RegistrationFallbackScreen for new design
Now only to be used for MRZ/NFC scan errors, instead of kyc connection/registration issues. Also updated to newest design for screen from Figma
* refactor: update AadhaarUploadErrorScreen for new design
* fix: removed unused countryCode variable and useRoute import
* fix: Sorted imports (moved kyc after documents/selection imports)
fix CI
* chore: lint/prettier
* fix: CI error
* refactor: Split RegistrationFallbackScreen into MRZ/NFC error versions
* feat: add link from RegistrationFallbackNFCScreen -> DocumentNFCTrouble
Clicking on the question mark now takes user to this screen that can help them diagnose issue
* fix: on clicking question mark, dismiss screen returns to correct screen
* chore: yarn prettier
* test: fix failing test for CI
---
app/src/assets/icons/epassport_logo.svg | 1 +
app/src/assets/icons/shield_error.svg | 3 +
app/src/hooks/useSumsubLauncher.ts | 26 +-
app/src/integrations/sumsub/sumsubService.ts | 44 ++-
app/src/navigation/documents.ts | 55 ++-
app/src/navigation/types.ts | 21 +-
.../notificationTrackingProvider.tsx | 29 +-
app/src/providers/selfClientProvider.tsx | 42 ++-
.../aadhaar/AadhaarUploadErrorScreen.tsx | 248 ++++++++++---
.../scanning/DocumentCameraScreen.tsx | 29 +-
.../scanning/DocumentCameraTroubleScreen.tsx | 2 +-
.../scanning/DocumentNFCScanScreen.tsx | 3 +-
.../scanning/DocumentNFCTroubleScreen.tsx | 14 +-
.../RegistrationFallbackMRZScreen.tsx | 256 ++++++++++++++
.../RegistrationFallbackNFCScreen.tsx | 282 +++++++++++++++
.../scanning/RegistrationFallbackScreen.tsx | 326 ------------------
.../selection/LogoConfirmationScreen.tsx | 152 ++++++++
.../screens/kyc/KycConnectionErrorScreen.tsx | 175 ++++++++++
app/src/screens/kyc/KycFailureScreen.tsx | 126 +++++++
app/tests/src/navigation.test.tsx | 6 +-
.../notificationTrackingProvider.test.tsx | 17 +-
.../src/components/buttons/AbstractButton.tsx | 1 +
.../onboarding/logo-confirmation-screen.tsx | 104 ++++++
packages/mobile-sdk-alpha/src/index.ts | 2 +
packages/mobile-sdk-alpha/src/types/events.ts | 22 ++
25 files changed, 1536 insertions(+), 450 deletions(-)
create mode 100644 app/src/assets/icons/epassport_logo.svg
create mode 100644 app/src/assets/icons/shield_error.svg
create mode 100644 app/src/screens/documents/scanning/RegistrationFallbackMRZScreen.tsx
create mode 100644 app/src/screens/documents/scanning/RegistrationFallbackNFCScreen.tsx
delete mode 100644 app/src/screens/documents/scanning/RegistrationFallbackScreen.tsx
create mode 100644 app/src/screens/documents/selection/LogoConfirmationScreen.tsx
create mode 100644 app/src/screens/kyc/KycConnectionErrorScreen.tsx
create mode 100644 app/src/screens/kyc/KycFailureScreen.tsx
create mode 100644 packages/mobile-sdk-alpha/src/flows/onboarding/logo-confirmation-screen.tsx
diff --git a/app/src/assets/icons/epassport_logo.svg b/app/src/assets/icons/epassport_logo.svg
new file mode 100644
index 000000000..3c4c0073d
--- /dev/null
+++ b/app/src/assets/icons/epassport_logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/assets/icons/shield_error.svg b/app/src/assets/icons/shield_error.svg
new file mode 100644
index 000000000..273e84aea
--- /dev/null
+++ b/app/src/assets/icons/shield_error.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/src/hooks/useSumsubLauncher.ts b/app/src/hooks/useSumsubLauncher.ts
index c5cad2260..30d4975ae 100644
--- a/app/src/hooks/useSumsubLauncher.ts
+++ b/app/src/hooks/useSumsubLauncher.ts
@@ -12,11 +12,7 @@ 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 type FallbackErrorSource = 'mrz_scan_failed' | 'nfc_scan_failed';
export interface UseSumsubLauncherOptions {
/**
@@ -90,10 +86,12 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
if (onError) {
await onError(safeError, result);
} else {
- navigation.navigate('RegistrationFallback', {
- errorSource,
- countryCode,
- });
+ // Navigate to the appropriate fallback screen based on error source
+ if (errorSource === 'mrz_scan_failed') {
+ navigation.navigate('RegistrationFallbackMRZ', { countryCode });
+ } else {
+ navigation.navigate('RegistrationFallbackNFC', { countryCode });
+ }
}
return;
}
@@ -110,10 +108,12 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
if (onError) {
await onError(safeError);
} else {
- navigation.navigate('RegistrationFallback', {
- errorSource,
- countryCode,
- });
+ // Navigate to the appropriate fallback screen based on error source
+ if (errorSource === 'mrz_scan_failed') {
+ navigation.navigate('RegistrationFallbackMRZ', { countryCode });
+ } else {
+ navigation.navigate('RegistrationFallbackNFC', { countryCode });
+ }
}
} finally {
setIsLoading(false);
diff --git a/app/src/integrations/sumsub/sumsubService.ts b/app/src/integrations/sumsub/sumsubService.ts
index 5df563d87..20f191399 100644
--- a/app/src/integrations/sumsub/sumsubService.ts
+++ b/app/src/integrations/sumsub/sumsubService.ts
@@ -5,15 +5,30 @@
import { SUMSUB_TEE_URL } from '@env';
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module';
+import { alpha2ToAlpha3 } from '@selfxyz/common';
+
import type {
AccessTokenResponse,
SumsubResult,
} from '@/integrations/sumsub/types';
+// Maps Self document type codes to Sumsub document types
+type SelfDocumentType = 'p' | 'i';
+type SumsubDocumentType = 'PASSPORT' | 'ID_CARD';
+
+const DOCUMENT_TYPE_MAP: Record = {
+ p: 'PASSPORT',
+ i: 'ID_CARD',
+};
+
export interface SumsubConfig {
accessToken: string;
locale?: string;
debug?: boolean;
+ /** Self document type code ('p' for passport, 'i' for ID card) */
+ documentType?: SelfDocumentType;
+ /** ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB') */
+ countryCode?: string;
onStatusChanged?: (prevStatus: string, newStatus: string) => void;
onEvent?: (eventType: string, payload: unknown) => void;
}
@@ -78,7 +93,7 @@ export const fetchAccessToken = async (
export const launchSumsub = async (
config: SumsubConfig,
): Promise => {
- const sdk = SNSMobileSDK.init(config.accessToken, async () => {
+ let sdk = SNSMobileSDK.init(config.accessToken, async () => {
// Token refresh not implemented for test flow
throw new Error(
'Sumsub token expired - refresh not implemented for test flow',
@@ -101,8 +116,29 @@ export const launchSumsub = async (
})
.withDebug(config.debug ?? __DEV__)
.withLocale(config.locale ?? 'en')
- .withAnalyticsEnabled(true) // Device Intelligence requires this
- .build();
+ .withAnalyticsEnabled(true); // Device Intelligence requires this
- return sdk.launch();
+ // Pre-select document type and country if provided
+ // This skips the document selection step in Sumsub
+ if (config.documentType && config.countryCode) {
+ const sumsubDocType = DOCUMENT_TYPE_MAP[config.documentType];
+ // Handle both 2-letter (US) and 3-letter (USA) country codes
+ // alpha2ToAlpha3 returns undefined for 3-letter codes, so use the original if conversion fails
+ const alpha3Country =
+ alpha2ToAlpha3(config.countryCode) ?? config.countryCode;
+
+ if (sumsubDocType && alpha3Country) {
+ console.log(
+ `[Sumsub] Pre-selecting document: ${sumsubDocType} from ${alpha3Country}`,
+ );
+ sdk = sdk.withPreferredDocumentDefinitions({
+ IDENTITY: {
+ idDocType: sumsubDocType,
+ country: alpha3Country,
+ },
+ });
+ }
+ }
+
+ return sdk.build().launch();
};
diff --git a/app/src/navigation/documents.ts b/app/src/navigation/documents.ts
index a80aaeecc..32aa325e3 100644
--- a/app/src/navigation/documents.ts
+++ b/app/src/navigation/documents.ts
@@ -19,11 +19,15 @@ 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 RegistrationFallbackMRZScreen from '@/screens/documents/scanning/RegistrationFallbackMRZScreen';
+import RegistrationFallbackNFCScreen from '@/screens/documents/scanning/RegistrationFallbackNFCScreen';
import ConfirmBelongingScreen from '@/screens/documents/selection/ConfirmBelongingScreen';
import CountryPickerScreen from '@/screens/documents/selection/CountryPickerScreen';
import DocumentOnboardingScreen from '@/screens/documents/selection/DocumentOnboardingScreen';
import IDPickerScreen from '@/screens/documents/selection/IDPickerScreen';
+import LogoConfirmationScreen from '@/screens/documents/selection/LogoConfirmationScreen';
+import KycConnectionErrorScreen from '@/screens/kyc/KycConnectionErrorScreen';
+import KycFailureScreen from '@/screens/kyc/KycFailureScreen';
const documentsScreens = {
DocumentCamera: {
@@ -94,6 +98,16 @@ const documentsScreens = {
documentTypes: [],
},
},
+ LogoConfirmation: {
+ screen: LogoConfirmationScreen,
+ options: {
+ headerShown: false,
+ } as NativeStackNavigationOptions,
+ initialParams: {
+ documentType: '',
+ countryCode: '',
+ },
+ },
ConfirmBelonging: {
screen: ConfirmBelongingScreen,
options: {
@@ -148,22 +162,49 @@ const documentsScreens = {
AadhaarUploadError: {
screen: AadhaarUploadErrorScreen,
options: {
- title: 'AADHAAR REGISTRATION',
- header: AadhaarNavBar,
- headerBackVisible: false,
+ headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
errorType: 'general',
},
},
- RegistrationFallback: {
- screen: RegistrationFallbackScreen,
+ RegistrationFallbackMRZ: {
+ screen: RegistrationFallbackMRZScreen,
options: {
title: 'REGISTRATION',
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
- errorSource: 'sumsub_initialization',
+ countryCode: '',
+ },
+ },
+ RegistrationFallbackNFC: {
+ screen: RegistrationFallbackNFCScreen,
+ options: {
+ title: 'REGISTRATION',
+ headerShown: false,
+ } as NativeStackNavigationOptions,
+ initialParams: {
+ countryCode: '',
+ },
+ },
+ KycFailure: {
+ screen: KycFailureScreen,
+ options: {
+ headerShown: false,
+ animation: 'fade',
+ } as NativeStackNavigationOptions,
+ initialParams: {
+ countryCode: '',
+ canRetry: true,
+ },
+ },
+ KycConnectionError: {
+ screen: KycConnectionErrorScreen,
+ options: {
+ headerShown: false,
+ } as NativeStackNavigationOptions,
+ initialParams: {
countryCode: '',
},
},
diff --git a/app/src/navigation/types.ts b/app/src/navigation/types.ts
index c0e7de6dc..fe158edd3 100644
--- a/app/src/navigation/types.ts
+++ b/app/src/navigation/types.ts
@@ -82,6 +82,10 @@ export type DocumentRoutesParamList = {
countryCode: string;
documentTypes: string[];
};
+ LogoConfirmation: {
+ documentType: string;
+ countryCode: string;
+ };
ConfirmBelonging:
| {
documentCategory?: DocumentCategory;
@@ -157,18 +161,23 @@ export type OnboardingRoutesParamList = {
userId?: string;
}
| undefined;
+ KycFailure: {
+ countryCode?: string;
+ canRetry?: boolean;
+ };
+ KycConnectionError: {
+ countryCode?: string;
+ };
};
// =============================================================================
// Registration Fallback Screens
// =============================================================================
export type RegistrationRoutesParamList = {
- RegistrationFallback: {
- errorSource:
- | 'mrz_scan_failed'
- | 'nfc_scan_failed'
- | 'sumsub_initialization'
- | 'sumsub_verification';
+ RegistrationFallbackMRZ: {
+ countryCode: string;
+ };
+ RegistrationFallbackNFC: {
countryCode: string;
};
};
diff --git a/app/src/providers/notificationTrackingProvider.tsx b/app/src/providers/notificationTrackingProvider.tsx
index 5462f291c..834c5088c 100644
--- a/app/src/providers/notificationTrackingProvider.tsx
+++ b/app/src/providers/notificationTrackingProvider.tsx
@@ -30,17 +30,26 @@ const executeNotificationNavigation = (
const status = remoteMessage.data?.status;
// Handle KYC result notifications
- if (notificationType === 'kyc_result' && status === 'approved') {
- navigationRef.navigate('KYCVerified', {
- status: String(status),
- userId: remoteMessage.data?.user_id
- ? String(remoteMessage.data.user_id)
- : undefined,
- });
- return true;
+ if (notificationType === 'kyc_result') {
+ if (status === 'approved') {
+ navigationRef.navigate('KYCVerified', {
+ status: String(status),
+ userId: remoteMessage.data?.user_id
+ ? String(remoteMessage.data.user_id)
+ : undefined,
+ });
+ return true;
+ } else if (status === 'rejected') {
+ navigationRef.navigate('KycFailure', {
+ canRetry: false,
+ });
+ return true;
+ } else if (status === 'retry') {
+ // Take user directly to verification flow to retry
+ navigationRef.navigate('CountryPicker');
+ return true;
+ }
}
- // Add handling for other notification types here as needed
- // For retry/rejected statuses, could navigate to appropriate screens in future
return true; // Navigation handled (or not applicable)
};
diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx
index 6bfbc9c2e..dfa74c787 100644
--- a/app/src/providers/selfClientProvider.tsx
+++ b/app/src/providers/selfClientProvider.tsx
@@ -284,8 +284,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
});
addListener(SdkEvents.DOCUMENT_MRZ_READ_FAILURE, () => {
- navigateIfReady('RegistrationFallback', {
- errorSource: 'mrz_scan_failed',
+ navigateIfReady('RegistrationFallbackMRZ', {
countryCode: currentCountryCode,
});
});
@@ -318,10 +317,12 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
if (navigationRef.isReady()) {
switch (documentType) {
case 'p':
- navigationRef.navigate('DocumentOnboarding');
- break;
case 'i':
- navigationRef.navigate('DocumentOnboarding');
+ // Navigate to logo confirmation screen for biometric IDs
+ navigationRef.navigate('LogoConfirmation', {
+ documentType,
+ countryCode,
+ });
break;
case 'a':
if (countryCode) {
@@ -348,8 +349,21 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
accessToken: accessToken.token,
});
- // User cancelled - return silently
- if (!result.success && result.status === 'Interrupted') {
+ console.log('[Sumsub] Result:', JSON.stringify(result));
+
+ // User cancelled/dismissed without completing verification
+ // Status values: 'Initial' (never started), 'Incomplete' (started but not finished),
+ // 'Interrupted' (explicitly cancelled)
+ const cancelledStatuses = [
+ 'Initial',
+ 'Incomplete',
+ 'Interrupted',
+ ];
+ if (cancelledStatuses.includes(result.status)) {
+ console.log(
+ '[Sumsub] User cancelled or closed without completing, status:',
+ result.status,
+ );
return;
}
@@ -370,15 +384,20 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
}
// Guard navigation call after async operations
if (navigationRef.isReady()) {
- navigationRef.navigate('RegistrationFallback', {
- errorSource: 'sumsub_verification',
+ navigationRef.navigate('KycFailure', {
countryCode,
+ canRetry: true,
});
}
return;
}
- // Success case: navigate to KYC success screen
+ // User completed verification (status: 'Pending', 'Approved', etc.)
+ // Navigate to KYC success screen
+ console.log(
+ '[Sumsub] Verification submitted, status:',
+ result.status,
+ );
if (navigationRef.isReady()) {
navigationRef.navigate('KycSuccess', {
userId: accessToken.userId,
@@ -391,8 +410,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
console.error('Error in KYC flow:', safeInitError);
// Guard navigation call after async operations
if (navigationRef.isReady()) {
- navigationRef.navigate('RegistrationFallback', {
- errorSource: 'sumsub_initialization',
+ navigationRef.navigate('KycConnectionError', {
countryCode,
});
}
diff --git a/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx b/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx
index 35e8d1726..9fe27ecc7 100644
--- a/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx
+++ b/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx
@@ -2,25 +2,34 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
-import React from 'react';
-import { XStack, YStack } from 'tamagui';
+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 { Image } from '@tamagui/lucide-icons';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
-import { BodyText, PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components';
+import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import { AadhaarEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
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 { getErrorMessages } from '@selfxyz/mobile-sdk-alpha/onboarding/import-aadhaar';
import WarningIcon from '@/assets/images/warning.svg';
+import { NavBar } from '@/components/navbar/BaseNavBar';
+import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
+import { buttonTap } from '@/integrations/haptics';
+import type { RootStackParamList } from '@/navigation';
import { extraYPadding } from '@/utils/styleUtils';
type AadhaarUploadErrorRouteParams = {
@@ -32,81 +41,214 @@ type AadhaarUploadErrorRoute = RouteProp<
string
>;
+const getErrorMessages = (
+ errorType: 'general' | 'expired',
+): { title: string; description: string } => {
+ switch (errorType) {
+ case 'expired':
+ return {
+ title: 'Your Aadhaar document has expired',
+ description: 'Please upload a valid Aadhaar document',
+ };
+ case 'general':
+ default:
+ return {
+ title: 'There was a problem reading the code',
+ description: 'Make sure the QR code is valid and try again',
+ };
+ }
+};
+
const AadhaarUploadErrorScreen: React.FC = () => {
+ const insets = useSafeAreaInsets();
const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
- const navigation = useNavigation();
+ const navigation =
+ useNavigation>();
const route = useRoute();
const { trackEvent } = useSelfClient();
- const errorType = route.params?.errorType || 'general';
+ const errorType = route.params?.errorType || 'general';
const { title, description } = getErrorMessages(errorType);
+ const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
+ {
+ countryCode: 'IND',
+ errorSource: 'mrz_scan_failed', // Use a compatible error source
+ onCancel: () => {
+ navigation.goBack();
+ },
+ onError: () => {
+ // Stay on this screen - user can try again
+ },
+ onSuccess: () => {
+ // Success - provider handles its own success UI
+ },
+ },
+ );
+
+ const handleClose = useCallback(() => {
+ buttonTap();
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleTryAgain = useCallback(() => {
+ trackEvent(AadhaarEvents.RETRY_BUTTON_PRESSED, { errorType });
+ navigation.goBack();
+ }, [errorType, navigation, trackEvent]);
+
+ const handleTryAlternative = useCallback(async () => {
+ trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
+ await launchSumsubVerification();
+ }, [errorType, launchSumsubVerification, trackEvent]);
+
return (
-
-
+
-
+
+
+ AADHAAR REGISTRATION
+
+ {/* Invisible spacer to balance header */}
+
+
+
+ {/* Progress Bar - Step 2 for Aadhaar upload */}
+
+
+ {[1, 2, 3, 4].map(step => (
+
+ ))}
+
+ {/* Main Content Area */}
+
+ {/* Warning Icon */}
+
+
+
+
+
+
+ {/* Error Message and Retry Button */}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {/* Retry Button - Primary style with icon */}
+
+
+
+
+ {/* Bottom Section */}
-
- {title}
-
+ {/* Secondary Button - White fill, black text, rounded */}
+
+
+ {/* Footer Text - Not italic */}
- {description}
+ Registering with alternative methods may take longer to verify your
+ document.
-
-
-
-
- {
- trackEvent(AadhaarEvents.RETRY_BUTTON_PRESSED, { errorType });
- // Navigate back to upload screen to try again
- navigation.goBack();
- }}
- >
- Try Again
-
-
- {/*
- {
- trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
- // TODO: Implement help functionality
- }}
- >
- Need Help?
-
- */}
-
-
);
};
diff --git a/app/src/screens/documents/scanning/DocumentCameraScreen.tsx b/app/src/screens/documents/scanning/DocumentCameraScreen.tsx
index 11115061a..cf47c6ead 100644
--- a/app/src/screens/documents/scanning/DocumentCameraScreen.tsx
+++ b/app/src/screens/documents/scanning/DocumentCameraScreen.tsx
@@ -2,10 +2,11 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
-import React, { useRef } from 'react';
+import React, { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native';
import { View, XStack, YStack } from 'tamagui';
-import { useIsFocused } from '@react-navigation/native';
+import { useIsFocused, useNavigation } from '@react-navigation/native';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
DelayedLottieView,
@@ -33,21 +34,45 @@ import {
import passportScanAnimation from '@/assets/animations/passport_scan.json';
import Scan from '@/assets/icons/passport_camera_scan.svg';
import { PassportCamera } from '@/components/native/PassportCamera';
+import { useErrorInjection } from '@/hooks/useErrorInjection';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
+import type { RootStackParamList } from '@/navigation';
import { getDocumentScanPrompt } from '@/utils/documentAttributes';
const DocumentCameraScreen: React.FC = () => {
const isFocused = useIsFocused();
+ const navigation =
+ useNavigation>();
const selfClient = useSelfClient();
const selectedDocumentType = selfClient.useMRZStore(
state => state.documentType,
);
+ const countryCode = selfClient.useMRZStore(state => state.countryCode);
+ const { shouldInjectError } = useErrorInjection();
// Add a ref to track when the camera screen is mounted
const scanStartTimeRef = useRef(Date.now());
const { onPassportRead } = useReadMRZ(scanStartTimeRef);
+ // Dev-only: Auto-trigger MRZ error after short delay if error injection is enabled
+ useEffect(() => {
+ if (
+ shouldInjectError('mrz_invalid_format') ||
+ shouldInjectError('mrz_unknown_error')
+ ) {
+ const timer = setTimeout(() => {
+ console.log(
+ '[DEV] Injecting MRZ error - navigating to fallback screen',
+ );
+ navigation.navigate('RegistrationFallbackMRZ', {
+ countryCode: countryCode || '',
+ });
+ }, 1500); // 1.5 second delay to show camera briefly
+ return () => clearTimeout(timer);
+ }
+ }, [shouldInjectError, navigation, countryCode]);
+
const scanPrompt = getDocumentScanPrompt(selectedDocumentType);
const navigateToHome = useHapticNavigation('Home', {
diff --git a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx
index 684d7b8f5..a0a23c2f1 100644
--- a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx
+++ b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx
@@ -58,7 +58,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
const kycEnabled = useSettingStore(state => state.kycEnabled);
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
- errorSource: 'sumsub_initialization',
+ errorSource: 'mrz_scan_failed',
});
// error screen, flush analytics
diff --git a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx
index 4f95a70d2..a6cd8fc61 100644
--- a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx
+++ b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx
@@ -191,8 +191,7 @@ const DocumentNFCScanScreen: React.FC = () => {
},
{ message: sanitizeErrorMessage(message) },
);
- navigation.navigate('RegistrationFallback', {
- errorSource: 'nfc_scan_failed',
+ navigation.navigate('RegistrationFallbackNFC', {
countryCode,
});
},
diff --git a/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx
index a75e96c36..8636754ee 100644
--- a/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx
+++ b/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx
@@ -2,10 +2,11 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
-import React, { useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
import { View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { YStack } from 'tamagui';
+import { useNavigation } from '@react-navigation/native';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
@@ -16,6 +17,7 @@ import Tips from '@/components/Tips';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
+import { selectionChange } from '@/integrations/haptics';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flushAllAnalytics } from '@/services/analytics';
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
@@ -49,7 +51,11 @@ const tips: TipProps[] = [
];
const DocumentNFCTroubleScreen: React.FC = () => {
- const go = useHapticNavigation('DocumentNFCScan', { action: 'cancel' });
+ const navigation = useNavigation();
+ const handleDismiss = useCallback(() => {
+ selectionChange();
+ navigation.goBack();
+ }, [navigation]);
const goToNFCMethodSelection = useHapticNavigation(
'DocumentNFCMethodSelection',
);
@@ -59,7 +65,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
const kycEnabled = useSettingStore(state => state.kycEnabled);
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
- errorSource: 'sumsub_initialization',
+ errorSource: 'nfc_scan_failed',
});
useFeedbackAutoHide();
@@ -78,7 +84,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
return (
,
+ string
+>;
+
+const getHeaderTitle = (documentType: string): string => {
+ switch (documentType) {
+ case 'p':
+ return 'PASSPORT REGISTRATION';
+ case 'i':
+ return 'ID CARD REGISTRATION';
+ default:
+ return 'DOCUMENT REGISTRATION';
+ }
+};
+
+const RegistrationFallbackMRZScreen: React.FC = () => {
+ const insets = useSafeAreaInsets();
+ const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
+ const navigation =
+ useNavigation>();
+ const route = useRoute();
+ const selfClient = useSelfClient();
+ const { trackEvent, useMRZStore } = selfClient;
+ const storeCountryCode = useMRZStore(state => state.countryCode);
+ const documentType = useMRZStore(state => state.documentType);
+
+ // Use country code from route params, or fall back to MRZ store
+ const countryCode = route.params?.countryCode || storeCountryCode || '';
+
+ const headerTitle = getHeaderTitle(documentType);
+
+ const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
+ {
+ countryCode,
+ errorSource: 'mrz_scan_failed',
+ 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: 'mrz_scan_failed',
+ });
+ await launchSumsubVerification();
+ }, [launchSumsubVerification, trackEvent]);
+
+ const handleRetryOriginal = useCallback(() => {
+ trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
+ errorSource: 'mrz_scan_failed',
+ });
+ navigation.navigate('DocumentCamera');
+ }, [navigation, trackEvent]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {headerTitle}
+
+ {/* Invisible spacer to balance header */}
+
+
+
+ {/* Progress Bar - Step 2 for MRZ */}
+
+
+ {[1, 2, 3, 4].map(step => (
+
+ ))}
+
+
+
+
+ {/* Main Content Area */}
+
+ {/* Warning Icon */}
+
+
+
+
+
+
+ {/* Error Message and Retry Button */}
+
+
+
+ We couldn't read your document's MRZ
+
+
+ Make sure the machine-readable zone at the bottom is clearly
+ visible and try again
+
+
+
+ {/* Retry Button - Primary style with very rounded corners */}
+
+
+
+
+ {/* Bottom Section */}
+
+ {/* Secondary Button - White fill, black text, rounded */}
+
+
+ {/* Footer Text - Not italic */}
+
+ Registering with alternative methods may take longer to verify your
+ document.
+
+
+
+ );
+};
+
+export default RegistrationFallbackMRZScreen;
diff --git a/app/src/screens/documents/scanning/RegistrationFallbackNFCScreen.tsx b/app/src/screens/documents/scanning/RegistrationFallbackNFCScreen.tsx
new file mode 100644
index 000000000..bbe4de02e
--- /dev/null
+++ b/app/src/screens/documents/scanning/RegistrationFallbackNFCScreen.tsx
@@ -0,0 +1,282 @@
+// 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 { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
+import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
+import {
+ black,
+ blue600,
+ 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 { NavBar } from '@/components/navbar/BaseNavBar';
+import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
+import { buttonTap } from '@/integrations/haptics';
+import type { RootStackParamList } from '@/navigation';
+import { extraYPadding } from '@/utils/styleUtils';
+
+type RegistrationFallbackNFCRouteParams = {
+ countryCode: string;
+};
+
+type RegistrationFallbackNFCRoute = RouteProp<
+ Record,
+ string
+>;
+
+const getHeaderTitle = (documentType: string): string => {
+ switch (documentType) {
+ case 'p':
+ return 'PASSPORT REGISTRATION';
+ case 'i':
+ return 'ID CARD REGISTRATION';
+ default:
+ return 'DOCUMENT REGISTRATION';
+ }
+};
+
+const RegistrationFallbackNFCScreen: React.FC = () => {
+ const insets = useSafeAreaInsets();
+ const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
+ const navigation =
+ useNavigation>();
+ const route = useRoute();
+ const selfClient = useSelfClient();
+ const { trackEvent, useMRZStore } = selfClient;
+ const storeCountryCode = useMRZStore(state => state.countryCode);
+ const documentType = useMRZStore(state => state.documentType);
+
+ // Use country code from route params, or fall back to MRZ store
+ const countryCode = route.params?.countryCode || storeCountryCode || '';
+
+ const headerTitle = getHeaderTitle(documentType);
+
+ const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
+ {
+ countryCode,
+ errorSource: 'nfc_scan_failed',
+ 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 handleHelp = useCallback(() => {
+ buttonTap();
+ navigation.navigate('DocumentNFCTrouble');
+ }, [navigation]);
+
+ const handleTryAlternative = useCallback(async () => {
+ trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
+ errorSource: 'nfc_scan_failed',
+ });
+ await launchSumsubVerification();
+ }, [launchSumsubVerification, trackEvent]);
+
+ const handleRetryOriginal = useCallback(() => {
+ trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
+ errorSource: 'nfc_scan_failed',
+ });
+ navigation.navigate('DocumentNFCScan', {});
+ }, [navigation, trackEvent]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {headerTitle}
+
+
+
+
+ {/* Progress Bar - Step 3 for NFC */}
+
+
+ {[1, 2, 3, 4].map(step => (
+
+ ))}
+
+
+
+
+ {/* Main Content Area */}
+
+ {/* Warning Icon */}
+
+
+
+
+
+
+ {/* Error Message and Retry Button */}
+
+
+
+ There was a problem reading the chip
+
+
+ Make sure NFC is enabled and try again
+
+
+
+ {/* Retry Button - Primary style with very rounded corners */}
+
+
+
+
+ {/* Bottom Section */}
+
+ {/* Secondary Button - White fill, black text, rounded */}
+
+
+ {/* Footer Text - Not italic */}
+
+ Registering with alternative methods may take longer to verify your
+ document.
+
+
+
+ );
+};
+
+export default RegistrationFallbackNFCScreen;
diff --git a/app/src/screens/documents/scanning/RegistrationFallbackScreen.tsx b/app/src/screens/documents/scanning/RegistrationFallbackScreen.tsx
deleted file mode 100644
index 7a204e0bd..000000000
--- a/app/src/screens/documents/scanning/RegistrationFallbackScreen.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-// 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
->;
-
-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>();
- const route = useRoute();
- 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 (
-
- {/* Header */}
-
-
-
-
-
- {headerTitle}
-
-
-
-
-
- {/* Progress Bar */}
-
-
- {[1, 2, 3, 4].map(step => (
-
- ))}
-
-
-
-
- {/* Warning Icon */}
-
-
-
-
-
-
- {/* Error Message */}
-
-
- {title}
-
-
- {description}
-
-
-
- {/* Top Button - Retry */}
- {canRetryOriginal && (
-
-
- {retryButtonText}
-
-
- )}
-
- {/* Bottom Section with Grey Line Separator */}
-
-
- {isRetrying ? 'Loading...' : 'Try a different method'}
-
-
- {/* Footer Text */}
-
- Registering with alternative methods may take longer to verify your
- document.
-
-
-
- );
-};
-
-export default RegistrationFallbackScreen;
diff --git a/app/src/screens/documents/selection/LogoConfirmationScreen.tsx b/app/src/screens/documents/selection/LogoConfirmationScreen.tsx
new file mode 100644
index 000000000..917ff4db2
--- /dev/null
+++ b/app/src/screens/documents/selection/LogoConfirmationScreen.tsx
@@ -0,0 +1,152 @@
+// 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 { StyleSheet, View } from 'react-native';
+import type { RouteProp } from '@react-navigation/native';
+import { useNavigation, useRoute } from '@react-navigation/native';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
+
+import {
+ BodyText,
+ ButtonsContainer,
+ PrimaryButton,
+ SecondaryButton,
+} from '@selfxyz/mobile-sdk-alpha/components';
+import {
+ black,
+ slate400,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+import EPassportLogo from '@/assets/icons/epassport_logo.svg';
+import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
+import useHapticNavigation from '@/hooks/useHapticNavigation';
+import { buttonTap } from '@/integrations/haptics';
+import {
+ fetchAccessToken,
+ launchSumsub,
+} from '@/integrations/sumsub/sumsubService';
+import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
+import type { RootStackParamList } from '@/navigation';
+import { useFeedback } from '@/providers/feedbackProvider';
+
+type LogoConfirmationScreenRouteProp = RouteProp<
+ RootStackParamList,
+ 'LogoConfirmation'
+>;
+
+const LogoConfirmationScreen: React.FC = () => {
+ const route = useRoute();
+ const { documentType, countryCode } = route.params;
+ const navigation =
+ useNavigation>();
+ const { showModal } = useFeedback();
+ const navigateToOnboarding = useHapticNavigation('DocumentOnboarding');
+
+ const handleConfirm = useCallback(() => {
+ buttonTap();
+ navigateToOnboarding();
+ }, [navigateToOnboarding]);
+
+ const handleNotFound = useCallback(() => {
+ buttonTap();
+ showModal({
+ titleText: 'Document Not Supported',
+ bodyText:
+ "To complete registration of a document without a biometric chip, you'll be redirected to our third party verification partner.",
+ buttonText: 'Proceed with an external verifier',
+ onButtonPress: async () => {
+ try {
+ const accessToken = await fetchAccessToken();
+ const result = await launchSumsub({
+ accessToken: accessToken.token,
+ // Pre-select document type and country based on user's earlier selection
+ documentType: documentType as 'p' | 'i',
+ countryCode,
+ });
+
+ // User cancelled/dismissed without completing verification
+ const cancelledStatuses = ['Initial', 'Incomplete', 'Interrupted'];
+ if (cancelledStatuses.includes(result.status)) {
+ return;
+ }
+
+ // User completed verification - navigate to KycSuccessScreen
+ navigation.navigate('KycSuccess', { userId: accessToken.userId });
+ } catch (error) {
+ console.error('Error launching Sumsub:', error);
+ showModal({
+ titleText: 'Error',
+ bodyText: 'Unable to start verification. Please try again.',
+ buttonText: 'OK',
+ onButtonPress: () => {},
+ });
+ }
+ },
+ });
+ }, [documentType, countryCode, navigation, showModal]);
+
+ return (
+
+
+
+
+
+ Does your document have this symbol?
+
+
+
+
+
+
+
+ This symbol indicates your document has a biometric chip, which is
+ required for registration.
+
+
+
+
+
+
+ Yes
+ No
+
+
+
+ );
+};
+
+export default LogoConfirmationScreen;
+
+const styles = StyleSheet.create({
+ contentContainer: {
+ alignItems: 'center',
+ gap: 24,
+ maxWidth: 340,
+ },
+ titleText: {
+ fontSize: 20,
+ fontFamily: advercase,
+ textAlign: 'center',
+ color: black,
+ },
+ logoContainer: {
+ backgroundColor: white,
+ borderRadius: 16,
+ padding: 24,
+ shadowColor: black,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 4,
+ },
+ descriptionText: {
+ fontSize: 16,
+ fontFamily: dinot,
+ textAlign: 'center',
+ color: slate400,
+ },
+});
diff --git a/app/src/screens/kyc/KycConnectionErrorScreen.tsx b/app/src/screens/kyc/KycConnectionErrorScreen.tsx
new file mode 100644
index 000000000..ff0b172b9
--- /dev/null
+++ b/app/src/screens/kyc/KycConnectionErrorScreen.tsx
@@ -0,0 +1,175 @@
+// 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 { useNavigation } from '@react-navigation/native';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import { X } from '@tamagui/lucide-icons';
+
+import {
+ BodyText,
+ PrimaryButton,
+ SecondaryButton,
+} from '@selfxyz/mobile-sdk-alpha/components';
+import {
+ black,
+ slate100,
+ slate200,
+ 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 { buttonTap } from '@/integrations/haptics';
+import type { RootStackParamList } from '@/navigation';
+import { extraYPadding } from '@/utils/styleUtils';
+
+const KycConnectionErrorScreen: React.FC = () => {
+ const insets = useSafeAreaInsets();
+ const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
+ const navigation =
+ useNavigation>();
+
+ const handleClose = useCallback(() => {
+ buttonTap();
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleRetry = useCallback(() => {
+ buttonTap();
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleTryDifferentMethod = useCallback(() => {
+ buttonTap();
+ navigation.navigate('CountryPicker');
+ }, [navigation]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ CONNECTION ERROR
+
+
+
+
+
+
+ {/* Warning Icon */}
+
+
+
+
+
+
+ {/* Error Message */}
+
+
+ Connection Error
+
+
+ Unable to connect to verification service. Please check your internet
+ connection and try again.
+
+
+
+ {/* Retry Button */}
+
+ Try again
+
+
+ {/* Bottom Section with Grey Line Separator */}
+
+
+ Try a different method
+
+
+ {/* Footer Text */}
+
+ Registering with alternative methods may take longer to verify your
+ document.
+
+
+
+ );
+};
+
+export default KycConnectionErrorScreen;
diff --git a/app/src/screens/kyc/KycFailureScreen.tsx b/app/src/screens/kyc/KycFailureScreen.tsx
new file mode 100644
index 000000000..9f9cb0a64
--- /dev/null
+++ b/app/src/screens/kyc/KycFailureScreen.tsx
@@ -0,0 +1,126 @@
+// 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 { StyleSheet, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { 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 {
+ AbstractButton,
+ Description,
+ Title,
+} from '@selfxyz/mobile-sdk-alpha/components';
+import {
+ black,
+ slate600,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+
+import ShieldErrorIcon from '@/assets/icons/shield_error.svg';
+import { buttonTap } from '@/integrations/haptics';
+import type { RootStackParamList } from '@/navigation';
+
+type KycFailureRouteParams = {
+ countryCode?: string;
+ canRetry?: boolean;
+};
+
+type KycFailureRoute = RouteProp, string>;
+
+const KycFailureScreen: React.FC = () => {
+ const navigation =
+ useNavigation>();
+ const route = useRoute();
+ const insets = useSafeAreaInsets();
+
+ const canRetry = route.params?.canRetry ?? true;
+
+ const handleDismiss = useCallback(() => {
+ buttonTap();
+ navigation.navigate('Home', {});
+ }, [navigation]);
+
+ const handleTryAgain = useCallback(() => {
+ buttonTap();
+ navigation.navigate('CountryPicker');
+ }, [navigation]);
+
+ return (
+
+
+
+
+
+
+ Unfortunately we couldn't verify your ID
+
+
+ This may be because the files you uploaded were unreadable for some
+ other issue.
+
+
+
+
+ Dismiss
+
+ {canRetry && (
+
+ Try again
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: black,
+ },
+ title: {
+ color: white,
+ textAlign: 'center',
+ fontSize: 36,
+ lineHeight: 44,
+ letterSpacing: 1,
+ },
+ description: {
+ color: white,
+ textAlign: 'center',
+ fontSize: 20,
+ lineHeight: 30,
+ },
+ button: {
+ borderRadius: 100,
+ },
+});
+
+export default KycFailureScreen;
diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx
index a46550ba5..f08f5995d 100644
--- a/app/tests/src/navigation.test.tsx
+++ b/app/tests/src/navigation.test.tsx
@@ -86,8 +86,11 @@ describe('navigation', () => {
'IDPicker',
'IdDetails',
'KYCVerified',
+ 'KycConnectionError',
+ 'KycFailure',
'KycSuccess',
'Loading',
+ 'LogoConfirmation',
'ManageDocuments',
'MockDataDeepLink',
'Modal',
@@ -103,7 +106,8 @@ describe('navigation', () => {
'QRCodeViewFinder',
'RecoverWithPhrase',
'Referral',
- 'RegistrationFallback',
+ 'RegistrationFallbackMRZ',
+ 'RegistrationFallbackNFC',
'SaveRecoveryPhrase',
'Settings',
'ShowRecoveryPhrase',
diff --git a/app/tests/src/providers/notificationTrackingProvider.test.tsx b/app/tests/src/providers/notificationTrackingProvider.test.tsx
index 84f1e43b9..c1654874f 100644
--- a/app/tests/src/providers/notificationTrackingProvider.test.tsx
+++ b/app/tests/src/providers/notificationTrackingProvider.test.tsx
@@ -116,7 +116,7 @@ describe('NotificationTrackingProvider', () => {
});
});
- it('should not navigate when status is retry', async () => {
+ it('should navigate to CountryPicker when status is retry', async () => {
let notificationHandler:
| ((message: FirebaseMessagingTypes.RemoteMessage) => void)
| null = null;
@@ -151,11 +151,10 @@ describe('NotificationTrackingProvider', () => {
expect(analytics.trackEvent).toHaveBeenCalled();
});
- // Should not navigate for retry status
- expect(mockNavigationRef.navigate).not.toHaveBeenCalled();
+ expect(mockNavigationRef.navigate).toHaveBeenCalledWith('CountryPicker');
});
- it('should not navigate when status is rejected', async () => {
+ it('should navigate to KycFailure when status is rejected', async () => {
let notificationHandler:
| ((message: FirebaseMessagingTypes.RemoteMessage) => void)
| null = null;
@@ -190,8 +189,9 @@ describe('NotificationTrackingProvider', () => {
expect(analytics.trackEvent).toHaveBeenCalled();
});
- // Should not navigate for rejected status
- expect(mockNavigationRef.navigate).not.toHaveBeenCalled();
+ expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KycFailure', {
+ canRetry: false,
+ });
});
it('should handle missing notification data gracefully', async () => {
@@ -331,7 +331,7 @@ describe('NotificationTrackingProvider', () => {
expect(mockNavigationRef.navigate).not.toHaveBeenCalled();
});
- it('should not navigate when status is retry on cold start', async () => {
+ it('should navigate to CountryPicker when status is retry on cold start', async () => {
mockOnNotificationOpenedApp.mockReturnValue(jest.fn());
const remoteMessage = {
@@ -358,8 +358,7 @@ describe('NotificationTrackingProvider', () => {
);
});
- // Should not navigate for retry status
- expect(mockNavigationRef.navigate).not.toHaveBeenCalled();
+ expect(mockNavigationRef.navigate).toHaveBeenCalledWith('CountryPicker');
});
it('should queue navigation when navigationRef is not ready on cold start', async () => {
diff --git a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx
index dabffb598..7e7dc7de3 100644
--- a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx
+++ b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx
@@ -131,6 +131,7 @@ const styles = StyleSheet.create({
borderRadius: 5,
},
text: {
+ flex: 1,
fontFamily: dinot,
textAlign: 'center',
fontSize: 18,
diff --git a/packages/mobile-sdk-alpha/src/flows/onboarding/logo-confirmation-screen.tsx b/packages/mobile-sdk-alpha/src/flows/onboarding/logo-confirmation-screen.tsx
new file mode 100644
index 000000000..e84447294
--- /dev/null
+++ b/packages/mobile-sdk-alpha/src/flows/onboarding/logo-confirmation-screen.tsx
@@ -0,0 +1,104 @@
+// 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 type React from 'react';
+import type { ReactNode } from 'react';
+import { StyleSheet } from 'react-native';
+
+import { BodyText, PrimaryButton, SecondaryButton, View, YStack } from '../../components';
+import ButtonsContainer from '../../components/ButtonsContainer';
+import { black, slate400, white } from '../../constants/colors';
+import { advercase, dinot } from '../../constants/fonts';
+import { useSelfClient } from '../../context';
+import { buttonTap } from '../../haptic';
+import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
+import { SdkEvents } from '../../types/events';
+
+type LogoConfirmationScreenProps = {
+ documentType: string;
+ countryCode: string;
+ logo: ReactNode;
+ safeAreaBottom?: number;
+ onConfirm?: () => void;
+ onNotFound?: () => void;
+};
+
+const LogoConfirmationScreen: React.FC = ({
+ documentType,
+ countryCode,
+ logo,
+ safeAreaBottom,
+ onConfirm,
+ onNotFound,
+}) => {
+ const selfClient = useSelfClient();
+
+ const onYesPress = () => {
+ buttonTap();
+ if (onConfirm) {
+ onConfirm();
+ } else {
+ selfClient.emit(SdkEvents.LOGO_CONFIRMED, { documentType, countryCode });
+ }
+ };
+
+ const onNoPress = () => {
+ buttonTap();
+ if (onNotFound) {
+ onNotFound();
+ } else {
+ selfClient.emit(SdkEvents.LOGO_NOT_FOUND, { documentType, countryCode });
+ }
+ };
+
+ return (
+
+
+
+ Does your document have this symbol?
+
+ {logo}
+
+
+ This symbol indicates your document has a biometric chip, which is required for registration.
+
+
+
+
+
+
+ Yes
+ No
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ titleText: {
+ fontSize: 20,
+ fontFamily: advercase,
+ textAlign: 'center',
+ color: black,
+ },
+ logoContainer: {
+ backgroundColor: white,
+ borderRadius: 16,
+ padding: 24,
+ shadowColor: black,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 4,
+ },
+ descriptionText: {
+ fontSize: 16,
+ fontFamily: dinot,
+ textAlign: 'center',
+ color: slate400,
+ },
+});
+
+export default LogoConfirmationScreen;
diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts
index ac8357c4e..1d2d2f64e 100644
--- a/packages/mobile-sdk-alpha/src/index.ts
+++ b/packages/mobile-sdk-alpha/src/index.ts
@@ -70,6 +70,8 @@ export {
sdkError,
} from './errors';
+export { default as LogoConfirmationScreen } from './flows/onboarding/logo-confirmation-screen';
+
export { NFCScannerScreen } from './components/screens/NFCScannerScreen';
export { type ProvingStateType } from './proving/provingMachine';
diff --git a/packages/mobile-sdk-alpha/src/types/events.ts b/packages/mobile-sdk-alpha/src/types/events.ts
index 0a60aca2d..62a5e03ca 100644
--- a/packages/mobile-sdk-alpha/src/types/events.ts
+++ b/packages/mobile-sdk-alpha/src/types/events.ts
@@ -165,6 +165,20 @@ export enum SdkEvents {
*
*/
DOCUMENT_OWNERSHIP_CONFIRMED = 'DOCUMENT_OWNERSHIP_CONFIRMED',
+
+ /**
+ * Emitted when the user confirms they see the e-passport chip logo on their document.
+ *
+ * **Required:** Navigate to the document scanning flow (DocumentOnboarding).
+ */
+ LOGO_CONFIRMED = 'LOGO_CONFIRMED',
+
+ /**
+ * Emitted when the user indicates they do not see the e-passport chip logo on their document.
+ *
+ * **Required:** Show an error message indicating the document is not supported as it is not a biometric ID.
+ */
+ LOGO_NOT_FOUND = 'LOGO_NOT_FOUND',
}
/**
@@ -223,6 +237,14 @@ export interface SDKEventMap {
signatureAlgorithm?: string;
curveOrExponent?: string;
};
+ [SdkEvents.LOGO_CONFIRMED]: {
+ documentType: string;
+ countryCode: string;
+ };
+ [SdkEvents.LOGO_NOT_FOUND]: {
+ documentType: string;
+ countryCode: string;
+ };
}
/**