Skip to content

Commit 3d72c17

Browse files
committed
feat: add PrimerPaymentMethodList and checkout flow (ACC-6492)
1 parent f69fdb6 commit 3d72c17

16 files changed

Lines changed: 630 additions & 9 deletions

example/ios/Podfile.lock

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,7 +1784,7 @@ PODS:
17841784
- React-RCTFBReactNativeSpec
17851785
- ReactCommon/turbomodule/core
17861786
- SocketRocket
1787-
- react-native-safe-area-context (5.6.1):
1787+
- react-native-safe-area-context (5.6.2):
17881788
- boost
17891789
- DoubleConversion
17901790
- fast_float
@@ -1802,8 +1802,8 @@ PODS:
18021802
- React-graphics
18031803
- React-ImageManager
18041804
- React-jsi
1805-
- react-native-safe-area-context/common (= 5.6.1)
1806-
- react-native-safe-area-context/fabric (= 5.6.1)
1805+
- react-native-safe-area-context/common (= 5.6.2)
1806+
- react-native-safe-area-context/fabric (= 5.6.2)
18071807
- React-NativeModulesApple
18081808
- React-RCTFabric
18091809
- React-renderercss
@@ -1814,7 +1814,7 @@ PODS:
18141814
- ReactCommon/turbomodule/core
18151815
- SocketRocket
18161816
- Yoga
1817-
- react-native-safe-area-context/common (5.6.1):
1817+
- react-native-safe-area-context/common (5.6.2):
18181818
- boost
18191819
- DoubleConversion
18201820
- fast_float
@@ -1842,7 +1842,7 @@ PODS:
18421842
- ReactCommon/turbomodule/core
18431843
- SocketRocket
18441844
- Yoga
1845-
- react-native-safe-area-context/fabric (5.6.1):
1845+
- react-native-safe-area-context/fabric (5.6.2):
18461846
- boost
18471847
- DoubleConversion
18481848
- fast_float
@@ -2733,7 +2733,7 @@ SPEC CHECKSUMS:
27332733
React-logger: 7aef4d74123e5e3d267e5af1fbf5135b5a0d8381
27342734
React-Mapbuffer: 91e0eab42a6ae7f3e34091a126d70fc53bd3823e
27352735
React-microtasksnativemodule: 1ead4fe154df3b1ba34b5a9e35ef3c4bdfa72ccb
2736-
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
2736+
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
27372737
react-native-segmented-control: bf6e0032726727498e18dd437ae88afcdbc18e99
27382738
React-NativeModulesApple: eff2eba56030eb0d107b1642b8f853bc36a833ac
27392739
React-oscompat: b12c633e9c00f1f99467b1e0e0b8038895dae436

