App/id picker flow (#1126)

* add new id picker flow

* refactor: update document management screen actions

- Renamed `handleScanDocument` to `handleAddDocument` for clarity.
- Updated navigation from 'DocumentOnboarding' to 'CountryPicker'.
- Removed unused `handleAddAadhaar` function and its associated button.

* address pr feedback

* address lint issues

* fix test

* fix typings and screen

* fix e2e button test

---------

Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
This commit is contained in:
turnoffthiscomputer
2025-10-01 19:32:11 +02:00
committed by GitHub
parent 3b14f09c30
commit 422d0cc259
30 changed files with 848 additions and 222 deletions

View File

@@ -98,7 +98,6 @@ const Container: React.FC<NavBarProps> = ({
<SystemBars style={barStyle} />
<XStack
backgroundColor={backgroundColor}
flexGrow={1}
justifyContent="flex-start"
alignItems="center"
{...props}

View File

@@ -0,0 +1,46 @@
// 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 from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { HelpCircle } from '@tamagui/lucide-icons';
import { NavBar } from '@/components/NavBar/BaseNavBar';
import { slate100 } from '@/utils/colors';
import { dinot } from '@/utils/fonts';
export const DocumentFlowNavBar = ({
title,
titleFontFamily = dinot,
fontSize = 17,
}: {
title: string;
titleFontFamily?: string;
fontSize?: number;
}) => {
const navigation = useNavigation();
const { top } = useSafeAreaInsets();
return (
<NavBar.Container
paddingTop={top}
backgroundColor={slate100}
paddingHorizontal="$4"
alignItems="center"
justifyContent="space-between"
>
<NavBar.LeftAction component="back" onPress={() => navigation.goBack()} />
<NavBar.Title fontFamily={titleFontFamily} fontSize={fontSize}>
{title}
</NavBar.Title>
<NavBar.RightAction
component={<HelpCircle color={'transparent'} />}
onPress={() => {
/* Handle help action, button is transparent for now as we dont have the help screen ready */
}}
/>
</NavBar.Container>
);
};

View File

@@ -0,0 +1,77 @@
// 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 getCountryISO2 from 'country-iso-3-to-2';
import React from 'react';
import { View } from 'react-native';
import * as CountryFlags from 'react-native-svg-circle-country-flags';
import { slate300 } from '@/utils/colors';
type CountryFlagComponent = React.ComponentType<{
width: number;
height: number;
}>;
type CountryFlagsRecord = Record<string, CountryFlagComponent>;
interface RoundFlagProps {
countryCode: string;
size: number;
}
const findFlagComponent = (formattedCode: string) => {
const patterns = [
formattedCode,
formattedCode.toLowerCase(),
formattedCode.charAt(0).toUpperCase() +
formattedCode.charAt(1).toLowerCase(),
];
for (const pattern of patterns) {
const component = (CountryFlags as unknown as CountryFlagsRecord)[pattern];
if (component) {
return component;
}
}
return null;
};
const getCountryFlag = (countryCode: string): CountryFlagComponent | null => {
try {
const normalizedCountryCode = countryCode === 'D<<' ? 'DEU' : countryCode;
const iso2 = getCountryISO2(normalizedCountryCode);
if (!iso2) {
return null;
}
const formattedCode = iso2.toUpperCase();
return findFlagComponent(formattedCode);
} catch (error) {
console.error('Error getting country flag:', error);
return null;
}
};
export const RoundFlag: React.FC<RoundFlagProps> = ({ countryCode, size }) => {
const CountryFlagComponent = getCountryFlag(countryCode);
if (!CountryFlagComponent) {
return (
<View
style={{
width: size,
height: size,
backgroundColor: slate300,
}}
/>
);
}
return (
<View style={{ alignItems: 'center' }}>
<CountryFlagComponent width={size} height={size} />
</View>
);
};

View File

@@ -0,0 +1,6 @@
<svg width="48" height="32" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="32" rx="2" fill="#075985"/>
<circle cx="24" cy="16" r="8.5" stroke="white" stroke-width="4"/>
<line x1="-1.74846e-07" y1="16" x2="15" y2="16" stroke="white" stroke-width="4"/>
<line x1="33" y1="16" x2="48" y2="16" stroke="white" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -0,0 +1,3 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.0644531 7.6543C0.0644531 7.34375 0.175781 7.07715 0.398438 6.85449C0.621094 6.63184 0.887695 6.52051 1.19824 6.52051H6.375V1.35254C6.375 1.04199 6.4834 0.775391 6.7002 0.552734C6.91699 0.330078 7.18359 0.21875 7.5 0.21875C7.81055 0.21875 8.07715 0.330078 8.2998 0.552734C8.52246 0.775391 8.63379 1.04199 8.63379 1.35254V6.52051H13.8105C14.1152 6.52051 14.3789 6.63184 14.6016 6.85449C14.8242 7.07715 14.9355 7.34375 14.9355 7.6543C14.9355 7.9707 14.8242 8.24023 14.6016 8.46289C14.3789 8.67969 14.1152 8.78809 13.8105 8.78809H8.63379V13.9648C8.63379 14.2695 8.52246 14.5332 8.2998 14.7559C8.07715 14.9785 7.81055 15.0898 7.5 15.0898C7.18359 15.0898 6.91699 14.9785 6.7002 14.7559C6.4834 14.5332 6.375 14.2695 6.375 13.9648V8.78809H1.19824C0.887695 8.78809 0.621094 8.67969 0.398438 8.46289C0.175781 8.24023 0.0644531 7.9707 0.0644531 7.6543Z" fill="#94A3B8"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

View File

@@ -4,13 +4,15 @@
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import ComingSoonScreen from '@/screens/document/ComingSoonScreen';
import CountryPickerScreen from '@/screens/document/CountryPickerScreen';
import DocumentCameraScreen from '@/screens/document/DocumentCameraScreen';
import DocumentCameraTroubleScreen from '@/screens/document/DocumentCameraTroubleScreen';
import DocumentNFCMethodSelectionScreen from '@/screens/document/DocumentNFCMethodSelectionScreen';
import DocumentNFCScanScreen from '@/screens/document/DocumentNFCScanScreen';
import DocumentNFCTroubleScreen from '@/screens/document/DocumentNFCTroubleScreen';
import DocumentOnboardingScreen from '@/screens/document/DocumentOnboardingScreen';
import UnsupportedDocumentScreen from '@/screens/document/UnsupportedDocumentScreen';
import IDPickerScreen from '@/screens/document/IDPickerScreen';
const documentScreens = {
DocumentCamera: {
@@ -56,8 +58,8 @@ const documentScreens = {
headerShown: false,
} as NativeStackNavigationOptions,
},
UnsupportedDocument: {
screen: UnsupportedDocumentScreen,
ComingSoon: {
screen: ComingSoonScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
@@ -73,6 +75,22 @@ const documentScreens = {
animation: 'slide_from_bottom',
} as NativeStackNavigationOptions,
},
CountryPicker: {
screen: CountryPickerScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
},
IDPicker: {
screen: IDPickerScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
countryCode: '',
documentTypes: [],
},
},
};
export default documentScreens;

View File

@@ -79,7 +79,7 @@ const NavigationWithTracking = () => {
return () => {
cleanup();
};
}, []);
}, [selfClient]);
return (
<GestureHandlerRootView>

View File

@@ -136,7 +136,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED,
({ countryCode, documentCategory }) => {
if (navigationRef.isReady()) {
navigationRef.navigate('UnsupportedDocument', {
navigationRef.navigate('ComingSoon', {
countryCode,
documentCategory,
} as any);

View File

@@ -124,6 +124,7 @@ function ParameterSection({
const items = [
'DevSettings',
'CountryPicker',
'AadhaarUpload',
'DevFeatureFlags',
'DevHapticFeedback',
@@ -149,7 +150,7 @@ const items = [
'RecoverWithPhrase',
'ShowRecoveryPhrase',
'CloudBackupSettings',
'UnsupportedDocument',
'ComingSoon',
'DocumentCameraTrouble',
'DocumentNFCTrouble',
] satisfies (keyof RootStackParamList)[];

View File

@@ -2,10 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import getCountryISO2 from 'country-iso-3-to-2';
import React, { useEffect, useMemo } from 'react';
import { View } from 'react-native';
import * as CountryFlags from 'react-native-svg-circle-country-flags';
import { XStack, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
@@ -19,6 +16,7 @@ import { PassportEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { PrimaryButton } from '@/components/buttons/PrimaryButton';
import { SecondaryButton } from '@/components/buttons/SecondaryButton';
import { RoundFlag } from '@/components/flag/RoundFlag';
import { BodyText } from '@/components/typography/BodyText';
import { Title } from '@/components/typography/Title';
import useHapticNavigation from '@/hooks/useHapticNavigation';
@@ -30,88 +28,66 @@ import { notificationError } from '@/utils/haptic';
const { flush: flushAnalytics } = analytics();
type CountryFlagComponent = React.ComponentType<{
width: number;
height: number;
}>;
type CountryFlagsRecord = Record<string, CountryFlagComponent>;
type UnsupportedDocumentScreenRouteProp = RouteProp<
type ComingSoonScreenRouteProp = RouteProp<
{
UnsupportedDocument: {
countryCode: string | null;
documentCategory: DocumentCategory | null;
ComingSoon: {
countryCode: string;
documentCategory?: DocumentCategory;
};
},
'UnsupportedDocument'
'ComingSoon'
>;
interface UnsupportedDocumentScreenProps {
route: UnsupportedDocumentScreenRouteProp;
interface ComingSoonScreenProps {
route: ComingSoonScreenRouteProp;
}
const UnsupportedDocumentScreen: React.FC<UnsupportedDocumentScreenProps> = ({
route,
}) => {
const ComingSoonScreen: React.FC<ComingSoonScreenProps> = ({ route }) => {
const selfClient = useSelfClient();
const navigateToLaunch = useHapticNavigation('Launch');
const navigateToHome = useHapticNavigation('Home');
const { countryName, country2AlphaCode, documentTypeText } = useMemo(() => {
const { countryName, countryCode, documentTypeText } = useMemo(() => {
try {
const countryCode = route.params?.countryCode;
if (countryCode) {
const routeCountryCode = route.params?.countryCode;
if (routeCountryCode) {
// Handle Germany corner case where country code is "D<<" instead of "DEU"
let normalizedCountryCode = countryCode;
if (countryCode === 'D<<') {
normalizedCountryCode = 'DEU';
}
const iso2 = getCountryISO2(normalizedCountryCode);
const extractedCode = iso2
? iso2.charAt(0).toUpperCase() + iso2.charAt(1).toLowerCase()
: 'Unknown';
const normalizedCountryCode =
routeCountryCode === 'D<<' ? 'DEU' : routeCountryCode;
const name =
countryCodes[normalizedCountryCode as keyof typeof countryCodes];
const docType =
route.params?.documentCategory === 'id_card'
? 'ID Cards'
: 'Passports';
let docType = '';
if (route.params?.documentCategory === 'id_card') {
docType = 'ID Cards';
} else if (route.params?.documentCategory === 'passport') {
docType = 'Passports';
}
return {
countryName: name,
country2AlphaCode: extractedCode,
countryCode: normalizedCountryCode,
documentTypeText: docType,
};
}
} catch (error) {
console.error('Error extracting country from passport data:', error);
}
const docType =
route.params?.documentCategory === 'id_card' ? 'ID Cards' : 'Passports';
let docType = '';
if (route.params?.documentCategory === 'id_card') {
docType = 'ID Cards';
} else if (route.params?.documentCategory === 'passport') {
docType = 'Passports';
}
return {
countryName: 'Unknown',
country2AlphaCode: 'Unknown',
countryCode: 'Unknown',
documentTypeText: docType,
};
}, [route.params?.documentCategory, route.params?.countryCode]);
// Get country flag component dynamically
const getCountryFlag = (code: string) => {
try {
const FlagComponent = (CountryFlags as unknown as CountryFlagsRecord)[
code.toUpperCase()
];
if (FlagComponent) {
return FlagComponent;
}
} catch (error) {
console.error('Error getting country flag:', error);
return null;
}
};
const CountryFlagComponent = getCountryFlag(country2AlphaCode);
const onDismiss = async () => {
const hasValidDocument = await hasAnyValidRegisteredDocument(selfClient);
if (hasValidDocument) {
@@ -125,9 +101,8 @@ const UnsupportedDocumentScreen: React.FC<UnsupportedDocumentScreenProps> = ({
try {
await sendCountrySupportNotification({
countryName,
countryCode:
country2AlphaCode !== 'Unknown' ? country2AlphaCode : undefined,
documentCategory: route.params?.documentCategory ?? '',
countryCode: countryCode !== 'Unknown' ? countryCode : '',
documentCategory: route.params?.documentCategory,
});
} catch (error) {
console.error('Failed to open email client:', error);
@@ -155,10 +130,8 @@ const UnsupportedDocumentScreen: React.FC<UnsupportedDocumentScreenProps> = ({
marginBottom={20}
gap={12}
>
{CountryFlagComponent && (
<View style={{ alignItems: 'center' }}>
<CountryFlagComponent width={60} height={60} />
</View>
{countryCode !== 'Unknown' && (
<RoundFlag countryCode={countryCode} size={60} />
)}
</XStack>
<Title
@@ -176,8 +149,9 @@ const UnsupportedDocumentScreen: React.FC<UnsupportedDocumentScreenProps> = ({
marginBottom={10}
paddingHorizontal={10}
>
We're working to roll out support for {documentTypeText} in{' '}
{countryName}.
{documentTypeText
? `We're working to roll out support for ${documentTypeText} in ${countryName}.`
: `We're working to roll out support in ${countryName}.`}
</BodyText>
<BodyText
fontSize={17}
@@ -198,12 +172,12 @@ const UnsupportedDocumentScreen: React.FC<UnsupportedDocumentScreenProps> = ({
>
<PrimaryButton
onPress={onNotifyMe}
trackEvent={PassportEvents.NOTIFY_UNSUPPORTED_PASSPORT}
trackEvent={PassportEvents.NOTIFY_COMING_SOON}
>
Sign up for updates
</PrimaryButton>
<SecondaryButton
trackEvent={PassportEvents.DISMISS_UNSUPPORTED_PASSPORT}
trackEvent={PassportEvents.DISMISS_COMING_SOON}
onPress={onDismiss}
>
Dismiss
@@ -213,4 +187,4 @@ const UnsupportedDocumentScreen: React.FC<UnsupportedDocumentScreenProps> = ({
);
};
export default UnsupportedDocumentScreen;
export default ComingSoonScreen;

View File

@@ -0,0 +1,197 @@
// 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 { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { FlatList, TouchableOpacity, View } from 'react-native';
import { Spinner, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { commonNames } from '@selfxyz/common/constants/countries';
import { SdkEvents, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { RoundFlag } from '@/components/flag/RoundFlag';
import { DocumentFlowNavBar } from '@/components/NavBar/DocumentFlowNavBar';
import { BodyText } from '@/components/typography/BodyText';
import type { RootStackParamList } from '@/navigation';
import { black, slate100, slate500 } from '@/utils/colors';
import { advercase } from '@/utils/fonts';
import { buttonTap } from '@/utils/haptic';
interface CountryData {
[countryCode: string]: string[];
}
interface CountryListItem {
key: string;
countryCode: string;
}
const ITEM_HEIGHT = 65;
const FLAG_SIZE = 32;
const CountryItem = memo<{
countryCode: string;
onSelect: (code: string) => void;
}>(({ countryCode, onSelect }) => {
const countryName = commonNames[countryCode as keyof typeof commonNames];
if (!countryName) return null;
return (
<TouchableOpacity
onPress={() => onSelect(countryCode)}
style={{
paddingVertical: 13,
}}
>
<XStack alignItems="center" gap={16}>
<RoundFlag countryCode={countryCode} size={FLAG_SIZE} />
<BodyText fontSize={16} color={black} flex={1}>
{countryName}
</BodyText>
</XStack>
</TouchableOpacity>
);
});
CountryItem.displayName = 'CountryItem';
const CountryPickerScreen: React.FC = () => {
const [countryData, setCountryData] = useState<CountryData>({});
const [loading, setLoading] = useState(true);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const selfClient = useSelfClient();
const onPressCountry = useCallback(
(countryCode: string) => {
buttonTap();
if (__DEV__) {
console.log('Selected country code:', countryCode);
console.log('Current countryData:', countryData);
console.log('Available country codes:', Object.keys(countryData));
}
const documentTypes = countryData[countryCode];
if (__DEV__) {
console.log('documentTypes for', countryCode, ':', documentTypes);
}
if (documentTypes && documentTypes.length > 0) {
const countryName =
commonNames[countryCode as keyof typeof commonNames] || countryCode;
// Emit the country selection event
selfClient.emit(SdkEvents.DOCUMENT_COUNTRY_SELECTED, {
countryCode: countryCode,
countryName: countryName,
documentTypes: documentTypes,
});
navigation.navigate('IDPicker', { countryCode, documentTypes });
} else {
navigation.navigate('ComingSoon', { countryCode });
}
},
[countryData, navigation, selfClient],
);
useEffect(() => {
const fetchCountryData = async () => {
try {
const response = await fetch('https://api.staging.self.xyz/id-picker');
const result = await response.json();
if (result.status === 'success') {
setCountryData(result.data);
if (__DEV__) {
console.log('Set country data:', result.data);
}
} else {
console.error('API returned non-success status:', result.status);
}
} catch (error) {
console.error('Error fetching country data:', error);
} finally {
setLoading(false);
}
};
fetchCountryData();
}, []);
const countryList = useMemo(
() =>
Object.keys(countryData).map(countryCode => ({
key: countryCode,
countryCode,
})),
[countryData],
);
const renderItem = useCallback(
({ item }: { item: CountryListItem }) => (
<CountryItem countryCode={item.countryCode} onSelect={onPressCountry} />
),
[onPressCountry],
);
const keyExtractor = useCallback(
(item: CountryListItem) => item.countryCode,
[],
);
const renderLoadingState = () => (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Spinner size="small" />
</View>
);
const getItemLayout = useCallback(
(
_data: ReadonlyArray<CountryListItem> | null | undefined,
index: number,
) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[],
);
return (
<YStack flex={1} backgroundColor={slate100}>
<DocumentFlowNavBar title="GETTING STARTED" />
<YStack flex={1} paddingTop="$4" paddingHorizontal="$4">
<YStack marginTop="$4" marginBottom="$6">
<BodyText fontSize={29} fontFamily={advercase}>
Select the country that issued your ID
</BodyText>
<BodyText fontSize={16} color={slate500} marginTop="$3">
Self has support for over 300 ID types. You can select the type of
ID in the next step
</BodyText>
</YStack>
{loading ? (
renderLoadingState()
) : (
<FlatList
data={countryList}
renderItem={renderItem}
keyExtractor={keyExtractor}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
initialNumToRender={10}
updateCellsBatchingPeriod={50}
getItemLayout={getItemLayout}
/>
)}
</YStack>
</YStack>
);
};
export default CountryPickerScreen;

View File

@@ -48,7 +48,7 @@ const DocumentOnboardingScreen: React.FC = () => {
onAnimationFinish={() => {
setTimeout(() => {
animationRef.current?.play();
}, 5000); // Pause 5 seconds before playing again
}, 220);
}}
source={passportOnboardingAnimation}
style={styles.animation}

View File

@@ -0,0 +1,207 @@
// 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 from 'react';
import { View, 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 { SdkEvents, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { RoundFlag } from '@/components/flag/RoundFlag';
import { DocumentFlowNavBar } from '@/components/NavBar/DocumentFlowNavBar';
import { BodyText } from '@/components/typography/BodyText';
import AadhaarLogo from '@/images/icons/aadhaar.svg';
import EPassportLogoRounded from '@/images/icons/epassport_rounded.svg';
import PlusIcon from '@/images/icons/plus.svg';
import SelfLogo from '@/images/logo.svg';
import { useSafeAreaInsets } from '@/mocks/react-native-safe-area-context';
import type { RootStackParamList } from '@/navigation';
import { black, slate100, slate300, slate400, white } from '@/utils/colors';
import { extraYPadding } from '@/utils/constants';
import { advercase, dinot } from '@/utils/fonts';
import { buttonTap } from '@/utils/haptic';
type IDPickerScreenRouteProp = RouteProp<RootStackParamList, 'IDPicker'>;
const getDocumentName = (docType: string): string => {
switch (docType) {
case 'p':
return 'Passport';
case 'i':
return 'ID card';
case 'a':
return 'Aadhaar';
default:
return 'Unknown Document';
}
};
const getDocumentNameForEvent = (docType: string): string => {
switch (docType) {
case 'p':
return 'passport';
case 'i':
return 'id_card';
case 'a':
return 'aadhaar';
default:
return 'unknown_document';
}
};
const getDocumentDescription = (docType: string): string => {
switch (docType) {
case 'p':
return 'Verified Biometric Passport';
case 'i':
return 'Verified Biometric ID card';
case 'a':
return 'Verified mAadhaar QR code';
default:
return 'Unknown Document';
}
};
const getDocumentLogo = (docType: string): React.ReactNode => {
switch (docType) {
case 'p':
return <EPassportLogoRounded />;
case 'i':
return <EPassportLogoRounded />;
case 'a':
return <AadhaarLogo />;
default:
return null;
}
};
const IDPickerScreen: React.FC = () => {
const route = useRoute<IDPickerScreenRouteProp>();
const { countryCode = '', documentTypes = [] } = route.params || {};
const bottom = useSafeAreaInsets().bottom;
const selfClient = useSelfClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const onSelectDocumentType = (docType: string) => {
buttonTap();
const countryName = getDocumentName(docType);
selfClient.emit(SdkEvents.DOCUMENT_TYPE_SELECTED, {
documentType: docType,
documentName: getDocumentNameForEvent(docType),
countryCode: countryCode,
countryName: countryName,
});
switch (docType) {
case 'p':
navigation.navigate('DocumentOnboarding');
break;
case 'i':
navigation.navigate('DocumentOnboarding');
break;
case 'a':
navigation.navigate('AadhaarUpload', { countryCode } as never);
break;
default:
navigation.navigate('ComingSoon', { countryCode } as never);
break;
}
// TODO: Navigate to the next screen based on document type
if (__DEV__) {
console.log(
`Selected document type: ${docType} for country: ${countryCode}`,
);
}
};
return (
<YStack
flex={1}
backgroundColor={slate100}
paddingBottom={bottom + extraYPadding + 24}
>
<DocumentFlowNavBar title="GETTING STARTED" />
<YStack
flex={1}
paddingTop="$4"
paddingHorizontal="$4"
justifyContent="center"
>
<YStack marginTop="$4" marginBottom="$6">
<XStack
justifyContent="center"
alignItems="center"
borderRadius={'$2'}
gap={'$2.5'}
>
<View width={48} height={48}>
<RoundFlag countryCode={countryCode} size={48} />
</View>
<PlusIcon width={18} height={18} color={slate400} />
<YStack
backgroundColor={black}
borderRadius={'$2'}
height={48}
width={48}
justifyContent="center"
alignItems="center"
>
<SelfLogo width={24} height={24} />
</YStack>
</XStack>
<BodyText
marginTop="$6"
fontSize={29}
fontFamily={advercase}
textAlign="center"
>
Select an ID type
</BodyText>
</YStack>
<YStack gap="$3">
{documentTypes.map((docType: string) => (
<XStack
key={docType}
backgroundColor={white}
borderWidth={1}
borderColor={slate300}
elevation={4}
borderRadius={'$5'}
padding={'$3'}
pressStyle={{ scale: 0.97, backgroundColor: slate100 }}
onPress={() => onSelectDocumentType(docType)}
>
<XStack alignItems="center" gap={'$3'} flex={1}>
{getDocumentLogo(docType)}
<YStack gap={'$1'}>
<BodyText fontSize={24} fontFamily={dinot} color={black}>
{getDocumentName(docType)}
</BodyText>
<BodyText fontSize={14} fontFamily={dinot} color="#9193A2">
{getDocumentDescription(docType)}
</BodyText>
</YStack>
</XStack>
</XStack>
))}
<BodyText
fontSize={18}
fontFamily={dinot}
color={slate400}
textAlign="center"
>
Be sure your document is ready to scan
</BodyText>
</YStack>
</YStack>
</YStack>
);
};
export default IDPickerScreen;

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import LottieView from 'lottie-react-native';
import LottieView, { type LottieViewProps } from 'lottie-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Linking, StyleSheet, View } from 'react-native';
import { SystemBars } from 'react-native-edge-to-edge';
@@ -48,7 +48,8 @@ const SuccessScreen: React.FC = () => {
const isFocused = useIsFocused();
const [animationSource, setAnimationSource] = useState<any>(loadingAnimation);
const [animationSource, setAnimationSource] =
useState<LottieViewProps['source']>(loadingAnimation);
const [countdown, setCountdown] = useState<number | null>(null);
const [countdownStarted, setCountdownStarted] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);

View File

@@ -111,7 +111,7 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
onRestoreFromCloudNext,
navigation,
toggleCloudBackupEnabled,
selfClient,
useProtocolStore,
]);
const handleManualRecoveryPress = useCallback(() => {

View File

@@ -102,7 +102,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
navigation,
restoreAccountFromMnemonic,
trackEvent,
selfClient,
useProtocolStore,
]);
return (

View File

@@ -280,10 +280,10 @@ const ManageDocumentsScreen: React.FC = () => {
trackEvent(DocumentEvents.MANAGE_SCREEN_OPENED);
}, [trackEvent]);
const handleScanDocument = () => {
const handleAddDocument = () => {
impactLight();
trackEvent(DocumentEvents.ADD_NEW_SCAN_SELECTED);
navigation.navigate('DocumentOnboarding');
navigation.navigate('CountryPicker');
};
const handleGenerateMock = () => {
@@ -292,12 +292,6 @@ const ManageDocumentsScreen: React.FC = () => {
navigation.navigate('CreateMock');
};
const handleAddAadhaar = () => {
impactLight();
trackEvent(DocumentEvents.ADD_NEW_AADHAAR_SELECTED);
navigation.navigate('AadhaarUpload');
};
return (
<YStack
flex={1}
@@ -322,11 +316,8 @@ const ManageDocumentsScreen: React.FC = () => {
</Text>
<ButtonsContainer>
<PrimaryButton onPress={handleScanDocument}>
Scan New ID Document
</PrimaryButton>
<PrimaryButton onPress={handleAddAadhaar}>
Add Aadhaar
<PrimaryButton onPress={handleAddDocument}>
Add New Document
</PrimaryButton>
<SecondaryButton onPress={handleGenerateMock}>
Generate Mock Document

View File

@@ -3,7 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Linking, StyleSheet, View } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Anchor, Text, YStack } from 'tamagui';
@@ -13,18 +13,23 @@ import { AppEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import AbstractButton from '@/components/buttons/AbstractButton';
import { BodyText } from '@/components/typography/BodyText';
import { Caption } from '@/components/typography/Caption';
import { privacyUrl, supportedBiometricIdsUrl, termsUrl } from '@/consts/links';
import { privacyUrl, termsUrl } from '@/consts/links';
import useConnectionModal from '@/hooks/useConnectionModal';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import Logo from '@/images/logo.svg';
import { black, slate400, white, zinc800, zinc900 } from '@/utils/colors';
import { extraYPadding } from '@/utils/constants';
import IDCardPlaceholder from '@/images/icons/id_card_placeholder.svg';
import {
black,
red500,
slate300,
slate400,
white,
zinc800,
} from '@/utils/colors';
import { advercase, dinot } from '@/utils/fonts';
const LaunchScreen: React.FC = () => {
useConnectionModal();
const onStartPress = useHapticNavigation('DocumentOnboarding');
const onAadhaarPress = useHapticNavigation('AadhaarUpload');
const onPress = useHapticNavigation('CountryPicker');
const createMock = useHapticNavigation('CreateMock');
const { bottom } = useSafeAreaInsets();
@@ -35,75 +40,59 @@ const LaunchScreen: React.FC = () => {
});
return (
<YStack
backgroundColor={black}
flex={1}
alignItems="center"
paddingHorizontal={20}
paddingBottom={bottom + extraYPadding}
>
<YStack backgroundColor={black} flex={1} alignItems="center">
<View style={styles.container}>
<View style={styles.card}>
<YStack flex={1} justifyContent="center" alignItems="center">
<GestureDetector gesture={devModeTap}>
<View style={styles.logoSection}>
<Logo style={styles.logo} />
</View>
<YStack
backgroundColor={red500}
borderRadius={14}
overflow="hidden"
>
<IDCardPlaceholder width={300} height={180} />
</YStack>
</GestureDetector>
<Text style={styles.title}>Get started</Text>
<BodyText style={styles.description}>
Register with Self using your passport, biometric ID or Aadhaar card
to prove your identity across the web without revealing your
personal information.
</BodyText>
</View>
</YStack>
<Text
color={white}
fontSize={38}
fontFamily={advercase}
fontWeight="500"
textAlign="center"
marginBottom={16}
>
Take control of your digital identity
</Text>
<BodyText
color={slate300}
fontSize={16}
textAlign="center"
marginHorizontal={40}
marginBottom={40}
>
Self is the easiest way to verify your identity safely wherever you
are.
</BodyText>
</View>
<YStack
gap="$3"
width="100%"
alignItems="center"
marginBottom={20}
marginTop={24}
paddingHorizontal={20}
paddingBottom={bottom}
paddingTop={30}
backgroundColor={zinc800}
>
<YStack gap="$3" width="100%">
<AbstractButton
bgColor={black}
borderColor={zinc800}
borderWidth={1}
color={white}
trackEvent={AppEvents.SUPPORTED_BIOMETRIC_IDS}
onPress={async () => {
try {
await Linking.openURL(supportedBiometricIdsUrl);
} catch (error) {
console.warn('Failed to open supported IDs URL:', error);
}
}}
>
List of Supported Biometric IDs
</AbstractButton>
<AbstractButton
trackEvent={AppEvents.GET_STARTED_BIOMETRIC}
onPress={onStartPress}
bgColor={white}
color={black}
testID="launch-get-started-button"
>
I have a Passport or Biometric ID
</AbstractButton>
<AbstractButton
trackEvent={AppEvents.GET_STARTED_AADHAAR}
onPress={onAadhaarPress}
bgColor={white}
color={black}
testID="launch-get-started-button"
>
I have an Aadhaar Card
</AbstractButton>
</YStack>
<AbstractButton
trackEvent={AppEvents.GET_STARTED}
onPress={onPress}
bgColor={white}
color={black}
testID="launch-get-started-button"
>
Get Started
</AbstractButton>
<Caption style={styles.notice}>
By continuing, you agree to the&nbsp;
@@ -125,8 +114,8 @@ export default LaunchScreen;
const styles = StyleSheet.create({
container: {
flex: 0,
justifyContent: 'flex-start',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
width: '102%',
paddingTop: '30%',
@@ -138,7 +127,6 @@ const styles = StyleSheet.create({
paddingVertical: 40,
paddingHorizontal: 20,
alignItems: 'center',
backgroundColor: zinc900,
shadowColor: black,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
@@ -157,25 +145,11 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
},
title: {
fontFamily: advercase,
fontSize: 38,
fontWeight: '500',
color: white,
textAlign: 'center',
marginBottom: 16,
},
description: {
color: white,
fontSize: 16,
lineHeight: 22,
textAlign: 'center',
marginBottom: 8,
},
notice: {
fontFamily: dinot,
paddingVertical: 10,
paddingHorizontal: 20,
paddingVertical: 16,
color: slate400,
textAlign: 'center',
lineHeight: 18,

View File

@@ -113,7 +113,7 @@ const SplashScreen: React.FC = ({}) => {
});
}
}
}, [isAnimationFinished, nextScreen, queuedDeepLink, navigation]);
}, [isAnimationFinished, nextScreen, queuedDeepLink, navigation, selfClient]);
return (
<LottieView

View File

@@ -41,11 +41,15 @@ export const sendCountrySupportNotification = async ({
['documentCategory', documentCategory || 'Unknown'],
['tz', getTimeZone()],
['ts', new Date().toISOString()],
['origin', 'unsupported_passport_screen'],
['origin', 'coming_soon_screen'],
] as [string, string][];
const documentTypeText =
documentCategory === 'id_card' ? 'ID cards' : 'passports';
documentCategory === 'id_card'
? 'ID cards'
: documentCategory === 'passport'
? 'passports'
: 'documents';
const body = `Hi SELF Team,