Skip to content

Commit 8b0aa40

Browse files
authored
Merge pull request #776 from reactioncommerce/feature/stripe-sca
Feature/stripe sca
2 parents e8332d5 + 73623ca commit 8b0aa40

File tree

14 files changed

+376
-18
lines changed

14 files changed

+376
-18
lines changed

components/StripeCard/.gitignore

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
.DS_Store
2+
.fileStorage/
3+
.vscode
4+
.idea
5+
.c9
6+
.env*
7+
!.env.example*
8+
!.env.prod*
9+
*.csv
10+
*.dat
11+
*.gz
12+
*.log
13+
*.out
14+
*.pid
15+
*.seed
16+
*.sublime-project
17+
*.sublime-workspace
18+
browser.config.js
19+
20+
lib-cov
21+
logs
22+
node_modules
23+
npm-debug.log
24+
pids
25+
results
26+
allure-results
27+
package-lock.json
28+
29+
.reaction/config.json
30+
31+
.next/*
32+
src/.next/*
33+
build
34+
/reports
35+
36+
docker-compose.override.yml
37+
38+
# Yalc
39+
.yalc/
40+
yalc.lock
41+
yalc-packages
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import React, { forwardRef, Fragment, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react";
2+
import { CardCvcElement, CardExpiryElement, CardNumberElement, useElements, useStripe } from "@stripe/react-stripe-js";
3+
import { Box, Grid, TextField } from "@material-ui/core";
4+
import { makeStyles } from "@material-ui/core/styles";
5+
import Alert from "@material-ui/lab/Alert";
6+
import useStripePaymentIntent from "./hooks/useStripePaymentIntent";
7+
import StripeInput from "./StripeInput";
8+
9+
const useStyles = makeStyles(() => ({
10+
stripeForm: {
11+
display: "flex",
12+
flexDirection: "column"
13+
}
14+
}));
15+
16+
function SplitForm(
17+
{
18+
isSaving,
19+
onSubmit,
20+
onReadyForSaveChange,
21+
stripeCardNumberInputLabel = "Card Number",
22+
stripeCardExpirationDateInputLabel = "Exp.",
23+
stripeCardCVCInputLabel = "CVC"
24+
},
25+
ref
26+
) {
27+
const classes = useStyles();
28+
const stripe = useStripe();
29+
const elements = useElements();
30+
const options = useMemo(
31+
() => ({
32+
showIcon: true,
33+
style: {
34+
base: {
35+
fontSize: "18px"
36+
}
37+
}
38+
}),
39+
[]
40+
);
41+
const [error, setError] = useState();
42+
43+
const [formCompletionState, setFormCompletionState] = useState({
44+
cardNumber: false,
45+
cardExpiry: false,
46+
cardCvc: false
47+
});
48+
49+
const [isConfirmationInFlight, setIsConfirmationInFlight] = useState(false);
50+
51+
const [createStripePaymentIntent] = useStripePaymentIntent();
52+
53+
const isReady = useMemo(() => {
54+
const { cardNumber, cardExpiry, cardCvc } = formCompletionState;
55+
56+
if (!isSaving && !isConfirmationInFlight && cardNumber && cardExpiry && cardCvc) return true;
57+
58+
return false;
59+
}, [formCompletionState, isSaving, isConfirmationInFlight]);
60+
61+
useEffect(() => {
62+
onReadyForSaveChange(isReady);
63+
}, [onReadyForSaveChange, isReady]);
64+
65+
const onInputChange = useCallback(
66+
({ elementType, complete }) => {
67+
if (formCompletionState[elementType] !== complete) {
68+
setFormCompletionState({
69+
...formCompletionState,
70+
[elementType]: complete
71+
});
72+
}
73+
},
74+
[formCompletionState, setFormCompletionState]
75+
);
76+
77+
const handleSubmit = useCallback(
78+
async (event) => {
79+
if (event) {
80+
event.preventDefault();
81+
}
82+
83+
if (!stripe || !elements || isSaving || isConfirmationInFlight) {
84+
// Stripe.js has not loaded yet, saving is in progress or card payment confirmation is in-flight.
85+
return;
86+
}
87+
88+
setError();
89+
setIsConfirmationInFlight(true);
90+
91+
// Await the server secret here
92+
const { paymentIntentClientSecret } = await createStripePaymentIntent();
93+
94+
const result = await stripe.confirmCardPayment(paymentIntentClientSecret, {
95+
// eslint-disable-next-line camelcase
96+
payment_method: {
97+
card: elements.getElement(CardNumberElement)
98+
}
99+
});
100+
101+
setIsConfirmationInFlight(false);
102+
103+
if (result.error) {
104+
// Show error to your customer (e.g., insufficient funds)
105+
console.error(result.error.message); // eslint-disable-line
106+
setError(result.error.message);
107+
} else if (result.paymentIntent.status === "succeeded" || result.paymentIntent.status === "requires_capture") {
108+
// Show a success message to your customer
109+
// There's a risk of the customer closing the window before callback
110+
// execution. Set up a webhook or plugin to listen for the
111+
// payment_intent.succeeded event that handles any business critical
112+
// post-payment actions.
113+
const { amount, id } = result.paymentIntent;
114+
onSubmit({
115+
amount: amount ? parseFloat(amount / 100) : null,
116+
data: { stripePaymentIntentId: id },
117+
displayName: "Stripe Payment"
118+
});
119+
} else {
120+
console.error("Payment was not successful"); // eslint-disable-line
121+
setError("Payment was not successful");
122+
}
123+
},
124+
[createStripePaymentIntent, onSubmit, stripe, setError, isConfirmationInFlight, setIsConfirmationInFlight]
125+
);
126+
127+
useImperativeHandle(ref, () => ({
128+
submit() {
129+
handleSubmit();
130+
}
131+
}));
132+
133+
return (
134+
<Fragment>
135+
<Box my={2}>
136+
<Grid container spacing={2}>
137+
{error && (
138+
<Grid item xs={12}>
139+
<Alert severity="error">{error}</Alert>
140+
</Grid>
141+
)}
142+
<Grid item xs={12}>
143+
<form onSubmit={handleSubmit} className={classes.stripeForm}>
144+
<Grid container spacing={2}>
145+
<Grid item xs={12}>
146+
<TextField
147+
label={stripeCardNumberInputLabel}
148+
name="ccnumber"
149+
variant="outlined"
150+
fullWidth
151+
InputProps={{
152+
inputComponent: StripeInput,
153+
inputProps: {
154+
component: CardNumberElement,
155+
options
156+
}
157+
}}
158+
InputLabelProps={{
159+
shrink: true
160+
}}
161+
onChange={onInputChange}
162+
required
163+
/>
164+
</Grid>
165+
166+
<Grid item xs={6}>
167+
<TextField
168+
label={stripeCardExpirationDateInputLabel}
169+
name="ccexp"
170+
variant="outlined"
171+
fullWidth
172+
InputProps={{
173+
inputComponent: StripeInput,
174+
inputProps: {
175+
component: CardExpiryElement,
176+
options
177+
}
178+
}}
179+
InputLabelProps={{
180+
shrink: true
181+
}}
182+
onChange={onInputChange}
183+
required
184+
/>
185+
</Grid>
186+
187+
<Grid item xs={6}>
188+
<TextField
189+
label={stripeCardCVCInputLabel}
190+
name="cvc"
191+
variant="outlined"
192+
fullWidth
193+
InputProps={{
194+
inputComponent: StripeInput,
195+
inputProps: {
196+
component: CardCvcElement,
197+
options
198+
}
199+
}}
200+
InputLabelProps={{
201+
shrink: true
202+
}}
203+
onChange={onInputChange}
204+
required
205+
/>
206+
</Grid>
207+
</Grid>
208+
</form>
209+
</Grid>
210+
</Grid>
211+
</Box>
212+
</Fragment>
213+
);
214+
}
215+
216+
export default forwardRef(SplitForm);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React, { useImperativeHandle, useRef } from "react";
2+
3+
function StripeInput({ component: Component, inputRef, ...props }) {
4+
const elementRef = useRef();
5+
useImperativeHandle(inputRef, () => ({
6+
focus: () => elementRef.current.focus
7+
}));
8+
return (
9+
<Component
10+
onReady={(element) => {
11+
elementRef.current = element;
12+
}}
13+
{...props}
14+
/>
15+
);
16+
}
17+
18+
export default StripeInput;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mutation createStripePaymentIntent($input: CreateStripePaymentIntentInput!) {
2+
createStripePaymentIntent(input: $input) {
3+
paymentIntentClientSecret
4+
}
5+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useMutation } from "@apollo/client";
2+
import useCartStore from "hooks/globalStores/useCartStore";
3+
import useShop from "hooks/shop/useShop";
4+
5+
import createStripePaymentIntentMutation from "./createStripePaymentIntent.gql";
6+
7+
export default function useStripePaymentIntent() {
8+
const shop = useShop();
9+
const { accountCartId, anonymousCartId, anonymousCartToken } = useCartStore();
10+
11+
const [createStripePaymentIntentFunc, { loading }] = useMutation(createStripePaymentIntentMutation);
12+
13+
const createStripePaymentIntent = async () => {
14+
const { data } = await createStripePaymentIntentFunc({
15+
variables: {
16+
input: {
17+
cartId: anonymousCartId || accountCartId,
18+
shopId: shop?._id,
19+
cartToken: anonymousCartToken
20+
}
21+
}
22+
});
23+
24+
return data?.createStripePaymentIntent;
25+
};
26+
27+
return [createStripePaymentIntent, loading];
28+
}

components/StripeCard/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import StripeWrapper from "./provider/StripeWrapper";
2+
3+
export { default } from "./StripeCard";
4+
5+
export { StripeWrapper };

components/StripeCard/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "reaction-stripe-sca-react",
3+
"version": "1.0.0",
4+
"description": "",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "echo \"Error: no test specified\" && exit 1"
8+
},
9+
"author": "Janus Reith",
10+
"license": "Apache-2.0",
11+
"dependencies": {
12+
"@stripe/react-stripe-js": "^1.4.1",
13+
"@stripe/stripe-js": "^1.15.0"
14+
}
15+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Elements } from "@stripe/react-stripe-js";
2+
import { loadStripe } from "@stripe/stripe-js";
3+
4+
const stripePromise = loadStripe(process.env.STRIPE_PUBLIC_API_KEY);
5+
6+
function StripeWrapper({ children }) {
7+
return <Elements stripe={stripePromise}>{children}</Elements>;
8+
}
9+
10+
export default StripeWrapper;

custom/paymentMethods.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import ExampleIOUPaymentForm from "@reactioncommerce/components/ExampleIOUPaymentForm/v1";
2-
import StripePaymentInput from "@reactioncommerce/components/StripePaymentInput/v1";
2+
import StripeCard from "components/StripeCard";
33

44
const paymentMethods = [
5-
{
6-
displayName: "Credit Card",
7-
InputComponent: StripePaymentInput,
8-
name: "stripe_card",
9-
shouldCollectBillingAddress: true
10-
},
115
{
126
displayName: "IOU",
137
InputComponent: ExampleIOUPaymentForm,
148
name: "iou_example",
159
shouldCollectBillingAddress: true
10+
},
11+
{
12+
displayName: "Credit Card (SCA)",
13+
InputComponent: StripeCard,
14+
name: "stripe_payment_intent",
15+
shouldCollectBillingAddress: true
1616
}
1717
];
1818

next-env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/types/global" />

0 commit comments

Comments
 (0)