Skip to content

Commit

Permalink
feat: Migrate to Stripe Payment Element #344
Browse files Browse the repository at this point in the history
  • Loading branch information
treoden committed Dec 13, 2024
1 parent b6b0f16 commit 1603238
Show file tree
Hide file tree
Showing 23 changed files with 380 additions and 175 deletions.
10 changes: 10 additions & 0 deletions packages/evershop/src/components/common/RenderIfTrue.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import PropTypes from 'prop-types';

RenderIfTrue.propTypes = {
condition: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
};

export default function RenderIfTrue({ condition, children }) {
return condition ? children : null;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import PropTypes from 'prop-types';
import React, { useState, useEffect } from 'react';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
import {
PaymentElement,
useStripe,
useElements
} from '@stripe/react-stripe-js';
import { useQuery } from 'urql';
import { useCheckout } from '@components/common/context/checkout';
import './CheckoutForm.scss';
import { Field } from '@components/common/form/Field';
import { _ } from '@evershop/evershop/src/lib/locale/translate';
import RenderIfTrue from '@components/common/RenderIfTrue';
import Spinner from '@components/common/Spinner';
import TestCards from './TestCards';

const cartQuery = `
Expand Down Expand Up @@ -50,36 +55,18 @@ const cartQuery = `
}
`;

const cardStyle = {
style: {
base: {
color: '#737373',
fontFamily: 'Arial, sans-serif',
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: '#737373'
}
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a'
}
},
hidePostalCode: true
};

export default function CheckoutForm({ stripePublishableKey }) {
const [, setSucceeded] = useState(false);
export default function CheckoutForm({
stripePublishableKey,
clientSecret,
returnUrl
}) {
const [cardComleted, setCardCompleted] = useState(false);
const [error, setError] = useState(null);
const [, setDisabled] = useState(true);
const [clientSecret, setClientSecret] = useState('');
const [showTestCard, setShowTestCard] = useState('success');
const stripe = useStripe();
const elements = useElements();
const { cartId, orderId, orderPlaced, paymentMethods, checkoutSuccessUrl } =
useCheckout();
const { cartId, orderId, orderPlaced, paymentMethods } = useCheckout();

const [result] = useQuery({
query: cartQuery,
Expand All @@ -89,64 +76,51 @@ export default function CheckoutForm({ stripePublishableKey }) {
pause: orderPlaced === true
});

useEffect(() => {
// Create PaymentIntent as soon as the order is placed
if (orderId) {
window
.fetch('/api/stripe/paymentIntents', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ order_id: orderId })
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
setError(_('Some error occurred. Please try again later.'));
} else {
setClientSecret(data.data.clientSecret);
}
});
}
}, [orderId]);

useEffect(() => {
const pay = async () => {
const billingAddress =
result.data.cart.billingAddress || result.data.cart.shippingAddress;
const payload = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: elements.getElement(CardElement),
billing_details: {
name: billingAddress.fullName,
email: result.data.cart.customerEmail,
phone: billingAddress.telephone,
address: {
line1: billingAddress.address1,
country: billingAddress.country.code,
state: billingAddress.province?.code,
postal_code: billingAddress.postcode,
city: billingAddress.city

const submit = await elements.submit();
if (submit.error) {
// Show error to your customer
setError(submit.error.message);
return;
}
const payload = await stripe.confirmPayment({
clientSecret,
elements,
confirmParams: {
payment_method_data: {
billing_details: {
name: billingAddress.fullName,
email: result.data.cart.customerEmail,
phone: billingAddress.telephone,
address: {
line1: billingAddress.address1,
country: billingAddress.country.code,
state: billingAddress.province?.code,
postal_code: billingAddress.postcode,
city: billingAddress.city
}
}
}
},
return_url: `${returnUrl}?order_id=${orderId}`
}
});

if (payload.error) {
setError(`Payment failed ${payload.error.message}`);
} else {
setError(null);
setSucceeded(true);
// Redirect to checkout success page
window.location.href = `${checkoutSuccessUrl}/${orderId}`;
// Get the payment intent ID
const paymentIntent = payload.error.payment_intent;
// Redirect to the return URL with the payment intent ID
window.location.href = `${returnUrl}?order_id=${orderId}&payment_intent=${paymentIntent.id}`;
}
};

if (orderPlaced === true && clientSecret) {
if (orderId && clientSecret) {
pay();
}
}, [orderPlaced, clientSecret, result]);
}, [orderId, clientSecret, result]);

const handleChange = (event) => {
// Listen for changes in the CardElement
Expand Down Expand Up @@ -180,42 +154,50 @@ export default function CheckoutForm({ stripePublishableKey }) {

return (
// eslint-disable-next-line react/jsx-filename-extension
<div>
<div className="stripe-form">
{stripePublishableKey && stripePublishableKey.startsWith('pk_test') && (
<TestCards
showTestCard={showTestCard}
testSuccess={testSuccess}
testFailure={testFailure}
<>
<RenderIfTrue condition={stripe && elements}>
<div>
<div className="stripe-form">
{stripePublishableKey &&
stripePublishableKey.startsWith('pk_test') && (
<TestCards
showTestCard={showTestCard}
testSuccess={testSuccess}
testFailure={testFailure}
/>
)}
<PaymentElement id="payment-element" onChange={handleChange} />
</div>
{/* Show any error that happens when processing the payment */}
{error && (
<div className="card-error text-critical mb-8" role="alert">
{error}
</div>
)}
<Field
type="hidden"
name="stripeCartComplete"
value={cardComleted ? 1 : ''}
validationRules={[
{
rule: 'notEmpty',
message: 'Please complete the card information'
}
]}
/>
)}
<CardElement
id="card-element"
options={cardStyle}
onChange={handleChange}
/>
</div>
{/* Show any error that happens when processing the payment */}
{error && (
<div className="card-error text-critical mb-8" role="alert">
{error}
</div>
)}
<Field
type="hidden"
name="stripeCartComplete"
value={cardComleted ? 1 : ''}
validationRules={[
{
rule: 'notEmpty',
message: 'Please complete the card information'
}
]}
/>
</div>
</RenderIfTrue>
<RenderIfTrue condition={!stripe || !elements}>
<div className="flex justify-center p-5">
<Spinner width={20} height={20} />
</div>
</RenderIfTrue>
</>
);
}

CheckoutForm.propTypes = {
stripePublishableKey: PropTypes.string.isRequired
stripePublishableKey: PropTypes.string.isRequired,
clientSecret: PropTypes.string.isRequired,
returnUrl: PropTypes.string.isRequired
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
.stripe-form {
border: 1px solid var(--divider);
padding: 2rem;
background-color: #fafafa;
border-radius: 5px;
margin-bottom: 2rem;
.stripe-form-heading {
border-bottom: 1px solid var(--divider);
padding-bottom: 1.5rem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function TestCards({ showTestCard, testSuccess, testFailure }) {
<div className="text-sm text-gray-600">
Test card number: 4242 4242 4242 4242
</div>
<div className="text-sm text-gray-600">Test card expiry: 04/24</div>
<div className="text-sm text-gray-600">Test card expiry: 04/26</div>
<div className="text-sm text-gray-600">Test card CVC: 242</div>
</div>
)}
Expand All @@ -34,7 +34,7 @@ function TestCards({ showTestCard, testSuccess, testFailure }) {
<div className="text-sm text-gray-600">
Test card number: 4000 0000 0000 9995
</div>
<div className="text-sm text-gray-600">Test card expiry: 04/24</div>
<div className="text-sm text-gray-600">Test card expiry: 04/26</div>
<div className="text-sm text-gray-600">Test card CVC: 242</div>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const jest = require('jest-mock');
const notFound = require('../../../../../../../../modules/base/pages/global/[notification]notFound[response]');
const notFound = require('../../../../../../../../modules/base/pages/global/[auth]notFound[response]');

module.exports = jest.fn(notFound);

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('buildMiddlewareFunction', () => {
});

it('It should not bypass the app level middleware when status is 404', async () => {
const notFound = require('../app/modules/basecopy/pages/global/[notification]notFound[response]');
const notFound = require('../app/modules/basecopy/pages/global/[auth]notFound[response]');
const dummy = require('../app/modules/basecopy/pages/global/[notFound]dummy[response]');
const response = require('../app/modules/basecopy/pages/global/response[errorHandler]');
expect(notFound).toHaveBeenCalledTimes(2);
Expand Down
9 changes: 7 additions & 2 deletions packages/evershop/src/lib/response/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const { get } = require('../util/get');
const isProductionMode = require('../util/isProductionMode');
const { getRouteBuildPath } = require('../webpack/getRouteBuildPath');
const { getConfig } = require('../util/getConfig');
const {
getNotifications
} = require('../../modules/base/services/notifications');

function normalizeAssets(assets) {
if (typeof assets === 'object' && !Array.isArray(assets) && assets !== null) {
Expand Down Expand Up @@ -37,7 +40,8 @@ function renderDevelopment(request, response) {
const contextValue = {
graphqlResponse: get(response, 'locals.graphqlResponse', {}),
propsMap: get(response, 'locals.propsMap', {}),
widgets: get(response, 'locals.widgets', [])
widgets: get(response, 'locals.widgets', []),
notifications: getNotifications(request)
};
const safeContextValue = jsesc(contextValue, {
json: true,
Expand Down Expand Up @@ -93,7 +97,8 @@ function renderProduction(request, response) {
const contextValue = {
graphqlResponse: get(response, 'locals.graphqlResponse', {}),
propsMap: get(response, 'locals.propsMap', {}),
widgets: get(response, 'locals.widgets', [])
widgets: get(response, 'locals.widgets', []),
notifications: getNotifications(request)
};
const safeContextValue = jsesc(contextValue, {
json: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const { buildUrl } = require('@evershop/evershop/src/lib/router/buildUrl');

module.exports = {
Query: {
url: (root, { routeId, params = [] }) => {
url: (root, { routeId, params = [] }, { homeUrl }) => {
const queries = [];
params.forEach((param) => {
// Check if the key is a string number
Expand All @@ -12,7 +12,7 @@ module.exports = {
queries[param.key] = param.value;
}
});
return buildUrl(routeId, queries);
return `${homeUrl}${buildUrl(routeId, queries)}`;
}
}
};

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const isDevelopmentMode = require('@evershop/evershop/src/lib/util/isDevelopment
const {
loadWidgetInstances
} = require('../../../cms/services/widget/loadWidgetInstances');
const { getNotifications } = require('../../services/notifications');

module.exports = async (request, response, delegate, next) => {
/** Get all promise delegate */
Expand Down Expand Up @@ -72,7 +73,8 @@ module.exports = async (request, response, delegate, next) => {
eContext: {
graphqlResponse: get(response, 'locals.graphqlResponse', {}),
propsMap: get(response, 'locals.propsMap', {}),
widgets: widgetInstances
widgets: widgetInstances,
notifications: getNotifications(request)
}
});
} else {
Expand Down
25 changes: 25 additions & 0 deletions packages/evershop/src/modules/base/services/notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const { hookable } = require('@evershop/evershop/src/lib/util/hookable');
const { getValueSync } = require('@evershop/evershop/src/lib/util/registry');

function addNotification(request, message, type = 'info') {
const notification = {
message: getValueSync('notificationMessage', message),
type // Suppport 'success', 'error', 'info', 'warning'
};
const { session } = request;
session.notifications = session.notifications || [];
session.notifications.push(notification);
}

function getNotifications(request) {
const { session } = request;
const notifications = session.notifications || [];
session.notifications = [];
return notifications;
}

module.exports = {
addNotification: (request, message, type) =>
hookable(addNotification)(request, message, type),
getNotifications
};
Loading

0 comments on commit 1603238

Please sign in to comment.