diff --git a/.cursor/mcp.json b/.cursor/mcp.json
index b2657c17c..7b71337c5 100644
--- a/.cursor/mcp.json
+++ b/.cursor/mcp.json
@@ -1,16 +1,4 @@
{
- "mcpServers": {
- "giga": {
- "command": "npx",
- "args": [
- "-y",
- "mcp-remote@latest",
- "https://mcp.gigamind.dev/mcp"
- ]
- }
- },
- "settings": {
- "disableAutoPRAnalysis": true,
- "manualReviewEnabled": true
- }
+ "mcpServers": {},
+ "settings": {}
}
diff --git a/app/jest.setup.js b/app/jest.setup.js
index a48de7dc0..1d0275e04 100644
--- a/app/jest.setup.js
+++ b/app/jest.setup.js
@@ -1031,6 +1031,9 @@ jest.mock('@react-native-clipboard/clipboard', () => ({
hasString: jest.fn().mockResolvedValue(false),
}));
+// Mock react-native-linear-gradient
+jest.mock('react-native-linear-gradient', () => 'LinearGradient');
+
// Mock react-native-localize
jest.mock('react-native-localize', () => ({
getLocales: jest.fn().mockReturnValue([
diff --git a/app/package.json b/app/package.json
index 2d837d22d..26ad5c742 100644
--- a/app/package.json
+++ b/app/package.json
@@ -154,6 +154,7 @@
"react-native-haptic-feedback": "^2.3.3",
"react-native-inappbrowser-reborn": "^3.7.0",
"react-native-keychain": "^10.0.0",
+ "react-native-linear-gradient": "^2.8.3",
"react-native-localize": "^3.5.2",
"react-native-logs": "^5.3.0",
"react-native-nfc-manager": "3.16.3",
diff --git a/app/src/assets/icons/checkmark_white.svg b/app/src/assets/icons/checkmark_white.svg
new file mode 100644
index 000000000..e903e1c42
--- /dev/null
+++ b/app/src/assets/icons/checkmark_white.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/src/assets/images/bg_starfall_push.png b/app/src/assets/images/bg_starfall_push.png
new file mode 100644
index 000000000..ad6f11e95
Binary files /dev/null and b/app/src/assets/images/bg_starfall_push.png differ
diff --git a/app/src/assets/logos/opera_minipay.svg b/app/src/assets/logos/opera_minipay.svg
new file mode 100644
index 000000000..8628c4f91
--- /dev/null
+++ b/app/src/assets/logos/opera_minipay.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/src/assets/icons/logo_white.svg b/app/src/assets/logos/self.svg
similarity index 100%
rename from app/src/assets/icons/logo_white.svg
rename to app/src/assets/logos/self.svg
diff --git a/app/src/components/starfall/StarfallLogoHeader.tsx b/app/src/components/starfall/StarfallLogoHeader.tsx
new file mode 100644
index 000000000..4a8f30352
--- /dev/null
+++ b/app/src/components/starfall/StarfallLogoHeader.tsx
@@ -0,0 +1,40 @@
+// 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 } from 'tamagui';
+
+import { black, zinc800 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
+
+import CheckmarkIcon from '@/assets/icons/checkmark_white.svg';
+import OperaLogo from '@/assets/logos/opera_minipay.svg';
+import SelfLogo from '@/assets/logos/self.svg';
+
+export const StarfallLogoHeader: React.FC = () => (
+
+ {/* Opera MiniPay logo */}
+
+
+
+
+ {/* Checkmark icon */}
+
+
+
+
+ {/* Self logo */}
+
+
+
+
+);
diff --git a/app/src/components/starfall/StarfallPIN.tsx b/app/src/components/starfall/StarfallPIN.tsx
new file mode 100644
index 000000000..fb0cac18b
--- /dev/null
+++ b/app/src/components/starfall/StarfallPIN.tsx
@@ -0,0 +1,62 @@
+// 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 { Text, XStack, YStack } from 'tamagui';
+
+import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
+
+export interface StarfallPINProps {
+ code: string;
+}
+
+export const StarfallPIN: React.FC = ({ code }) => {
+ // Split the code into individual digits (expects 4 digits)
+ const digits = code.split('').slice(0, 4);
+
+ // Pad with empty strings if less than 4 digits
+ while (digits.length < 4) {
+ digits.push('');
+ }
+
+ return (
+
+ {digits.map((digit, index) => (
+
+
+ {digit}
+
+
+ ))}
+
+ );
+};
diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx
index b481036cb..4ce48e8f3 100644
--- a/app/src/navigation/index.tsx
+++ b/app/src/navigation/index.tsx
@@ -27,6 +27,7 @@ import documentsScreens from '@/navigation/documents';
import homeScreens from '@/navigation/home';
import onboardingScreens from '@/navigation/onboarding';
import sharedScreens from '@/navigation/shared';
+import starfallScreens from '@/navigation/starfall';
import verificationScreens from '@/navigation/verification';
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen';
@@ -41,6 +42,7 @@ export const navigationScreens = {
...verificationScreens,
...accountScreens,
...sharedScreens,
+ ...starfallScreens,
...devScreens, // allow in production for testing
};
@@ -158,6 +160,7 @@ export type RootStackParamList = Omit<
Gratification: {
points?: number;
};
+ StarfallPushCode: undefined;
// Home screens
Home: {
diff --git a/app/src/navigation/starfall.ts b/app/src/navigation/starfall.ts
new file mode 100644
index 000000000..6de84c5cf
--- /dev/null
+++ b/app/src/navigation/starfall.ts
@@ -0,0 +1,18 @@
+// 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 { NativeStackNavigationOptions } from '@react-navigation/native-stack';
+
+import StarfallPushCodeScreen from '@/screens/starfall/StarfallPushCodeScreen';
+
+const starfallScreens = {
+ StarfallPushCode: {
+ screen: StarfallPushCodeScreen,
+ options: {
+ headerShown: false,
+ } as NativeStackNavigationOptions,
+ },
+};
+
+export default starfallScreens;
diff --git a/app/src/screens/app/GratificationScreen.tsx b/app/src/screens/app/GratificationScreen.tsx
index cecd21144..e9c378260 100644
--- a/app/src/screens/app/GratificationScreen.tsx
+++ b/app/src/screens/app/GratificationScreen.tsx
@@ -26,8 +26,8 @@ import {
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
-import LogoWhite from '@/assets/icons/logo_white.svg';
import GratificationBg from '@/assets/images/gratification_bg.svg';
+import SelfLogo from '@/assets/logos/self.svg';
import type { RootStackParamList } from '@/navigation';
const GratificationScreen: React.FC = () => {
@@ -160,7 +160,7 @@ const GratificationScreen: React.FC = () => {
>
{/* Logo icon */}
-
+
{/* Points display */}
diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx
index 09b171929..143a90b8b 100644
--- a/app/src/screens/dev/DevSettingsScreen.tsx
+++ b/app/src/screens/dev/DevSettingsScreen.tsx
@@ -660,11 +660,11 @@ const DevSettingsScreen: React.FC = ({}) => {
>
handleTopicToggle(['nova'], 'Nova')}
+ onToggle={() => handleTopicToggle(['nova'], 'Starfall')}
/>
= ({}) => {
onToggle={() => handleTopicToggle(['general'], 'General')}
/>
{
+ const navigation = useNavigation();
+ const [code, setCode] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isCopied, setIsCopied] = useState(false);
+ const copyTimeoutRef = useRef(null);
+
+ const handleFetchCode = async () => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ confirmTap();
+
+ const walletAddress = await getOrGeneratePointsAddress();
+ const fetchedCode = await fetchPushCode(walletAddress);
+
+ setCode(fetchedCode);
+ } catch (err) {
+ console.error('Failed to fetch push code:', err);
+ setError('Failed to generate code. Please try again.');
+ setCode(null); // Clear stale code on error
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (copyTimeoutRef.current) {
+ clearTimeout(copyTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleRetry = () => {
+ handleFetchCode();
+ };
+
+ const handleCopyCode = async () => {
+ if (!code || code === DASH_CODE) {
+ return;
+ }
+
+ try {
+ confirmTap();
+ await Clipboard.setString(code);
+ setIsCopied(true);
+
+ // Clear any existing timeout before creating a new one
+ if (copyTimeoutRef.current) {
+ clearTimeout(copyTimeoutRef.current);
+ }
+
+ // Reset after 1.65 seconds
+ copyTimeoutRef.current = setTimeout(() => {
+ setIsCopied(false);
+ copyTimeoutRef.current = null;
+ }, 1650);
+ } catch (copyError) {
+ console.error('Failed to copy to clipboard:', copyError);
+ }
+ };
+
+ const handleDismiss = () => {
+ confirmTap();
+ navigation.goBack();
+ };
+
+ return (
+
+
+ {/* Colorful background image */}
+
+ {/* Fade to black overlay - stronger at bottom */}
+
+
+
+ {/* Content container */}
+
+ {/* App logos section */}
+
+
+ {/* Title and content */}
+
+
+ Your Starfall code awaits
+
+
+
+
+
+ Open Starfall in Opera MiniPay and enter this four digit code
+ to continue your journey.
+
+
+
+
+
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ {/* Bottom buttons */}
+
+ {/* Debug: Fetch code button or Retry button on error */}
+ {error ? (
+
+ Retry
+
+ ) : (
+
+ {isLoading ? 'Fetching...' : 'Fetch code'}
+
+ )}
+
+
+ {isCopied ? 'Code copied!' : 'Copy code'}
+
+
+ Dismiss
+
+
+
+
+ );
+};
+
+export default StarfallPushCodeScreen;
diff --git a/app/src/services/starfall/pushCodeService.ts b/app/src/services/starfall/pushCodeService.ts
new file mode 100644
index 000000000..c55a65f69
--- /dev/null
+++ b/app/src/services/starfall/pushCodeService.ts
@@ -0,0 +1,67 @@
+// 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 { POINTS_API_BASE_URL } from '@/services/points/constants';
+
+const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
+
+/**
+ * Fetches a one-time push code for the specified wallet address.
+ * The code has a TTL of 30 minutes and refreshes with each call.
+ *
+ * @param walletAddress - The wallet address to generate a push code for
+ * @returns The 4-digit push code as a string
+ * @throws Error if the API request fails or times out
+ */
+export async function fetchPushCode(walletAddress: string): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(
+ `${POINTS_API_BASE_URL}/push/wallet/${walletAddress}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ signal: controller.signal,
+ },
+ );
+
+ // Clear timeout on successful response
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch push code: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const code = await response.json();
+
+ // The API returns a JSON string like "5932"
+ if (typeof code !== 'string' || code.length !== 4) {
+ throw new Error('Invalid push code format received from API');
+ }
+
+ return code;
+ } catch (error) {
+ // Clear timeout on error
+ clearTimeout(timeoutId);
+
+ // Handle abort/timeout specifically
+ if (error instanceof Error && error.name === 'AbortError') {
+ console.error('Push code request timed out');
+ throw new Error(
+ 'Request timed out. Please check your connection and try again.',
+ );
+ }
+
+ console.error('Error fetching push code:', error);
+ throw error;
+ }
+}
diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx
index fbb8e3514..5f0ea721c 100644
--- a/app/tests/src/navigation.test.tsx
+++ b/app/tests/src/navigation.test.tsx
@@ -84,6 +84,7 @@ describe('navigation', () => {
'Settings',
'ShowRecoveryPhrase',
'Splash',
+ 'StarfallPushCode',
'WebView',
]);
});
diff --git a/app/tests/src/navigation/index.test.ts b/app/tests/src/navigation/index.test.ts
deleted file mode 100644
index 4897dd12c..000000000
--- a/app/tests/src/navigation/index.test.ts
+++ /dev/null
@@ -1,131 +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.
-
-// Mock the navigation module to avoid deep import chains that overwhelm the parser
-jest.mock('@/navigation', () => {
- const mockScreens = {
- // App screens
- Home: {},
- Launch: {},
- Loading: {},
- Modal: {},
- Gratification: {},
- WebView: {},
- Points: {},
- // Onboarding screens
- Disclaimer: {},
- Splash: {},
- // Documents screens
- IDPicker: {},
- IdDetails: {},
- CountryPicker: {
- statusBar: { hidden: true, style: 'dark' },
- },
- DocumentCamera: {},
- DocumentCameraTrouble: {},
- DocumentDataInfo: {},
- DocumentDataNotFound: {},
- DocumentNFCMethodSelection: {},
- DocumentNFCScan: {},
- DocumentNFCTrouble: {},
- DocumentOnboarding: {},
- ManageDocuments: {},
- // Verification screens
- ConfirmBelonging: {},
- Prove: {},
- ProofHistory: {},
- ProofHistoryDetail: {},
- ProofRequestStatus: {},
- QRCodeViewFinder: {},
- QRCodeTrouble: {},
- // Account screens
- AccountRecovery: {},
- AccountRecoveryChoice: {},
- AccountVerifiedSuccess: {},
- CloudBackupSettings: {},
- SaveRecoveryPhrase: {},
- ShowRecoveryPhrase: {},
- RecoverWithPhrase: {},
- Settings: {},
- Referral: {},
- DeferredLinkingInfo: {},
- // Shared screens
- ComingSoon: {},
- // Dev screens
- DevSettings: {},
- DevFeatureFlags: {},
- DevHapticFeedback: {},
- DevLoadingScreen: {},
- DevPrivateKey: {},
- CreateMock: {},
- MockDataDeepLink: {},
- // Aadhaar screens
- AadhaarUpload: {},
- AadhaarUploadSuccess: {},
- AadhaarUploadError: {},
- };
-
- return {
- navigationScreens: mockScreens,
- navigationRef: { current: null },
- };
-});
-
-describe('navigation', () => {
- it('should have the correct navigation screens', () => {
- const navigationScreens = require('@/navigation').navigationScreens;
- const listOfScreens = Object.keys(navigationScreens).sort();
- expect(listOfScreens).toEqual([
- 'AadhaarUpload',
- 'AadhaarUploadError',
- 'AadhaarUploadSuccess',
- 'AccountRecovery',
- 'AccountRecoveryChoice',
- 'AccountVerifiedSuccess',
- 'CloudBackupSettings',
- 'ComingSoon',
- 'ConfirmBelonging',
- 'CountryPicker',
- 'CreateMock',
- 'DeferredLinkingInfo',
- 'DevFeatureFlags',
- 'DevHapticFeedback',
- 'DevLoadingScreen',
- 'DevPrivateKey',
- 'DevSettings',
- 'Disclaimer',
- 'DocumentCamera',
- 'DocumentCameraTrouble',
- 'DocumentDataInfo',
- 'DocumentDataNotFound',
- 'DocumentNFCMethodSelection',
- 'DocumentNFCScan',
- 'DocumentNFCTrouble',
- 'DocumentOnboarding',
- 'Gratification',
- 'Home',
- 'IDPicker',
- 'IdDetails',
- 'Launch',
- 'Loading',
- 'ManageDocuments',
- 'MockDataDeepLink',
- 'Modal',
- 'Points',
- 'ProofHistory',
- 'ProofHistoryDetail',
- 'ProofRequestStatus',
- 'Prove',
- 'QRCodeTrouble',
- 'QRCodeViewFinder',
- 'RecoverWithPhrase',
- 'Referral',
- 'SaveRecoveryPhrase',
- 'Settings',
- 'ShowRecoveryPhrase',
- 'Splash',
- 'WebView',
- ]);
- });
-});
diff --git a/app/tests/src/screens/GratificationScreen.test.tsx b/app/tests/src/screens/GratificationScreen.test.tsx
index 608f4d270..5c4128398 100644
--- a/app/tests/src/screens/GratificationScreen.test.tsx
+++ b/app/tests/src/screens/GratificationScreen.test.tsx
@@ -97,7 +97,7 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
}));
jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft');
-jest.mock('@/assets/icons/logo_white.svg', () => 'LogoWhite');
+jest.mock('@/assets/logos/self.svg', () => 'SelfLogo');
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
diff --git a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx
index a9f8a45b6..dabffb598 100644
--- a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx
+++ b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx
@@ -15,16 +15,40 @@ export interface ButtonProps extends PressableProps {
animatedComponent?: React.ReactNode;
trackEvent?: string;
borderWidth?: number;
+ borderColor?: string;
+ fontSize?: number;
onLayout?: (event: LayoutChangeEvent) => void;
}
+/**
+ * Standard interface for extracting style props from button components.
+ * Use this to separate style-related props from other button props.
+ */
+export interface ExtractedButtonStyleProps {
+ borderWidth?: number;
+ borderColor?: string;
+ fontSize?: number;
+}
+
interface AbstractButtonProps extends ButtonProps {
bgColor: string;
borderColor?: string;
borderWidth?: number;
+ fontSize?: number;
color: string;
}
+// Helper to extract border props from style object
+function extractBorderFromStyle(style: ViewStyle | undefined): {
+ borderColor?: string;
+ borderWidth?: number;
+ restStyle: ViewStyle;
+} {
+ if (!style) return { restStyle: {} };
+ const { borderColor, borderWidth, ...restStyle } = style;
+ return { borderColor: borderColor as string | undefined, borderWidth, restStyle };
+}
+
/*
Base Button component that can be used to create different types of buttons
use PrimaryButton and SecondaryButton instead of this component or create a new button component
@@ -35,8 +59,9 @@ export default function AbstractButton({
children,
bgColor,
color,
- borderColor,
- borderWidth = 4,
+ borderColor: propBorderColor,
+ borderWidth: propBorderWidth,
+ fontSize,
style,
animatedComponent,
trackEvent,
@@ -44,7 +69,15 @@ export default function AbstractButton({
...props
}: AbstractButtonProps) {
const selfClient = useSelfClient();
- const hasBorder = borderColor ? true : false;
+
+ // Extract border from style prop if provided there
+ const flatStyle = StyleSheet.flatten(style) as ViewStyle | undefined;
+ const { borderColor: styleBorderColor, borderWidth: styleBorderWidth, restStyle } = extractBorderFromStyle(flatStyle);
+
+ // Props take precedence over style
+ const borderColor = propBorderColor ?? styleBorderColor;
+ const borderWidth = propBorderWidth ?? styleBorderWidth;
+ const hasBorder = borderColor != null;
const handlePress = (e: GestureResponderEvent) => {
if (trackEvent) {
@@ -69,17 +102,16 @@ export default function AbstractButton({
{ backgroundColor: bgColor },
hasBorder
? {
- borderWidth: borderWidth,
+ borderWidth: borderWidth ?? 1,
borderColor: borderColor,
- padding: 20 - borderWidth, // Adjust padding to maintain total size
}
: Platform.select({ web: { borderWidth: 0 }, default: {} }),
!animatedComponent && pressed ? pressedStyle : {},
- style as ViewStyle,
+ restStyle as ViewStyle,
]}
>
{animatedComponent}
- {children}
+ {children}
);
}
diff --git a/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx
index 99bfa3a28..74bee5fdf 100644
--- a/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx
+++ b/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx
@@ -4,23 +4,41 @@
import { amber50, black, slate300, white } from '../../constants/colors';
import { normalizeBorderWidth } from '../../utils/styleUtils';
-import type { ButtonProps } from './AbstractButton';
+import type { ButtonProps, ExtractedButtonStyleProps } from './AbstractButton';
import AbstractButton from './AbstractButton';
+/**
+ * Extract standard style props for primary button.
+ * Separates border and font props from other button props.
+ */
+function extractPrimaryButtonStyleProps(props: Omit): {
+ styleProps: ExtractedButtonStyleProps;
+ restProps: Omit;
+} {
+ const { borderWidth, borderColor, fontSize, ...restProps } = props;
+ return {
+ styleProps: {
+ borderWidth: normalizeBorderWidth(borderWidth),
+ borderColor,
+ fontSize,
+ },
+ restProps,
+ };
+}
+
export function PrimaryButton({ children, ...props }: ButtonProps) {
- const { borderWidth, ...restProps } = props;
+ const { styleProps, restProps } = extractPrimaryButtonStyleProps(props);
const isDisabled = restProps.disabled;
const bgColor = isDisabled ? white : black;
const color = isDisabled ? slate300 : amber50;
- const borderColor = isDisabled ? slate300 : undefined;
-
- const numericBorderWidth = normalizeBorderWidth(borderWidth);
+ const borderColor = isDisabled ? slate300 : styleProps.borderColor;
return (
diff --git a/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx
index 0212bd9c8..814674a06 100644
--- a/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx
+++ b/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx
@@ -4,25 +4,47 @@
import { slate200, slate300, slate500, white } from '../../constants/colors';
import { normalizeBorderWidth } from '../../utils/styleUtils';
-import type { ButtonProps } from './AbstractButton';
+import type { ButtonProps, ExtractedButtonStyleProps } from './AbstractButton';
import AbstractButton from './AbstractButton';
-export function SecondaryButton({ children, ...props }: ButtonProps) {
- const { borderWidth, ...restProps } = props;
+export interface SecondaryButtonProps extends ButtonProps {
+ textColor?: string;
+}
+
+/**
+ * Extract standard style props for secondary button.
+ * Separates border and font props from other button props.
+ */
+function extractSecondaryButtonStyleProps(props: Omit): {
+ styleProps: ExtractedButtonStyleProps;
+ restProps: Omit;
+} {
+ const { borderWidth, borderColor, fontSize, ...restProps } = props;
+ return {
+ styleProps: {
+ borderWidth: normalizeBorderWidth(borderWidth),
+ borderColor,
+ fontSize,
+ },
+ restProps,
+ };
+}
+
+export function SecondaryButton({ children, textColor, ...props }: SecondaryButtonProps) {
+ const { styleProps, restProps } = extractSecondaryButtonStyleProps(props);
const isDisabled = restProps.disabled;
const bgColor = isDisabled ? white : slate200;
- const color = isDisabled ? slate300 : slate500;
- const borderColor = isDisabled ? slate200 : undefined;
-
- const numericBorderWidth = normalizeBorderWidth(borderWidth);
+ const color = textColor ?? (isDisabled ? slate300 : slate500);
+ const borderColor = isDisabled ? slate300 : styleProps.borderColor;
return (
{children}
diff --git a/packages/mobile-sdk-alpha/src/components/index.ts b/packages/mobile-sdk-alpha/src/components/index.ts
index 9f76f5f5d..02a33013d 100644
--- a/packages/mobile-sdk-alpha/src/components/index.ts
+++ b/packages/mobile-sdk-alpha/src/components/index.ts
@@ -2,53 +2,33 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+export type { ButtonProps, ExtractedButtonStyleProps } from './buttons/AbstractButton';
+// Type exports
+export type { SecondaryButtonProps } from './buttons/SecondaryButton';
export type { ViewProps } from './layout/View';
+// Button components
export { default as AbstractButton } from './buttons/AbstractButton';
-
-// Typography components
export { default as Additional } from './typography/Additional';
-
-// Layout components
export { BodyText } from './typography/BodyText';
export { Button } from './layout/Button';
export { default as ButtonsContainer } from './ButtonsContainer';
export { Caption } from './typography/Caption';
export { default as Caution } from './typography/Caution';
-
export { default as Description } from './typography/Description';
-
export { DescriptionTitle } from './typography/DescriptionTitle';
-
export { HeldPrimaryButton } from './buttons/PrimaryButtonLongHold';
-
export { HeldPrimaryButtonProveScreen } from './buttons/HeldPrimaryButtonProveScreen';
-
export { MRZScannerView } from './MRZScannerView';
-
-// Button components
export { PrimaryButton } from './buttons/PrimaryButton';
-
-// Flag components
export { RoundFlag } from './flag/RoundFlag';
-
export { SecondaryButton } from './buttons/SecondaryButton';
-
export { SubHeader } from './typography/SubHeader';
-
export { Text } from './layout/Text';
-
export { default as TextsContainer } from './TextsContainer';
-
export { Title } from './typography/Title';
-
export { View } from './layout/View';
-
export { XStack } from './layout/XStack';
-
-// Export types
export { YStack } from './layout/YStack';
-
export { pressedStyle } from './buttons/pressedStyle';
-
export { typography } from './typography/styles';
diff --git a/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx b/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx
new file mode 100644
index 000000000..e10a0cf32
--- /dev/null
+++ b/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx
@@ -0,0 +1,352 @@
+// 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.
+
+/* @vitest-environment jsdom */
+import type { ReactNode } from 'react';
+import { Platform } from 'react-native';
+import { describe, expect, it, vi } from 'vitest';
+
+import AbstractButton from '../../../src/components/buttons/AbstractButton';
+import { SelfClientProvider } from '../../../src/index';
+import { mockAdapters } from '../../utils/testHelpers';
+
+import { render } from '@testing-library/react';
+
+// Helper to wrap component in SelfClientProvider
+function TestWrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+describe('AbstractButton', () => {
+ describe('borderColor prop', () => {
+ it('should apply borderColor from prop', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ // Note: In jsdom, styles are applied as inline styles or style objects
+ // The actual style checking depends on how react-native-web or mocks handle it
+ });
+
+ it('should apply borderColor from style prop', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+
+ it('should prioritize borderColor prop over style', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+
+ it('should handle borderWidth prop', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ describe('fontSize prop', () => {
+ it('should apply fontSize from prop', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ const text = button?.querySelector('span');
+ expect(text).toBeTruthy();
+ expect(text?.textContent).toBe('Test Button');
+ });
+
+ it('should use default fontSize of 18 when not provided', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ const text = button?.querySelector('span');
+ expect(text).toBeTruthy();
+ });
+
+ it('should accept various fontSize values', () => {
+ const fontSizes = [12, 16, 20, 24, 28, 32];
+
+ fontSizes.forEach(fontSize => {
+ const { container } = render(
+
+
+ Test {fontSize}
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+ });
+
+ describe('Platform.select behavior', () => {
+ it('should apply borderWidth: 0 on web when no border is specified', () => {
+ // Platform is mocked as 'web' in setup.ts
+ expect(Platform.OS).toBe('web');
+
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+
+ it('should not apply borderWidth: 0 when border is specified', () => {
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ describe('event tracking', () => {
+ it('should call trackEvent when trackEvent prop is provided', () => {
+ // This test verifies the trackEvent functionality exists
+ // The actual implementation is tested through the SelfClient
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+
+ it('should parse event category from trackEvent string', () => {
+ // Tests that "Category: Event" format gets parsed to "Event"
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ describe('style prop handling', () => {
+ it('should merge style prop with internal styles', () => {
+ const customStyle = {
+ padding: 10,
+ backgroundColor: 'blue',
+ };
+
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+
+ it('should handle StyleSheet.flatten for style prop', () => {
+ const style1 = { padding: 10 };
+ const style2 = { margin: 5 };
+ const combinedStyle = [style1, style2];
+
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ describe('disabled state', () => {
+ it('should accept disabled prop', () => {
+ const { container } = render(
+
+
+ Disabled Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ describe('animatedComponent', () => {
+ it('should render animatedComponent when provided', () => {
+ const AnimatedComponent = Animated
;
+
+ const { container, getByTestId } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ expect(getByTestId('animated')).toBeTruthy();
+ });
+ });
+
+ describe('cross-platform compatibility', () => {
+ it('should render consistently on web platform', () => {
+ // Verify Platform.OS is 'web' as expected from setup.ts
+ expect(Platform.OS).toBe('web');
+
+ const { container } = render(
+
+
+ Cross-Platform Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ expect(button?.textContent).toBe('Cross-Platform Button');
+ });
+
+ it('should handle Platform.select correctly', () => {
+ // Verify that Platform.select returns web or default values
+ const result = Platform.select({ web: 'web-value', default: 'default-value' });
+ expect(result).toBe('web-value');
+
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ });
+ });
+
+ describe('onPress handling', () => {
+ it('should call onPress when button is pressed', () => {
+ const onPressMock = vi.fn();
+
+ const { container } = render(
+
+
+ Test Button
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+
+ // Note: In jsdom environment, we can't easily simulate Pressable's onPress
+ // This test verifies the button is renderable with onPress prop
+ });
+ });
+
+ describe('children rendering', () => {
+ it('should render children as text', () => {
+ const { container } = render(
+
+
+ Button Text
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ expect(button?.textContent).toBe('Button Text');
+ });
+
+ it('should render complex children', () => {
+ const { container } = render(
+
+
+ {'Button '}
+ {'with '}
+ {'multiple '}
+ {'parts'}
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button).toBeTruthy();
+ expect(button?.textContent).toBe('Button with multiple parts');
+ });
+ });
+});
diff --git a/packages/mobile-sdk-alpha/tests/setup.ts b/packages/mobile-sdk-alpha/tests/setup.ts
index 5eeea990e..b249fdcad 100644
--- a/packages/mobile-sdk-alpha/tests/setup.ts
+++ b/packages/mobile-sdk-alpha/tests/setup.ts
@@ -7,6 +7,8 @@
* Reduces console noise during testing and mocks React Native modules
*/
+import { createElement } from 'react';
+
const originalConsole = {
warn: console.warn,
error: console.error,
@@ -48,10 +50,22 @@ vi.mock('react-native', () => ({
requireNativeComponent: vi.fn(() => 'div'),
StyleSheet: {
create: vi.fn(styles => styles),
+ flatten: vi.fn(style => {
+ if (!style) return {};
+ if (Array.isArray(style)) {
+ return style.reduce((acc, s) => ({ ...acc, ...s }), {});
+ }
+ return style;
+ }),
},
Image: 'div',
Text: 'span',
View: 'div',
+ Pressable: vi.fn(({ children, style, ...props }) => {
+ // Handle style as function (for pressed state)
+ const computedStyle = typeof style === 'function' ? style({ pressed: false }) : style;
+ return createElement('button', { ...props, style: computedStyle }, children);
+ }),
TouchableOpacity: 'button',
ScrollView: 'div',
FlatList: 'div',
diff --git a/yarn.lock b/yarn.lock
index 4c438ead4..acf20c195 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8663,6 +8663,7 @@ __metadata:
react-native-haptic-feedback: "npm:^2.3.3"
react-native-inappbrowser-reborn: "npm:^3.7.0"
react-native-keychain: "npm:^10.0.0"
+ react-native-linear-gradient: "npm:^2.8.3"
react-native-localize: "npm:^3.5.2"
react-native-logs: "npm:^5.3.0"
react-native-nfc-manager: "npm:3.16.3"
@@ -29892,6 +29893,16 @@ __metadata:
languageName: node
linkType: hard
+"react-native-linear-gradient@npm:^2.8.3":
+ version: 2.8.3
+ resolution: "react-native-linear-gradient@npm:2.8.3"
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: 10c0/cd41bf28e9f468173f1e5e768685128ebf8bbf9077710e43b63482c1a76f37bff8ab3d1d6adfd7b4d54e648672356c02bea46c47cdbdb1844ebe5c5caf720114
+ languageName: node
+ linkType: hard
+
"react-native-localize@npm:^3.5.2, react-native-localize@npm:^3.5.4":
version: 3.6.0
resolution: "react-native-localize@npm:3.6.0"