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"