Skip to content

Commit 1864c32

Browse files
committed
feat: add prebuilt PrimerCardForm and payment outcome flow (ACC-6495)
1 parent f199509 commit 1864c32

23 files changed

Lines changed: 922 additions & 352 deletions

example/src/screens/CheckoutComponentsListScreen.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,12 @@ export function CheckoutComponentsListScreen() {
130130
clientToken={checkoutToken}
131131
settings={settings}
132132
onCheckoutComplete={checkoutData => {
133-
console.log('Checkout complete:', checkoutData);
134-
setSheetVisible(false);
133+
console.log('Checkout complete:', JSON.stringify(checkoutData));
134+
// Don't close the sheet here — the flow renders the Success screen
135+
// and auto-dismisses after 5s. Closing manually would skip it.
135136
}}
136137
onError={error => {
137-
console.error('Checkout error:', error);
138-
Alert.alert('Checkout Error', error.errorId ?? 'Unknown error');
138+
console.error('Checkout error:', JSON.stringify(error));
139139
}}>
140140
<PrimerCheckoutSheet
141141
visible={sheetVisible}

example/src/screens/CustomCardFormScreen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ function ThemePicker({
105105
}
106106

107107
function CardForm() {
108-
const cardForm = useCardForm({collectCardholderName: true});
108+
const cardForm = useCardForm();
109109
const [themeIndex, setThemeIndex] = useState(0);
110110
const [disabledPreview, setDisabledPreview] = useState(false);
111111
const current = THEMES[themeIndex]!;

src/Components/PrimerCardForm.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { useEffect, useMemo, useRef } from 'react';
2+
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3+
import type { TextStyle } from 'react-native';
4+
import { useTheme } from './internal/theme';
5+
import type { PrimerTokens } from './internal/theme';
6+
import { useLocalization } from './internal/localization';
7+
import { useCardForm } from './hooks/useCardForm';
8+
import { CardNumberInput } from './inputs/CardNumberInput';
9+
import { ExpiryDateInput } from './inputs/ExpiryDateInput';
10+
import { CVVInput } from './inputs/CVVInput';
11+
import { CardholderNameInput } from './inputs/CardholderNameInput';
12+
import type { PrimerTextInputRef } from './types/CardInputTypes';
13+
import type { PrimerCardFormProps } from './types/PrimerCardFormTypes';
14+
15+
export function PrimerCardForm({
16+
onSubmitStart,
17+
autoFocus = false,
18+
style,
19+
testID = 'primer-card-form',
20+
}: PrimerCardFormProps) {
21+
const form = useCardForm();
22+
const { t } = useLocalization();
23+
const tokens = useTheme();
24+
const styles = useMemo(() => createStyles(tokens), [tokens]);
25+
26+
const cardRef = useRef<PrimerTextInputRef>(null);
27+
const expiryRef = useRef<PrimerTextInputRef>(null);
28+
const cvvRef = useRef<PrimerTextInputRef>(null);
29+
const nameRef = useRef<PrimerTextInputRef>(null);
30+
31+
// Delay tracks NavigationContainer's 250ms push; focusing sooner opens the
32+
// keyboard mid-slide and jumps the layout.
33+
useEffect(() => {
34+
if (!autoFocus) return;
35+
const handle = setTimeout(() => cardRef.current?.focus(), 300);
36+
return () => clearTimeout(handle);
37+
}, [autoFocus]);
38+
39+
const disabled = form.isSubmitting;
40+
const canSubmit = form.isValid && !form.isSubmitting;
41+
42+
const handlePay = () => {
43+
if (!canSubmit) return;
44+
onSubmitStart?.();
45+
form.submit();
46+
};
47+
48+
return (
49+
<View style={[styles.container, style]} testID={testID}>
50+
<CardNumberInput
51+
ref={cardRef}
52+
cardForm={form}
53+
editable={!disabled}
54+
returnKeyType="next"
55+
onSubmitEditing={() => expiryRef.current?.focus()}
56+
testID={`${testID}-card-number`}
57+
/>
58+
59+
<View style={styles.row}>
60+
<View style={styles.halfField}>
61+
<ExpiryDateInput
62+
ref={expiryRef}
63+
cardForm={form}
64+
editable={!disabled}
65+
returnKeyType="next"
66+
onSubmitEditing={() => cvvRef.current?.focus()}
67+
testID={`${testID}-expiry`}
68+
/>
69+
</View>
70+
<View style={styles.halfField}>
71+
<CVVInput
72+
ref={cvvRef}
73+
cardForm={form}
74+
editable={!disabled}
75+
returnKeyType="next"
76+
onSubmitEditing={() => nameRef.current?.focus()}
77+
testID={`${testID}-cvv`}
78+
/>
79+
</View>
80+
</View>
81+
82+
<CardholderNameInput
83+
ref={nameRef}
84+
cardForm={form}
85+
editable={!disabled}
86+
returnKeyType="done"
87+
onSubmitEditing={handlePay}
88+
testID={`${testID}-cardholder-name`}
89+
/>
90+
91+
<TouchableOpacity
92+
onPress={handlePay}
93+
disabled={!canSubmit}
94+
activeOpacity={0.7}
95+
style={[styles.payButton, !canSubmit && styles.payButtonDisabled]}
96+
accessibilityRole="button"
97+
accessibilityState={{ disabled: !canSubmit, busy: form.isSubmitting }}
98+
accessibilityLabel={t('accessibility_card_form_submit_label')}
99+
accessibilityHint={
100+
form.isSubmitting
101+
? t('accessibility_card_form_submit_loading')
102+
: canSubmit
103+
? t('accessibility_card_form_submit_hint')
104+
: t('accessibility_card_form_submit_disabled')
105+
}
106+
testID={`${testID}-submit`}
107+
>
108+
{form.isSubmitting ? (
109+
<ActivityIndicator color={tokens.colors.background} />
110+
) : (
111+
<Text style={styles.payButtonText}>{t('primer_common_button_pay')}</Text>
112+
)}
113+
</TouchableOpacity>
114+
</View>
115+
);
116+
}
117+
118+
function createStyles(tokens: PrimerTokens) {
119+
const { colors, spacing, typography, radii } = tokens;
120+
/* eslint-disable react-native/no-unused-styles */
121+
return StyleSheet.create({
122+
container: {
123+
gap: spacing.medium,
124+
width: '100%',
125+
},
126+
halfField: {
127+
flex: 1,
128+
},
129+
payButton: {
130+
alignItems: 'center',
131+
backgroundColor: colors.primary,
132+
borderRadius: radii.medium,
133+
justifyContent: 'center',
134+
marginTop: spacing.small,
135+
minHeight: 44,
136+
padding: spacing.medium,
137+
width: '100%',
138+
},
139+
payButtonDisabled: {
140+
opacity: 0.5,
141+
},
142+
payButtonText: {
143+
color: colors.background,
144+
fontFamily: typography.titleLarge.fontFamily,
145+
fontSize: typography.titleLarge.fontSize,
146+
fontWeight: typography.titleLarge.fontWeight as TextStyle['fontWeight'],
147+
letterSpacing: typography.titleLarge.letterSpacing,
148+
lineHeight: typography.titleLarge.lineHeight,
149+
textAlign: 'center',
150+
},
151+
row: {
152+
flexDirection: 'row',
153+
gap: spacing.medium,
154+
},
155+
});
156+
/* eslint-enable react-native/no-unused-styles */
157+
}

0 commit comments

Comments
 (0)