From 276b0a4cf4218df4dc9c592cc651532285ea1307 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 15 Jul 2026 11:01:06 -0600 Subject: [PATCH] feat(example): rebuild demo app to match native iOS/Android feature set Replace the 3-screen example (Home/Transact/Action) with a bottom-tab demo mirroring the native demo apps: - Tabs: Pay Link, User Link, Actions, Events, Settings - Sub-screens: Company Login, Change Actions JSON editor, Data Response - Actions screen cloned from the iOS SDK demo (PayLinkActions): collapsible account/bill groups, per-action launch, headless toggle, pay-link/accounts fetch, JSON editor (3 shapes), staleness warning - On-screen Events log (filter/sort/clear, raw JSON detail) fed by all callbacks - Settings: paste public token, environment, theme, language, debug, iOS presentation style - Single-select task pickers (Pay Link / User Link) with starting-screen + company - Data Response provider (identity + card) returned via onDataRequest for the SDK payment response; round-trip is iOS-only in the RN bridge - Shared React Context + AsyncStorage persistence, reusable components - Company suggestions ported from the Android demo - Add @react-navigation/bottom-tabs, @expo/vector-icons, expo-clipboard, expo-font; remove unused @react-native-picker/picker - Rewrite example README --- example/App.tsx | 66 +- example/README.md | 305 ++----- example/components/AccountSection.tsx | 130 +++ example/components/CapsuleButton.tsx | 48 + example/components/Card.tsx | 47 + example/components/CheckPublicTokenBanner.tsx | 66 ++ example/components/ConfigPreviewSheet.tsx | 88 ++ example/components/FullWidthButton.tsx | 70 ++ example/components/RadioRow.tsx | 58 ++ example/components/SelectGrid.tsx | 82 ++ example/components/ToggleRow.tsx | 52 ++ example/config/buildConfig.ts | 66 ++ example/constants/actionModels.ts | 139 +++ example/constants/companyData.ts | 864 ++++++++++++++++++ example/constants/eventTypes.ts | 32 + example/hooks/useNow.ts | 14 + example/navigation/RootNavigator.tsx | 41 + example/navigation/Tabs.tsx | 79 ++ example/navigation/types.ts | 33 + example/package.json | 5 +- example/screens/ActionScreen.tsx | 700 -------------- example/screens/ActionsJsonEditorScreen.tsx | 115 +++ example/screens/ActionsScreen.tsx | 179 ++++ example/screens/CompanyLoginScreen.tsx | 123 +++ example/screens/DataRequestProviderScreen.tsx | 420 +++++++++ example/screens/EventsScreen.tsx | 393 ++++++++ example/screens/HomeScreen.tsx | 143 --- example/screens/PayLinkScreen.tsx | 285 ++++++ example/screens/SettingsScreen.tsx | 258 ++++++ example/screens/TransactScreen.tsx | 557 ----------- example/screens/UserLinkScreen.tsx | 222 +++++ example/state/ActionsContext.tsx | 95 ++ example/state/DataRequestContext.tsx | 154 ++++ example/state/EventLogContext.tsx | 155 ++++ example/state/PayLinkContext.tsx | 65 ++ example/state/SettingsContext.tsx | 139 +++ example/state/UserLinkContext.tsx | 65 ++ example/state/usePersistedState.ts | 49 + example/theme.ts | 32 + yarn.lock | 54 +- 40 files changed, 4804 insertions(+), 1684 deletions(-) create mode 100644 example/components/AccountSection.tsx create mode 100644 example/components/CapsuleButton.tsx create mode 100644 example/components/Card.tsx create mode 100644 example/components/CheckPublicTokenBanner.tsx create mode 100644 example/components/ConfigPreviewSheet.tsx create mode 100644 example/components/FullWidthButton.tsx create mode 100644 example/components/RadioRow.tsx create mode 100644 example/components/SelectGrid.tsx create mode 100644 example/components/ToggleRow.tsx create mode 100644 example/config/buildConfig.ts create mode 100644 example/constants/actionModels.ts create mode 100644 example/constants/companyData.ts create mode 100644 example/constants/eventTypes.ts create mode 100644 example/hooks/useNow.ts create mode 100644 example/navigation/RootNavigator.tsx create mode 100644 example/navigation/Tabs.tsx create mode 100644 example/navigation/types.ts delete mode 100644 example/screens/ActionScreen.tsx create mode 100644 example/screens/ActionsJsonEditorScreen.tsx create mode 100644 example/screens/ActionsScreen.tsx create mode 100644 example/screens/CompanyLoginScreen.tsx create mode 100644 example/screens/DataRequestProviderScreen.tsx create mode 100644 example/screens/EventsScreen.tsx delete mode 100644 example/screens/HomeScreen.tsx create mode 100644 example/screens/PayLinkScreen.tsx create mode 100644 example/screens/SettingsScreen.tsx delete mode 100644 example/screens/TransactScreen.tsx create mode 100644 example/screens/UserLinkScreen.tsx create mode 100644 example/state/ActionsContext.tsx create mode 100644 example/state/DataRequestContext.tsx create mode 100644 example/state/EventLogContext.tsx create mode 100644 example/state/PayLinkContext.tsx create mode 100644 example/state/SettingsContext.tsx create mode 100644 example/state/UserLinkContext.tsx create mode 100644 example/state/usePersistedState.ts create mode 100644 example/theme.ts diff --git a/example/App.tsx b/example/App.tsx index 6512a1e..fa3ae63 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,51 +1,33 @@ import { NavigationContainer } from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import HomeScreen from './screens/HomeScreen'; -import TransactScreen from './screens/TransactScreen'; -import ActionScreen from './screens/ActionScreen'; - -export type RootStackParamList = { - Home: undefined; - Transact: undefined; - Action: undefined; -}; - -const Stack = createNativeStackNavigator(); +import { StatusBar } from 'expo-status-bar'; +import { RootNavigator } from './navigation/RootNavigator'; +import { SettingsProvider } from './state/SettingsContext'; +import { EventLogProvider } from './state/EventLogContext'; +import { PayLinkProvider } from './state/PayLinkContext'; +import { UserLinkProvider } from './state/UserLinkContext'; +import { ActionsProvider } from './state/ActionsContext'; +import { DataRequestProviderState } from './state/DataRequestContext'; export default function App() { return ( - - - - - - - + + + + + + + + + + + + + + + + ); } diff --git a/example/README.md b/example/README.md index 1956a97..20b941f 100644 --- a/example/README.md +++ b/example/README.md @@ -1,267 +1,112 @@ # Atomic Transact React Native Example App -This is an example app demonstrating the integration of the `@atomicfi/transact-react-native` package using Expo. +A demo/testing harness for `@atomicfi/transact-react-native`, built with Expo. It +mirrors the structure of the native iOS and Android demo apps so you can exercise +the full Transact experience — Pay Link, User Link, and **Actions** flows — and +watch every SDK callback on-device. -## Workspace Setup +The **Actions** tab is a port of the iOS SDK demo app's Actions screen, built to +test the complete action flow end-to-end. -This example app is part of the `@atomicfi/transact-react-native` workspace. Follow these steps to get it running: +## Setup -### 1. Clone and Setup the Workspace +This example is a workspace member. From the repository root: ```bash -# Clone the repository (if not already done) -git clone -cd atomic-transact-react-native - -# Install all workspace dependencies and run prepare scripts -yarn install -``` - -**Note**: `yarn install` automatically runs the `prepare` script which builds the main package using `bob build`. If you need to rebuild the package later, you can run: - -```bash -# Manually rebuild the @atomicfi/transact-react-native package -yarn prepare -``` - -### 2. Navigate to Example Directory - -```bash -cd example -``` - -### 3. Install Example Dependencies - -```bash -# Install example-specific dependencies -yarn install -``` - -## Running the App - -Since this app uses native modules, you **must** use Expo Dev Client instead of Expo Go. - -### Option 1: Build and Run Locally (Recommended for Development) - -#### iOS: - -```bash -yarn ios -``` - -#### Android: - -```bash -yarn android -``` - -These commands will: - -1. Build a development client with the native modules -2. Install it on your simulator/device -3. Start the Metro bundler - -### Option 2: Build with EAS Build (For Physical Devices) - -#### Setup EAS (first time only): - -```bash -eas login -eas build:configure -``` - -#### Build Development Client: - -```bash -# For iOS -yarn build:ios - -# For Android -yarn build:android +yarn install # installs all workspaces and builds the library (bob) +yarn run prepare # rebuild the library after changing src/ ``` -Then install the built app on your device and run: +Because the app uses native modules, it must run in an **Expo Dev Client** (not +Expo Go): ```bash -yarn start +yarn example ios # build + run on an iOS simulator +yarn example android # build + run on an Android emulator +yarn example start # start Metro (dev client already installed) ``` -### Development Server Only: +## Screens -```bash -yarn start -``` - -This starts the Metro bundler, but you need a dev client already installed. - -## Development Workflow - -When making changes to the main `@atomicfi/transact-react-native` package: - -1. Make your changes in the `src/` directory (workspace root) -2. Rebuild the package: `yarn prepare` (from workspace root) -3. Restart the Metro bundler in the example app -4. Test your changes in the example app - -## Using the Example App - -### 1. Home Screen - -- Overview of available features -- Navigation to different demo screens -- Information about the SDK capabilities +The app uses a five-tab layout plus two pushed sub-screens. -### 2. Transact Screen +### Settings -- **Configuration**: Enter your Atomic public token -- **Environment**: Choose between Sandbox, Production, or Custom URL -- **Theme**: Toggle between Dark and Light modes -- **Product Selection**: Choose from available products (Deposit, Present, Switch) -- **Automatic Scope**: Scope is automatically set based on product selection: - - **Deposit** → User Link scope - - **Present/Switch** → Pay Link scope -- **Launch**: Initiate the Transact flow with your configuration +Configure everything shared across flows (persisted on device): -### 3. Present Action Screen +- **Public Token** — paste a token; a banner warns if it's missing or >24h old. +- **Environment** — Production / Sandbox / Custom (custom reveals Transact + API + URL fields). +- **Theme** — brand color, overlay color, and dark mode (Dark / Light / System), + applied to every launch. +- **Language** — System / English / Spanish / French. +- **Debug** — enables webview inspection and SDK debug logging. +- **Present Fullscreen** (iOS) — use `fullScreen` instead of `formSheet`. -- **Action ID Input**: Enter a specific action ID to launch -- **Environment**: Choose between Sandbox, Production, or Custom URL -- **Launch**: Present the specified action +There is no in-app token generation — generate a public token from your backend +or the Atomic dashboard and paste it here. -## Configuration +### Pay Link -### Getting Atomic Credentials +Configure and launch a `pay-link` flow: task selection (Switch / Bill +Presentment / Payments Hub), Payments Hub apps, starting screen (Welcome / +Search / Company Login), a company picker, and "Use SDK Payment Response" +(`deferredPaymentMethodStrategy`). Tap the code icon in the header to preview the +config JSON before launching. -To test the actual flows, you'll need: +### User Link -1. **Public Token**: Obtain from Atomic Financial dashboard -2. **Environment**: Use Sandbox for testing, Production for live transactions, or Custom URL for custom environments -3. **Action IDs**: Get specific action IDs from your Atomic integration +Configure and launch a `user-link` flow: Direct Deposit Switching and/or Payroll +Data, starting screen, a company picker, and an optional Search Rule ID. -### Environment Options +### Actions -- **Sandbox**: Default testing environment -- **Production**: Live environment for real transactions -- **Custom URL**: Enter a custom environment URL (e.g., for staging environments) +Test custom actions end-to-end: -### Example Configuration +- **Fetch Latest Actions** — calls `GET /pay-link/accounts` with your public + token and groups the response into accounts, bills, and actions. +- **Change Actions** — paste actions JSON (a full `pay-link/accounts` response, a + grouped array, or a flat `[{ type, actionId }]` array) via the editor, with a + "Paste From Clipboard" shortcut. +- Each account is a collapsible section with one launch button per action. +- **Headless** toggles headless action execution. +- A warning appears when the actions are more than an hour old. -The app automatically generates configurations like this: +### Events -```typescript -const config = { - publicToken: 'your-public-token-here', - scope: getScope(), // Automatically determined by product selection - tasks: [ - { - operation: selectedProduct, // Product.DEPOSIT, Product.PRESENT, or Product.SWITCH - }, - ], -}; +A real-time, on-screen log of every SDK callback (`onLaunch`, `onInteraction`, +`onDataRequest`, `onAuthStatusUpdate`, `onTaskStatusUpdate`, `onFinish`, +`onClose`) — the primary debugging surface. Filter by type, sort, clear, and tap +any event to inspect its raw JSON payload. For actions, `onTaskStatusUpdate` +surfaces the `actionType`. -// Environment is automatically configured based on selection: -const environment = getEnvironment(); // Environment.sandbox, Environment.production, or Environment.custom(url, apiUrl) -``` - -## Package Scripts +## Testing the full action flow -Available scripts in the example directory: +1. **Settings** → paste a sandbox public token, pick the Sandbox environment. +2. **Actions** → **Fetch Latest Actions** (or paste JSON via **Change Actions**). +3. Expand an account and tap an action (try Headless on and off). +4. **Events** → watch the callback stream and inspect raw payloads. -```bash -# Development -yarn start # Start Metro bundler -yarn ios # Build and run on iOS simulator -yarn android # Build and run on Android emulator +## Platform notes -# Building -yarn build:ios # Build iOS development client with EAS -yarn build:android # Build Android development client with EAS - -# Linting and Type Checking -yarn lint # Run ESLint -yarn type-check # Run TypeScript compiler check -``` +- Actions run through `Atomic.transact()` with a task of + `{ operation: 'action', action: { id }, headless }` and `scope: 'pay-link'` — + there is no separate `presentAction()` API. +- The `onDataRequest → response` round-trip and `hideTransact` ("dismiss after + auth") are iOS-only in the RN bridge, so the Data Request Provider screen and + dismiss-after-auth toggle from the native demos are intentionally omitted. -## Workspace Scripts - -Available scripts from the workspace root: +## Scripts ```bash -# Build the main package -yarn prepare - -# Run tests -yarn test - -# Lint the entire workspace -yarn lint - -# Type check the entire workspace -yarn typecheck +# from the example directory +yarn start # Metro bundler +yarn ios # build + run on iOS +yarn android # build + run on Android +yarn lint # ESLint ``` -## Troubleshooting - -### Common Issues - -1. **"Cannot run on Expo Go"**: This app requires Expo Dev Client due to native modules -2. **"Linking Error"**: Ensure you've built a development client with native modules -3. **"Invalid Public Token"**: Verify your token is correct and environment matches -4. **"Action Not Found"**: Check that the action ID exists in your environment -5. **"Package not found"**: Run `yarn prepare` from the workspace root to build the main package -6. **"Metro bundler issues"**: Try clearing Metro cache: `yarn start --clear` - -### Development Tips - -- Always run `yarn prepare` from workspace root after making changes to the main package -- Use Sandbox environment for testing -- Check console logs for detailed callback information -- Verify your Atomic dashboard configuration -- Test on both iOS and Android platforms -- Use `yarn ios` or `yarn android` for the easiest setup - -### Building for Physical Devices - -If you need to test on a physical device: - -1. Install EAS CLI: `npm install -g eas-cli` -2. Login to EAS: `eas login` -3. Build development client: `yarn build:ios` or `yarn build:android` -4. Install the built app on your device -5. Run `yarn start` and scan QR code - -### Workspace Development - -When working on the main package: - -1. Make changes in `src/` (workspace root) -2. Run `yarn prepare` (workspace root) -3. Restart Metro in example app -4. Test changes - -## File Structure - -``` -atomic-transact-react-native/ -├── src/ # Main package source -├── example/ # Example app -│ ├── screens/ # App screens -│ ├── App.tsx # Main app component -│ └── package.json # Example dependencies -├── package.json # Workspace root -└── README.md # Main package README -``` - -## Package Information - -- **Package**: `@atomicfi/transact-react-native` -- **Version**: Check package.json for current version -- **Documentation**: [Atomic Financial Docs](https://docs.atomicfi.com/reference/transact-sdk#libraries__react-native) - -## Support - -For additional support: +## Links -- [Atomic Financial Documentation](https://docs.atomicfi.com) -- [Expo Dev Client Documentation](https://docs.expo.dev/development/build/) -- [React Native Documentation](https://reactnative.dev) +- [Atomic Transact React Native docs](https://docs.atomicfi.com/reference/transact-sdk#libraries__react-native) +- [Expo Dev Client](https://docs.expo.dev/development/build/) diff --git a/example/components/AccountSection.tsx b/example/components/AccountSection.tsx new file mode 100644 index 0000000..fa5e7c6 --- /dev/null +++ b/example/components/AccountSection.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import type { AccountGroup, PayLinkAction } from '../constants/actionModels'; +import { colors, radius, spacing } from '../theme'; + +interface AccountSectionProps { + account: AccountGroup; + onLaunchAction: (action: PayLinkAction) => void; +} + +// Collapsible account accordion mirroring the iOS AccountSectionView: account +// title header, then (when expanded) "Account Actions" + per-bill sub-headers, +// each with one launch button per action. +export const AccountSection: React.FC = ({ + account, + onLaunchAction, +}) => { + const [expanded, setExpanded] = useState(false); + + return ( + + setExpanded((e) => !e)} + activeOpacity={0.7} + > + {account.title} + + + + {expanded ? ( + + {account.actions.length > 0 ? ( + <> + Account Actions + {account.actions.map((action) => ( + + ))} + + ) : null} + + {account.bills.map((bill, index) => ( + + {bill.title} Bill + {bill.actions.map((action) => ( + + ))} + + ))} + + ) : null} + + ); +}; + +const ActionButton: React.FC<{ + action: PayLinkAction; + onPress: (action: PayLinkAction) => void; +}> = ({ action, onPress }) => ( + onPress(action)} + activeOpacity={0.8} + > + {action.type} + +); + +const styles = StyleSheet.create({ + wrapper: { + marginHorizontal: spacing.lg, + marginTop: spacing.md, + backgroundColor: colors.card, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.lg, + }, + headerTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.text, + flex: 1, + paddingRight: spacing.md, + }, + body: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + }, + subHeader: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginTop: spacing.md, + marginBottom: spacing.xs, + }, + actionButton: { + paddingVertical: spacing.md, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.separator, + alignItems: 'center', + }, + actionText: { + fontSize: 16, + fontWeight: '600', + color: colors.accent, + textAlign: 'center', + }, +}); diff --git a/example/components/CapsuleButton.tsx b/example/components/CapsuleButton.tsx new file mode 100644 index 0000000..5d70d85 --- /dev/null +++ b/example/components/CapsuleButton.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; +import { colors, radius, spacing } from '../theme'; + +interface CapsuleButtonProps { + label: string; + selected: boolean; + onPress: () => void; +} + +export const CapsuleButton: React.FC = ({ + label, + selected, + onPress, +}) => ( + + + {label} + + +); + +const styles = StyleSheet.create({ + capsule: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + borderRadius: radius.pill, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.card, + }, + selected: { + borderColor: colors.accent, + backgroundColor: colors.accentMuted, + }, + label: { + fontSize: 14, + color: colors.textSecondary, + }, + selectedLabel: { + color: colors.accent, + fontWeight: '600', + }, +}); diff --git a/example/components/Card.tsx b/example/components/Card.tsx new file mode 100644 index 0000000..722faa0 --- /dev/null +++ b/example/components/Card.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { colors, radius, spacing } from '../theme'; + +interface CardProps { + title?: string; + footer?: string; + children: React.ReactNode; +} + +// Grouped-list style section container used across the screens. +export const Card: React.FC = ({ title, footer, children }) => ( + + {title ? {title} : null} + {children} + {footer ? {footer} : null} + +); + +const styles = StyleSheet.create({ + wrapper: { + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + }, + title: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.sm, + marginLeft: spacing.xs, + }, + card: { + backgroundColor: colors.card, + borderRadius: radius.md, + padding: spacing.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + footer: { + fontSize: 12, + color: colors.textSecondary, + marginTop: spacing.sm, + marginLeft: spacing.xs, + }, +}); diff --git a/example/components/CheckPublicTokenBanner.tsx b/example/components/CheckPublicTokenBanner.tsx new file mode 100644 index 0000000..b3fde98 --- /dev/null +++ b/example/components/CheckPublicTokenBanner.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSettings } from '../state/SettingsContext'; +import { useNow } from '../hooks/useNow'; +import { colors, radius, spacing } from '../theme'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// Warns when the public token is missing or likely expired (>24h old) and +// offers a shortcut to Settings. Renders nothing when the token looks healthy. +export const CheckPublicTokenBanner: React.FC<{ + onGoToSettings: () => void; +}> = ({ onGoToSettings }) => { + const { settings } = useSettings(); + const now = useNow(settings.publicTokenLastChange); + const isEmpty = settings.publicToken.trim() === ''; + const isStale = + !isEmpty && + settings.publicTokenLastChange != null && + now - settings.publicTokenLastChange > DAY_MS; + + if (!isEmpty && !isStale) return null; + + return ( + + + {isEmpty + ? "Don't forget to set your public token in Settings." + : 'Public token was last changed more than 24 hours ago and has likely expired.'} + + + Add it now → + + + ); +}; + +const styles = StyleSheet.create({ + banner: { + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + alignItems: 'center', + }, + error: { + backgroundColor: '#fef2f2', + borderColor: '#fecaca', + }, + warning: { + backgroundColor: '#fffbeb', + borderColor: '#fde68a', + }, + message: { + fontSize: 13, + color: colors.text, + textAlign: 'center', + }, + link: { + fontSize: 14, + fontWeight: '600', + color: colors.accent, + marginTop: spacing.sm, + }, +}); diff --git a/example/components/ConfigPreviewSheet.tsx b/example/components/ConfigPreviewSheet.tsx new file mode 100644 index 0000000..3b8dc97 --- /dev/null +++ b/example/components/ConfigPreviewSheet.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { + Modal, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import { colors, radius, spacing } from '../theme'; + +interface ConfigPreviewSheetProps { + visible: boolean; + onClose: () => void; + config: unknown; +} + +// Shows the JSON config we're about to send to Atomic.transact. Accurate because +// the config is built in JS before being handed to the native bridge. +export const ConfigPreviewSheet: React.FC = ({ + visible, + onClose, + config, +}) => ( + + + + + Config Preview + + Done + + + + {JSON.stringify(config, null, 2)} + + + + +); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.lg, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + close: { + fontSize: 16, + color: colors.accent, + fontWeight: '600', + }, + body: { + flex: 1, + }, + bodyContent: { + padding: spacing.lg, + }, + code: { + fontFamily: 'Courier', + fontSize: 13, + color: colors.text, + backgroundColor: colors.card, + padding: spacing.md, + borderRadius: radius.sm, + }, +}); diff --git a/example/components/FullWidthButton.tsx b/example/components/FullWidthButton.tsx new file mode 100644 index 0000000..d226a04 --- /dev/null +++ b/example/components/FullWidthButton.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, +} from 'react-native'; +import { colors, radius, spacing } from '../theme'; + +interface FullWidthButtonProps { + title: string; + onPress: () => void; + disabled?: boolean; + loading?: boolean; + variant?: 'primary' | 'secondary'; +} + +export const FullWidthButton: React.FC = ({ + title, + onPress, + disabled, + loading, + variant = 'primary', +}) => { + const isSecondary = variant === 'secondary'; + return ( + + {loading ? ( + + ) : ( + + {title} + + )} + + ); +}; + +const styles = StyleSheet.create({ + button: { + backgroundColor: colors.accent, + paddingVertical: spacing.md + 2, + borderRadius: radius.md, + alignItems: 'center', + justifyContent: 'center', + }, + secondary: { + backgroundColor: colors.accentMuted, + }, + disabled: { + backgroundColor: colors.disabled, + }, + text: { + fontSize: 16, + fontWeight: '600', + color: '#ffffff', + }, + secondaryText: { + color: colors.accent, + }, +}); diff --git a/example/components/RadioRow.tsx b/example/components/RadioRow.tsx new file mode 100644 index 0000000..19c0044 --- /dev/null +++ b/example/components/RadioRow.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { colors, spacing } from '../theme'; + +interface RadioRowProps { + title: string; + subtitle?: string; + selected: boolean; + onPress: () => void; +} + +// A single-select (radio) row: title + optional subtitle with a checkmark on the +// selected option. Use for mutually-exclusive choices (only one can be active). +export const RadioRow: React.FC = ({ + title, + subtitle, + selected, + onPress, +}) => ( + + + + {title} + + {subtitle ? {subtitle} : null} + + {selected ? ( + + ) : null} + +); + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + }, + labels: { + flex: 1, + paddingRight: spacing.md, + }, + title: { + fontSize: 16, + color: colors.text, + }, + titleSelected: { + color: colors.accent, + fontWeight: '600', + }, + subtitle: { + fontSize: 13, + color: colors.textSecondary, + marginTop: 2, + }, +}); diff --git a/example/components/SelectGrid.tsx b/example/components/SelectGrid.tsx new file mode 100644 index 0000000..e490590 --- /dev/null +++ b/example/components/SelectGrid.tsx @@ -0,0 +1,82 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { CapsuleButton } from './CapsuleButton'; +import { colors, spacing } from '../theme'; + +export interface Option { + value: T; + label: string; +} + +interface SingleSelectGridProps { + title?: string; + options: Option[]; + value: T; + onChange: (value: T) => void; +} + +export function SingleSelectGrid({ + title, + options, + value, + onChange, +}: SingleSelectGridProps) { + return ( + + {title ? {title} : null} + + {options.map((option) => ( + onChange(option.value)} + /> + ))} + + + ); +} + +interface MultiSelectGridProps { + title?: string; + options: Option[]; + values: T[]; + onToggle: (value: T) => void; +} + +export function MultiSelectGrid({ + title, + options, + values, + onToggle, +}: MultiSelectGridProps) { + return ( + + {title ? {title} : null} + + {options.map((option) => ( + onToggle(option.value)} + /> + ))} + + + ); +} + +const styles = StyleSheet.create({ + title: { + fontSize: 14, + fontWeight: '500', + color: colors.text, + marginBottom: spacing.sm, + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, +}); diff --git a/example/components/ToggleRow.tsx b/example/components/ToggleRow.tsx new file mode 100644 index 0000000..4eeac2f --- /dev/null +++ b/example/components/ToggleRow.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { colors, spacing } from '../theme'; + +interface ToggleRowProps { + title: string; + subtitle?: string; + value: boolean; + onValueChange: (value: boolean) => void; +} + +export const ToggleRow: React.FC = ({ + title, + subtitle, + value, + onValueChange, +}) => ( + + + {title} + {subtitle ? {subtitle} : null} + + + +); + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + }, + labels: { + flex: 1, + paddingRight: spacing.md, + }, + title: { + fontSize: 16, + color: colors.text, + }, + subtitle: { + fontSize: 13, + color: colors.textSecondary, + marginTop: 2, + }, +}); diff --git a/example/config/buildConfig.ts b/example/config/buildConfig.ts new file mode 100644 index 0000000..88bf34e --- /dev/null +++ b/example/config/buildConfig.ts @@ -0,0 +1,66 @@ +import { Atomic } from '@atomicfi/transact-react-native'; +import { useSettings } from '../state/SettingsContext'; +import { useTransactCallbacks } from '../state/EventLogContext'; +import { useDataRequest } from '../state/DataRequestContext'; + +export interface LaunchConfig { + scope: string; + tasks: any[]; + deeplink?: Record; + deferredPaymentMethodStrategy?: string; + search?: Record; +} + +// Merges a per-screen scope/tasks/deeplink with the shared settings (token, +// theme, language) into the config object handed to Atomic.transact. Exposed on +// its own so screens can also render an accurate config preview. +export function useTransact(): { + build: (launch: LaunchConfig) => Record; + launch: (launch: LaunchConfig) => void; +} { + const { + settings, + transactEnvironment, + theme, + language, + presentationStyleIOS, + } = useSettings(); + const callbacks = useTransactCallbacks(); + const { makeResponse } = useDataRequest(); + + const build = (launch: LaunchConfig): Record => { + const config: Record = { + publicToken: settings.publicToken.trim(), + scope: launch.scope, + tasks: launch.tasks, + theme, + }; + if (language) config.language = language; + if (launch.deeplink) config.deeplink = launch.deeplink; + if (launch.deferredPaymentMethodStrategy) { + config.deferredPaymentMethodStrategy = + launch.deferredPaymentMethodStrategy; + } + if (launch.search) config.search = launch.search; + return config; + }; + + const launch = (l: LaunchConfig) => { + Atomic.transact({ + config: build(l) as any, + environment: transactEnvironment, + presentationStyleIOS, + setDebug: settings.debug, + ...callbacks, + // Log the request (via callbacks.onDataRequest) and return the configured + // identity/card so deferred payments (deferredPaymentMethodStrategy: sdk) + // can resolve. The response round-trip is iOS-only in the RN bridge. + onDataRequest: (request: any) => { + callbacks.onDataRequest(request); + return makeResponse(); + }, + }); + }; + + return { build, launch }; +} diff --git a/example/constants/actionModels.ts b/example/constants/actionModels.ts new file mode 100644 index 0000000..c5c78c2 --- /dev/null +++ b/example/constants/actionModels.ts @@ -0,0 +1,139 @@ +// Models + helpers for the Actions screen, mirroring the iOS demo's +// PayLinkActions.swift (PayLinkAction / PayLinkAccountGroup / PayLinkBillGroup, +// makeGroups, decodeGroups, and the pay-link/accounts fetch). + +export interface PayLinkAction { + type: string; + actionId: string; +} + +export interface BillGroup { + title: string; + actions: PayLinkAction[]; +} + +export interface AccountGroup { + title: string; + actions: PayLinkAction[]; + bills: BillGroup[]; +} + +// Shape of the GET /pay-link/accounts response we care about. +interface ApiAction { + type?: string; + actionId?: string; +} +interface ApiBill { + name?: string; + actions?: ApiAction[]; +} +interface ApiAccount { + company?: { name?: string }; + actions?: ApiAction[]; + bills?: ApiBill[]; +} +interface ApiResponse { + accounts?: ApiAccount[]; +} + +function normalizeActions(list?: ApiAction[]): PayLinkAction[] { + const actions: PayLinkAction[] = []; + for (const a of list ?? []) { + if (a && typeof a.actionId === 'string' && a.actionId.length > 0) { + actions.push({ type: a.type ?? 'Action', actionId: a.actionId }); + } + } + return actions; +} + +// One group per account (account-level actions) plus a group per bill that has +// actions. Accounts with no actionable content are dropped. +export function makeGroups(accounts: ApiAccount[]): AccountGroup[] { + const groups: AccountGroup[] = []; + for (const account of accounts ?? []) { + const bills = (account.bills ?? []) + .map((b) => ({ + title: b.name ?? 'Bill', + actions: normalizeActions(b.actions), + })) + .filter((b) => b.actions.length > 0); + const actions = normalizeActions(account.actions); + if (actions.length === 0 && bills.length === 0) continue; + groups.push({ title: account.company?.name ?? 'Account', actions, bills }); + } + return groups; +} + +function isAccountGroup(x: unknown): x is AccountGroup { + return ( + !!x && + typeof x === 'object' && + typeof (x as AccountGroup).title === 'string' && + Array.isArray((x as AccountGroup).actions) + ); +} + +function isPayLinkAction(x: unknown): x is PayLinkAction { + return ( + !!x && + typeof x === 'object' && + typeof (x as PayLinkAction).actionId === 'string' + ); +} + +// Accepts the three JSON shapes the "Change Actions" editor supports: +// 1. The full pay-link/accounts response ({ "accounts": [...] }) +// 2. The already-grouped format we persist ([{ title, actions, bills }]) +// 3. A flat array of actions ([{ type, actionId }]), wrapped into one group +// Returns null when the text can't be interpreted as any of them. +export function decodeGroups(text: string): AccountGroup[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray((parsed as ApiResponse).accounts) + ) { + return makeGroups((parsed as ApiResponse).accounts as ApiAccount[]); + } + + if (Array.isArray(parsed)) { + if (parsed.every(isAccountGroup)) { + return parsed as AccountGroup[]; + } + if (parsed.every(isPayLinkAction)) { + return [ + { title: 'Account', actions: parsed as PayLinkAction[], bills: [] }, + ]; + } + } + + return null; +} + +export function groupsToJson(groups: AccountGroup[]): string { + return JSON.stringify(groups, null, 2); +} + +export async function fetchActionGroups( + apiBase: string, + publicToken: string +): Promise { + const res = await fetch(`${apiBase}/pay-link/accounts`, { + headers: { + 'x-public-token': publicToken, + 'x-api-version': 'v2', + }, + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const body = (await res.json()) as ApiResponse; + return makeGroups(body.accounts ?? []); +} diff --git a/example/constants/companyData.ts b/example/constants/companyData.ts new file mode 100644 index 0000000..de5742e --- /dev/null +++ b/example/constants/companyData.ts @@ -0,0 +1,864 @@ +// Company suggestions for the Company Login deeplink picker. +// Ported from the Android demo-app CompanyData.kt (absolute CDN logo URLs). +// Do not hand-edit — regenerate from the source of truth if it changes. + +export interface CompanySuggestion { + id: string; + name: string; + fullName?: string; + logoUrl?: string; + brandColor?: string; +} + +export const payLinkSuggestions: CompanySuggestion[] = [ + { + id: '65272c415d8a530008e972df', + name: 'Amazon', + logoUrl: + 'https://cdn-public.atomicfi.com/348195d5-7de7-499a-b5a7-f7535f62368f_amazon.png', + brandColor: '#EDBB80', + }, + { + id: '64ecca15ec669e000851d5d2', + name: 'Netflix', + logoUrl: + 'https://cdn-public.atomicfi.com/a246ba37-4491-4da6-955a-3778b0c67e69_92a85256-f90a-43e9-a201-5450781bd7c8_netflix.png', + brandColor: '#000000', + }, + { + id: '6536d5456f184e00095f9df8', + name: 'Apple', + logoUrl: + 'https://cdn-public.atomicfi.com/2d4c9c92-432a-4009-9c2d-fefb0b6e48c4.png', + brandColor: '#F5F5F7', + }, + { + id: '652802875d8a530008e9e410', + name: 'Walmart', + logoUrl: + 'https://cdn-public.atomicfi.com/966bb46c-6db9-4ada-a214-7bb58c457f0b_walmart.png', + brandColor: '#306FD5', + }, + { + id: '6529bb9185376a0008a77933', + name: 'T-Mobile', + logoUrl: + 'https://cdn-public.atomicfi.com/df8170db-c930-4511-b8ef-514de0f0c536.png', + brandColor: '#ED008C', + }, + { + id: '66fffdeda315d308227ce4d6', + name: 'Metro', + fullName: 'Metro by T-Mobile', + logoUrl: + 'https://cdn-public.atomicfi.com/84059191-8ffd-4dc2-9fea-231204326026_metro.png', + brandColor: '#46186E', + }, + { + id: '65298dfc2c00c70008a87746', + name: 'AT&T', + logoUrl: + 'https://cdn-public.atomicfi.com/e9939fb6-f56c-4bfb-af25-c876dab8c554.png', + brandColor: '#00A1DC', + }, + { + id: '65690996fc1e152a3903f4f5', + name: 'Visible', + logoUrl: + 'https://cdn-public.atomicfi.com/f2727198-dac3-4715-9027-ffbf76bac4a4_visible.png', + brandColor: '#1700FF', + }, + { + id: '66a29bfa9153403b760e1da7', + name: 'Afterpay', + logoUrl: + 'https://cdn-public.atomicfi.com/0a8a0bb3-de3a-416b-b55a-aa6b818e0888_after.png', + brandColor: '#B2FCE5', + }, + { + id: '66aa6ca85226ea2387eb981e', + name: 'Klarna', + logoUrl: + 'https://cdn-public.atomicfi.com/9c21536a-7323-4b2d-b033-72fc6fb6dfba_kla.png', + brandColor: '#FFA7CC', + }, + { + id: '66a7ebd57cac0a85943aac54', + name: 'Affirm', + logoUrl: + 'https://cdn-public.atomicfi.com/901cd91b-cb15-4770-a18c-76e45589c6d3_460x0w.png', + brandColor: '#484AF4', + }, + { + id: '6712a2da6ce091a63b2125e4', + name: 'PayPal', + logoUrl: + 'https://cdn-public.atomicfi.com/ccf6a3a7-261a-445e-a658-537cc57f7003_paypal.png', + brandColor: '#003087', + }, + { + id: '6529a36ecb8e7200085c687f', + name: 'DoorDash', + logoUrl: + 'https://cdn-public.atomicfi.com/3dfae77b-afbc-40fc-84b0-15b82f393fdf.png', + brandColor: '#FFFFFF', + }, + { + id: '656f9d342f195dd2c21f6a50', + name: 'Uber Eats', + logoUrl: + 'https://cdn-public.atomicfi.com/849eaa8c-9b67-495f-b418-0f5b02c2a125_f30f948d-8504-406e-be4f-d4604673ab21.sketchpad.png', + brandColor: '#06C168', + }, + { + id: '668d567b4ca50e51f027806d', + name: 'Lyft', + logoUrl: + 'https://cdn-public.atomicfi.com/fd09588e-c472-44b5-850a-ecf8dbab3eea_lyft.png', + brandColor: '#EA0B8C', + }, + { + id: '6529bd993e34a5000922ebae', + name: 'Uber', + logoUrl: + 'https://cdn-public.atomicfi.com/2e18d066-d18e-4a58-b2fb-a85979a9ddde_uber.png', + brandColor: '#000000', + }, + { + id: '6529c1463e34a5000922efc4', + name: 'Xfinity', + logoUrl: + 'https://cdn-public.atomicfi.com/04f19e68-7fcd-4d52-8032-022f70c1cc76_image-removebg-preview.png', + brandColor: '#623AF9', + }, + { + id: '65d637696da1bb6be4966a83', + name: 'Duke Energy', + logoUrl: + 'https://cdn-public.atomicfi.com/5b94785f-13ee-4348-a3ea-6fc80f9ddc01_duke-energy.png', + brandColor: '#FFFFFF', + }, + { + id: '6903929607e90daf19152d6c', + name: 'PGE', + logoUrl: + 'https://cdn-public.atomicfi.com/e984ee12-dc97-4252-92d6-cc469861d15e_pge3.png', + brandColor: '#0E8AC6', + }, + { + id: '6529be98f1930500082b11fb', + name: 'Verizon', + logoUrl: + 'https://cdn-public.atomicfi.com/ed3bcc01-57ed-4186-afed-4004275ba23c_ver__1_-removebg-preview.png', + brandColor: '#000', + }, + { + id: '655d0d64630d0ce4cb4d15d7', + name: 'AAA', + logoUrl: + 'https://cdn-public.atomicfi.com/da3ab00d-87fd-49eb-95d4-e32e37563b7b_aaa.png', + brandColor: '#FFFFFF', + }, + { + id: '65297fd16695ab0008a26c79', + name: 'Allstate', + logoUrl: + 'https://cdn-public.atomicfi.com/2118f75a-07fc-4a3c-a5a1-0101208adb4c_allstate.png', + brandColor: '#0033A0', + }, + { + id: '6824c34ab2801079121f53df', + name: 'Prime', + logoUrl: + 'https://cdn-public.atomicfi.com/547c7007-8220-4f85-b2b0-91eaec93ef6a_amazon.png', + brandColor: '#000000', + }, + { + id: '65298cf128c44100096f89c0', + name: 'American Family', + logoUrl: + 'https://cdn-public.atomicfi.com/d5b262e2-a154-4582-aa94-1aa7ca3628b4_American-Family-Insurance-logo.png', + brandColor: '#FFFFFF', + }, + { + id: '669e7a7afcba1d3d10c82711', + name: 'Amex', + fullName: 'American Express', + logoUrl: + 'https://cdn-public.atomicfi.com/76c7bf00-6ba2-4298-9de9-ed748104cf9e_amex.png', + brandColor: '#006FCF', + }, + { + id: '68124f2b92a29a9d10f799c1', + name: 'Apartments.com', + logoUrl: + 'https://cdn-public.atomicfi.com/f6b54c1f-718c-44a2-bf33-f22356d59b06_apartments.png', + brandColor: '#F0F0F0', + }, + { + id: '6671df750b6c67b0a405175f', + name: 'Audible', + logoUrl: + 'https://cdn-public.atomicfi.com/9e93b08a-1b54-46d1-8205-dfa3c7d6269c_audible.png', + brandColor: '#FB9600', + }, + { + id: '665a0d7e3c6388329a589afc', + name: 'Boost', + fullName: 'Boost Mobile', + logoUrl: + 'https://cdn-public.atomicfi.com/333262ad-1507-4a3a-b6c9-418456ee2f09_boost.png', + brandColor: '#FFFFFF', + }, + { + id: '670d7088db177e071c839d68', + name: 'Capital One', + logoUrl: + 'https://cdn-public.atomicfi.com/95e0b189-b85f-441b-8464-49be0276a1f3_capital-one-.png', + brandColor: '#FFFFFF', + }, + { + id: '66744fd5ce050627119b42f7', + name: 'CenturyLink', + logoUrl: + 'https://cdn-public.atomicfi.com/b709e275-716f-4f07-869b-ed960a77165c_century-link.png', + brandColor: '#0047BB', + }, + { + id: '66a2b0c603b1a73a82373a54', + name: 'Chase', + logoUrl: + 'https://cdn-public.atomicfi.com/e1693657-53f0-4c6b-9e06-64ffcba76e88_chase.png', + brandColor: '#0F5BA8', + }, + { + id: '65298ea2d592c40008acd053', + name: 'Chewy', + logoUrl: + 'https://cdn-public.atomicfi.com/8aca4e72-596c-4f53-ad49-698bb4e534b3_chewy.png', + brandColor: '#1c49c2', + }, + { + id: '6887e0cfb6d2ce7e9c5f9e4d', + name: 'Copilot', + fullName: 'Microsoft Copilot', + logoUrl: + 'https://cdn-public.atomicfi.com/e2053995-8a69-4a4e-b3cc-8e89a8b24de7_copiolot.png', + brandColor: '#FFFFFF', + }, + { + id: '668eec607c6ab1082467a80a', + name: 'Costco', + logoUrl: + 'https://cdn-public.atomicfi.com/d978f2ee-848d-489a-995f-3c55ff2b18a7_costco.png', + brandColor: '#FFFFFF', + }, + { + id: '65b7fe6246707779d0b348ee', + name: 'Cox', + logoUrl: + 'https://cdn-public.atomicfi.com/1af12d1f-e95e-4dcf-a760-e6529b670733_cox.png', + brandColor: '#FFFFFF', + }, + { + id: '665a3200ed5cdbede76893eb', + name: 'Cricket', + logoUrl: + 'https://cdn-public.atomicfi.com/b0be9453-8522-4583-a22e-2b0d6e1f126c_cricket.png', + brandColor: '#60A630', + }, + { + id: '66bb9796100941757bbbc8dc', + name: 'Discover', + logoUrl: + 'https://cdn-public.atomicfi.com/c117ab47-cfec-4323-8b11-d34dde109883_discover.png', + brandColor: '#FFFFFF', + }, + { + id: '6529915dde3f69000834fbe1', + name: 'Disney+', + logoUrl: + 'https://cdn-public.atomicfi.com/3e6748fa-6516-4956-b146-2b14877325fd_Disney_Plus_logo.svg-(1).png', + brandColor: '#141450', + }, + { + id: '655d0ecb55bfbf70e0769cd2', + name: 'ESPN+', + logoUrl: + 'https://cdn-public.atomicfi.com/a188a7dc-06ba-4136-b3a5-6490adb32db7_espn-plus.png', + brandColor: '#000000', + }, + { + id: '681bcc42b98068d121632fb6', + name: 'Flex', + logoUrl: + 'https://cdn-public.atomicfi.com/acd2be5d-7ac9-435f-8189-96787965b144_flex.png', + brandColor: '#6246A3', + }, + { + id: '6529a5dca815cc0008866a23', + name: 'Geico', + logoUrl: + 'https://cdn-public.atomicfi.com/42432e7a-ba34-43c4-b574-62cdd6976368.png', + brandColor: '#2058AB', + }, + { + id: '653c354756bbb40008dd8e01', + name: 'Google', + logoUrl: + 'https://cdn-public.atomicfi.com/d856a0fc-48c4-4990-8908-e2923e0784ee.png', + brandColor: '#FFFFFF', + }, + { + id: '655d090455bfbf70e0769147', + name: 'Grubhub', + logoUrl: + 'https://cdn-public.atomicfi.com/19d7f198-f6b7-4c37-ba0a-7c056de99083_grubhub.png', + brandColor: '#EF8733', + }, + { + id: '6529ab9c85376a0008a76730', + name: 'HBO Max', + logoUrl: + 'https://cdn-public.atomicfi.com/b27390d9-5365-4a67-8b91-2c7199220c85_hbo-max.png', + brandColor: '#000000', + }, + { + id: '6529a6eccb8e7200085c6ef6', + name: 'Hulu', + logoUrl: + 'https://cdn-public.atomicfi.com/60e83c17-cda4-4b5a-98c6-c83199c54d48.png', + brandColor: '#000', + }, + { + id: '655d0e02ffc31b8be69760cb', + name: 'Liberty Mutual', + logoUrl: + 'https://cdn-public.atomicfi.com/95281c4f-d977-4926-a56c-8322abc4c13b_liberty-mutual.png', + brandColor: '#F8D247', + }, + { + id: '6887dc4b54450aafd775e43e', + name: 'Microsoft', + logoUrl: + 'https://cdn-public.atomicfi.com/9f6cfb88-d12f-486c-9c56-b00ddfe77a85_microsoft.png', + brandColor: '#FFFFFF', + }, + { + id: '6568f70dd053315898a6443b', + name: 'Mint', + fullName: 'Mint Mobile', + logoUrl: + 'https://cdn-public.atomicfi.com/9cb26d09-612b-48ce-9fc1-8fd26b2b5582_mintMobileLogo.png', + brandColor: '#78BA97', + }, + { + id: '66a00452f14beb7f4a15247b', + name: 'NY Times', + fullName: 'New York Times', + logoUrl: + 'https://cdn-public.atomicfi.com/ca9c753d-2702-4b53-92aa-c7f30c307f69_new-york-times.png', + brandColor: '#000000', + }, + { + id: '67129b6592a4863eae997c7a', + name: 'Optimum', + logoUrl: + 'https://cdn-public.atomicfi.com/5efb9176-462c-46f0-be4c-0ee87c5cf610_optimum.png', + brandColor: '#002864', + }, + { + id: '6529b25f134644000877dbe9', + name: 'Paramount+', + logoUrl: + 'https://cdn-public.atomicfi.com/ca2dc5aa-75e9-4943-a412-6acec18d8b50.png', + brandColor: '#0164FF', + }, + { + id: '65b7fdf5f8c265b827b94e06', + name: 'Peacock', + logoUrl: + 'https://cdn-public.atomicfi.com/4af34d9a-5c3f-46ce-928a-cfa2a2d983e3_peacock.png', + brandColor: '#000000', + }, + { + id: '653c308bbbd8bd0009e54759', + name: 'PlayStation', + logoUrl: + 'https://cdn-public.atomicfi.com/061c7a9c-710e-4b65-b968-f13db83642a8.png', + brandColor: '#FFFFFF', + }, + { + id: '68408dfd3d8b1445587b89d5', + name: 'Quantum Fiber', + logoUrl: + 'https://cdn-public.atomicfi.com/df6c3ec8-531f-4ccd-a755-9e55b13883fd_quantum.png', + brandColor: '#4C3290', + }, + { + id: '665a4b1215402edfe2409e42', + name: 'Roku', + logoUrl: + 'https://cdn-public.atomicfi.com/f2896a8f-33d2-41f7-88e5-76faed65dcba_roku.png', + brandColor: '#662D91', + }, + { + id: '657b2d3981768582fc94ab27', + name: 'Shop', + logoUrl: + 'https://cdn-public.atomicfi.com/7512eed8-901a-4226-888e-ca0c739e2913_shop.png', + brandColor: '#5433EB', + }, + { + id: '66aa7bfd8a0f797ff291c93d', + name: 'SiriusXM', + logoUrl: + 'https://cdn-public.atomicfi.com/899cc2b7-37db-4173-83f0-d78af5af5c9b_492x0w.png', + brandColor: '#0000EB', + }, + { + id: '663bee7aebe9c939a7d946d6', + name: 'Spectrum', + logoUrl: + 'https://cdn-public.atomicfi.com/e1ecb328-8f53-460f-bd75-2c3dbccde32d_spectrum.png', + brandColor: '#001B33', + }, + { + id: '652801e35d8a530008e9e2cd', + name: 'Spotify', + logoUrl: + 'https://cdn-public.atomicfi.com/80ed8aee-f253-4635-aae2-ea02e255e181.png', + brandColor: '#000000', + }, + { + id: '6529b9ccf1930500082b0b1f', + name: 'Starbucks', + logoUrl: + 'https://cdn-public.atomicfi.com/69865a34-ca34-4551-8e5b-fa20cc1f7db2_starbucks-logo-BFBFE6C3A3-seeklogo.com.png', + brandColor: '#FFFFFF', + }, + { + id: '6529bab0a815cc000886852f', + name: 'State Farm', + logoUrl: + 'https://cdn-public.atomicfi.com/1ef6c183-7c0e-4123-9b7d-b0ddfa412e50_State-Farm-Symbol-removebg-preview.png', + brandColor: '#FFFFFF', + }, + { + id: '669845ca5ee9cdb5ec197b8a', + name: 'Straight Talk', + logoUrl: + 'https://cdn-public.atomicfi.com/94492f67-db98-47d6-838a-dd8fb37fedb5_straight-talk.png', + brandColor: '#CCFD31', + }, + { + id: '6529bc88134644000877e848', + name: 'Target', + logoUrl: + 'https://cdn-public.atomicfi.com/d799f90f-12b5-4721-98d4-17711f2ae9bc.png', + brandColor: '#FFFFFF', + }, + { + id: '655d0a366075be94d646b4b4', + name: 'USAA', + logoUrl: + 'https://cdn-public.atomicfi.com/397f111d-b88e-404d-a79f-b8bf30a9ea7e_usaa.png', + brandColor: '#12395B', + }, + { + id: '6671dfbb3d3a8e262a7bbd5b', + name: 'Venmo', + logoUrl: + 'https://cdn-public.atomicfi.com/533ad124-efb4-453d-89c0-cdf20ea00c2f_venmoLogo.png', + brandColor: '#008AFF', + }, + { + id: '6567763f9d5ad6f0afee080b', + name: 'Xbox', + fullName: 'Xbox Game Pass', + logoUrl: + 'https://cdn-public.atomicfi.com/2e009f1f-6b2d-41b3-9b40-7c8c1b3ce39e_xbox.png', + brandColor: '#0F7C10', + }, + { + id: '68408f326ab2b0b07207a2d5', + name: 'Xfinity Mobile', + logoUrl: + 'https://cdn-public.atomicfi.com/11858b04-fb0d-480b-842f-9125700f7c36_Xfinity_Mobile.png', + brandColor: '#FFFFFF', + }, + { + id: '6529c2173e34a5000922f040', + name: 'Xpress Bill Pay', + logoUrl: + 'https://cdn-public.atomicfi.com/ab9c5a97-aa21-40cc-bfdd-94d6d62977df_image-removebg-preview-(1).png', + brandColor: '#FFFFFF', + }, + { + id: '6529c2aaa815cc0008868df0', + name: 'YouTube', + logoUrl: + 'https://cdn-public.atomicfi.com/ceaa0c1f-631a-4b8e-bc1d-8bec3aef0a17_free-youtube-logo-icon-2431-thumb.png', + brandColor: '#FFFFFF', + }, + { + id: '66bb963170d655be79817e4c', + name: 'Zip', + logoUrl: + 'https://cdn-public.atomicfi.com/c353d730-4dac-422b-b00e-60d7f60a906c_zip.png', + brandColor: '#1A0724', + }, +]; + +export const userLinkSuggestions: CompanySuggestion[] = [ + { + id: '618ea4c1c5976900087b381d', + name: 'Uplink Donuts', + }, + { + id: '5e4c4d18b7d75c37aac54a8f', + name: 'ADP', + logoUrl: + 'https://cdn-public.atomicfi.com/b02670fa-ce1b-4171-855b-6856b37bb938.png', + brandColor: '#3D62B7', + }, + { + id: '6142614a3680f80008e5301e', + name: 'Social Security', + fullName: 'Disability, Retirement & Survivors', + logoUrl: + 'https://cdn-public.atomicfi.com/ffaafc04-06b8-49cc-8d83-b40fad4020f0_SocialLogo.png', + brandColor: '#002A5A', + }, + { + id: '5df8043e6e517060b0629b41', + name: 'DoorDash', + fullName: 'Dasher', + logoUrl: + 'https://cdn-public.atomicfi.com/f3258bc1-dac6-40a2-90d9-f88644ad9c90.png', + brandColor: '#EB1700', + }, + { + id: '60e5fbcbb627cd0008e7836b', + name: 'Workday', + logoUrl: + 'https://cdn-public.atomicfi.com/23637ab4-0989-42f1-a68b-9d943d3d49dc.png', + brandColor: '#3069B5', + }, + { + id: '5ec2fbf4398fed000810a42c', + name: 'Walmart', + logoUrl: + 'https://cdn-public.atomicfi.com/41439b82-9143-4abb-a596-673e9e3a7db1_walmart.png', + brandColor: '#306FD5', + }, + { + id: '5fc810d63279fe0009c493ec', + name: 'Paychex Flex', + logoUrl: + 'https://cdn-public.atomicfi.com/9d736728-7ac7-46f6-b559-ddc711a9253b_paychex.png', + brandColor: '#004B8D', + }, + { + id: '5e06ef8bbf631d1a52bad8c0', + name: 'Uber', + fullName: 'Driver', + logoUrl: + 'https://cdn-public.atomicfi.com/b233ef8b-aad2-48ba-9351-75ff50f6f789.png', + brandColor: '#000000', + }, + { + id: '614a2dcb06386500092df038', + name: 'UKG Pro', + fullName: 'UltiPro', + logoUrl: + 'https://cdn-public.atomicfi.com/47747d6f-1c04-4473-8ea7-1bf3e25d69f9.png', + brandColor: '#015152', + }, + { + id: '5f5007f218d35500081c0c83', + name: 'Paycom', + logoUrl: + 'https://cdn-public.atomicfi.com/a0e67274-43fd-459e-b674-a7df12cf7b5a_paycom.png', + brandColor: '#04924D', + }, + { + id: '60901f2b7431b50008ec47e5', + name: 'DailyPay', + logoUrl: + 'https://cdn-public.atomicfi.com/720c4ef7-d724-4864-a56c-6ec4d3e728b9.png', + brandColor: '#FF4B00', + }, + { + id: '5f62af86aaf1410008f15d1c', + name: 'Paylocity', + logoUrl: + 'https://cdn-public.atomicfi.com/64973750-3f23-4528-99c8-459eada06f4a.png', + brandColor: '#FF8F1C', + }, + { + id: '5e4c4d18b7d75c37aac54a47', + name: 'Amazon', + logoUrl: + 'https://cdn-public.atomicfi.com/800eab29-cfdc-4f8f-a3f2-701f534f3901_amazon.png', + brandColor: '#AF6408', + }, + { + id: '609ada7a98ae06000980e124', + name: "McDonald's", + logoUrl: + 'https://cdn-public.atomicfi.com/268923ac-6088-4bb1-92da-d4f096373479.png', + brandColor: '#D42814', + }, + { + id: '5f50093218d35500081c0c84', + name: 'Paycor', + logoUrl: + 'https://cdn-public.atomicfi.com/8520b054-4933-4702-a98f-89521d900184.png', + brandColor: '#E46B2A', + }, + { + id: '5fc810d63279fe0009c493ea', + name: 'DFAS myPay', + logoUrl: + 'https://cdn-public.atomicfi.com/d4656523-e853-4599-9313-0e3981836dd0_mypay.png', + brandColor: '#411F78', + }, + { + id: '61082da14a1e3e00090bba79', + name: 'Self Employed', + logoUrl: + 'https://cdn-public.atomicfi.com/3cfea3d5-3f69-432d-b9eb-ad669d1bb62f_selfemployed.png', + brandColor: '#112E51', + }, + { + id: '5e320f2686581d32ce8e2666', + name: 'Lyft', + fullName: 'Driver', + logoUrl: + 'https://cdn-public.atomicfi.com/ce74799f-e1dc-4a8a-ad61-775029a2caee.png', + brandColor: '#EA0B8C', + }, + { + id: '5e4c4d1ab7d75c37aac56b7c', + name: 'Gusto', + logoUrl: + 'https://cdn-public.atomicfi.com/b9ba3f7e-1209-4d2e-96b7-3c0443fa7941.png', + brandColor: '#F45D48', + }, + { + id: '5efb7777219f3e0007373bb2', + name: 'Dollar Tree', + logoUrl: + 'https://cdn-public.atomicfi.com/4025454f-e8f7-41df-a0b9-9e5d4755d75c.png', + brandColor: '#62B33C', + }, + { + id: '6267297185af63000975b467', + name: 'Instacart (Full-Service Shopper)', + logoUrl: + 'https://cdn-public.atomicfi.com/a960a640-b592-4d70-87d2-cec1176adfa5_instacart.png', + brandColor: '#108910', + }, + { + id: '5ed54597e4bd3800085d441d', + name: 'UPS', + fullName: 'United Parcel Service', + logoUrl: + 'https://cdn-public.atomicfi.com/048605eb-bf52-460f-bda7-4bb85331ebc6.png', + brandColor: '#341B14', + }, + { + id: '65c176873b721bbd3a2c6cc5', + name: 'Dayforce', + logoUrl: + 'https://cdn-public.atomicfi.com/d70f69bb-46d0-405f-9990-275b0fcbd0bc_dayforce.png', + brandColor: '#226CEE', + }, + { + id: '5f0df90a2031f400089944e4', + name: 'Target', + logoUrl: + 'https://cdn-public.atomicfi.com/da090ccd-5f87-4ff4-8884-3b352cd33f09.png', + brandColor: '#CC0003', + }, + { + id: '6529923c6695ab0008a2ed96', + name: 'Dollar General', + logoUrl: + 'https://cdn-public.atomicfi.com/755fcc3a-6a21-4772-a98f-f0d3db5859d3.png', + brandColor: '#0A0B09', + }, + { + id: '644853ea57bf2c00084a558a', + name: 'Quickbooks Workforce', + logoUrl: + 'https://cdn-public.atomicfi.com/6cd6d4b5-cd66-4db6-b1e0-3612b84b43fb.png', + brandColor: '#2CA01C', + }, + { + id: '5e4c4d18b7d75c37aac54a4b', + name: 'The Home Depot', + logoUrl: + 'https://cdn-public.atomicfi.com/b2ad42c5-1348-4bfd-a19d-250583791e8c.png', + brandColor: '#F96302', + }, + { + id: '60300080a82d320008da12e4', + name: 'SSA Government Employees', + fullName: 'Current SSA Employees', + logoUrl: + 'https://cdn-public.atomicfi.com/1f95b82d-fb90-4056-a843-6341b9ad7566_ssa.png', + brandColor: '#05295B', + }, + { + id: '62a36b2364f14200098cb033', + name: "Veteran's Benefits", + fullName: 'VA Beneficiaries Only', + logoUrl: + 'https://cdn-public.atomicfi.com/cdf389ae-1d4e-4bfc-8301-a142652a5618_va-removebg-preview.png', + brandColor: '#000000', + }, + { + id: '5ed54530e4bd3800085d441c', + name: 'Kroger', + logoUrl: + 'https://cdn-public.atomicfi.com/8ea78a88-e615-4f56-a2a8-a5083b141005.png', + brandColor: '#0468B3', + }, + { + id: '60ca3b42430aba0008f617cb', + name: 'UberEats', + fullName: 'Delivery', + logoUrl: + 'https://cdn-public.atomicfi.com/e6f35eb3-5a21-4e5d-b5d6-4c11ebbfac2a_uber-eats-logo-1.png', + brandColor: '#000000', + }, + { + id: '5ef50d037f4dad0007723356', + name: "Lowe's", + logoUrl: + 'https://cdn-public.atomicfi.com/93a0ecc8-62f7-47c3-93ee-d0cd6479d1cc.png', + brandColor: '#214178', + }, + { + id: '603e95f3b1b5350008408557', + name: 'USPS', + logoUrl: + 'https://cdn-public.atomicfi.com/4772d42d-22a2-4865-99cb-66fbf4400d60_USPSNew.png', + brandColor: '#333366', + }, + { + id: '5f1b7bd0f5ee6500088e183d', + name: 'Circle K', + logoUrl: + 'https://cdn-public.atomicfi.com/2d228b35-6086-4c7e-927f-7c8569c3fbcb.png', + brandColor: '#EE2E24', + }, + { + id: '605238749479840008e9d9d2', + name: 'iSolved', + logoUrl: + 'https://cdn-public.atomicfi.com/3617ab4f-17f3-4cec-b78d-4a42352fe813_isolved.png', + brandColor: '#000000', + }, + { + id: '6010a68dfee22e00095f1e0c', + name: 'FedEx', + fullName: 'Fedex Employees', + logoUrl: + 'https://cdn-public.atomicfi.com/bdbf83bf-c870-40c0-82da-b72136a2c026.png', + brandColor: '#3A2C78', + }, + { + id: '64a755875d1c4c2a6f40ccbb', + name: 'Spark Driver', + logoUrl: + 'https://cdn-public.atomicfi.com/38d65d90-a90c-4640-8b96-5d2c3d44200f_spark.png', + brandColor: '#216CFF', + }, + { + id: '5f0f23d0acd6870007be180d', + name: 'Amazon Fulfillment', + logoUrl: + 'https://cdn-public.atomicfi.com/4a234ee5-58d5-4b79-bc4b-ea55843697bd_amazon.png', + brandColor: '#FF9900', + }, + { + id: '62bb539b0eef3d0009cb45d6', + name: 'Shipt', + fullName: 'Shopper', + logoUrl: + 'https://cdn-public.atomicfi.com/4a71d9d3-def9-4968-804f-21c9a75560fb_shipt.png', + brandColor: '#231239', + }, + { + id: '5f0e1e76b2056e0008f67e11', + name: 'Family Dollar', + logoUrl: + 'https://cdn-public.atomicfi.com/792acc1f-8e22-4fbf-b1e3-d9399b9dc10c_family-dollar.png', + brandColor: '#DB422D', + }, + { + id: '631f98fa208cce00085e35d7', + name: 'Allied Universal Security Services', + logoUrl: + 'https://cdn-public.atomicfi.com/63b23b30-6379-4628-b0f7-1b78999dc701_Screen_Shot_2022-09-12_at_4.38.06_PM-removebg-preview.png', + }, + { + id: '5f87610728f31400088b8d5c', + name: 'Taco Bell', + logoUrl: + 'https://cdn-public.atomicfi.com/8f87cdb0-183a-4dcd-8d24-63582938cff2_TacoBell_Logo.png', + brandColor: '#682A8F', + }, + { + id: '5e38d9abd383b3be9f7efe5b', + name: 'Aramark', + logoUrl: + 'https://cdn-public.atomicfi.com/8d44a659-4510-48bb-9bb8-24938fc0ed78.png', + brandColor: '#D71712', + }, + { + id: '5e4c4d1bb7d75c37aac576a8', + name: 'Homebase', + logoUrl: + 'https://cdn-public.atomicfi.com/0e45faf5-dc56-43f2-81ec-1514fd986195.png', + brandColor: '#8F42A4', + }, + { + id: '603ec888fad9230008738d1a', + name: 'Toast', + logoUrl: + 'https://cdn-public.atomicfi.com/43cff449-c187-4711-b1ce-02782fe3f0de.png', + brandColor: '#F43A22', + }, + { + id: '5f0e15f6b2056e0008f67dfc', + name: 'Chipotle Mexican Grill', + logoUrl: + 'https://cdn-public.atomicfi.com/e9029b17-353b-4c75-8bd8-e7be79cf4d4a.png', + brandColor: '#3E0A00', + }, + { + id: '60aea7b388f98a0008e590b7', + name: 'Burger King', + logoUrl: + 'https://cdn-public.atomicfi.com/6e44b122-c53d-44a3-9c25-d7517f053aa6.png', + brandColor: '#0354A4', + }, + { + id: '63619dd0de44990009eee975', + name: "Wendy's", + logoUrl: + 'https://cdn-public.atomicfi.com/b1ebd107-7866-439c-bf58-f61bf7fee451_wendys.png', + brandColor: '#3AACCF', + }, + { + id: '5ef611a55a08d70008433933', + name: 'Waffle House', + logoUrl: + 'https://cdn-public.atomicfi.com/6d6569c5-c7a1-4ce1-8e13-38c45bbb47ec.png', + brandColor: '#000000', + }, +]; diff --git a/example/constants/eventTypes.ts b/example/constants/eventTypes.ts new file mode 100644 index 0000000..a00d2e2 --- /dev/null +++ b/example/constants/eventTypes.ts @@ -0,0 +1,32 @@ +// Event categories surfaced in the Events tab. Every category here maps to a +// Transact callback the RN SDK actually forwards (plus `error` for demo-side +// failures like a failed actions fetch). + +export type EventType = + | 'launch' + | 'interaction' + | 'dataRequest' + | 'authStatusUpdate' + | 'taskStatusUpdate' + | 'finish' + | 'close' + | 'error'; + +interface EventMeta { + label: string; + title: string; + color: string; +} + +export const EVENT_META: Record = { + launch: { label: 'LAUNCH', title: 'Launch', color: '#8b5cf6' }, + interaction: { label: 'INTERACTION', title: 'Interaction', color: '#3b82f6' }, + dataRequest: { label: 'DATA', title: 'Data Request', color: '#10b981' }, + authStatusUpdate: { label: 'AUTH', title: 'Auth Status', color: '#6366f1' }, + taskStatusUpdate: { label: 'TASK', title: 'Task Status', color: '#06b6d4' }, + finish: { label: 'FINISH', title: 'Finish', color: '#22c55e' }, + close: { label: 'CLOSE', title: 'Close', color: '#64748b' }, + error: { label: 'ERROR', title: 'Error', color: '#ef4444' }, +}; + +export const EVENT_TYPES = Object.keys(EVENT_META) as EventType[]; diff --git a/example/hooks/useNow.ts b/example/hooks/useNow.ts new file mode 100644 index 0000000..3968e99 --- /dev/null +++ b/example/hooks/useNow.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; + +// Returns a timestamp captured off-render (reading the clock during render is +// disallowed by the react-hooks purity rule). Re-reads whenever `refreshKey` +// changes, so time-based checks like staleness update when their inputs do. The +// update is deferred to a timeout so it doesn't run synchronously in the effect. +export function useNow(refreshKey?: number | null): number { + const [now, setNow] = useState(0); + useEffect(() => { + const id = setTimeout(() => setNow(Date.now()), 0); + return () => clearTimeout(id); + }, [refreshKey]); + return now; +} diff --git a/example/navigation/RootNavigator.tsx b/example/navigation/RootNavigator.tsx new file mode 100644 index 0000000..0bbe74b --- /dev/null +++ b/example/navigation/RootNavigator.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { Tabs } from './Tabs'; +import CompanyLoginScreen from '../screens/CompanyLoginScreen'; +import ActionsJsonEditorScreen from '../screens/ActionsJsonEditorScreen'; +import DataRequestProviderScreen from '../screens/DataRequestProviderScreen'; +import type { RootStackParamList } from './types'; +import { colors } from '../theme'; + +const Stack = createNativeStackNavigator(); + +export const RootNavigator: React.FC = () => ( + + + + + + +); diff --git a/example/navigation/Tabs.tsx b/example/navigation/Tabs.tsx new file mode 100644 index 0000000..1589c7d --- /dev/null +++ b/example/navigation/Tabs.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Ionicons } from '@expo/vector-icons'; +import PayLinkScreen from '../screens/PayLinkScreen'; +import UserLinkScreen from '../screens/UserLinkScreen'; +import ActionsScreen from '../screens/ActionsScreen'; +import EventsScreen from '../screens/EventsScreen'; +import SettingsScreen from '../screens/SettingsScreen'; +import { useEventLog } from '../state/EventLogContext'; +import type { TabParamList } from './types'; +import { colors, spacing } from '../theme'; + +const Tab = createBottomTabNavigator(); + +type IoniconName = React.ComponentProps['name']; + +const ICONS: Record = { + PayLink: ['card-outline', 'card'], + UserLink: ['shield-checkmark-outline', 'shield-checkmark'], + Actions: ['list-outline', 'list'], + Events: ['layers-outline', 'layers'], + Settings: ['settings-outline', 'settings'], +}; + +export const Tabs: React.FC = () => { + const { unreadCount } = useEventLog(); + + return ( + ({ + headerStyle: { backgroundColor: colors.card }, + headerTitleStyle: { color: colors.text }, + headerTintColor: colors.accent, + headerRightContainerStyle: { paddingRight: spacing.md }, + tabBarActiveTintColor: colors.accent, + tabBarInactiveTintColor: colors.textSecondary, + tabBarIcon: ({ focused, color, size }) => { + const [outline, filled] = ICONS[route.name]; + return ( + + ); + }, + })} + > + + + + 0 ? unreadCount : undefined, + }} + /> + + + ); +}; diff --git a/example/navigation/types.ts b/example/navigation/types.ts new file mode 100644 index 0000000..716323e --- /dev/null +++ b/example/navigation/types.ts @@ -0,0 +1,33 @@ +import type { + NavigatorScreenParams, + CompositeScreenProps, +} from '@react-navigation/native'; +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; + +export type CompanyScope = 'paylink' | 'userlink'; + +export type TabParamList = { + PayLink: undefined; + UserLink: undefined; + Actions: undefined; + Events: undefined; + Settings: undefined; +}; + +export type RootStackParamList = { + Tabs: NavigatorScreenParams | undefined; + CompanyLogin: { scope: CompanyScope }; + ActionsJsonEditor: undefined; + DataRequestProvider: undefined; +}; + +// Props for a screen that lives in the bottom tabs but also needs to reach the +// root stack (to push CompanyLogin / ActionsJsonEditor or switch tabs). +export type TabScreenProps = CompositeScreenProps< + BottomTabScreenProps, + NativeStackScreenProps +>; + +export type RootScreenProps = + NativeStackScreenProps; diff --git a/example/package.json b/example/package.json index 0781f9a..c51e934 100644 --- a/example/package.json +++ b/example/package.json @@ -12,13 +12,16 @@ }, "dependencies": { "@atomicfi/transact-react-native": "link:..", + "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "3.1.1", - "@react-native-picker/picker": "2.11.4", + "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.2.2", "@react-navigation/native-stack": "^7.14.12", "expo": "~57.0.4", "expo-build-properties": "~57.0.3", + "expo-clipboard": "~57.0.0", "expo-dev-client": "~57.0.5", + "expo-font": "~57.0.0", "expo-status-bar": "~57.0.0", "expo-updates": "~57.0.6", "react": "19.2.7", diff --git a/example/screens/ActionScreen.tsx b/example/screens/ActionScreen.tsx deleted file mode 100644 index 07ca1da..0000000 --- a/example/screens/ActionScreen.tsx +++ /dev/null @@ -1,700 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - Alert, - TextInput, - Platform, - Switch, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Picker } from '@react-native-picker/picker'; -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import type { RootStackParamList } from '../App'; -import { - Atomic, - Environment, - PresentationStyles, - Scope, -} from '@atomicfi/transact-react-native'; -import type { PresentationStyleIOS } from '@atomicfi/transact-react-native'; - -type Props = NativeStackScreenProps; - -type EnvironmentOption = 'sandbox' | 'production' | 'custom'; - -type FetchedAction = { type: string; actionId: string }; -type FetchedAccount = { company?: string; actions: FetchedAction[] }; - -type RawAction = { type: string; actionId: string }; -type RawBillOrExpense = { actions?: RawAction[] }; -type RawAccount = { - company?: { name?: string }; - actions?: RawAction[]; - bills?: RawBillOrExpense[]; - expenses?: RawBillOrExpense[]; -}; - -const ACTIONS_STORAGE_KEY = 'atomic_paylink_actions'; -const ACTIONS_LAST_CHANGED_KEY = 'atomic_paylink_actions_last_changed'; -const STALE_THRESHOLD_MS = 60 * 60 * 1000; - -const buildDefaultSelections = ( - accounts: FetchedAccount[] -): Record => { - const next: Record = {}; - accounts.forEach((account, index) => { - if (account.actions[0]) { - next[index] = account.actions[0].actionId; - } - }); - return next; -}; - -const ActionScreen: React.FC = () => { - const [publicToken, setPublicToken] = useState(''); - const [actionId, setActionId] = useState(''); - const [selectedEnvironment, setSelectedEnvironment] = - useState('sandbox'); - const [customTransactPath, setCustomTransactPath] = useState(''); - const [customApiPath, setCustomApiPath] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [presentationStyleIOS, setPresentationStyleIOS] = - useState(PresentationStyles.formSheet); - const [debugEnabled, setDebugEnabled] = useState(false); - const [headless, setHeadless] = useState(false); - const [fetchedAccounts, setFetchedAccounts] = useState([]); - const [selectedActionByAccount, setSelectedActionByAccount] = useState< - Record - >({}); - const [lastChanged, setLastChanged] = useState(null); - const [isFetching, setIsFetching] = useState(false); - - useEffect(() => { - (async () => { - try { - const [storedActions, storedTimestamp] = await Promise.all([ - AsyncStorage.getItem(ACTIONS_STORAGE_KEY), - AsyncStorage.getItem(ACTIONS_LAST_CHANGED_KEY), - ]); - if (storedActions) { - const parsed = JSON.parse(storedActions); - if ( - Array.isArray(parsed) && - parsed.every( - (a) => a && Array.isArray((a as FetchedAccount).actions) - ) - ) { - const accounts = parsed as FetchedAccount[]; - setFetchedAccounts(accounts); - setSelectedActionByAccount(buildDefaultSelections(accounts)); - } else { - await AsyncStorage.removeItem(ACTIONS_STORAGE_KEY); - await AsyncStorage.removeItem(ACTIONS_LAST_CHANGED_KEY); - } - } - if (storedTimestamp) { - const ms = Number(storedTimestamp); - if (Number.isFinite(ms)) { - setLastChanged(new Date(ms)); - } - } - } catch (err) { - console.warn('Failed to hydrate stored actions', err); - } - })(); - }, []); - - const environmentOptions = [ - { key: 'sandbox' as EnvironmentOption, label: 'Sandbox' }, - { key: 'production' as EnvironmentOption, label: 'Production' }, - { key: 'custom' as EnvironmentOption, label: 'Custom URL' }, - ]; - - const presentationStyleOptions: { - key: PresentationStyleIOS; - label: string; - }[] = [ - { key: PresentationStyles.formSheet, label: 'Form Sheet' }, - { key: PresentationStyles.fullScreen, label: 'Full Screen' }, - ]; - - const getEnvironment = () => { - switch (selectedEnvironment) { - case 'sandbox': - return Environment.sandbox; - case 'production': - return Environment.production; - case 'custom': { - const transactPath = customTransactPath.trim(); - const apiPath = customApiPath.trim(); - return Environment.custom(transactPath, apiPath); - } - default: - return Environment.sandbox; - } - }; - - const getApiBase = () => { - switch (selectedEnvironment) { - case 'sandbox': - return 'https://sandbox-api.atomicfi.com'; - case 'production': - return 'https://api.atomicfi.com'; - case 'custom': - return customApiPath.trim(); - default: - return 'https://sandbox-api.atomicfi.com'; - } - }; - - const validateCustomEnv = () => { - if ( - selectedEnvironment === 'custom' && - (!customTransactPath.trim() || !customApiPath.trim()) - ) { - Alert.alert( - 'Error', - 'Please enter both transact path and API path for custom environment' - ); - return false; - } - return true; - }; - - const fetchActions = async () => { - if (!publicToken.trim()) { - Alert.alert('Error', 'Please enter a valid public token'); - return; - } - if (!validateCustomEnv()) return; - - setIsFetching(true); - try { - const res = await fetch(`${getApiBase()}/pay-link/accounts`, { - headers: { - 'x-public-token': publicToken.trim(), - 'x-api-version': 'v2', - }, - }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const body = (await res.json()) as { accounts?: RawAccount[] }; - const accounts: FetchedAccount[] = []; - for (const account of body.accounts ?? []) { - const actions: FetchedAction[] = []; - const seen = new Set(); - const sources: (RawAction[] | undefined)[] = [ - account.actions, - ...(account.bills ?? []).map((b) => b.actions), - ...(account.expenses ?? []).map((e) => e.actions), - ]; - for (const list of sources) { - for (const action of list ?? []) { - if (!action?.actionId || seen.has(action.actionId)) continue; - seen.add(action.actionId); - actions.push({ type: action.type, actionId: action.actionId }); - } - } - if (actions.length > 0) { - accounts.push({ company: account.company?.name, actions }); - } - } - const now = new Date(); - setFetchedAccounts(accounts); - setSelectedActionByAccount(buildDefaultSelections(accounts)); - setLastChanged(now); - await AsyncStorage.setItem(ACTIONS_STORAGE_KEY, JSON.stringify(accounts)); - await AsyncStorage.setItem( - ACTIONS_LAST_CHANGED_KEY, - String(now.getTime()) - ); - } catch (err) { - Alert.alert( - 'Fetch failed', - err instanceof Error ? err.message : 'Unknown error' - ); - } finally { - setIsFetching(false); - } - }; - - const launchAction = (idOverride?: string) => { - if (!publicToken.trim()) { - Alert.alert('Error', 'Please enter a valid public token'); - return; - } - - const targetActionId = (idOverride ?? actionId).trim(); - if (!targetActionId) { - Alert.alert('Error', 'Please enter a valid action ID'); - return; - } - - if (!validateCustomEnv()) return; - - setIsLoading(true); - - Atomic.transact({ - config: { - publicToken: publicToken.trim(), - scope: Scope.PAYLINK, - tasks: [ - { - operation: 'action', - action: { id: targetActionId }, - headless, - }, - ], - }, - environment: getEnvironment(), - presentationStyleIOS, - setDebug: debugEnabled, - onLaunch: () => { - console.log('Action launched'); - setIsLoading(false); - }, - onFinish: (result: any) => { - setIsLoading(false); - console.log('Action finished:', result); - }, - onClose: () => { - setIsLoading(false); - console.log('Action closed'); - }, - onAuthStatusUpdate: (status: any) => { - console.log('Auth Status Update:', status); - }, - onTaskStatusUpdate: (status: any) => { - console.log('Task Status Update:', status); - }, - }); - }; - - const isStale = - lastChanged !== null && - Date.now() - lastChanged.getTime() > STALE_THRESHOLD_MS; - - return ( - - - Action - - Launch a specific Atomic action through transact() - - - - - Configuration - - - Public Token * - - - - - Environment - - {environmentOptions.map((option) => ( - setSelectedEnvironment(option.key)} - > - - - - {option.label} - - ))} - - {selectedEnvironment === 'custom' && ( - <> - - - - )} - - - - - Debug Mode - - - - - - - Headless - - - - - - - Fetched Actions - - - - - {isFetching ? 'Fetching...' : 'Fetch Latest Actions'} - - - {isStale && lastChanged && ( - - {`Last Changed: ${lastChanged.toLocaleString()}. The default expiration is 1 hour, make sure these actions are up to date.`} - - )} - - - {fetchedAccounts.map((account, index) => { - const selected = selectedActionByAccount[index] ?? ''; - return ( - - - {account.company ?? `Account ${index + 1}`} - - - - setSelectedActionByAccount((prev) => ({ - ...prev, - [index]: String(value), - })) - } - > - {account.actions.map((action) => ( - - ))} - - - launchAction(selected)} - disabled={isLoading || !selected} - > - - {isLoading ? 'Launching...' : 'Launch Action'} - - - - ); - })} - - - - Manual Action - - - Action ID - - - - launchAction()} - disabled={isLoading || !actionId.trim()} - > - - {isLoading ? 'Launching...' : 'Launch Action'} - - - - - {Platform.OS === 'ios' && ( - - iOS Presentation Style - - {presentationStyleOptions.map((style) => ( - setPresentationStyleIOS(style.key)} - > - - {style.label} - - - ))} - - - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f8fafc', - }, - scrollContent: { - paddingBottom: 48, - }, - accountGroup: { - marginTop: 16, - }, - accountLaunchButton: { - marginTop: 12, - }, - header: { - padding: 24, - backgroundColor: '#3b82f6', - alignItems: 'center', - }, - headerTitle: { - fontSize: 24, - fontWeight: 'bold', - color: '#ffffff', - marginBottom: 8, - }, - headerSubtitle: { - fontSize: 14, - color: '#e0e7ff', - textAlign: 'center', - }, - section: { - margin: 16, - padding: 16, - backgroundColor: '#ffffff', - borderRadius: 12, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - color: '#1f2937', - marginBottom: 8, - }, - sectionDescription: { - fontSize: 14, - color: '#6b7280', - marginBottom: 16, - }, - inputGroup: { - marginBottom: 16, - }, - label: { - fontSize: 14, - fontWeight: '500', - color: '#374151', - marginBottom: 8, - }, - input: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 8, - padding: 12, - fontSize: 16, - color: '#1f2937', - backgroundColor: '#ffffff', - }, - customUrlInput: { - marginTop: 12, - }, - radioGroup: { - gap: 12, - }, - radioOption: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - radioButton: { - width: 20, - height: 20, - borderRadius: 10, - borderWidth: 2, - borderColor: '#d1d5db', - alignItems: 'center', - justifyContent: 'center', - }, - radioButtonInner: { - width: 10, - height: 10, - borderRadius: 5, - backgroundColor: 'transparent', - }, - radioButtonSelected: { - backgroundColor: '#3b82f6', - }, - radioLabel: { - fontSize: 16, - color: '#374151', - }, - switchRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - pickerWrapper: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 8, - backgroundColor: '#ffffff', - overflow: 'hidden', - }, - launchButton: { - backgroundColor: '#3b82f6', - padding: 16, - borderRadius: 12, - alignItems: 'center', - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - disabledButton: { - backgroundColor: '#9ca3af', - }, - launchButtonText: { - fontSize: 18, - fontWeight: '600', - color: '#ffffff', - }, - fetchButton: { - borderWidth: 1, - borderColor: '#3b82f6', - backgroundColor: '#ffffff', - padding: 12, - borderRadius: 8, - alignItems: 'center', - }, - fetchButtonText: { - fontSize: 16, - fontWeight: '600', - color: '#3b82f6', - }, - staleText: { - fontSize: 12, - color: '#6b7280', - marginTop: 8, - }, - actionList: { - gap: 8, - }, - actionButton: { - backgroundColor: '#10b981', - padding: 14, - borderRadius: 8, - alignItems: 'center', - }, - actionButtonText: { - fontSize: 16, - fontWeight: '600', - color: '#ffffff', - }, - optionGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - optionButton: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - borderWidth: 1, - borderColor: '#d1d5db', - backgroundColor: '#ffffff', - }, - selectedOption: { - backgroundColor: '#3b82f6', - borderColor: '#3b82f6', - }, - optionText: { - fontSize: 14, - color: '#6b7280', - }, - selectedOptionText: { - color: '#ffffff', - }, -}); - -export default ActionScreen; diff --git a/example/screens/ActionsJsonEditorScreen.tsx b/example/screens/ActionsJsonEditorScreen.tsx new file mode 100644 index 0000000..341a67b --- /dev/null +++ b/example/screens/ActionsJsonEditorScreen.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useLayoutEffect } from 'react'; +import { + Alert, + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { FullWidthButton } from '../components/FullWidthButton'; +import { useActions } from '../state/ActionsContext'; +import type { RootScreenProps } from '../navigation/types'; +import { colors, spacing } from '../theme'; + +const ActionsJsonEditorScreen: React.FC< + RootScreenProps<'ActionsJsonEditor'> +> = ({ navigation }) => { + const { editorText, setEditorText, saveEditor, syncEditorFromStored } = + useActions(); + + const onCancel = useCallback(() => { + syncEditorFromStored(); + navigation.goBack(); + }, [navigation, syncEditorFromStored]); + + const onSave = useCallback(() => { + if (saveEditor()) { + navigation.goBack(); + } else { + Alert.alert( + 'Invalid JSON', + 'Paste a pay-link/accounts response, a grouped array, or a flat [{ type, actionId }] array.' + ); + } + }, [navigation, saveEditor]); + + useLayoutEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + + Cancel + + ), + headerRight: () => ( + + + Save + + + ), + }); + }, [navigation, onCancel, onSave]); + + const onPaste = async () => { + const text = await Clipboard.getStringAsync(); + if (text) setEditorText(text); + }; + + return ( + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + editor: { + flex: 1, + margin: spacing.lg, + padding: spacing.md, + backgroundColor: colors.card, + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + fontSize: 13, + color: colors.text, + }, + footer: { + padding: spacing.lg, + paddingTop: 0, + }, + headerButton: { + fontSize: 16, + color: colors.accent, + }, + headerButtonBold: { + fontWeight: '700', + }, +}); + +export default ActionsJsonEditorScreen; diff --git a/example/screens/ActionsScreen.tsx b/example/screens/ActionsScreen.tsx new file mode 100644 index 0000000..29c340f --- /dev/null +++ b/example/screens/ActionsScreen.tsx @@ -0,0 +1,179 @@ +import React, { useState } from 'react'; +import { + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Card } from '../components/Card'; +import { ToggleRow } from '../components/ToggleRow'; +import { FullWidthButton } from '../components/FullWidthButton'; +import { AccountSection } from '../components/AccountSection'; +import { CheckPublicTokenBanner } from '../components/CheckPublicTokenBanner'; +import { useSettings } from '../state/SettingsContext'; +import { useActions } from '../state/ActionsContext'; +import { useEventLog } from '../state/EventLogContext'; +import { useTransact } from '../config/buildConfig'; +import { Scope } from '@atomicfi/transact-react-native'; +import type { PayLinkAction } from '../constants/actionModels'; +import type { TabScreenProps } from '../navigation/types'; +import { useNow } from '../hooks/useNow'; +import { colors, spacing } from '../theme'; + +const STALE_THRESHOLD_MS = 60 * 60 * 1000; + +const ActionsScreen: React.FC> = ({ navigation }) => { + const { settings, apiBase } = useSettings(); + const { groups, lastChanged, fetchActions } = useActions(); + const { logEvent } = useEventLog(); + const { launch } = useTransact(); + const [headless, setHeadless] = useState(false); + const [fetching, setFetching] = useState(false); + const now = useNow(lastChanged); + + const hasToken = settings.publicToken.trim().length > 0; + + const isStale = lastChanged != null && now - lastChanged > STALE_THRESHOLD_MS; + + const onFetch = async () => { + if (!hasToken) { + Alert.alert('Missing token', 'Set a public token in Settings first.'); + return; + } + setFetching(true); + try { + await fetchActions(apiBase, settings.publicToken.trim()); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.warn('Failed to fetch actions', err); + logEvent('error', { + body: `Fetch actions failed: ${message}`, + raw: message, + }); + Alert.alert('Fetch failed', message); + } finally { + setFetching(false); + } + }; + + const launchAction = (action: PayLinkAction) => { + if (!hasToken) { + Alert.alert('Missing token', 'Set a public token in Settings first.'); + return; + } + launch({ + scope: Scope.PAYLINK, + tasks: [ + { + operation: 'action', + action: { id: action.actionId }, + headless, + }, + ], + }); + }; + + return ( + + navigation.navigate('Settings')} + /> + + + navigation.navigate('ActionsJsonEditor')} + activeOpacity={0.7} + > + Change Actions + + + + + + + + + + + {groups.length === 0 ? ( + + No actions yet. Fetch the latest actions or paste your own via Change + Actions. + + ) : ( + groups.map((account, index) => ( + + )) + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + paddingBottom: spacing.xl, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + }, + rowLabel: { + fontSize: 16, + color: colors.text, + }, + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.separator, + marginVertical: spacing.md, + }, + empty: { + marginHorizontal: spacing.lg, + marginTop: spacing.xl, + textAlign: 'center', + color: colors.textSecondary, + fontSize: 14, + lineHeight: 20, + }, + spacer: { + height: spacing.xl, + }, +}); + +export default ActionsScreen; diff --git a/example/screens/CompanyLoginScreen.tsx b/example/screens/CompanyLoginScreen.tsx new file mode 100644 index 0000000..3d015cc --- /dev/null +++ b/example/screens/CompanyLoginScreen.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { + FlatList, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { + payLinkSuggestions, + userLinkSuggestions, +} from '../constants/companyData'; +import type { CompanySuggestion } from '../constants/companyData'; +import { usePayLink } from '../state/PayLinkContext'; +import { useUserLink } from '../state/UserLinkContext'; +import type { RootScreenProps } from '../navigation/types'; +import { colors, radius, spacing } from '../theme'; + +const CompanyLoginScreen: React.FC> = ({ + navigation, + route, +}) => { + const { scope } = route.params; + const payLink = usePayLink(); + const userLink = useUserLink(); + + const suggestions = + scope === 'paylink' ? payLinkSuggestions : userLinkSuggestions; + + const onSelect = (company: CompanySuggestion) => { + if (scope === 'paylink') { + payLink.setCompany(company.id, company.name); + } else { + userLink.setCompany(company.id, company.name); + } + navigation.goBack(); + }; + + const renderItem = ({ item }: { item: CompanySuggestion }) => ( + onSelect(item)} + activeOpacity={0.7} + > + + {item.logoUrl ? ( + + ) : null} + + + {item.name} + {item.fullName ? ( + {item.fullName} + ) : null} + + + ); + + return ( + item.id} + renderItem={renderItem} + /> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + backgroundColor: colors.card, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.separator, + }, + logoWrap: { + width: 44, + height: 44, + borderRadius: radius.sm, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + logo: { + width: 36, + height: 36, + }, + labels: { + marginLeft: spacing.md, + flex: 1, + }, + name: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, + fullName: { + fontSize: 13, + color: colors.textSecondary, + marginTop: 2, + }, +}); + +export default CompanyLoginScreen; diff --git a/example/screens/DataRequestProviderScreen.tsx b/example/screens/DataRequestProviderScreen.tsx new file mode 100644 index 0000000..fb84c7b --- /dev/null +++ b/example/screens/DataRequestProviderScreen.tsx @@ -0,0 +1,420 @@ +import React, { useLayoutEffect, useState } from 'react'; +import { + KeyboardAvoidingView, + Modal, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useDataRequest } from '../state/DataRequestContext'; +import type { RootScreenProps } from '../navigation/types'; +import { colors, radius, spacing } from '../theme'; + +type KeyboardType = 'default' | 'number-pad' | 'email-address' | 'phone-pad'; +type AutoCap = 'none' | 'words' | 'characters'; + +interface DataFieldProps { + label: string; + value: string; + displayValue?: string; + onChangeText: (text: string) => void; + keyboardType?: KeyboardType; + autoCapitalize?: AutoCap; + maxLength?: number; + placeholder?: string; + flex?: number; +} + +const DataField: React.FC = ({ + label, + value, + displayValue, + onChangeText, + keyboardType = 'default', + autoCapitalize = 'words', + maxLength, + placeholder, + flex, +}) => ( + + {label} + + + {value.length > 0 ? ( + onChangeText('')} + hitSlop={8} + style={styles.clearIcon} + > + + + ) : null} + + +); + +const SectionCard: React.FC<{ + icon: React.ComponentProps['name']; + title: string; + filled: number; + total: number; + children: React.ReactNode; +}> = ({ icon, title, filled, total, children }) => ( + + + + {title} + + 0 && styles.filledCountActive]} + > + {filled}/{total} filled + + + {children} + +); + +const CARD_TYPES: { value: 'credit' | 'debit'; label: string }[] = [ + { value: 'credit', label: 'Credit' }, + { value: 'debit', label: 'Debit' }, +]; + +const CardTypeDropdown: React.FC<{ + value: 'credit' | 'debit'; + onSelect: (value: 'credit' | 'debit') => void; +}> = ({ value, onSelect }) => { + const [open, setOpen] = useState(false); + const selectedLabel = + CARD_TYPES.find((o) => o.value === value)?.label ?? 'Credit'; + return ( + + Type + setOpen(true)} + activeOpacity={0.7} + > + {selectedLabel} + + + setOpen(false)} + > + setOpen(false)} + > + + {CARD_TYPES.map((option) => ( + { + onSelect(option.value); + setOpen(false); + }} + > + {option.label} + {option.value === value ? ( + + ) : null} + + ))} + + + + + ); +}; + +function formatExpiry(raw: string): string { + return raw.length > 2 ? `${raw.slice(0, 2)}/${raw.slice(2)}` : raw; +} + +const DataRequestProviderScreen: React.FC< + RootScreenProps<'DataRequestProvider'> +> = ({ navigation }) => { + const { state, update, clearAll } = useDataRequest(); + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + + ), + }); + }, [navigation, clearAll]); + + const identityFilled = [ + state.firstName, + state.lastName, + state.postalCode, + state.address, + state.address2, + state.city, + state.state, + state.phone, + state.email, + ].filter((v) => v.trim().length > 0).length; + + const cardFilled = [ + state.cardNumber, + state.cardExpiry, + state.cardCvv, + state.cardType, + ].filter((v) => v.trim().length > 0).length; + + return ( + + + + Store identity and payment data for the SDK Data Response. Returned + from onDataRequest on iOS; on Android the request is logged but the + wrapper can't return a response. Use test data only. + + + + + update({ firstName: t })} + flex={1} + /> + update({ lastName: t })} + flex={1} + /> + + update({ address: t })} + /> + update({ address2: t })} + /> + update({ postalCode: t })} + keyboardType="number-pad" + autoCapitalize="none" + placeholder="84121" + /> + + update({ city: t })} + flex={0.65} + /> + update({ state: t })} + autoCapitalize="characters" + maxLength={2} + placeholder="UT" + flex={0.35} + /> + + update({ phone: t })} + keyboardType="phone-pad" + autoCapitalize="none" + /> + update({ email: t })} + keyboardType="email-address" + autoCapitalize="none" + /> + + + + update({ cardNumber: t })} + keyboardType="number-pad" + autoCapitalize="none" + placeholder="4242424242424242" + /> + + + update({ cardExpiry: t.replace(/\D/g, '').slice(0, 4) }) + } + keyboardType="number-pad" + autoCapitalize="none" + placeholder="MM/YY" + flex={0.6} + /> + update({ cardCvv: t })} + keyboardType="number-pad" + autoCapitalize="none" + maxLength={4} + flex={0.4} + /> + + update({ cardType: v })} + /> + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + paddingBottom: spacing.xl, + }, + note: { + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + fontSize: 13, + color: colors.textSecondary, + lineHeight: 18, + }, + sectionCard: { + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + backgroundColor: colors.card, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + padding: spacing.md, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.text, + marginLeft: spacing.sm, + }, + flexSpacer: { + flex: 1, + }, + filledCount: { + fontSize: 12, + color: colors.textSecondary, + }, + filledCountActive: { + color: colors.accent, + fontWeight: '600', + }, + row: { + flexDirection: 'row', + gap: spacing.sm, + }, + field: { + marginBottom: spacing.sm, + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + inputWrap: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + backgroundColor: colors.card, + }, + inputInner: { + flex: 1, + paddingVertical: spacing.md, + fontSize: 15, + color: colors.text, + }, + clearIcon: { + paddingLeft: spacing.sm, + }, + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.3)', + justifyContent: 'center', + padding: spacing.xl, + }, + menu: { + backgroundColor: colors.card, + borderRadius: radius.md, + overflow: 'hidden', + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.lg, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.separator, + }, + menuItemText: { + fontSize: 16, + color: colors.text, + }, + spacer: { + height: spacing.xl, + }, +}); + +export default DataRequestProviderScreen; diff --git a/example/screens/EventsScreen.tsx b/example/screens/EventsScreen.tsx new file mode 100644 index 0000000..80a8493 --- /dev/null +++ b/example/screens/EventsScreen.tsx @@ -0,0 +1,393 @@ +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { + FlatList, + Modal, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; +import { useEventLog } from '../state/EventLogContext'; +import type { LoggedEvent } from '../state/EventLogContext'; +import { EVENT_META, EVENT_TYPES } from '../constants/eventTypes'; +import type { EventType } from '../constants/eventTypes'; +import type { TabScreenProps } from '../navigation/types'; +import { colors, radius, spacing } from '../theme'; + +function formatTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +const EventsScreen: React.FC> = ({ navigation }) => { + const { events, clear, markRead } = useEventLog(); + const [selectedTypes, setSelectedTypes] = useState([ + ...EVENT_TYPES, + ]); + const [sortDesc, setSortDesc] = useState(true); + const [filterVisible, setFilterVisible] = useState(false); + const [detail, setDetail] = useState(null); + + useFocusEffect( + useCallback(() => { + markRead(); + }, [markRead]) + ); + + const visibleEvents = useMemo(() => { + const filtered = events.filter((e) => selectedTypes.includes(e.type)); + const sorted = [...filtered].sort((a, b) => + sortDesc ? b.time - a.time : a.time - b.time + ); + return sorted; + }, [events, selectedTypes, sortDesc]); + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + setSortDesc((s) => !s)}> + + + setFilterVisible(true)}> + + + {events.length > 0 ? ( + + + + ) : null} + + ), + }); + }, [navigation, events.length, clear, selectedTypes.length]); + + const toggleType = (type: EventType) => + setSelectedTypes((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] + ); + + const renderItem = ({ item }: { item: LoggedEvent }) => { + const meta = EVENT_META[item.type]; + return ( + setDetail(item)} + activeOpacity={0.7} + > + + {formatTime(item.time)} + + + + + + + {meta.label} + + + {meta.title} + + {item.body ? {item.body} : null} + {item.secondary ? ( + {item.secondary} + ) : null} + + + ); + }; + + return ( + + {visibleEvents.length === 0 ? ( + + + + {events.length === 0 ? 'No Events Yet' : 'No Matching Events'} + + + {events.length === 0 + ? 'Launch Transact to start collecting events.' + : 'Adjust the filter to show more event types.'} + + + ) : ( + item.id} + renderItem={renderItem} + contentContainerStyle={styles.list} + /> + )} + + setFilterVisible(false)} + > + + + + Filter Events + setFilterVisible(false)}> + Done + + + + setSelectedTypes([...EVENT_TYPES])} + > + Show All + + setSelectedTypes([])}> + Hide All + + + {EVENT_TYPES.map((type) => { + const meta = EVENT_META[type]; + const active = selectedTypes.includes(type); + return ( + toggleType(type)} + > + + + {meta.title} + + {active ? ( + + ) : null} + + ); + })} + + + + + setDetail(null)} + > + + + + + {detail ? EVENT_META[detail.type].title : ''} + + setDetail(null)}> + Done + + + {detail ? ( + + + {new Date(detail.time).toLocaleString()} + + {detail.body ? ( + {detail.body} + ) : null} + Raw Callback Data + + {JSON.stringify(detail.raw, null, 2)} + + + ) : null} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + list: { + padding: spacing.md, + }, + row: { + flexDirection: 'row', + marginBottom: spacing.sm, + }, + timeCol: { + width: 68, + alignItems: 'center', + paddingTop: spacing.sm, + }, + time: { + fontSize: 11, + fontFamily: 'Courier', + color: colors.textSecondary, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginTop: spacing.xs, + }, + card: { + flex: 1, + backgroundColor: colors.card, + borderRadius: radius.md, + borderWidth: 1, + padding: spacing.md, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.xs, + }, + badge: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radius.pill, + }, + badgeText: { + fontSize: 10, + fontWeight: '700', + fontFamily: 'Courier', + }, + title: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + }, + body: { + fontSize: 14, + color: colors.text, + }, + secondary: { + fontSize: 12, + fontFamily: 'Courier', + color: colors.textSecondary, + marginTop: 2, + }, + empty: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + marginTop: spacing.md, + }, + emptyBody: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.sm, + }, + headerText: { + fontSize: 16, + color: colors.accent, + fontWeight: '600', + }, + headerButtons: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.lg, + }, + sheet: { + flex: 1, + backgroundColor: colors.background, + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.lg, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + sheetTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + sheetActions: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + filterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.separator, + }, + filterLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + filterText: { + fontSize: 16, + color: colors.text, + }, + detailBody: { + padding: spacing.lg, + }, + detailMeta: { + fontSize: 13, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + detailText: { + fontSize: 15, + color: colors.text, + marginBottom: spacing.md, + }, + detailLabel: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + marginBottom: spacing.sm, + }, + code: { + fontFamily: 'Courier', + fontSize: 13, + color: colors.text, + backgroundColor: colors.card, + padding: spacing.md, + borderRadius: radius.sm, + }, +}); + +export default EventsScreen; diff --git a/example/screens/HomeScreen.tsx b/example/screens/HomeScreen.tsx deleted file mode 100644 index 9217275..0000000 --- a/example/screens/HomeScreen.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - Alert, -} from 'react-native'; -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import type { RootStackParamList } from '../App'; - -type Props = NativeStackScreenProps; - -const HomeScreen: React.FC = ({ navigation }) => { - const showInfo = () => { - Alert.alert( - 'Atomic Transact SDK', - 'This example app demonstrates the integration of the @atomicfi/transact-react-native package.\n\n' + - 'Features:\n' + - '• Transact Flow - Complete financial connection flow\n' + - '• Action - Launch a specific action through transact()\n' + - '• Environment switching (Sandbox/Production)\n' + - '• Custom theming and configuration\n\n' + - 'Note: You need valid Atomic credentials to test the actual flows.', - [{ text: 'OK' }] - ); - }; - - return ( - - - Atomic Transact SDK - React Native Integration Example - - - - navigation.navigate('Transact')} - > - Launch Transact Flow - - Test the complete financial connection experience - - - - navigation.navigate('Action')} - > - Launch Action - - Launch a specific action through transact() - - - - - About This Example - - Learn more about the SDK features - - - - - - - Powered by Atomic Financial • Built with React Native + Expo - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f8fafc', - }, - header: { - padding: 24, - backgroundColor: '#6366f1', - alignItems: 'center', - }, - title: { - fontSize: 28, - fontWeight: 'bold', - color: '#ffffff', - marginBottom: 8, - }, - subtitle: { - fontSize: 16, - color: '#e0e7ff', - textAlign: 'center', - }, - content: { - padding: 24, - gap: 16, - }, - button: { - padding: 20, - borderRadius: 12, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - primaryButton: { - backgroundColor: '#10b981', - }, - secondaryButton: { - backgroundColor: '#3b82f6', - }, - infoButton: { - backgroundColor: '#6b7280', - }, - buttonText: { - fontSize: 18, - fontWeight: '600', - color: '#ffffff', - marginBottom: 4, - }, - buttonSubtext: { - fontSize: 14, - color: '#e5e7eb', - }, - footer: { - padding: 24, - alignItems: 'center', - marginTop: 40, - }, - footerText: { - fontSize: 12, - color: '#6b7280', - textAlign: 'center', - }, -}); - -export default HomeScreen; diff --git a/example/screens/PayLinkScreen.tsx b/example/screens/PayLinkScreen.tsx new file mode 100644 index 0000000..5ed6724 --- /dev/null +++ b/example/screens/PayLinkScreen.tsx @@ -0,0 +1,285 @@ +import React, { useLayoutEffect, useState } from 'react'; +import { + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { + App, + DeferredPaymentMethodStrategy, + Product, + Scope, + Step, +} from '@atomicfi/transact-react-native'; +import { Card } from '../components/Card'; +import { ToggleRow } from '../components/ToggleRow'; +import { RadioRow } from '../components/RadioRow'; +import { FullWidthButton } from '../components/FullWidthButton'; +import { MultiSelectGrid, SingleSelectGrid } from '../components/SelectGrid'; +import { CheckPublicTokenBanner } from '../components/CheckPublicTokenBanner'; +import { ConfigPreviewSheet } from '../components/ConfigPreviewSheet'; +import { useSettings } from '../state/SettingsContext'; +import { usePayLink } from '../state/PayLinkContext'; +import { useDataRequest } from '../state/DataRequestContext'; +import type { + PayLinkStartingScreen, + PayLinkTask, +} from '../state/PayLinkContext'; +import { useTransact } from '../config/buildConfig'; +import type { LaunchConfig } from '../config/buildConfig'; +import type { TabScreenProps } from '../navigation/types'; +import { colors, spacing } from '../theme'; + +const STARTING_SCREENS: { value: PayLinkStartingScreen; label: string }[] = [ + { value: 'welcome', label: 'Welcome' }, + { value: 'search', label: 'Search' }, + { value: 'companyLogin', label: 'Company Login' }, +]; + +const TASK_PRODUCT: Record = { + switch: Product.SWITCH, + present: Product.PRESENT, + manage: Product.MANAGE, +}; + +const APPS: { value: string; label: string }[] = [ + { value: App.PAY_NOW, label: 'Pay Now' }, + { value: App.EXPENSES, label: 'Expenses' }, + { value: App.ORDERS, label: 'Orders' }, + { value: App.SUGGESTIONS, label: 'Suggestions' }, +]; + +const PayLinkScreen: React.FC> = ({ navigation }) => { + const { settings } = useSettings(); + const { state, update } = usePayLink(); + const { makeResponse } = useDataRequest(); + const { build, launch } = useTransact(); + const [previewVisible, setPreviewVisible] = useState(false); + + const hasToken = settings.publicToken.trim().length > 0; + const hasPaymentResponse = makeResponse() != null; + const showApps = state.task === 'manage'; + const showStartingScreen = + state.task === 'switch' || state.task === 'present'; + + const buildLaunchConfig = (): LaunchConfig => { + const task: any = { operation: TASK_PRODUCT[state.task] }; + if (showApps) task.apps = state.apps; + + let deeplink: Record | undefined; + if (showStartingScreen) { + if (state.startingScreen === 'search') { + deeplink = { step: Step.SEARCH_COMPANY }; + } else if ( + state.startingScreen === 'companyLogin' && + state.companyLoginId + ) { + deeplink = { + step: Step.LOGIN_COMPANY, + companyId: state.companyLoginId, + }; + } + } + + return { + scope: Scope.PAYLINK, + tasks: [task], + deeplink, + deferredPaymentMethodStrategy: state.useSDKPaymentResponse + ? DeferredPaymentMethodStrategy.SDK + : undefined, + }; + }; + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + setPreviewVisible(true)}> + + + ), + }); + }, [navigation]); + + const toggleApp = (value: string) => + update({ + apps: state.apps.includes(value) + ? state.apps.filter((a) => a !== value) + : [...state.apps, value], + }); + + const onInitialize = () => { + if (!hasToken) { + Alert.alert('Missing token', 'Set a public token in Settings first.'); + return; + } + launch(buildLaunchConfig()); + }; + + return ( + + + navigation.navigate('Settings')} + /> + + + update({ task: 'switch' })} + /> + update({ task: 'present' })} + /> + update({ task: 'manage' })} + /> + {showApps ? ( + + + + ) : null} + + + {state.task === 'switch' ? ( + + update({ useSDKPaymentResponse: v })} + /> + {state.useSDKPaymentResponse ? ( + + navigation.navigate('DataRequestProvider')} + activeOpacity={0.7} + > + + {hasPaymentResponse ? 'Edit' : 'Add'} + + + + ) : null} + + ) : null} + + {showStartingScreen ? ( + + update({ startingScreen: value })} + /> + {state.startingScreen === 'companyLogin' ? ( + + navigation.navigate('CompanyLogin', { scope: 'paylink' }) + } + activeOpacity={0.7} + > + Company + + + {state.companyLoginName || 'Select company'} + + + + + ) : null} + + ) : null} + + + + + + + + + setPreviewVisible(false)} + config={build(buildLaunchConfig())} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + paddingBottom: spacing.xl, + }, + appsGrid: { + marginTop: spacing.md, + }, + linkRow: { + alignItems: 'flex-end', + marginTop: spacing.sm, + }, + linkText: { + fontSize: 15, + fontWeight: '600', + color: colors.accent, + }, + companyRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: spacing.lg, + }, + companyLabel: { + fontSize: 16, + color: colors.text, + }, + companyValue: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + companyName: { + fontSize: 15, + color: colors.textSecondary, + }, + footer: { + padding: spacing.lg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + backgroundColor: colors.card, + }, + spacer: { + height: spacing.xl, + }, +}); + +export default PayLinkScreen; diff --git a/example/screens/SettingsScreen.tsx b/example/screens/SettingsScreen.tsx new file mode 100644 index 0000000..22ec13b --- /dev/null +++ b/example/screens/SettingsScreen.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import { + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Card } from '../components/Card'; +import { ToggleRow } from '../components/ToggleRow'; +import { SingleSelectGrid } from '../components/SelectGrid'; +import { useSettings } from '../state/SettingsContext'; +import type { + DarkModeOption, + EnvironmentOption, + LanguageOption, +} from '../state/SettingsContext'; +import { colors, radius, spacing } from '../theme'; + +const ENV_OPTIONS: { value: EnvironmentOption; label: string }[] = [ + { value: 'production', label: 'Production' }, + { value: 'sandbox', label: 'Sandbox' }, + { value: 'custom', label: 'Custom' }, +]; + +const DARK_MODE_OPTIONS: { value: DarkModeOption; label: string }[] = [ + { value: 'system', label: 'System' }, + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' }, +]; + +const LANGUAGE_OPTIONS: { value: LanguageOption; label: string }[] = [ + { value: 'system', label: 'System' }, + { value: 'en', label: 'English' }, + { value: 'es', label: 'Spanish' }, + { value: 'fr', label: 'French' }, +]; + +const COLOR_SWATCHES = [ + '#4a5cff', + '#3b82f6', + '#10b981', + '#ef4444', + '#f59e0b', + '#8b5cf6', + '#111827', + '#ffffff', +]; + +const ColorField: React.FC<{ + label: string; + value: string; + onChange: (value: string) => void; +}> = ({ label, value, onChange }) => ( + + {label} + + {COLOR_SWATCHES.map((swatch) => ( + onChange(swatch)} + style={[ + styles.swatch, + { backgroundColor: swatch }, + value.toLowerCase() === swatch.toLowerCase() && + styles.swatchSelected, + ]} + /> + ))} + + + +); + +const SettingsScreen: React.FC = () => { + const { settings, update } = useSettings(); + + return ( + + + update({ publicToken: text })} + onEndEditing={(e) => + update({ + publicToken: e.nativeEvent.text.trim(), + publicTokenLastChange: Date.now(), + }) + } + placeholder="Paste your public token" + placeholderTextColor={colors.disabled} + autoCapitalize="none" + autoCorrect={false} + multiline + /> + + update({ publicToken: '', publicTokenLastChange: null }) + } + style={styles.clearButton} + > + Clear + + + + + update({ environment: value })} + /> + {settings.environment === 'custom' ? ( + + update({ customTransactPath: text })} + placeholder="Transact URL" + placeholderTextColor={colors.disabled} + autoCapitalize="none" + autoCorrect={false} + /> + update({ customApiPath: text })} + placeholder="API URL" + placeholderTextColor={colors.disabled} + autoCapitalize="none" + autoCorrect={false} + /> + + ) : null} + + + + update({ brandColor: value })} + /> + update({ overlayColor: value })} + /> + Dark Mode + update({ darkMode: value })} + /> + + + + update({ language: value })} + /> + + + + update({ debug: value })} + /> + {Platform.OS === 'ios' ? ( + update({ presentFullscreen: value })} + /> + ) : null} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + paddingBottom: spacing.xl, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.sm, + padding: spacing.md, + fontSize: 15, + color: colors.text, + backgroundColor: colors.card, + }, + spaced: { + marginTop: spacing.md, + }, + clearButton: { + alignSelf: 'flex-end', + marginTop: spacing.sm, + }, + clearText: { + color: colors.accent, + fontWeight: '600', + }, + customUrls: { + marginTop: spacing.md, + }, + colorField: { + marginBottom: spacing.lg, + }, + inlineLabel: { + fontSize: 14, + fontWeight: '500', + color: colors.text, + marginBottom: spacing.sm, + }, + swatchRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginBottom: spacing.sm, + }, + swatch: { + width: 28, + height: 28, + borderRadius: radius.pill, + borderWidth: 1, + borderColor: colors.border, + }, + swatchSelected: { + borderWidth: 3, + borderColor: colors.accent, + }, + spacer: { + height: spacing.xl, + }, +}); + +export default SettingsScreen; diff --git a/example/screens/TransactScreen.tsx b/example/screens/TransactScreen.tsx deleted file mode 100644 index 7b2d282..0000000 --- a/example/screens/TransactScreen.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import React, { useState } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - Alert, - Switch, - TextInput, - Platform, -} from 'react-native'; -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import type { RootStackParamList } from '../App'; -import { - Atomic, - Product, - Scope, - Environment, - PresentationStyles, - Step, -} from '@atomicfi/transact-react-native'; -import type { - PresentationStyleIOS, - StepType, -} from '@atomicfi/transact-react-native'; - -type Props = NativeStackScreenProps; - -type EnvironmentOption = 'sandbox' | 'production' | 'custom'; - -const TransactScreen: React.FC = () => { - const [publicToken, setPublicToken] = useState(''); - const [selectedEnvironment, setSelectedEnvironment] = - useState('sandbox'); - const [customTransactPath, setCustomTransactPath] = useState(''); - const [customApiPath, setCustomApiPath] = useState(''); - const [selectedProduct, setSelectedProduct] = useState(Product.DEPOSIT); - const [darkMode, setDarkMode] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [presentationStyleIOS, setPresentationStyleIOS] = - useState(PresentationStyles.formSheet); - const [useDeeplink, setUseDeeplink] = useState(false); - const [deeplinkCompanyId, setDeeplinkCompanyId] = useState(''); - const [singleSwitch, setSingleSwitch] = useState(false); - const [deeplinkStep, setDeeplinkStep] = useState( - Step.LOGIN_COMPANY - ); - const [debugEnabled, setDebugEnabled] = useState(false); - - const products = [ - { key: Product.DEPOSIT, label: 'Deposit' }, - { key: Product.PRESENT, label: 'Present' }, - { key: Product.SWITCH, label: 'Switch' }, - { key: Product.MANAGE, label: 'Manage' }, - ]; - - const getScope = () => { - // Deposit uses USERLINK, Present and Switch use PAYLINK - return selectedProduct === Product.DEPOSIT ? Scope.USERLINK : Scope.PAYLINK; - }; - - const environmentOptions = [ - { key: 'sandbox' as EnvironmentOption, label: 'Sandbox' }, - { key: 'production' as EnvironmentOption, label: 'Production' }, - { key: 'custom' as EnvironmentOption, label: 'Custom URL' }, - ]; - - const presentationStyleOptions: { - key: PresentationStyleIOS; - label: string; - }[] = [ - { key: PresentationStyles.formSheet, label: 'Form Sheet' }, - { key: PresentationStyles.fullScreen, label: 'Full Screen' }, - ]; - - const getEnvironment = () => { - switch (selectedEnvironment) { - case 'sandbox': - return Environment.sandbox; - case 'production': - return Environment.production; - case 'custom': { - const transactPath = customTransactPath.trim(); - const apiPath = customApiPath.trim(); - return Environment.custom(transactPath, apiPath); - } - default: - return Environment.sandbox; - } - }; - - const launchTransact = () => { - if (!publicToken.trim()) { - Alert.alert('Error', 'Please enter a valid public token'); - return; - } - - if ( - selectedEnvironment === 'custom' && - (!customTransactPath.trim() || !customApiPath.trim()) - ) { - Alert.alert( - 'Error', - 'Please enter both transact path and API path for custom environment' - ); - return; - } - - // Company ID is only required for steps that target a specific company (not search) - const requiresCompanyId = - deeplinkStep !== Step.SEARCH_COMPANY && - deeplinkStep !== Step.SEARCH_PAYROLL; - if (useDeeplink && requiresCompanyId && !deeplinkCompanyId.trim()) { - Alert.alert('Error', 'Please enter a Company ID when using deeplink'); - return; - } - - setIsLoading(true); - - const config: any = { - publicToken: publicToken.trim(), - scope: getScope(), - tasks: [ - { - operation: selectedProduct, - }, - ], - }; - - // Add deeplink configuration if enabled - if (useDeeplink) { - config.deeplink = { - step: deeplinkStep, - ...(deeplinkCompanyId.trim() && { - companyId: deeplinkCompanyId.trim(), - }), - singleSwitch: singleSwitch, - }; - } - - Atomic.transact({ - config, - environment: getEnvironment(), - presentationStyleIOS, - setDebug: debugEnabled, - onInteraction: (interaction: any) => { - console.log('Interaction:', interaction); - }, - onDataRequest: (data: any) => { - console.log('Data Request:', data); - }, - onAuthStatusUpdate: (status: any) => { - console.log('Auth Status Update:', status); - }, - onTaskStatusUpdate: (status: any) => { - console.log('Task Status Update:', status); - }, - onFinish: (result: any) => { - setIsLoading(false); - console.log('Transact Finished:', result); - Alert.alert('Success', 'Transact flow completed successfully!'); - }, - onClose: () => { - setIsLoading(false); - console.log('Transact Closed'); - Alert.alert('Info', 'Transact flow was closed by user'); - }, - }); - }; - - return ( - - - Configuration - - - Public Token * - - - - - Environment - - {environmentOptions.map((option) => ( - setSelectedEnvironment(option.key)} - > - - - - {option.label} - - ))} - - {selectedEnvironment === 'custom' && ( - <> - - - - )} - - - - Theme - - - {darkMode ? 'Dark Mode' : 'Light Mode'} - - - - - - - Debug Mode - - Off - - On - - - - - - Product - - {products.map((product) => ( - setSelectedProduct(product.key)} - > - - {product.label} - - - ))} - - - - {Platform.OS === 'ios' && ( - - iOS Presentation Style - - {presentationStyleOptions.map((style) => ( - setPresentationStyleIOS(style.key)} - > - - {style.label} - - - ))} - - - )} - - - Deeplink Options (Optional) - - - Use Deeplink - - Off - - On - - - - {useDeeplink && ( - <> - - Step - - setDeeplinkStep(Step.LOGIN_COMPANY)} - > - - Login Company - - - setDeeplinkStep(Step.SEARCH_COMPANY)} - > - - Search Company - - - - - - - Company ID - - - - {selectedProduct === Product.SWITCH && ( - - Single Switch - - Off - - On - - - )} - - )} - - - - - - {isLoading ? 'Launching...' : 'Launch Transact'} - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f8fafc', - }, - section: { - margin: 16, - padding: 16, - backgroundColor: '#ffffff', - borderRadius: 12, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - color: '#1f2937', - marginBottom: 16, - }, - inputGroup: { - marginBottom: 16, - }, - label: { - fontSize: 14, - fontWeight: '500', - color: '#374151', - marginBottom: 8, - }, - input: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 8, - padding: 12, - fontSize: 16, - color: '#1f2937', - backgroundColor: '#ffffff', - }, - helperText: { - fontSize: 12, - color: '#6b7280', - marginTop: 4, - }, - customUrlInput: { - marginTop: 12, - }, - radioGroup: { - gap: 12, - }, - radioOption: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - radioButton: { - width: 20, - height: 20, - borderRadius: 10, - borderWidth: 2, - borderColor: '#d1d5db', - alignItems: 'center', - justifyContent: 'center', - }, - radioButtonInner: { - width: 10, - height: 10, - borderRadius: 5, - backgroundColor: 'transparent', - }, - radioButtonSelected: { - backgroundColor: '#6366f1', - }, - radioLabel: { - fontSize: 16, - color: '#374151', - }, - switchGroup: { - marginBottom: 16, - }, - switchContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - switchLabel: { - fontSize: 16, - color: '#6b7280', - }, - optionGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - optionButton: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - borderWidth: 1, - borderColor: '#d1d5db', - backgroundColor: '#ffffff', - }, - selectedOption: { - backgroundColor: '#6366f1', - borderColor: '#6366f1', - }, - optionText: { - fontSize: 14, - color: '#6b7280', - }, - selectedOptionText: { - color: '#ffffff', - }, - buttonContainer: { - padding: 16, - }, - launchButton: { - backgroundColor: '#10b981', - padding: 16, - borderRadius: 12, - alignItems: 'center', - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - disabledButton: { - backgroundColor: '#9ca3af', - }, - launchButtonText: { - fontSize: 18, - fontWeight: '600', - color: '#ffffff', - }, -}); - -export default TransactScreen; diff --git a/example/screens/UserLinkScreen.tsx b/example/screens/UserLinkScreen.tsx new file mode 100644 index 0000000..139124f --- /dev/null +++ b/example/screens/UserLinkScreen.tsx @@ -0,0 +1,222 @@ +import React, { useLayoutEffect, useState } from 'react'; +import { + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Product, Scope, Step } from '@atomicfi/transact-react-native'; +import { Card } from '../components/Card'; +import { RadioRow } from '../components/RadioRow'; +import { FullWidthButton } from '../components/FullWidthButton'; +import { SingleSelectGrid } from '../components/SelectGrid'; +import { CheckPublicTokenBanner } from '../components/CheckPublicTokenBanner'; +import { ConfigPreviewSheet } from '../components/ConfigPreviewSheet'; +import { useSettings } from '../state/SettingsContext'; +import { useUserLink } from '../state/UserLinkContext'; +import type { + UserLinkStartingScreen, + UserLinkTask, +} from '../state/UserLinkContext'; +import { useTransact } from '../config/buildConfig'; +import type { LaunchConfig } from '../config/buildConfig'; +import type { TabScreenProps } from '../navigation/types'; +import { colors, radius, spacing } from '../theme'; + +const STARTING_SCREENS: { value: UserLinkStartingScreen; label: string }[] = [ + { value: 'welcome', label: 'Welcome' }, + { value: 'search', label: 'Search' }, + { value: 'companyLogin', label: 'Company Login' }, +]; + +const TASK_PRODUCT: Record = { + deposit: Product.DEPOSIT, + verify: Product.VERIFY, +}; + +const UserLinkScreen: React.FC> = ({ + navigation, +}) => { + const { settings } = useSettings(); + const { state, update } = useUserLink(); + const { build, launch } = useTransact(); + const [previewVisible, setPreviewVisible] = useState(false); + + const hasToken = settings.publicToken.trim().length > 0; + + const buildLaunchConfig = (): LaunchConfig => { + const tasks: any[] = [{ operation: TASK_PRODUCT[state.task] }]; + + let deeplink: Record | undefined; + if (state.startingScreen === 'search') { + deeplink = { step: Step.SEARCH_COMPANY }; + } else if ( + state.startingScreen === 'companyLogin' && + state.companyLoginId + ) { + deeplink = { step: Step.LOGIN_COMPANY, companyId: state.companyLoginId }; + } + + const ruleId = state.searchRuleId.trim(); + + return { + scope: Scope.USERLINK, + tasks, + deeplink, + search: ruleId ? { ruleId } : undefined, + }; + }; + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + setPreviewVisible(true)}> + + + ), + }); + }, [navigation]); + + const onInitialize = () => { + if (!hasToken) { + Alert.alert('Missing token', 'Set a public token in Settings first.'); + return; + } + launch(buildLaunchConfig()); + }; + + return ( + + + navigation.navigate('Settings')} + /> + + + update({ task: 'deposit' })} + /> + update({ task: 'verify' })} + /> + + + + update({ startingScreen: value })} + /> + {state.startingScreen === 'companyLogin' ? ( + + navigation.navigate('CompanyLogin', { scope: 'userlink' }) + } + activeOpacity={0.7} + > + Company + + + {state.companyLoginName || 'Select company'} + + + + + ) : null} + + + + update({ searchRuleId: text })} + placeholder="Optional search rule ID" + placeholderTextColor={colors.disabled} + autoCapitalize="none" + autoCorrect={false} + /> + + + + + + + + + + setPreviewVisible(false)} + config={build(buildLaunchConfig())} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + paddingBottom: spacing.xl, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.sm, + padding: spacing.md, + fontSize: 15, + color: colors.text, + backgroundColor: colors.card, + }, + companyRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: spacing.lg, + }, + companyLabel: { + fontSize: 16, + color: colors.text, + }, + companyValue: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + companyName: { + fontSize: 15, + color: colors.textSecondary, + }, + footer: { + padding: spacing.lg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + backgroundColor: colors.card, + }, + spacer: { + height: spacing.xl, + }, +}); + +export default UserLinkScreen; diff --git a/example/state/ActionsContext.tsx b/example/state/ActionsContext.tsx new file mode 100644 index 0000000..69b9c53 --- /dev/null +++ b/example/state/ActionsContext.tsx @@ -0,0 +1,95 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { usePersistedState } from './usePersistedState'; +import { + decodeGroups, + fetchActionGroups, + groupsToJson, +} from '../constants/actionModels'; +import type { AccountGroup } from '../constants/actionModels'; + +interface ActionsContextValue { + groups: AccountGroup[]; + lastChanged: number | null; + editorText: string; + setEditorText: (text: string) => void; + syncEditorFromStored: () => void; + saveEditor: () => boolean; + clearAll: () => void; + fetchActions: (apiBase: string, publicToken: string) => Promise; +} + +const ActionsContext = createContext(null); + +export const ActionsProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [groups, setGroups, groupsHydrated] = usePersistedState( + 'transact_paylink_action_groups', + [] + ); + const [lastChanged, setLastChanged] = usePersistedState( + 'transact_paylink_actions_last_changed', + null + ); + const [editorText, setEditorText] = useState('[]'); + const seeded = useRef(false); + + // Seed the editor draft from stored groups once, after hydration. + useEffect(() => { + if (groupsHydrated && !seeded.current) { + seeded.current = true; + setEditorText(groupsToJson(groups)); + } + }, [groupsHydrated, groups]); + + const value = useMemo(() => { + const persist = (next: AccountGroup[]) => { + setGroups(next); + setLastChanged(Date.now()); + }; + + return { + groups, + lastChanged, + editorText, + setEditorText, + syncEditorFromStored: () => setEditorText(groupsToJson(groups)), + saveEditor: () => { + const decoded = decodeGroups(editorText); + if (!decoded) return false; + persist(decoded); + setEditorText(groupsToJson(decoded)); + return true; + }, + clearAll: () => { + setGroups([]); + setLastChanged(null); + setEditorText('[]'); + }, + fetchActions: async (apiBase: string, publicToken: string) => { + const fetched = await fetchActionGroups(apiBase, publicToken); + persist(fetched); + setEditorText(groupsToJson(fetched)); + }, + }; + }, [groups, lastChanged, editorText, setGroups, setLastChanged]); + + return ( + {children} + ); +}; + +export function useActions(): ActionsContextValue { + const ctx = useContext(ActionsContext); + if (!ctx) { + throw new Error('useActions must be used within an ActionsProvider'); + } + return ctx; +} diff --git a/example/state/DataRequestContext.tsx b/example/state/DataRequestContext.tsx new file mode 100644 index 0000000..dfcac57 --- /dev/null +++ b/example/state/DataRequestContext.tsx @@ -0,0 +1,154 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { usePersistedState } from './usePersistedState'; + +// Identity + card fields returned from onDataRequest when a deferred payment +// (deferredPaymentMethodStrategy: sdk) asks the host app for data. Shape mirrors +// the SDK's TransactDataResponse (card / identity). +export interface DataRequestState { + firstName: string; + lastName: string; + address: string; + address2: string; + postalCode: string; + city: string; + state: string; + phone: string; + email: string; + cardNumber: string; + cardExpiry: string; + cardCvv: string; + cardType: 'credit' | 'debit'; +} + +const DEFAULT_STATE: DataRequestState = { + firstName: '', + lastName: '', + address: '', + address2: '', + postalCode: '', + city: '', + state: '', + phone: '', + email: '', + cardNumber: '', + cardExpiry: '', + cardCvv: '', + cardType: 'credit', +}; + +export interface DataResponse { + card?: { + number: string; + expiry?: string; + cvv?: string; + cardType?: 'credit' | 'debit'; + }; + identity?: { + firstName?: string; + lastName?: string; + postalCode?: string; + address?: string; + address2?: string; + city?: string; + state?: string; + phone?: string; + email?: string; + }; +} + +interface DataRequestContextValue { + state: DataRequestState; + update: (patch: Partial) => void; + clearAll: () => void; + // Builds the onDataRequest response, or null when nothing is filled in. + makeResponse: () => DataResponse | null; +} + +const DataRequestContext = createContext(null); + +function trimmedOrUndefined(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export const DataRequestProviderState: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [state, setState] = usePersistedState( + 'demo_data_request', + DEFAULT_STATE + ); + + const value = useMemo(() => { + const update = (patch: Partial) => + setState((prev) => ({ ...prev, ...patch })); + + const makeResponse = (): DataResponse | null => { + const identityEntries = { + firstName: trimmedOrUndefined(state.firstName), + lastName: trimmedOrUndefined(state.lastName), + postalCode: trimmedOrUndefined(state.postalCode), + address: trimmedOrUndefined(state.address), + address2: trimmedOrUndefined(state.address2), + city: trimmedOrUndefined(state.city), + state: trimmedOrUndefined(state.state), + phone: trimmedOrUndefined(state.phone), + email: trimmedOrUndefined(state.email), + }; + // Matches the Android demo: address2 alone doesn't count as identity data. + const hasIdentity = [ + identityEntries.firstName, + identityEntries.lastName, + identityEntries.postalCode, + identityEntries.address, + identityEntries.city, + identityEntries.state, + identityEntries.phone, + identityEntries.email, + ].some((v) => v !== undefined); + + const number = trimmedOrUndefined(state.cardNumber); + const rawExpiry = state.cardExpiry.replace(/\D/g, ''); + const card = number + ? { + number, + expiry: + rawExpiry.length === 4 + ? `${rawExpiry.slice(0, 2)}/${rawExpiry.slice(2)}` + : rawExpiry || undefined, + cvv: trimmedOrUndefined(state.cardCvv), + cardType: state.cardType, + } + : undefined; + + if (!hasIdentity && !card) return null; + return { + ...(card ? { card } : {}), + ...(hasIdentity ? { identity: identityEntries } : {}), + }; + }; + + return { + state, + update, + clearAll: () => setState(DEFAULT_STATE), + makeResponse, + }; + }, [state, setState]); + + return ( + + {children} + + ); +}; + +export function useDataRequest(): DataRequestContextValue { + const ctx = useContext(DataRequestContext); + if (!ctx) { + throw new Error( + 'useDataRequest must be used within a DataRequestProviderState' + ); + } + return ctx; +} diff --git a/example/state/EventLogContext.tsx b/example/state/EventLogContext.tsx new file mode 100644 index 0000000..fdb0d51 --- /dev/null +++ b/example/state/EventLogContext.tsx @@ -0,0 +1,155 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import { usePersistedState } from './usePersistedState'; +import type { EventType } from '../constants/eventTypes'; + +export interface LoggedEvent { + id: string; + time: number; + type: EventType; + body?: string; + secondary?: string; + raw: unknown; +} + +interface EventPayload { + body?: string; + secondary?: string; + raw?: unknown; +} + +interface EventLogContextValue { + events: LoggedEvent[]; + unreadCount: number; + logEvent: (type: EventType, payload?: EventPayload) => void; + clear: () => void; + markRead: () => void; +} + +const EventLogContext = createContext(null); + +const STORAGE_KEY = 'demo_event_log'; +const MAX_EVENTS = 200; + +export const EventLogProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [events, setEvents] = usePersistedState(STORAGE_KEY, []); + const [unreadCount, setUnreadCount] = useState(0); + const counter = useRef(0); + + const logEvent = useCallback( + (type: EventType, payload: EventPayload = {}) => { + counter.current += 1; + const event: LoggedEvent = { + id: `${Date.now()}-${counter.current}`, + time: Date.now(), + type, + body: payload.body, + secondary: payload.secondary, + raw: payload.raw ?? null, + }; + setEvents((prev) => { + const next = [...prev, event]; + return next.length > MAX_EVENTS + ? next.slice(next.length - MAX_EVENTS) + : next; + }); + setUnreadCount((c) => c + 1); + }, + [setEvents] + ); + + const clear = useCallback(() => { + setEvents([]); + setUnreadCount(0); + }, [setEvents]); + + const markRead = useCallback(() => setUnreadCount(0), []); + + const value = useMemo( + () => ({ events, unreadCount, logEvent, clear, markRead }), + [events, unreadCount, logEvent, clear, markRead] + ); + + return ( + + {children} + + ); +}; + +export function useEventLog(): EventLogContextValue { + const ctx = useContext(EventLogContext); + if (!ctx) { + throw new Error('useEventLog must be used within an EventLogProvider'); + } + return ctx; +} + +function asString(value: unknown): string | undefined { + if (value == null) return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') + return String(value); + return undefined; +} + +// Callbacks passed to Atomic.transact that funnel every SDK event into the log. +// Payload shapes differ slightly between iOS and Android, so every field is read +// defensively. +export function useTransactCallbacks() { + const { logEvent } = useEventLog(); + + return useMemo( + () => ({ + onLaunch: () => + logEvent('launch', { body: 'Transact launched', raw: {} }), + onInteraction: (interaction: any) => + logEvent('interaction', { + body: asString(interaction?.name) ?? 'Interaction', + secondary: asString(interaction?.identifier), + raw: interaction, + }), + onDataRequest: (request: any) => + logEvent('dataRequest', { + body: asString(request?.identifier) ?? 'Data requested', + secondary: request?.fields + ? `fields: ${Array.isArray(request.fields) ? request.fields.length : ''}` + : undefined, + raw: request, + }), + onAuthStatusUpdate: (update: any) => + logEvent('authStatusUpdate', { + body: asString(update?.status) ?? 'Auth status', + secondary: asString(update?.company?.name), + raw: update, + }), + onTaskStatusUpdate: (update: any) => { + const status = asString(update?.status) ?? ''; + const product = asString(update?.product) ?? ''; + const parts = [asString(update?.taskId) && `task: ${update.taskId}`]; + if (update?.actionType) + parts.push(`action: ${asString(update.actionType)}`); + if (update?.failReason) + parts.push(`reason: ${asString(update.failReason)}`); + logEvent('taskStatusUpdate', { + body: [status, product].filter(Boolean).join(' | ') || 'Task status', + secondary: parts.filter(Boolean).join(' | ') || undefined, + raw: update, + }); + }, + onFinish: (response: any) => + logEvent('finish', { body: 'Transact finished', raw: response ?? {} }), + onClose: (response: any) => + logEvent('close', { body: 'Transact closed', raw: response ?? {} }), + }), + [logEvent] + ); +} diff --git a/example/state/PayLinkContext.tsx b/example/state/PayLinkContext.tsx new file mode 100644 index 0000000..ad06a18 --- /dev/null +++ b/example/state/PayLinkContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { usePersistedState } from './usePersistedState'; + +export type PayLinkStartingScreen = 'welcome' | 'search' | 'companyLogin'; + +// Pay Link tasks are mutually exclusive — exactly one runs at a time. +export type PayLinkTask = 'switch' | 'present' | 'manage'; + +export interface PayLinkState { + task: PayLinkTask; + apps: string[]; + startingScreen: PayLinkStartingScreen; + companyLoginId: string; + companyLoginName: string; + useSDKPaymentResponse: boolean; +} + +const DEFAULT_STATE: PayLinkState = { + task: 'switch', + apps: [], + startingScreen: 'welcome', + companyLoginId: '', + companyLoginName: '', + useSDKPaymentResponse: false, +}; + +interface PayLinkContextValue { + state: PayLinkState; + update: (patch: Partial) => void; + setCompany: (id: string, name: string) => void; +} + +const PayLinkContext = createContext(null); + +export const PayLinkProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [state, setState] = usePersistedState( + 'demo_paylink_v2', + DEFAULT_STATE + ); + + const value = useMemo(() => { + const update = (patch: Partial) => + setState((prev) => ({ ...prev, ...patch })); + return { + state, + update, + setCompany: (id, name) => + update({ companyLoginId: id, companyLoginName: name }), + }; + }, [state, setState]); + + return ( + {children} + ); +}; + +export function usePayLink(): PayLinkContextValue { + const ctx = useContext(PayLinkContext); + if (!ctx) { + throw new Error('usePayLink must be used within a PayLinkProvider'); + } + return ctx; +} diff --git a/example/state/SettingsContext.tsx b/example/state/SettingsContext.tsx new file mode 100644 index 0000000..908d099 --- /dev/null +++ b/example/state/SettingsContext.tsx @@ -0,0 +1,139 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { + Environment, + PresentationStyles, +} from '@atomicfi/transact-react-native'; +import type { + PresentationStyleIOS, + TransactEnvironment, +} from '@atomicfi/transact-react-native'; +import { usePersistedState } from './usePersistedState'; + +export type EnvironmentOption = 'production' | 'sandbox' | 'custom'; +export type DarkModeOption = 'system' | 'dark' | 'light'; +export type LanguageOption = 'system' | 'en' | 'es' | 'fr'; + +export interface SettingsState { + publicToken: string; + publicTokenLastChange: number | null; + environment: EnvironmentOption; + customTransactPath: string; + customApiPath: string; + brandColor: string; + overlayColor: string; + darkMode: DarkModeOption; + language: LanguageOption; + debug: boolean; + presentFullscreen: boolean; +} + +const DEFAULT_SETTINGS: SettingsState = { + publicToken: '', + publicTokenLastChange: null, + environment: 'sandbox', + customTransactPath: '', + customApiPath: '', + brandColor: '#4a5cff', + overlayColor: '#1a1a2e', + darkMode: 'system', + language: 'system', + debug: false, + presentFullscreen: false, +}; + +export interface DemoTheme { + brandColor?: string; + overlayColor?: string; + dark?: boolean; +} + +interface SettingsContextValue { + settings: SettingsState; + update: (patch: Partial) => void; + setPublicToken: (token: string) => void; + transactEnvironment: TransactEnvironment; + apiBase: string; + theme: DemoTheme; + language: string | undefined; + presentationStyleIOS: PresentationStyleIOS; +} + +const SettingsContext = createContext(null); + +const STORAGE_KEY = 'demo_settings'; + +export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [settings, setSettings] = usePersistedState( + STORAGE_KEY, + DEFAULT_SETTINGS + ); + + const value = useMemo(() => { + const update = (patch: Partial) => + setSettings((prev) => ({ ...prev, ...patch })); + + const setPublicToken = (token: string) => + update({ publicToken: token, publicTokenLastChange: Date.now() }); + + const transactEnvironment: TransactEnvironment = + settings.environment === 'production' + ? Environment.production + : settings.environment === 'sandbox' + ? Environment.sandbox + : Environment.custom( + settings.customTransactPath.trim(), + settings.customApiPath.trim() + ); + + const apiBase = + settings.environment === 'production' + ? 'https://api.atomicfi.com' + : settings.environment === 'sandbox' + ? 'https://sandbox-api.atomicfi.com' + : settings.customApiPath.trim(); + + const theme: DemoTheme = { + brandColor: settings.brandColor, + overlayColor: settings.overlayColor, + // Leave `dark` undefined for "system" so the SDK follows the OS scheme. + dark: + settings.darkMode === 'system' + ? undefined + : settings.darkMode === 'dark', + }; + + const language = + settings.language === 'system' ? undefined : settings.language; + + const presentationStyleIOS = settings.presentFullscreen + ? PresentationStyles.fullScreen + : PresentationStyles.formSheet; + + return { + settings, + update, + setPublicToken, + transactEnvironment, + apiBase, + theme, + language, + presentationStyleIOS, + }; + }, [settings, setSettings]); + + return ( + + {children} + + ); +}; + +export function useSettings(): SettingsContextValue { + const ctx = useContext(SettingsContext); + if (!ctx) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return ctx; +} diff --git a/example/state/UserLinkContext.tsx b/example/state/UserLinkContext.tsx new file mode 100644 index 0000000..7adb867 --- /dev/null +++ b/example/state/UserLinkContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { usePersistedState } from './usePersistedState'; + +export type UserLinkStartingScreen = 'welcome' | 'search' | 'companyLogin'; + +// User Link tasks are mutually exclusive — exactly one runs at a time. +export type UserLinkTask = 'deposit' | 'verify'; + +export interface UserLinkState { + task: UserLinkTask; + startingScreen: UserLinkStartingScreen; + companyLoginId: string; + companyLoginName: string; + searchRuleId: string; +} + +const DEFAULT_STATE: UserLinkState = { + task: 'deposit', + startingScreen: 'welcome', + companyLoginId: '', + companyLoginName: '', + searchRuleId: '', +}; + +interface UserLinkContextValue { + state: UserLinkState; + update: (patch: Partial) => void; + setCompany: (id: string, name: string) => void; +} + +const UserLinkContext = createContext(null); + +export const UserLinkProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [state, setState] = usePersistedState( + 'demo_userlink_v2', + DEFAULT_STATE + ); + + const value = useMemo(() => { + const update = (patch: Partial) => + setState((prev) => ({ ...prev, ...patch })); + return { + state, + update, + setCompany: (id, name) => + update({ companyLoginId: id, companyLoginName: name }), + }; + }, [state, setState]); + + return ( + + {children} + + ); +}; + +export function useUserLink(): UserLinkContextValue { + const ctx = useContext(UserLinkContext); + if (!ctx) { + throw new Error('useUserLink must be used within a UserLinkProvider'); + } + return ctx; +} diff --git a/example/state/usePersistedState.ts b/example/state/usePersistedState.ts new file mode 100644 index 0000000..f8aa070 --- /dev/null +++ b/example/state/usePersistedState.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// useState backed by AsyncStorage. Hydrates once on mount and writes on every +// change after hydration. `hydrated` lets callers avoid persisting the initial +// value before the stored value has loaded. +export function usePersistedState( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void, boolean] { + const [state, setState] = useState(initialValue); + const [hydrated, setHydrated] = useState(false); + const hydratedRef = useRef(false); + + useEffect(() => { + let active = true; + (async () => { + try { + const raw = await AsyncStorage.getItem(key); + if (active && raw != null) { + setState(JSON.parse(raw) as T); + } + } catch (err) { + console.warn(`Failed to hydrate "${key}"`, err); + } finally { + if (active) { + hydratedRef.current = true; + setHydrated(true); + } + } + })(); + return () => { + active = false; + }; + }, [key]); + + useEffect(() => { + if (!hydratedRef.current) return; + AsyncStorage.setItem(key, JSON.stringify(state)).catch((err) => + console.warn(`Failed to persist "${key}"`, err) + ); + }, [key, state]); + + const update = useCallback((value: T | ((prev: T) => T)) => { + setState(value); + }, []); + + return [state, update, hydrated]; +} diff --git a/example/theme.ts b/example/theme.ts new file mode 100644 index 0000000..396544b --- /dev/null +++ b/example/theme.ts @@ -0,0 +1,32 @@ +// Shared visual tokens for the demo app UI (not the Transact SDK theme). + +export const colors = { + background: '#f2f2f7', + card: '#ffffff', + cardAlt: '#f9fafb', + border: '#e5e7eb', + separator: '#e5e5ea', + text: '#111827', + textSecondary: '#6b7280', + accent: '#3b82f6', + accentMuted: '#eff6ff', + danger: '#ef4444', + warning: '#f59e0b', + success: '#10b981', + disabled: '#9ca3af', +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, +}; + +export const radius = { + sm: 8, + md: 12, + lg: 16, + pill: 999, +}; diff --git a/yarn.lock b/yarn.lock index 9c496c2..2b354e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3267,6 +3267,17 @@ __metadata: languageName: node linkType: hard +"@expo/vector-icons@npm:^15.0.2": + version: 15.1.1 + resolution: "@expo/vector-icons@npm:15.1.1" + peerDependencies: + expo-font: ">=14.0.4" + react: "*" + react-native: "*" + checksum: 10/204fafd5141c81bd55dd33f6c00cdc48ec1d37b6460be6fa3f851ccb235e1fad1097f22d034470daa49a5b839d058bbcadda1efd349c670c2fdce2ae65fb9bba + languageName: node + linkType: hard + "@expo/ws-tunnel@npm:^2.0.0": version: 2.0.0 resolution: "@expo/ws-tunnel@npm:2.0.0" @@ -3896,16 +3907,6 @@ __metadata: languageName: node linkType: hard -"@react-native-picker/picker@npm:2.11.4": - version: 2.11.4 - resolution: "@react-native-picker/picker@npm:2.11.4" - peerDependencies: - react: "*" - react-native: "*" - checksum: 10/cb8f0b7bf52044a5bbb89db5a3f1be7bd3cdf3a04845f0475c492ec3f24e63cd915b993b23012b674d3503499efd8ff4eebe18561f4e38ba67b4c45742de4c74 - languageName: node - linkType: hard - "@react-native/assets-registry@npm:0.86.0": version: 0.86.0 resolution: "@react-native/assets-registry@npm:0.86.0" @@ -4082,6 +4083,23 @@ __metadata: languageName: node linkType: hard +"@react-navigation/bottom-tabs@npm:^7.4.0": + version: 7.18.8 + resolution: "@react-navigation/bottom-tabs@npm:7.18.8" + dependencies: + "@react-navigation/elements": "npm:^2.9.30" + color: "npm:^4.2.3" + sf-symbols-typescript: "npm:^2.1.0" + peerDependencies: + "@react-navigation/native": ^7.3.8 + react: ">= 18.2.0" + react-native: "*" + react-native-safe-area-context: ">= 4.0.0" + react-native-screens: ">= 4.0.0" + checksum: 10/559f6deac94a8b5f710ad40570074e22b17a342c9a8b77a2146be58ca3a8d57601ba70a3f5f4157ed2ee60750f8336253da26fb807e8bad627c4a6641de32e8e + languageName: node + linkType: hard + "@react-navigation/core@npm:^7.21.5": version: 7.21.5 resolution: "@react-navigation/core@npm:7.21.5" @@ -7081,8 +7099,9 @@ __metadata: dependencies: "@atomicfi/transact-react-native": "link:.." "@babel/core": "npm:^8.0.1" + "@expo/vector-icons": "npm:^15.0.2" "@react-native-async-storage/async-storage": "npm:3.1.1" - "@react-native-picker/picker": "npm:2.11.4" + "@react-navigation/bottom-tabs": "npm:^7.4.0" "@react-navigation/native": "npm:^7.2.2" "@react-navigation/native-stack": "npm:^7.14.12" "@types/react": "npm:~19.2.14" @@ -7092,7 +7111,9 @@ __metadata: eslint-plugin-prettier: "npm:^5.5.5" expo: "npm:~57.0.4" expo-build-properties: "npm:~57.0.3" + expo-clipboard: "npm:~57.0.0" expo-dev-client: "npm:~57.0.5" + expo-font: "npm:~57.0.0" expo-status-bar: "npm:~57.0.0" expo-updates: "npm:~57.0.6" prettier: "npm:^3.8.3" @@ -7183,6 +7204,17 @@ __metadata: languageName: node linkType: hard +"expo-clipboard@npm:~57.0.0": + version: 57.0.0 + resolution: "expo-clipboard@npm:57.0.0" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + checksum: 10/fb3082f813236e7e8e0ecc3821c9cddd1233900674fb9fa6b5a5190ea8842d6105f6d3b75807360f777b485ea15676083980b0e34487c6931f6abdf7101ccc63 + languageName: node + linkType: hard + "expo-constants@npm:~57.0.3": version: 57.0.3 resolution: "expo-constants@npm:57.0.3"