example/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import HeadlessCheckoutKlarnaScreen from './screens/HeadlessCheckoutKlarnaScreen
1515
import HeadlessCheckoutWithRedirect from './screens/HeadlessCheckoutWithRedirect';
1616
import HeadlessCheckoutStripeAchScreen from './screens/HeadlessCheckoutStripeAchScreen';
1717
import LocalizationDebugScreen from './screens/LocalizationDebugScreen';
18+
import {CheckoutComponentsListScreen} from './screens/CheckoutComponentsListScreen';
1819
import {LogBox} from 'react-native';
1920
import {
2021
SafeAreaProvider,
@@ -51,6 +52,11 @@ const App = () => {
5152
name="RawRetailOutlet"
5253
component={RawRetailOutletScreen}
5354
/>
55+
<Stack.Screen
56+
name="CheckoutComponentsList"
57+
component={CheckoutComponentsListScreen}
58+
options={{title: 'Checkout Components'}}
59+
/>
5460
<Stack.Screen name="Klarna" component={HeadlessCheckoutKlarnaScreen} />
5561
<Stack.Screen
5662
name="HeadlessCheckoutWithRedirect"
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import React, {useState} from 'react';
2+
import {
3+
ActivityIndicator,
4+
Alert,
5+
ScrollView,
6+
StyleSheet,
7+
Text,
8+
TouchableOpacity,
9+
View,
10+
} from 'react-native';
11+
import {
12+
PrimerCheckoutProvider,
13+
PrimerCheckoutSheet,
14+
} from '@primer-io/react-native';
15+
import type {PrimerSettings} from '@primer-io/react-native';
16+
import {createClientSession} from '../network/api';
17+
import {appPaymentParameters} from '../models/IClientSessionRequestBody';
18+
import {customAppearanceMode} from './SettingsScreen';
19+
import {getPaymentHandlingStringVal} from '../network/Environment';
20+
import {STRIPE_ACH_PUBLISHABLE_KEY} from '../Keys';
21+
22+
interface Example {
23+
id: string;
24+
title: string;
25+
description: string;
26+
}
27+
28+
const EXAMPLES: Example[] = [
29+
{
30+
id: 'default',
31+
title: 'Default',
32+
description: 'Basic checkout flow with payment method list',
33+
},
34+
];
35+
36+
export function CheckoutComponentsListScreen() {
37+
const [loadingId, setLoadingId] = useState<string | null>(null);
38+
const [checkoutToken, setCheckoutToken] = useState<string | null>(null);
39+
const [sheetVisible, setSheetVisible] = useState(false);
40+
41+
const handleOpen = async (example: Example) => {
42+
setLoadingId(example.id);
43+
try {
44+
const response = await createClientSession();
45+
setCheckoutToken(response.clientToken);
46+
setSheetVisible(true);
47+
} catch (error) {
48+
Alert.alert('Error', String(error));
49+
} finally {
50+
setLoadingId(null);
51+
}
52+
};
53+
54+
let settings: PrimerSettings = {
55+
paymentHandling: getPaymentHandlingStringVal(
56+
appPaymentParameters.paymentHandling,
57+
),
58+
paymentMethodOptions: {
59+
iOS: {
60+
urlScheme: 'merchant://primer.io',
61+
},
62+
stripeOptions: {
63+
publishableKey: STRIPE_ACH_PUBLISHABLE_KEY,
64+
mandateData: {
65+
merchantName: 'My Merchant Name',
66+
},
67+
},
68+
googlePayOptions: {
69+
isCaptureBillingAddressEnabled: true,
70+
isExistingPaymentMethodRequired: false,
71+
shippingAddressParameters: {isPhoneNumberRequired: true},
72+
requireShippingMethod: false,
73+
emailAddressRequired: true,
74+
},
75+
},
76+
uiOptions: {
77+
appearanceMode: customAppearanceMode,
78+
},
79+
debugOptions: {
80+
is3DSSanityCheckEnabled: false,
81+
},
82+
clientSessionCachingEnabled: true,
83+
apiVersion: '2.4',
84+
};
85+
86+
if (appPaymentParameters.merchantName) {
87+
settings.paymentMethodOptions!.applePayOptions = {
88+
merchantIdentifier: 'merchant.checkout.team',
89+
merchantName: appPaymentParameters.merchantName,
90+
};
91+
}
92+
93+
return (
94+
<>
95+
<ScrollView style={componentStyles.container}>
96+
{EXAMPLES.map(example => {
97+
const isLoading = loadingId === example.id;
98+
return (
99+
<TouchableOpacity
100+
key={example.id}
101+
style={componentStyles.item}
102+
onPress={() => handleOpen(example)}
103+
disabled={loadingId !== null}>
104+
<View style={componentStyles.itemContent}>
105+
<Text style={componentStyles.itemTitle}>{example.title}</Text>
106+
<Text style={componentStyles.itemDescription}>
107+
{example.description}
108+
</Text>
109+
</View>
110+
{isLoading && <ActivityIndicator />}
111+
</TouchableOpacity>
112+
);
113+
})}
114+
</ScrollView>
115+
{checkoutToken !== null && (
116+
<PrimerCheckoutProvider
117+
clientToken={checkoutToken}
118+
settings={settings}
119+
onCheckoutComplete={checkoutData => {
120+
console.log('Checkout complete:', checkoutData);
121+
setSheetVisible(false);
122+
}}
123+
onError={error => {
124+
console.error('Checkout error:', error);
125+
Alert.alert('Checkout Error', error.errorId ?? 'Unknown error');
126+
}}>
127+
<PrimerCheckoutSheet
128+
visible={sheetVisible}
129+
onRequestDismiss={() => setSheetVisible(false)}
130+
onDismiss={() => setCheckoutToken(null)}
131+
/>
132+
</PrimerCheckoutProvider>
133+
)}
134+
</>
135+
);
136+
}
137+
138+
const componentStyles = StyleSheet.create({
139+
container: {
140+
backgroundColor: '#f5f5f5',
141+
flex: 1,
142+
},
143+
item: {
144+
alignItems: 'center',
145+
backgroundColor: 'white',
146+
borderBottomColor: '#e0e0e0',
147+
borderBottomWidth: 1,
148+
flexDirection: 'row',
149+
padding: 16,
150+
},
151+
itemContent: {
152+
flex: 1,
153+
},
154+
itemDescription: {
155+
color: '#666',
156+
fontSize: 14,
157+
marginTop: 4,
158+
},
159+
itemTitle: {
160+
color: '#212121',
161+
fontSize: 16,
162+
fontWeight: '600',
163+
},
164+
});

example/src/screens/SettingsScreen.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,22 @@ const SettingsScreen = ({navigation}) => {
13111311
style={{
13121312
marginHorizontal: 24,
13131313
}}>
1314+
<TouchableOpacity
1315+
style={{
1316+
...styles.button,
1317+
marginTop: 12,
1318+
marginBottom: 8,
1319+
backgroundColor: '#2f98ff',
1320+
}}
1321+
onPress={() => {
1322+
updateAppPaymentParameters();
1323+
navigation.navigate('CheckoutComponentsList');
1324+
}}>
1325+
<Text style={{...styles.buttonText, color: 'white'}}>
1326+
Checkout Components
1327+
</Text>
1328+
</TouchableOpacity>
1329+
13141330
{renderRequiredSettings()}
13151331
{renderOptionalSettings()}
13161332

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { CheckoutSheet } from './internal/checkout-sheet/CheckoutSheet';
2+
import { CheckoutFlow } from './internal/checkout-flow/CheckoutFlow';
3+
4+
export interface PrimerCheckoutSheetProps {
5+
/** Whether the sheet is visible. Drives show/hide animation. */
6+
visible: boolean;
7+
/** Called when the sheet finishes its dismiss animation (fully hidden). */
8+
onDismiss?: () => void;
9+
/** Called when the user requests dismissal (backdrop tap, Android back). */
10+
onRequestDismiss?: () => void;
11+
/** Whether tapping the backdrop dismisses the sheet. Default: true. */
12+
dismissOnBackdropPress?: boolean;
13+
}
14+
15+
/**
16+
* Pre-built checkout sheet that renders the full checkout flow:
17+
* loading screen → method selection → payment forms.
18+
*
19+
* Must be rendered inside a PrimerCheckoutProvider.
20+
*/
21+
export function PrimerCheckoutSheet({
22+
visible,
23+
onDismiss,
24+
onRequestDismiss,
25+
dismissOnBackdropPress,
26+
}: PrimerCheckoutSheetProps) {
27+
return (
28+
<CheckoutSheet
29+
visible={visible}
30+
onDismiss={onDismiss}
31+
onRequestDismiss={onRequestDismiss}
32+
dismissOnBackdropPress={dismissOnBackdropPress}
33+
>
34+
<CheckoutFlow onCancel={onRequestDismiss} />
35+
</CheckoutSheet>
36+
);
37+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useMemo, useCallback } from 'react';
2+
import { ActivityIndicator, FlatList, View, StyleSheet } from 'react-native';
3+
import { useTheme } from './internal/theme';
4+
import type { PrimerTokens } from './internal/theme';
5+
import { usePaymentMethods } from './hooks/usePaymentMethods';
6+
import { PaymentMethodButton } from './internal/ui/PaymentMethodButton';
7+
import type { PaymentMethodItem } from './types/PaymentMethodTypes';
8+
import type { PrimerPaymentMethodListProps } from './types/PrimerPaymentMethodListTypes';
9+
10+
export function PrimerPaymentMethodList({
11+
data,
12+
include,
13+
exclude,
14+
onSelect,
15+
onLoad,
16+
style,
17+
}: PrimerPaymentMethodListProps) {
18+
const tokens = useTheme();
19+
const styles = useMemo(() => createStyles(tokens), [tokens]);
20+
21+
const hook = usePaymentMethods(
22+
data != null
23+
? {}
24+
: {
25+
include,
26+
exclude,
27+
onLoad,
28+
}
29+
);
30+
const paymentMethods = data ?? hook.paymentMethods;
31+
const isLoading = data != null ? false : hook.isLoading;
32+
33+
const handlePress = useCallback(
34+
(item: PaymentMethodItem) => {
35+
// TODO: Fire PAYMENT_METHOD_SELECTION analytics event when analytics bridge is available
36+
onSelect(item);
37+
},
38+
[onSelect]
39+
);
40+
41+
const Separator = useCallback(() => <View style={styles.separator} />, [styles.separator]);
42+
43+
if (isLoading) {
44+
return (
45+
<View style={[styles.centered, style]}>
46+
<ActivityIndicator size="small" color={tokens.colors.primary} />
47+
</View>
48+
);
49+
}
50+
51+
if (paymentMethods.length === 0) {
52+
return <View style={style} />;
53+
}
54+
55+
return (
56+
<View style={style}>
57+
<FlatList
58+
data={paymentMethods}
59+
keyExtractor={(item) => item.type}
60+
renderItem={({ item }) => <PaymentMethodButton item={item} onPress={() => handlePress(item)} />}
61+
ItemSeparatorComponent={Separator}
62+
scrollEnabled={false}
63+
/>
64+
</View>
65+
);
66+
}
67+
68+
function createStyles(tokens: PrimerTokens) {
69+
const { spacing } = tokens;
70+
71+
/* eslint-disable react-native/no-unused-styles */
72+
return StyleSheet.create({
73+
centered: {
74+
alignItems: 'center',
75+
justifyContent: 'center',
76+
paddingVertical: spacing.large,
77+
},
78+
separator: {
79+
height: spacing.small,
80+
},
81+
});
82+
/* eslint-enable react-native/no-unused-styles */
83+
}

src/Components/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ export type {
1010
UsePaymentMethodsOptions,
1111
UsePaymentMethodsReturn,
1212
} from './types/PaymentMethodTypes';
13+
export { PrimerPaymentMethodList } from './PrimerPaymentMethodList';
14+
export type { PrimerPaymentMethodListProps } from './types/PrimerPaymentMethodListTypes';
15+
export { PrimerCheckoutSheet } from './PrimerCheckoutSheet';
16+
export type { PrimerCheckoutSheetProps } from './PrimerCheckoutSheet';

0 commit comments

Comments
 (0)