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; + }; } /**