diff --git a/app/Gemfile.lock b/app/Gemfile.lock
index 85a4f3c2c..af6eb14f9 100644
--- a/app/Gemfile.lock
+++ b/app/Gemfile.lock
@@ -133,7 +133,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
- fastlane (2.232.0)
+ fastlane (2.232.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
diff --git a/app/src/assets/images/card_background_id1.png b/app/src/assets/images/card_background_id1.png
new file mode 100644
index 000000000..5b17fa731
Binary files /dev/null and b/app/src/assets/images/card_background_id1.png differ
diff --git a/app/src/assets/images/card_background_id2.png b/app/src/assets/images/card_background_id2.png
new file mode 100644
index 000000000..19bb3ce75
Binary files /dev/null and b/app/src/assets/images/card_background_id2.png differ
diff --git a/app/src/assets/images/card_background_id3.png b/app/src/assets/images/card_background_id3.png
new file mode 100644
index 000000000..7b48bce3e
Binary files /dev/null and b/app/src/assets/images/card_background_id3.png differ
diff --git a/app/src/assets/images/card_background_id4.png b/app/src/assets/images/card_background_id4.png
new file mode 100644
index 000000000..7781f399e
Binary files /dev/null and b/app/src/assets/images/card_background_id4.png differ
diff --git a/app/src/assets/images/card_background_id5.png b/app/src/assets/images/card_background_id5.png
new file mode 100644
index 000000000..42771d941
Binary files /dev/null and b/app/src/assets/images/card_background_id5.png differ
diff --git a/app/src/assets/images/card_background_id6.png b/app/src/assets/images/card_background_id6.png
new file mode 100644
index 000000000..2735393ec
Binary files /dev/null and b/app/src/assets/images/card_background_id6.png differ
diff --git a/app/src/assets/images/dev_card_logo.svg b/app/src/assets/images/dev_card_logo.svg
new file mode 100644
index 000000000..1742ab720
--- /dev/null
+++ b/app/src/assets/images/dev_card_logo.svg
@@ -0,0 +1,14 @@
+
diff --git a/app/src/assets/images/dev_card_wave.svg b/app/src/assets/images/dev_card_wave.svg
new file mode 100644
index 000000000..3722fb1ad
--- /dev/null
+++ b/app/src/assets/images/dev_card_wave.svg
@@ -0,0 +1,56 @@
+
diff --git a/app/src/assets/images/self_logo_inactive.svg b/app/src/assets/images/self_logo_inactive.svg
new file mode 100644
index 000000000..65cccf8ce
--- /dev/null
+++ b/app/src/assets/images/self_logo_inactive.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/src/assets/images/self_logo_pending.svg b/app/src/assets/images/self_logo_pending.svg
new file mode 100644
index 000000000..f28076f18
--- /dev/null
+++ b/app/src/assets/images/self_logo_pending.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/src/assets/images/self_logo_unverified.svg b/app/src/assets/images/self_logo_unverified.svg
new file mode 100644
index 000000000..67807f06a
--- /dev/null
+++ b/app/src/assets/images/self_logo_unverified.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/src/assets/images/wave_overlay.png b/app/src/assets/images/wave_overlay.png
new file mode 100644
index 000000000..436c6fce1
Binary files /dev/null and b/app/src/assets/images/wave_overlay.png differ
diff --git a/app/src/assets/images/wave_pattern_body.png b/app/src/assets/images/wave_pattern_body.png
new file mode 100644
index 000000000..0d4c90de7
Binary files /dev/null and b/app/src/assets/images/wave_pattern_body.png differ
diff --git a/app/src/assets/images/wave_pattern_pending.png b/app/src/assets/images/wave_pattern_pending.png
new file mode 100644
index 000000000..4c86c41be
Binary files /dev/null and b/app/src/assets/images/wave_pattern_pending.png differ
diff --git a/app/src/assets/images/wave_pattern_transparent.png b/app/src/assets/images/wave_pattern_transparent.png
new file mode 100644
index 000000000..7a9547a7c
Binary files /dev/null and b/app/src/assets/images/wave_pattern_transparent.png differ
diff --git a/app/src/components/homescreen/EmptyIdCard.tsx b/app/src/components/homescreen/EmptyIdCard.tsx
new file mode 100644
index 000000000..8d46e487e
--- /dev/null
+++ b/app/src/components/homescreen/EmptyIdCard.tsx
@@ -0,0 +1,179 @@
+// 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 { FC } from 'react';
+import React from 'react';
+import { Dimensions, Image, StyleSheet } from 'react-native';
+import { Text, XStack, YStack } from 'tamagui';
+
+import {
+ black,
+ gray400,
+ slate200,
+ slate300,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+import SelfLogoUnverified from '@/assets/images/self_logo_unverified.svg';
+import WavePatternBody from '@/assets/images/wave_pattern_body.png';
+
+interface EmptyIdCardProps {
+ onRegisterPress: () => void;
+}
+
+/**
+ * Empty state card shown when user has no registered documents.
+ * Matches Figma design exactly:
+ * - White header with gray Self logo and "NO IDENTITY FOUND" text
+ * - Solid gray divider line
+ * - White body with gray wave pattern (from original unverified_human.png)
+ * - Pill-shaped white button with gray border
+ */
+const EmptyIdCard: FC = ({ onRegisterPress }) => {
+ const { width: screenWidth } = Dimensions.get('window');
+
+ // Card dimensions (matching IdCardLayout)
+ const cardWidth = screenWidth * 0.95 - 16;
+ const cardHeight = cardWidth * 0.635;
+ const borderRadius = 12;
+
+ // Figma exact dimensions (scaled from 353px reference width)
+ const scale = cardWidth / 353;
+ const headerHeight = 67 * scale;
+ const figmaPadding = 14 * scale;
+ const logoSize = 32 * scale;
+ const headerGap = 12 * scale;
+
+ // Font sizes from Figma
+ const fontSize = {
+ header: 20 * scale, // 20px in Figma
+ subtitle: 7 * scale, // 7px in Figma
+ button: 16 * scale, // 16px in Figma
+ };
+
+ return (
+
+
+ {/* Header Section - White background with bottom border */}
+
+ {/* Content row */}
+
+ {/* Logo + Text */}
+
+ {/* Self logo (gray) - exact Figma asset */}
+
+
+
+ {/* Text container */}
+
+
+ NO IDENTITY FOUND
+
+
+ NO IDENTITY FOUND
+
+
+
+
+
+
+ {/* Body Section - White background with wave pattern */}
+
+ {/* Wave pattern background - exact same as unverified_human.png */}
+
+
+ {/* Register button - pill-shaped with gray border */}
+
+
+
+ Register a new ID
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ body: {
+ flex: 1,
+ position: 'relative',
+ overflow: 'hidden',
+ backgroundColor: 'white',
+ },
+ wavePattern: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: '100%',
+ height: '100%',
+ },
+});
+
+export default EmptyIdCard;
diff --git a/app/src/components/homescreen/ExpiredIdCard.tsx b/app/src/components/homescreen/ExpiredIdCard.tsx
new file mode 100644
index 000000000..2f010fd7d
--- /dev/null
+++ b/app/src/components/homescreen/ExpiredIdCard.tsx
@@ -0,0 +1,163 @@
+// 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 { FC } from 'react';
+import React from 'react';
+import { Dimensions, Image, StyleSheet } from 'react-native';
+import { Text, XStack, YStack } from 'tamagui';
+
+import {
+ black,
+ gray400,
+ red600,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+import SelfLogoInactive from '@/assets/images/self_logo_inactive.svg';
+import WavePatternBody from '@/assets/images/wave_pattern_body.png';
+
+/**
+ * Expired state card shown when user's identity document has expired.
+ * Matches Figma design exactly:
+ * - White header with red Self logo and "EXPIRED ID" text
+ * - Red divider line
+ * - White body with gray wave pattern
+ * - Black "EXPIRED ID" badge in bottom right
+ */
+const ExpiredIdCard: FC = () => {
+ const { width: screenWidth } = Dimensions.get('window');
+
+ // Card dimensions (matching IdCardLayout)
+ const cardWidth = screenWidth * 0.95 - 16;
+ const cardHeight = cardWidth * 0.635;
+ const borderRadius = 12;
+
+ // Figma exact dimensions (scaled from 353px reference width)
+ const scale = cardWidth / 353;
+ const headerHeight = 67 * scale;
+ const figmaPadding = 14 * scale;
+ const logoSize = 32 * scale;
+ const headerGap = 12 * scale;
+
+ // Font sizes from Figma
+ const fontSize = {
+ header: 20 * scale, // 20px in Figma
+ subtitle: 7 * scale, // 7px in Figma
+ };
+
+ return (
+
+
+ {/* Header Section - White background with red divider */}
+
+ {/* Content row */}
+
+ {/* Logo + Text */}
+
+ {/* Red Self logo (reuses inactive logo) */}
+
+
+
+ {/* Text container */}
+
+
+ EXPIRED ID
+
+
+ TIME TO REGISTER A VALID COPY
+
+
+
+
+
+
+ {/* Body Section - White background with wave pattern */}
+
+ {/* Wave pattern background */}
+
+
+ {/* Expired badge - bottom right (black background) */}
+
+
+ EXPIRED ID
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wavePattern: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: '100%',
+ height: '100%',
+ },
+});
+
+export default ExpiredIdCard;
diff --git a/app/src/components/homescreen/IdCard.tsx b/app/src/components/homescreen/IdCard.tsx
index 1a9cfbffc..db76832a5 100644
--- a/app/src/components/homescreen/IdCard.tsx
+++ b/app/src/components/homescreen/IdCard.tsx
@@ -4,520 +4,632 @@
import type { FC } from 'react';
import React from 'react';
-import { Dimensions } from 'react-native';
-import { Separator, Text, XStack, YStack } from 'tamagui';
+import { Dimensions, Image, StyleSheet } from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { Text, XStack, YStack } from 'tamagui';
import type { AadhaarData } from '@selfxyz/common';
import type { PassportData } from '@selfxyz/common/types/passport';
-import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
+import type { KycData } from '@selfxyz/common/utils/types';
import {
- black,
- slate100,
- slate300,
- slate400,
- slate500,
- white,
-} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+ isAadhaarDocument,
+ isKycDocument,
+ isMRZDocument,
+} from '@selfxyz/common/utils/types';
+import { RoundFlag } from '@selfxyz/mobile-sdk-alpha/components';
+import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
-import AadhaarIcon from '@selfxyz/mobile-sdk-alpha/svgs/icons/aadhaar.svg';
-import EPassport from '@selfxyz/mobile-sdk-alpha/svgs/icons/epassport.svg';
-import LogoGray from '@/assets/images/logo_gray.svg';
-import { SvgXml } from '@/components/homescreen/SvgXmlWrapper';
-import {
- formatDateFromYYMMDD,
- getDocumentAttributes,
- getNameAndSurname,
-} from '@/utils/documentAttributes';
+import CardBackgroundId1 from '@/assets/images/card_background_id1.png';
+import CardBackgroundId2 from '@/assets/images/card_background_id2.png';
+import CardBackgroundId3 from '@/assets/images/card_background_id3.png';
+import CardBackgroundId4 from '@/assets/images/card_background_id4.png';
+import CardBackgroundId5 from '@/assets/images/card_background_id5.png';
+import CardBackgroundId6 from '@/assets/images/card_background_id6.png';
+import DevCardLogo from '@/assets/images/dev_card_logo.svg';
+import DevCardWave from '@/assets/images/dev_card_wave.svg';
+import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
+import WaveOverlay from '@/assets/images/wave_overlay.png';
+import { getSecurityLevel } from '@/components/homescreen/cardSecurityBadge';
+import KycIdCard from '@/components/homescreen/KycIdCard';
+import { getBackgroundIndex } from '@/utils/cardBackgroundSelector';
+import { getDocumentAttributes } from '@/utils/documentAttributes';
-// Import the logo SVG as a string
-const logoSvg = ``;
+const CARD_BACKGROUNDS = [
+ CardBackgroundId1,
+ CardBackgroundId2,
+ CardBackgroundId3,
+ CardBackgroundId4,
+ CardBackgroundId5,
+ CardBackgroundId6,
+];
+
+// Design tokens from Figma
+const DEV_LOGO_BG = '#52525B'; // zinc/600 - grey circle background for dev logo
+const DEV_BODY_COLOR = '#1E1B4B'; // indigo/950 - dev card body background
+
+// Country code to demonym mapping - comprehensive list for all supported countries
+const COUNTRY_DEMONYMS: Record = {
+ // Major countries
+ USA: 'AMERICAN',
+ GBR: 'BRITISH',
+ JPN: 'JAPANESE',
+ DEU: 'GERMAN',
+ 'D<<': 'GERMAN', // German passports use D<<
+ FRA: 'FRENCH',
+ CAN: 'CANADIAN',
+ IND: 'INDIAN',
+ AUS: 'AUSTRALIAN',
+ NGA: 'NIGERIAN',
+ FIN: 'FINNISH',
+ ITA: 'ITALIAN',
+ ESP: 'SPANISH',
+ BRA: 'BRAZILIAN',
+ MEX: 'MEXICAN',
+ CHN: 'CHINESE',
+ KOR: 'SOUTH KOREAN',
+ PRK: 'NORTH KOREAN',
+ NLD: 'DUTCH',
+ SWE: 'SWEDISH',
+ NOR: 'NORWEGIAN',
+ DNK: 'DANISH',
+ CHE: 'SWISS',
+ AUT: 'AUSTRIAN',
+ BEL: 'BELGIAN',
+ PRT: 'PORTUGUESE',
+ GRC: 'GREEK',
+ POL: 'POLISH',
+ IRL: 'IRISH',
+ NZL: 'NEW ZEALANDER',
+ ZAF: 'SOUTH AFRICAN',
+ SGP: 'SINGAPOREAN',
+ MYS: 'MALAYSIAN',
+ THA: 'THAI',
+ PHL: 'FILIPINO',
+ IDN: 'INDONESIAN',
+ VNM: 'VIETNAMESE',
+ ARE: 'EMIRATI',
+ SAU: 'SAUDI',
+ ISR: 'ISRAELI',
+ EGY: 'EGYPTIAN',
+ TUR: 'TURKISH',
+ RUS: 'RUSSIAN',
+ UKR: 'UKRAINIAN',
+ ARG: 'ARGENTINIAN',
+ COL: 'COLOMBIAN',
+ CHL: 'CHILEAN',
+ PER: 'PERUVIAN',
+ // Europe
+ ALB: 'ALBANIAN',
+ AND: 'ANDORRAN',
+ ARM: 'ARMENIAN',
+ AZE: 'AZERBAIJANI',
+ BLR: 'BELARUSIAN',
+ BIH: 'BOSNIAN',
+ BGR: 'BULGARIAN',
+ HRV: 'CROATIAN',
+ CYP: 'CYPRIOT',
+ CZE: 'CZECH',
+ EST: 'ESTONIAN',
+ GEO: 'GEORGIAN',
+ HUN: 'HUNGARIAN',
+ ISL: 'ICELANDIC',
+ LVA: 'LATVIAN',
+ LIE: 'LIECHTENSTEINER',
+ LTU: 'LITHUANIAN',
+ LUX: 'LUXEMBOURGISH',
+ MLT: 'MALTESE',
+ MDA: 'MOLDOVAN',
+ MCO: 'MONACAN',
+ MNE: 'MONTENEGRIN',
+ MKD: 'MACEDONIAN',
+ ROU: 'ROMANIAN',
+ SMR: 'SAMMARINESE',
+ SRB: 'SERBIAN',
+ SVK: 'SLOVAK',
+ SVN: 'SLOVENIAN',
+ VAT: 'VATICAN',
+ // Americas
+ ATG: 'ANTIGUAN',
+ BHS: 'BAHAMIAN',
+ BRB: 'BARBADIAN',
+ BLZ: 'BELIZEAN',
+ BOL: 'BOLIVIAN',
+ CRI: 'COSTA RICAN',
+ CUB: 'CUBAN',
+ DMA: 'DOMINICAN',
+ DOM: 'DOMINICAN',
+ ECU: 'ECUADORIAN',
+ SLV: 'SALVADORAN',
+ GRD: 'GRENADIAN',
+ GTM: 'GUATEMALAN',
+ GUY: 'GUYANESE',
+ HTI: 'HAITIAN',
+ HND: 'HONDURAN',
+ JAM: 'JAMAICAN',
+ NIC: 'NICARAGUAN',
+ PAN: 'PANAMANIAN',
+ PRY: 'PARAGUAYAN',
+ KNA: 'KITTITIAN',
+ LCA: 'SAINT LUCIAN',
+ VCT: 'VINCENTIAN',
+ SUR: 'SURINAMESE',
+ TTO: 'TRINIDADIAN',
+ URY: 'URUGUAYAN',
+ VEN: 'VENEZUELAN',
+ // Africa
+ DZA: 'ALGERIAN',
+ AGO: 'ANGOLAN',
+ BEN: 'BENINESE',
+ BWA: 'BOTSWANAN',
+ BFA: 'BURKINABE',
+ BDI: 'BURUNDIAN',
+ CPV: 'CAPE VERDEAN',
+ CMR: 'CAMEROONIAN',
+ CAF: 'CENTRAL AFRICAN',
+ TCD: 'CHADIAN',
+ COM: 'COMORIAN',
+ COG: 'CONGOLESE',
+ COD: 'CONGOLESE',
+ CIV: 'IVORIAN',
+ DJI: 'DJIBOUTIAN',
+ GNQ: 'EQUATOGUINEAN',
+ ERI: 'ERITREAN',
+ SWZ: 'SWAZI',
+ ETH: 'ETHIOPIAN',
+ GAB: 'GABONESE',
+ GMB: 'GAMBIAN',
+ GHA: 'GHANAIAN',
+ GIN: 'GUINEAN',
+ GNB: 'BISSAU-GUINEAN',
+ KEN: 'KENYAN',
+ LSO: 'BASOTHO',
+ LBR: 'LIBERIAN',
+ LBY: 'LIBYAN',
+ MDG: 'MALAGASY',
+ MWI: 'MALAWIAN',
+ MLI: 'MALIAN',
+ MRT: 'MAURITANIAN',
+ MUS: 'MAURITIAN',
+ MAR: 'MOROCCAN',
+ MOZ: 'MOZAMBICAN',
+ NAM: 'NAMIBIAN',
+ NER: 'NIGERIEN',
+ RWA: 'RWANDAN',
+ STP: 'SAO TOMEAN',
+ SEN: 'SENEGALESE',
+ SYC: 'SEYCHELLOIS',
+ SLE: 'SIERRA LEONEAN',
+ SOM: 'SOMALI',
+ SSD: 'SOUTH SUDANESE',
+ SDN: 'SUDANESE',
+ TZA: 'TANZANIAN',
+ TGO: 'TOGOLESE',
+ TUN: 'TUNISIAN',
+ UGA: 'UGANDAN',
+ ZMB: 'ZAMBIAN',
+ ZWE: 'ZIMBABWEAN',
+ // Asia & Middle East
+ AFG: 'AFGHAN',
+ BHR: 'BAHRAINI',
+ BGD: 'BANGLADESHI',
+ BTN: 'BHUTANESE',
+ BRN: 'BRUNEIAN',
+ KHM: 'CAMBODIAN',
+ TWN: 'TAIWANESE',
+ HKG: 'HONG KONGER',
+ IRQ: 'IRAQI',
+ IRN: 'IRANIAN',
+ JOR: 'JORDANIAN',
+ KAZ: 'KAZAKHSTANI',
+ KWT: 'KUWAITI',
+ KGZ: 'KYRGYZSTANI',
+ LAO: 'LAOTIAN',
+ LBN: 'LEBANESE',
+ MAC: 'MACANESE',
+ MDV: 'MALDIVIAN',
+ MNG: 'MONGOLIAN',
+ MMR: 'MYANMAR',
+ NPL: 'NEPALI',
+ OMN: 'OMANI',
+ PAK: 'PAKISTANI',
+ PSE: 'PALESTINIAN',
+ QAT: 'QATARI',
+ LKA: 'SRI LANKAN',
+ SYR: 'SYRIAN',
+ TJK: 'TAJIKISTANI',
+ TKM: 'TURKMEN',
+ UZB: 'UZBEKISTANI',
+ YEM: 'YEMENI',
+ // Oceania
+ FJI: 'FIJIAN',
+ KIR: 'I-KIRIBATI',
+ MHL: 'MARSHALLESE',
+ FSM: 'MICRONESIAN',
+ NRU: 'NAURUAN',
+ PLW: 'PALAUAN',
+ PNG: 'PAPUA NEW GUINEAN',
+ WSM: 'SAMOAN',
+ SLB: 'SOLOMON ISLANDER',
+ TON: 'TONGAN',
+ TUV: 'TUVALUAN',
+ VUT: 'NI-VANUATU',
+ TLS: 'TIMORESE',
+};
+
+/**
+ * Get country demonym from 3-letter country code.
+ * Falls back to the code itself if no mapping exists.
+ * Note: D<< (German passports) should be normalized to DEU before calling this.
+ */
+const getCountryDemonym = (code: string): string => {
+ if (!code) return '';
+ const upperCode = code.toUpperCase().replace(/ = ({
idDocument,
selected,
hidden,
}) => {
- // Early return if document is null
if (!idDocument) {
return null;
}
- // Function to mask MRZ characters except '<' and spaces
- const maskMrzValue = (text: string): string => {
- return text.replace(/./g, 'X');
- };
+ // KYC documents use a distinct dark card design
+ if (isKycDocument(idDocument)) {
+ return (
+
+ );
+ }
- // Get screen dimensions for adaptive sizing
const { width: screenWidth } = Dimensions.get('window');
- // Calculate adaptive sizes based on screen dimensions
- // Reduce width slightly to account for horizontal margins (8px each side = 16px total)
- const cardWidth = screenWidth * 0.95 - 16; // 90% of screen width minus margin space
- const cardHeight = selected ? cardWidth * 0.645 : cardWidth * 0.645 * 0.3; // ID card aspect ratio (roughly 1.6:1)
- const borderRadius = cardWidth * 0.04; // 4% of card width
- const padding = cardWidth * 0.035; // 4% of card width
+ // Card dimensions (matching Figma: 353x224 for expanded, 353x67 for header only)
+ const cardWidth = screenWidth * 0.95 - 16;
+ const cardHeight = selected ? cardWidth * 0.635 : cardWidth * 0.19;
+ const borderRadius = 12;
+ const padding = cardWidth * 0.04;
+
+ // Get document attributes
+ const attributes = getDocumentAttributes(idDocument);
+ // Handle special case: German passports use "D<<" as nationality code
+ // Must normalize BEFORE stripping < characters
+ const rawNationality = attributes.nationalitySlice;
+ const nationalityCode =
+ rawNationality === 'D<<' || rawNationality.startsWith('D<')
+ ? 'DEU'
+ : rawNationality.replace(/ {
+ if (isAadhaarDocument(idDocument)) {
+ return 'AADHAAR';
+ }
+ if (isMRZDocument(idDocument)) {
+ return idDocument.documentCategory === 'passport'
+ ? 'PASSPORT'
+ : 'ID CARD';
+ }
+ return 'DOCUMENT';
+ };
+
+ // Get security level for badge (only for real documents)
+ const securityLevel = getSecurityLevel(idDocument);
+
+ // Header title - add "DEV" prefix for mock documents
+ const headerTitle = isMockDocument
+ ? `DEV ${getDocumentTypeLabel()}`
+ : getDocumentTypeLabel();
+
+ // Subtitle text (uses demonym: "VERIFIED AMERICAN PASSPORT")
+ const subtitleText = isMockDocument
+ ? `SELF DEVELOPER ${getDocumentTypeLabel()}`
+ : `VERIFIED ${countryDemonym} ${getDocumentTypeLabel()}`;
+
+ // Bottom label (uses demonym: "AMERICAN PASSPORT")
+ const bottomLabel = `${countryDemonym} ${getDocumentTypeLabel()}`;
+
+ // Figma exact dimensions (scaled from 353px reference width)
+ const scale = cardWidth / 353;
+ const headerHeight = 67 * scale;
+ const bodyHeight = 157 * scale;
+ const figmaPadding = 14 * scale;
+ const logoCircleSize = 32 * scale;
+ const logoIconSize = 32 * scale;
+ const headerGap = 12 * scale;
+
+ // Get truncated selfId for display (e.g., "0xd9..b94")
+ const getTruncatedId = (): string => {
+ if (isMRZDocument(idDocument)) {
+ // Use selfId if available, otherwise generate a deterministic mock ID from MRZ
+ const id = (idDocument as PassportData & { selfId?: string }).selfId;
+ if (id && id.length > 10) {
+ return `${id.slice(0, 4)}..${id.slice(-3)}`;
+ }
+ if (id) {
+ return id;
+ }
+ // Generate mock display ID from MRZ hash for visual testing
+ const mrz = idDocument.mrz;
+ let hash = 0;
+ for (let i = 0; i < mrz.length; i++) {
+ // eslint-disable-next-line no-bitwise
+ hash = (hash * 31 + mrz.charCodeAt(i)) >>> 0;
+ }
+ const mockId = `0x${hash.toString(16).padStart(8, '0')}`;
+ return `${mockId.slice(0, 4)}..${mockId.slice(-3)}`;
+ }
+ if (isAadhaarDocument(idDocument)) {
+ const last4 = idDocument.extractedFields?.aadhaarLast4Digits;
+ return last4 ? `****${last4}` : '';
+ }
+ return '';
+ };
+
+ const truncatedId = getTruncatedId();
+
+ // Font sizes
const fontSize = {
- large: cardWidth * 0.045,
- medium: cardWidth * 0.032,
- small: cardWidth * 0.028,
- xsmall: cardWidth * 0.022,
+ header: cardWidth * 0.057, // 20px at 353px width
+ subtitle: cardWidth * 0.02, // 7px at 353px width
+ badge: cardWidth * 0.028, // 10px at 353px width
+ bottomLabel: cardWidth * 0.043, // 15px at 353px width
+ bottomId: cardWidth * 0.028, // 10px at 353px width
};
- // Image dimensions (standard ID photo ratio)
- const imageSize = {
- width: cardWidth * 0.2, // 25% of card width
- height: cardWidth * 0.29, // ID photo aspect ratio
- };
-
- // Shared left offset for content that should align with the start of the attributes block
- const contentLeftOffset = imageSize.width + padding;
-
return (
- // Container wrapper to handle shadow space properly
-
+
- {/* Header Section */}
-
-
- {idDocument.documentCategory === 'aadhaar' ? (
-
- ) : (
-
- )}
-
-
- {idDocument.documentCategory === 'passport'
- ? 'Passport'
- : idDocument.documentCategory === 'aadhaar'
- ? 'Aadhaar'
- : 'ID Card'}
-
-
- Verified{' '}
- {idDocument.documentCategory === 'passport'
- ? 'Biometric Passport'
- : idDocument.documentCategory === 'aadhaar'
- ? 'Aadhaar Document'
- : 'Biometric ID Card'}
-
-
-
-
- {idDocument.mock && (
-
-
+ {/* Content row */}
+
+ {/* Logo + Text */}
+
+ {isMockDocument ? (
+ // Dev card: Self logo (white) in grey circle - exact Figma asset
+
- DEVELOPER
+
+
+ ) : (
+ // Real document: Country flag
+
+ )}
+ {/* Text container */}
+
+
+ {headerTitle}
+
+
+ {subtitleText}
+
+
+ {/* Right spacer for dev cards, Self logo for real documents */}
+ {isMockDocument ? (
+ // Empty spacer matching Figma (85x19)
+
+ ) : (
+
)}
-
+
- {selected && (
-
)}
- {/* Main Content Section */}
- {selected && (
-
- {/* Person Image */}
+ {/* Body Section - Dark gradient with wave pattern */}
+ {selected &&
+ (isMockDocument ? (
+ // Dev card body - solid indigo background with wave pattern (exact Figma)
-
-
-
- {/* ID Attributes */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {idDocument.documentCategory === 'aadhaar' ? (
- // Aadhaar: Combined name field spanning two columns
- <>
-
- {
- const nameData = getNameAndSurname(
- getDocumentAttributes(idDocument).nameSlice,
- );
- const fullName = [
- ...nameData.surname,
- ...nameData.names,
- ].join(' ');
- return fullName;
- })()}
- maskValue="XXXXXXXXXXXXX"
- hidden={hidden}
- />
-
-
-
-
- >
- ) : (
- // Other documents: Separate surname and name fields
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Footer Section - MRZ or QR Data */}
- {selected && isMRZDocument(idDocument) && idDocument.mrz && (
-
- {/* Fixed-width spacer to align MRZ content with the attributes block */}
-
-
-
-
-
- {idDocument.documentCategory === 'passport' ? (
- // Passport: 2 lines, 88 chars total (44 chars each)
- <>
-
- {hidden
- ? maskMrzValue(idDocument.mrz.slice(0, 44))
- : idDocument.mrz.slice(0, 44)}
-
-
- {hidden
- ? maskMrzValue(idDocument.mrz.slice(44, 88))
- : idDocument.mrz.slice(44, 88)}
-
- >
- ) : (
- // ID Card: 3 lines, 90 chars total (30 chars each)
- <>
-
- {hidden
- ? maskMrzValue(idDocument.mrz.slice(0, 30))
- : idDocument.mrz.slice(0, 30)}
-
-
- {hidden
- ? maskMrzValue(idDocument.mrz.slice(30, 60))
- : idDocument.mrz.slice(30, 60)}
-
-
- {hidden
- ? maskMrzValue(idDocument.mrz.slice(60, 90))
- : idDocument.mrz.slice(60, 90)}
-
- >
- )}
-
-
- )}
-
- {/* Footer Section - Empty placeholder for Aadhaar (no MRZ) */}
- {selected && isAadhaarDocument(idDocument) && (
-
- {/* Fixed-width spacer to align content with the attributes block */}
-
-
-
-
-
-
- {/* Empty placeholder - no MRZ for Aadhaar */}
-
+
+
-
- )}
+ ) : (
+ // Real document body - gradient background with wave overlay
+
+ {/* Gradient background */}
+
+ {/* Wave pattern overlay */}
+
+
+ {/* Bottom content: Left text + Right badge (real documents only) */}
+
+ {/* Bottom Left: ID + Document Label */}
+
+ {truncatedId ? (
+
+ {truncatedId}
+
+ ) : null}
+
+ {bottomLabel}
+
+
+
+ {/* Security Badge */}
+
+
+ {securityLevel}
+
+
+
+
+ ))}
);
};
-// Interface for IdAttribute props
-interface IdAttributeProps {
- name: string;
- value: string;
- maskValue: string;
- hidden?: boolean;
-}
-
-// This layout should be fully adaptative. I should perfectly fit in any screen size.
-// the font size should adapt according to the size available to fit perfectly.
-// only svg are allowed.
-// each element size should be determined as % of the screen or the parent element
-const IdAttribute: FC = ({
- name,
- value,
- maskValue,
- hidden = false,
-}) => {
- const { width: screenWidth } = Dimensions.get('window');
- const fontSize = {
- label: screenWidth * 0.024,
- value: screenWidth * 0.02,
- };
-
- const displayValue = hidden ? maskValue : value;
-
- return (
-
-
- {name}
-
-
- {displayValue}
-
-
- );
-};
+const styles = StyleSheet.create({
+ header: {
+ justifyContent: 'center',
+ width: '100%',
+ },
+ body: {
+ flex: 1,
+ position: 'relative',
+ overflow: 'hidden',
+ },
+ backgroundImage: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: '100%',
+ height: '100%',
+ },
+ waveOverlay: {
+ position: 'absolute',
+ top: -10,
+ left: 0,
+ width: '100%',
+ height: '90%',
+ opacity: 0.6,
+ },
+});
export default IdCardLayout;
diff --git a/app/src/components/homescreen/KycIdCard.tsx b/app/src/components/homescreen/KycIdCard.tsx
new file mode 100644
index 000000000..1e9fa4cc5
--- /dev/null
+++ b/app/src/components/homescreen/KycIdCard.tsx
@@ -0,0 +1,311 @@
+// 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 { FC } from 'react';
+import React from 'react';
+import { Dimensions, Image, StyleSheet, View } from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { Text, XStack, YStack } from 'tamagui';
+
+import { deserializeApplicantInfo } from '@selfxyz/common';
+import { commonNames } from '@selfxyz/common/constants/countries';
+import type { KycData } from '@selfxyz/common/utils/types';
+import { RoundFlag } from '@selfxyz/mobile-sdk-alpha/components';
+import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+import CardBackgroundId1 from '@/assets/images/card_background_id1.png';
+import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
+
+interface KycIdCardProps {
+ idDocument: KycData;
+ selected: boolean;
+ hidden: boolean;
+}
+
+/**
+ * Maps KYC idType to display title.
+ * idType values from Sumsub: "drivers_licence", "passport", "NATIONAL ID", etc.
+ */
+function getKycDocTitle(idType: string): string {
+ const normalized = idType
+ .toLowerCase()
+ .replace(/[_\s]+/g, ' ')
+ .trim();
+ if (normalized.includes('driver')) return 'DRIVERS LICENSE';
+ if (normalized.includes('passport')) return 'PASSPORT';
+ if (normalized.includes('national')) return 'NATIONAL ID';
+ if (normalized.includes('residence')) return 'RESIDENCE PERMIT';
+ return 'ID CARD';
+}
+
+/**
+ * Derives a demonym-like adjective from the country code.
+ * Falls back to the country code if no mapping found.
+ */
+function getCountryAdjective(countryCode: string): string {
+ const name = commonNames[countryCode as keyof typeof commonNames];
+ if (!name) return countryCode;
+
+ const demonyms: Record = {
+ USA: 'US',
+ GBR: 'UK',
+ CAN: 'CANADIAN',
+ AUS: 'AUSTRALIAN',
+ IND: 'INDIAN',
+ DEU: 'GERMAN',
+ FRA: 'FRENCH',
+ JPN: 'JAPANESE',
+ KOR: 'KOREAN',
+ BRA: 'BRAZILIAN',
+ MEX: 'MEXICAN',
+ ITA: 'ITALIAN',
+ ESP: 'SPANISH',
+ NLD: 'DUTCH',
+ PRT: 'PORTUGUESE',
+ CHN: 'CHINESE',
+ RUS: 'RUSSIAN',
+ KEN: 'KENYAN',
+ NGA: 'NIGERIAN',
+ ZAF: 'SOUTH AFRICAN',
+ SGP: 'SINGAPOREAN',
+ MYS: 'MALAYSIAN',
+ PHL: 'PHILIPPINE',
+ IDN: 'INDONESIAN',
+ THA: 'THAI',
+ VNM: 'VIETNAMESE',
+ ARE: 'UAE',
+ SAU: 'SAUDI',
+ EGY: 'EGYPTIAN',
+ TUR: 'TURKISH',
+ POL: 'POLISH',
+ SWE: 'SWEDISH',
+ NOR: 'NORWEGIAN',
+ DNK: 'DANISH',
+ FIN: 'FINNISH',
+ CHE: 'SWISS',
+ AUT: 'AUSTRIAN',
+ BEL: 'BELGIAN',
+ IRL: 'IRISH',
+ NZL: 'NEW ZEALAND',
+ ARG: 'ARGENTINE',
+ COL: 'COLOMBIAN',
+ PER: 'PERUVIAN',
+ CHL: 'CHILEAN',
+ };
+
+ return demonyms[countryCode] || name.toUpperCase();
+}
+
+/**
+ * KYC document card - matches IdCard design exactly but shows "STANDARD" badge.
+ * Used for documents verified through Sumsub KYC flow (drivers license, etc.).
+ */
+const KycIdCard: FC = ({
+ idDocument,
+ selected,
+ hidden: _hidden,
+}) => {
+ // Extract KYC fields from serialized applicant info
+ const applicantInfo = deserializeApplicantInfo(
+ idDocument.serializedApplicantInfo,
+ );
+ const country = applicantInfo.country || '';
+ const idType = applicantInfo.idType || '';
+ const idNumber = applicantInfo.idNumber || '';
+
+ const docTitle = getKycDocTitle(idType);
+ const countryAdj = getCountryAdjective(country);
+
+ const { width: screenWidth } = Dimensions.get('window');
+
+ // Card dimensions (matching IdCard: 353x224 for expanded, 353x67 for header only)
+ const cardWidth = screenWidth * 0.95 - 16;
+ const cardHeight = selected ? cardWidth * 0.635 : cardWidth * 0.19;
+ const borderRadius = 12;
+ const padding = cardWidth * 0.04;
+
+ // Figma exact dimensions (scaled from 353px reference width)
+ const scale = cardWidth / 353;
+ const headerHeight = 67 * scale;
+ const figmaPadding = 14 * scale;
+ const logoCircleSize = 32 * scale;
+ const headerGap = 12 * scale;
+
+ // Get truncated ID for display (e.g., "0xD123..345")
+ const getTruncatedId = (): string => {
+ if (idNumber && idNumber.length > 10) {
+ return `0x${idNumber.slice(0, 4)}..${idNumber.slice(-3)}`;
+ }
+ return idNumber ? `0x${idNumber}` : '';
+ };
+
+ const truncatedId = getTruncatedId();
+
+ // Header title (e.g., "DRIVERS LICENSE")
+ const headerTitle = docTitle;
+
+ // Subtitle text (e.g., "VERIFIED US DRIVERS LICENSE")
+ const subtitleText = `VERIFIED ${countryAdj} ${docTitle}`;
+
+ // Bottom label (e.g., "US DRIVERS LICENSE")
+ const bottomLabel = `${countryAdj} ${docTitle}`;
+
+ // Font sizes (matching IdCard exactly)
+ const fontSize = {
+ header: cardWidth * 0.057, // 20px at 353px width
+ subtitle: cardWidth * 0.02, // 7px at 353px width
+ badge: cardWidth * 0.028, // 10px at 353px width
+ bottomLabel: cardWidth * 0.043, // 15px at 353px width
+ bottomId: cardWidth * 0.028, // 10px at 353px width
+ };
+
+ return (
+
+
+ {/* Header Section - Dark gradient (same as IdCard) */}
+
+
+ {/* Logo + Text */}
+
+ {/* Country flag */}
+
+
+ {/* Text container */}
+
+
+ {headerTitle}
+
+
+ {subtitleText}
+
+
+
+
+ {/* Self logo on right */}
+
+
+
+
+ {/* Body Section - Colorful wave pattern (same as IdCard real documents) */}
+ {selected && (
+
+ {/* Pre-composited background image (colorful gradient + chrome wave) */}
+
+
+ {/* Bottom content: Left text + Right badge */}
+
+ {/* Bottom Left: ID + Document Label */}
+
+ {truncatedId ? (
+
+ {truncatedId}
+
+ ) : null}
+
+ {bottomLabel}
+
+
+
+ {/* STANDARD Badge - KYC documents always show STANDARD */}
+
+
+ STANDARD
+
+
+
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ backgroundImage: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: '100%',
+ height: '100%',
+ },
+});
+
+export default KycIdCard;
diff --git a/app/src/components/homescreen/PendingIdCard.tsx b/app/src/components/homescreen/PendingIdCard.tsx
new file mode 100644
index 000000000..f4c6787a6
--- /dev/null
+++ b/app/src/components/homescreen/PendingIdCard.tsx
@@ -0,0 +1,179 @@
+// 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 { FC } from 'react';
+import React from 'react';
+import { Dimensions, Image, StyleSheet } from 'react-native';
+import { Text, XStack, YStack } from 'tamagui';
+
+import {
+ amber50,
+ amber200,
+ amber500,
+ amber700,
+ black,
+ gray400,
+ yellow50,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
+import WavePatternPending from '@/assets/images/wave_pattern_pending.png';
+
+interface PendingIdCardProps {
+ onClick?: () => void;
+}
+
+/**
+ * Pending state card shown when user has submitted identity for KYC verification.
+ * Matches Figma design exactly:
+ * - Amber-50 tinted header and body
+ * - Orange divider line
+ * - Orange logo circle with white Self logo
+ * - "IDENTITY UNDER REVIEW" title
+ * - Yellow "Pending" badge in bottom right
+ */
+const PendingIdCard: FC = ({ onClick }) => {
+ const { width: screenWidth } = Dimensions.get('window');
+
+ // Card dimensions (matching IdCardLayout)
+ const cardWidth = screenWidth * 0.95 - 16;
+ const cardHeight = cardWidth * 0.635;
+ const borderRadius = 12;
+
+ // Figma exact dimensions (scaled from 353px reference width)
+ const scale = cardWidth / 353;
+ const headerHeight = 67 * scale;
+ const figmaPadding = 14 * scale;
+ const logoSize = 32 * scale;
+ const headerGap = 12 * scale;
+
+ // Font sizes from Figma
+ const fontSize = {
+ header: 20 * scale, // 20px in Figma
+ subtitle: 7 * scale, // 7px in Figma
+ };
+
+ return (
+
+
+ {/* Header Section */}
+
+ {/* Content row */}
+
+ {/* Logo + Text */}
+
+ {/* Orange circle with white Self logo */}
+
+
+
+ {/* Text container */}
+
+
+ IDENTITY UNDER REVIEW
+
+
+ NO IDENTITY FOUND
+
+
+
+
+
+
+ {/* Body Section */}
+
+ {/* Wave pattern background */}
+
+
+ {/* Pending badge - bottom right */}
+
+
+ Pending
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wavePattern: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: '100%',
+ height: '100%',
+ },
+});
+
+export default PendingIdCard;
diff --git a/app/src/components/homescreen/UnregisteredIdCard.tsx b/app/src/components/homescreen/UnregisteredIdCard.tsx
new file mode 100644
index 000000000..21f091271
--- /dev/null
+++ b/app/src/components/homescreen/UnregisteredIdCard.tsx
@@ -0,0 +1,183 @@
+// 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 { FC } from 'react';
+import React from 'react';
+import { Dimensions, Image, StyleSheet } from 'react-native';
+import { Text, XStack, YStack } from 'tamagui';
+
+import {
+ gray400,
+ red600,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+import SelfLogoInactive from '@/assets/images/self_logo_inactive.svg';
+import WavePatternBody from '@/assets/images/wave_pattern_body.png';
+
+interface UnregisteredIdCardProps {
+ onRegisterPress: () => void;
+}
+
+/**
+ * Unregistered state card shown when user has a scanned document that
+ * hasn't been registered on-chain yet.
+ * Matches design pattern:
+ * - White header with red Self logo and "UNREGISTERED ID" text
+ * - Red divider line
+ * - White body with gray wave pattern
+ * - Full-width red pill button "Complete Registration"
+ */
+const UnregisteredIdCard: FC = ({
+ onRegisterPress,
+}) => {
+ const { width: screenWidth } = Dimensions.get('window');
+
+ // Card dimensions (matching IdCardLayout)
+ const cardWidth = screenWidth * 0.95 - 16;
+ const cardHeight = cardWidth * 0.635;
+ const borderRadius = 12;
+
+ // Figma exact dimensions (scaled from 353px reference width)
+ const scale = cardWidth / 353;
+ const headerHeight = 67 * scale;
+ const figmaPadding = 14 * scale;
+ const logoSize = 32 * scale;
+ const headerGap = 12 * scale;
+
+ // Font sizes from Figma
+ const fontSize = {
+ header: 20 * scale, // 20px in Figma
+ subtitle: 7 * scale, // 7px in Figma
+ button: 16 * scale, // 16px in Figma
+ };
+
+ return (
+
+
+ {/* Header Section - White background with red divider */}
+
+ {/* Content row */}
+
+ {/* Logo + Text */}
+
+ {/* Red Self logo */}
+
+
+
+ {/* Text container */}
+
+
+ UNREGISTERED ID
+
+
+ DOCUMENT NEEDS TO FINISH REGISTRATION
+
+
+
+
+
+
+ {/* Body Section - White background with wave pattern */}
+
+ {/* Wave pattern background */}
+
+
+ {/* Register button - full-width red pill */}
+
+
+
+ Complete Registration
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ body: {
+ flex: 1,
+ position: 'relative',
+ overflow: 'hidden',
+ backgroundColor: 'white',
+ },
+ wavePattern: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: '100%',
+ height: '100%',
+ },
+});
+
+export default UnregisteredIdCard;
diff --git a/app/src/components/homescreen/cardSecurityBadge.ts b/app/src/components/homescreen/cardSecurityBadge.ts
new file mode 100644
index 000000000..d268c3721
--- /dev/null
+++ b/app/src/components/homescreen/cardSecurityBadge.ts
@@ -0,0 +1,39 @@
+// 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 { AadhaarData } from '@selfxyz/common';
+import type { PassportData } from '@selfxyz/common/types/passport';
+import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
+
+export type SecurityLevel = 'HI-SECURITY' | 'LOW-SECURITY' | 'STANDARD';
+
+/**
+ * Determines security badge based on document type and NFC presence.
+ * - KYC documents -> STANDARD (always)
+ * - Aadhaar -> LOW-SECURITY (always, no NFC)
+ * - MRZ documents (passport, ID card) -> HI-SECURITY if NFC, LOW-SECURITY otherwise
+ *
+ * NFC presence is determined by checking if dg2Hash exists and is not empty.
+ * dg2Hash contains the facial image data which is only available via NFC read.
+ */
+export function getSecurityLevel(
+ document: PassportData | AadhaarData,
+): SecurityLevel {
+ if (isAadhaarDocument(document)) {
+ return 'LOW-SECURITY'; // Aadhaar never has NFC
+ }
+
+ if (isMRZDocument(document)) {
+ // Check if document has NFC data (dg2Hash presence indicates NFC read)
+ // dg2Hash contains facial image data which requires NFC to extract
+ const hasNfc = Boolean(
+ document.dg2Hash &&
+ Array.isArray(document.dg2Hash) &&
+ document.dg2Hash.length > 0,
+ );
+ return hasNfc ? 'HI-SECURITY' : 'LOW-SECURITY';
+ }
+
+ return 'LOW-SECURITY'; // Fallback
+}
diff --git a/app/src/hooks/usePendingKycRecovery.ts b/app/src/hooks/usePendingKycRecovery.ts
new file mode 100644
index 000000000..530927840
--- /dev/null
+++ b/app/src/hooks/usePendingKycRecovery.ts
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import { useCallback, useEffect, useRef } from 'react';
+
+import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
+import { navigationRef } from '@/navigation';
+import { usePendingKycStore } from '@/stores/pendingKycStore';
+
+/**
+ * Hook to recover pending KYC verifications on app restart.
+ *
+ * This hook runs on app startup and:
+ * 1. Checks for any pending verifications in the store
+ * 2. For each non-expired pending/processing verification, reconnects to websocket
+ * 3. Subscribes to the userId to receive any cached results
+ * 4. Updates verification status based on server response
+ * 5. Initiates proving machine after document storage (handled in useSumsubWebSocket)
+ *
+ * NOTE: This requires the TEE server to cache completed verification results
+ * so they can be retrieved when the app reopens.
+ */
+export function usePendingKycRecovery() {
+ const { pendingVerifications, removeExpiredVerifications } =
+ usePendingKycStore();
+
+ const hasAttemptedRecoveryRef = useRef>(new Set());
+
+ const handleSuccess = useCallback(() => {
+ console.log('[PendingKycRecovery] Successfully recovered verification');
+ }, []);
+
+ const handleError = useCallback((error: string) => {
+ console.error('[PendingKycRecovery] Error:', error);
+ }, []);
+
+ const handleVerificationFailed = useCallback((reason: string) => {
+ console.log('[PendingKycRecovery] Verification failed:', reason);
+ }, []);
+
+ const { subscribe, unsubscribeAll } = useSumsubWebSocket({
+ skipAddPending: true,
+ onSuccess: handleSuccess,
+ onError: handleError,
+ onVerificationFailed: handleVerificationFailed,
+ });
+
+ // Clean up expired verifications once on mount
+ useEffect(() => {
+ removeExpiredVerifications();
+
+ return () => unsubscribeAll();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // Only run once on mount
+
+ useEffect(() => {
+ console.log(
+ '[PendingKycRecovery] Already attempted userIds:',
+ Array.from(hasAttemptedRecoveryRef.current),
+ );
+
+ const processingWithDocument = pendingVerifications.find(
+ v =>
+ v.status === 'processing' &&
+ v.documentId &&
+ v.timeoutAt > Date.now() &&
+ !hasAttemptedRecoveryRef.current.has(v.userId),
+ );
+
+ if (processingWithDocument) {
+ hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
+ console.log(
+ '[PendingKycRecovery] Resuming processing verification, navigating to KYCVerified:',
+ processingWithDocument.userId,
+ );
+ if (navigationRef.isReady()) {
+ navigationRef.navigate('KYCVerified', {
+ documentId: processingWithDocument.documentId,
+ });
+ }
+ return;
+ }
+
+ const firstPending = pendingVerifications.find(
+ v =>
+ v.status === 'pending' &&
+ v.timeoutAt > Date.now() &&
+ !hasAttemptedRecoveryRef.current.has(v.userId),
+ );
+
+ if (firstPending) {
+ hasAttemptedRecoveryRef.current.add(firstPending.userId);
+ console.log(
+ '[PendingKycRecovery] Recovering pending verification:',
+ firstPending.userId,
+ );
+ subscribe(firstPending.userId);
+ }
+ }, [pendingVerifications, subscribe, unsubscribeAll]);
+}
diff --git a/app/src/hooks/useSumsubWebSocket.ts b/app/src/hooks/useSumsubWebSocket.ts
new file mode 100644
index 000000000..f596344ea
--- /dev/null
+++ b/app/src/hooks/useSumsubWebSocket.ts
@@ -0,0 +1,201 @@
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import { useCallback, useRef } from 'react';
+import { io, type Socket } from 'socket.io-client';
+import { SUMSUB_TEE_URL } from '@env';
+
+import { deserializeApplicantInfo } from '@selfxyz/common';
+import type { DocumentType, KycData } from '@selfxyz/common/utils/types';
+
+import type { SumsubApplicantInfoSerialized } from '@/integrations/sumsub/types';
+import { navigationRef } from '@/navigation';
+import { storeDocumentWithDeduplication } from '@/providers/passportDataProvider';
+import { usePendingKycStore } from '@/stores/pendingKycStore';
+
+interface UseSumsubWebSocketOptions {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+ onVerificationFailed?: (reason: string) => void;
+ skipAddPending?: boolean;
+}
+
+/**
+ * Shared hook for Sumsub websocket subscription logic.
+ * Handles connecting to the TEE service, subscribing to a userId,
+ * and processing verification results.
+ */
+export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
+ const {
+ onSuccess,
+ onError,
+ onVerificationFailed,
+ skipAddPending = false,
+ } = options;
+
+ const addPendingVerification = usePendingKycStore(
+ state => state.addPendingVerification,
+ );
+ const updateVerificationStatus = usePendingKycStore(
+ state => state.updateVerificationStatus,
+ );
+ const getPendingVerification = usePendingKycStore(
+ state => state.getPendingVerification,
+ );
+
+ const socketsRef = useRef