PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0
GiveWP – Donation Plugin and Fundraising Platform v4.18.0
4.18.0 4.17.0 4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 All 257 releases
give / src / PaymentGateways / Gateways / PayPalCommerce / payPalCommerceGateway.tsx

payPalCommerceGateway.tsx in GiveWP – Donation Plugin and Fundraising Platform 4.18.0, at src/PaymentGateways/Gateways/PayPalCommerce/payPalCommerceGateway.tsx

576 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 DISPATCH_ACTION,
3 PayPalButtons,
4 PayPalButtonsComponentProps,
5 PayPalCardFieldsForm,
6 PayPalCardFieldsProvider,
7 PayPalScriptProvider,
8 usePayPalCardFields,
9 usePayPalScriptReducer,
10 } from '@paypal/react-paypal-js';
11 import {__} from '@wordpress/i18n';
12 import {useEffect} from 'react';
13 import {PayPalCommerceGateway, PayPalSubscriber} from './types';
14 import handleValidationRequest from '@givewp/forms/app/utilities/handleValidationRequest';
15 import createOrder from './resources/js/createOrder';
16 import type {
17 CreateSubscriptionRequestBody,
18 PayPalButtonsComponentOptions,
19 PayPalCardFieldsComponent,
20 PayPalCardFieldsComponentBasics,
21 } from '@paypal/paypal-js';
22 import createSubscriptionPlan from './resources/js/createSubscriptionPlan';
23 import convertValuesToFormData from '@givewp/forms/app/utilities/convertValuesToFormData';
24
25 (() => {
26 /**
27 * Hoisted values are used to pass data in contexts where hooks cannot be used.
28 * For example, the form uses hooks to get the values of the form fields,
29 * but the callbacks for PayPal cannot use hooks to access those values.
30 *
31 * The <FormFieldsProvider /> component is used to hoist the form field values
32 * which are then collected in createOrderHandler(). This callback is not
33 * a component, so it cannot use hooks to access the form field values.
34 */
35 let amount;
36 let firstName;
37 let lastName;
38 let email;
39 let payPalDonationsSettings;
40 let payPalOrderId;
41 let payPalSubscriptionId;
42
43 let subscriptionFrequency;
44 let subscriptionInstallments;
45 let subscriptionPeriod;
46
47 let country;
48 let state;
49 let city;
50 let addressLine1;
51 let addressLine2;
52 let postalCode;
53
54 let currency;
55 let submitButton;
56 let getFormValues;
57
58 let payPalCardFieldsForm: PayPalCardFieldsComponent = null;
59
60 /**
61 * @since 4.1.1 updated to reassign the submit button when not assigned yet
62 * @since 4.1.0
63 */
64 const showOrHideDonateButton = (showOrHide: 'show' | 'hide') => {
65 submitButton = submitButton || window.givewp.form.hooks.useFormSubmitButton();
66
67 if (submitButton) {
68 submitButton.style.display = showOrHide === 'hide' ? 'none' : '';
69 }
70 };
71
72 const buttonsStyle = {
73 color: 'gold' as 'gold' | 'blue' | 'silver' | 'white' | 'black',
74 label: 'paypal' as 'paypal' | 'checkout' | 'buynow' | 'pay' | 'installment' | 'subscribe' | 'donate',
75 layout: 'vertical' as 'vertical' | 'horizontal',
76 shape: 'rect' as 'rect' | 'pill',
77 tagline: false,
78 };
79
80 /**
81 * Get PayPal script options.
82 *
83 * This function return the paypal script options on basis of context.
84 * - If donation type is subscription then remove hosted fields from components.
85 *
86 * @return {object} PayPal script options.
87 */
88 const getPayPalScriptOptions = ({isSubscription}) => {
89 let paypalScriptOptions = {...payPalDonationsSettings.sdkOptions};
90
91 // Remove hosted fields from components if subscription.
92 if (isSubscription && -1 !== paypalScriptOptions.components.indexOf('card-fields')) {
93 paypalScriptOptions.components = paypalScriptOptions.components
94 .split(',')
95 .filter((component) => component !== 'card-fields')
96 .join(',');
97 }
98
99 return paypalScriptOptions;
100 };
101
102 /**
103 * The complete form values go along with the gateway's own keys so the server can hold the
104 * request to the form's validation rules before it creates the PayPal order. File fields stay
105 * behind: the order endpoints never read them.
106 *
107 * @since 4.16.7.1
108 */
109 const getFormData = () => {
110 const values = getFormValues ? getFormValues() : {};
111 const formData = convertValuesToFormData(
112 Object.fromEntries(Object.entries(values).filter(([, value]) => !(value instanceof File)))
113 );
114
115 formData.append('give-form-id', payPalDonationsSettings.donationFormId);
116 formData.append('give-form-hash', payPalDonationsSettings.donationFormNonce);
117
118 formData.append('give_payment_mode', 'paypal-commerce');
119
120 formData.append('give-amount', amount.toString());
121
122 formData.append('give-recurring-period', subscriptionPeriod);
123 formData.append('period', subscriptionPeriod);
124 formData.append('frequency', subscriptionFrequency);
125 formData.append('times', subscriptionInstallments);
126
127 formData.append('give_first', firstName);
128 formData.append('give_last', lastName);
129 formData.append('give_email', email);
130
131 if (country && country.length === 2) {
132 formData.append('card_address', addressLine1);
133 formData.append('card_address_2', addressLine2);
134 formData.append('card_city', city);
135 formData.append('card_state', state);
136 formData.append('card_zip', postalCode);
137 formData.append('billing_country', country);
138 }
139
140 /**
141 * Ensure the proper currency will be used when using the Currency Switcher add-on.
142 */
143 formData.append('give-cs-form-currency', currency);
144
145 return formData;
146 };
147
148 const smartButtonsCreateOrderHandler: PayPalButtonsComponentOptions['createOrder'] = async (): Promise<string> => {
149 return await createOrder(
150 `${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_create_order`,
151 payPalCommerceGateway,
152 getFormData()
153 );
154 };
155
156 /**
157 * @since 4.1.0
158 */
159 const cardFieldsOnErrorHandler: PayPalCardFieldsComponentBasics['onError'] = (error) => {
160 console.error('PayPal Card Fields Error:', error);
161
162 throw new Error(__('PayPal Card Fields Error:', 'give') + error.message);
163 };
164
165 /**
166 * @since 4.1.0
167 */
168 const cardFieldsCreateOrderHandler = async () => {
169 return await createOrder(
170 `${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_create_order`,
171 payPalCommerceGateway,
172 getFormData()
173 );
174 };
175
176 /**
177 * @since 4.1.0
178 */
179 const cardFieldsOnApproveHandler: PayPalCardFieldsComponentBasics['onApprove'] = async (data) => {
180 // @ts-ignore
181 const {orderID, liabilityShift} = data;
182 payPalOrderId = orderID;
183
184 if (liabilityShift && !['POSSIBLE', 'YES'].includes(liabilityShift)) {
185 console.log('Liability shift not possible or not accepted.');
186 throw new Error(
187 __('Card type and issuing bank are not ready to complete a 3D Secure authentication.', 'give')
188 );
189 }
190
191 return;
192 };
193
194 const smartButtonsCreateSubscriptionHandler: PayPalButtonsComponentOptions['createSubscription'] = async (
195 data,
196 actions
197 ) => {
198 const {planId, userAction} = await createSubscriptionPlan(
199 `${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_create_plan_id`,
200 payPalCommerceGateway,
201 getFormData()
202 );
203
204 const subscriberData: PayPalSubscriber = {
205 name: {
206 given_name: firstName,
207 surname: lastName,
208 },
209 email_address: email,
210 };
211
212 if (country) {
213 subscriberData.shipping_address = {
214 name: {
215 full_name: `${firstName} ${lastName}`.trim(),
216 },
217 address: {
218 address_line_1: addressLine1,
219 address_line_2: addressLine2,
220 admin_area_2: city,
221 admin_area_1: state,
222 postal_code: postalCode,
223 country_code: country,
224 },
225 };
226 }
227
228 const createSubscriptionPayload: CreateSubscriptionRequestBody = {
229 plan_id: planId,
230 subscriber: subscriberData,
231 };
232
233 if (userAction) {
234 createSubscriptionPayload.application_context = {
235 ...createSubscriptionPayload.application_context,
236 user_action: userAction as 'CONTINUE' | 'SUBSCRIBE_NOW',
237 };
238 }
239
240 return actions.subscription.create(createSubscriptionPayload).then((orderId) => {
241 return (payPalSubscriptionId = orderId);
242 });
243 };
244
245 const Divider = ({label, style = {}}) => {
246 const styles = {
247 container: {
248 fontSize: '16px',
249 fontStyle: 'italic',
250 display: 'flex',
251 justifyContent: 'center',
252 alignItems: 'center',
253 ...style,
254 },
255 dashedLine: {
256 border: '1px solid #d4d4d4',
257 flexGrow: 1,
258 },
259 label: {
260 padding: '0 6px',
261 fontSize: '14px',
262 color: '#8d8e8e',
263 },
264 };
265
266 return (
267 <div className="separator-with-text" style={styles.container}>
268 <div className="dashed-line" style={styles.dashedLine} />
269 <div className="label" style={styles.label}>
270 {label}
271 </div>
272 <div className="dashed-line" style={styles.dashedLine} />
273 </div>
274 );
275 };
276
277 /**
278 * @since 4.16.7.1 Hoist getValues so the PayPal callbacks can post the complete form values.
279 */
280 const FormFieldsProvider = ({children}) => {
281 const formData = window.givewp.form.hooks.useFormData();
282 const {getValues} = window.givewp.form.hooks.useFormContext();
283
284 getFormValues = getValues;
285 amount = formData.amount;
286 firstName = formData.firstName;
287 lastName = formData.lastName;
288 email = formData.email;
289
290 subscriptionFrequency = formData.subscriptionFrequency;
291 subscriptionInstallments = formData.subscriptionInstallments;
292 subscriptionPeriod = formData.subscriptionPeriod;
293
294 addressLine1 = formData.billingAddress.addressLine1;
295 addressLine2 = formData.billingAddress.addressLine2;
296 city = formData.billingAddress.city;
297 state = formData.billingAddress.state;
298 postalCode = formData.billingAddress.postalCode;
299 country = formData.billingAddress.country;
300
301 currency = formData.currency;
302
303 return children;
304 };
305
306 const SmartButtonsContainer = () => {
307 const {useWatch, useFormState} = window.givewp.form.hooks;
308 const donationType = useWatch({name: 'donationType'});
309 const {isSubmitting, isSubmitSuccessful} = useFormState();
310 const {useFormContext} = window.givewp.form.hooks;
311 const {
312 getFieldState,
313 setFocus,
314 getValues,
315 trigger,
316 setError,
317 } = useFormContext();
318 const gateway = window.givewp.gateways.get('paypal-commerce') as PayPalCommerceGateway;
319
320 const props: PayPalButtonsComponentProps = {
321 style: buttonsStyle,
322 disabled: isSubmitting || isSubmitSuccessful,
323 forceReRender: [donationType, amount, firstName, lastName, email, currency],
324 onClick: async (data, actions) => {
325 // Validate whether payment gateway support subscriptions.
326 if (donationType === 'subscription' && !gateway.supportsSubscriptions) {
327 setError(
328 'FORM_ERROR',
329 {
330 message: __(
331 'This payment gateway does not support recurring payments, please try selecting another payment gateway.',
332 'give'
333 ),
334 },
335 {shouldFocus: true}
336 );
337
338 // Scroll to the top of the form.
339 // Add this moment we do not have a way to scroll to the error message.
340 // In the future we can add a way to scroll to the error message and remove this code.
341 submitButton.scrollIntoView({behavior: 'smooth'});
342
343 return actions.reject();
344 }
345
346 // Validate the form values in the client side before proceeding.
347 const isClientValidationSuccessful = await trigger();
348 if (!isClientValidationSuccessful) {
349 // Set focus on first invalid field.
350 for (const fieldName in getValues()) {
351 if (getFieldState(fieldName).invalid) {
352 setFocus(fieldName);
353 }
354 }
355 return actions.reject();
356 }
357
358 /**
359 * Validate the form values in the server side before proceeding - this is important to prevent problems with some blocks.
360 *
361 * Ideally, the client-side validations should be enough. However, in some cases, these validations are reached
362 * later when the donation is already created on the PayPal side. This way, we need the request below to check
363 * it earlier and prevent the donation creation on the PayPal side if the required fields are missing.
364 *
365 * Know cases:
366 *
367 * #1 - Billing Address Block: depending on the selected country, the city, state, and zip fields
368 * can be required or not and there are custom validation rules on the server side that check it.
369 *
370 * #2 - Gift Aid Block: when users opt-in to the gift aid checkbox, it will display some
371 * required fields that should be filled, but as this block is not a group and even so has
372 * "children" fields, the validation rules for it live only on the server.
373 */
374 const isServerValidationSuccessful = await handleValidationRequest(
375 payPalDonationsSettings.validateUrl,
376 getValues(),
377 setError,
378 gateway
379 );
380
381 if (!isServerValidationSuccessful) {
382 return actions.reject();
383 }
384
385 return actions.resolve();
386 },
387 onApprove: async (data, actions) => {
388 const orderId = data.orderID;
389 const subscriptionId = data?.subscriptionID;
390
391 const submitButtonDefaultText = submitButton.textContent;
392 submitButton.textContent = __('Waiting for PayPal...', 'give');
393 submitButton.disabled = true;
394
395 if (subscriptionId && donationType === 'subscription') {
396 payPalSubscriptionId = subscriptionId;
397
398 submitButton.disabled = false;
399 submitButton.textContent = submitButtonDefaultText;
400 submitButton.click();
401 return;
402 }
403
404 if (orderId) {
405 payPalOrderId = orderId;
406 }
407
408 submitButton.disabled = false;
409 submitButton.textContent = submitButtonDefaultText;
410 submitButton.click();
411 return;
412 },
413 };
414
415 return donationType === 'subscription' ? (
416 <PayPalButtons {...props} createSubscription={smartButtonsCreateSubscriptionHandler} createOrder={null} />
417 ) : (
418 <PayPalButtons {...props} createOrder={smartButtonsCreateOrderHandler} createSubscription={null} />
419 );
420 };
421
422 /**
423 * @since 4.1.0
424 */
425 const PayPalGatewayCardFieldsForm = () => {
426 const {cardFieldsForm} = usePayPalCardFields();
427 payPalCardFieldsForm = cardFieldsForm;
428 payPalCommerceGateway.payPalCardFieldsForm = cardFieldsForm;
429
430 return <PayPalCardFieldsForm />;
431 };
432
433 const CardFieldsContainer = () => {
434 showOrHideDonateButton('show');
435
436 return (
437 <PayPalCardFieldsProvider
438 createOrder={cardFieldsCreateOrderHandler}
439 onApprove={cardFieldsOnApproveHandler}
440 onError={cardFieldsOnErrorHandler}
441 >
442 <>
443 <Divider label={__('Or pay with card', 'give')} style={{padding: '30px 0'}} />
444 <PayPalGatewayCardFieldsForm />
445 </>
446 </PayPalCardFieldsProvider>
447 );
448 };
449
450 function PaymentMethodsWrapper() {
451 const {isRecurring} = window.givewp.form.hooks.useFormData();
452
453 const [{options}, dispatch] = usePayPalScriptReducer();
454 const shouldShowCardFields = -1 !== options.components.indexOf('card-fields');
455
456 useEffect(() => {
457 const options = getPayPalScriptOptions({isSubscription: isRecurring});
458
459 dispatch({
460 type: DISPATCH_ACTION.RESET_OPTIONS,
461 value: {
462 ...options,
463 currency: currency,
464 vault: isRecurring,
465 intent: isRecurring ? 'subscription' : options.intent,
466 },
467 });
468 }, [currency, isRecurring]);
469
470 useEffect(() => {
471 // hide donate buttons if card fields are not expected to be shown
472 if (!shouldShowCardFields) {
473 showOrHideDonateButton('hide');
474 }
475
476 return () => {
477 showOrHideDonateButton('show');
478 };
479 }, [shouldShowCardFields]);
480
481 return (
482 <>
483 <SmartButtonsContainer />
484 {shouldShowCardFields && <CardFieldsContainer />}
485 </>
486 );
487 }
488
489 const payPalCommerceGateway: PayPalCommerceGateway = {
490 id: 'paypal-commerce',
491 initialize() {
492 payPalDonationsSettings = this.settings;
493 },
494
495 /**
496 * Before create payment.
497 * @since 4.16.7.1 Re-enable the submit button when the card fields submission fails.
498 * @since 3.2.0 Handle error response in approveOrderCallback.
499 * @param {Object} values
500 */
501 beforeCreatePayment: async function (values): Promise<object> {
502 if (payPalSubscriptionId) {
503 return {
504 payPalSubscriptionId: payPalSubscriptionId,
505 };
506 }
507
508 if (payPalOrderId) {
509 // If order ID already set by payment buttons then return early.
510 return {
511 payPalOrderId: payPalOrderId,
512 };
513 }
514
515 if (!payPalCardFieldsForm) {
516 throw new Error(__('PayPal Card Fields are not available.', 'give'));
517 }
518
519 const cardFormState = await payPalCardFieldsForm.getState();
520
521 if (!cardFormState.isFormValid) {
522 throw new Error(__('PayPal Card Fields are invalid', 'give'));
523 }
524
525 const submitButtonDefaultText = submitButton.textContent;
526 submitButton.textContent = __('Waiting for PayPal...', 'give');
527 submitButton.disabled = true;
528
529 try {
530 await payPalCardFieldsForm.submit();
531 } catch (error) {
532 submitButton.disabled = false;
533 throw error;
534 } finally {
535 submitButton.textContent = submitButtonDefaultText;
536 }
537
538 if (!payPalOrderId) {
539 submitButton.disabled = false;
540
541 throw new Error(__('Missing PayPal Order ID.', 'give'));
542 }
543
544 return {
545 payPalOrderId,
546 };
547 },
548
549 /**
550 * @since 4.1.1 updated the submit button to assign on mount
551 * @since 4.1.0 updated to use card fields api
552 * @since 3.17.1 Hide submit button when PayPal Commerce is selected.
553 */
554 Fields() {
555 const {isRecurring} = window.givewp.form.hooks.useFormData();
556
557 useEffect(() => {
558 submitButton = window.givewp.form.hooks.useFormSubmitButton();
559 }, []);
560
561 return (
562 <FormFieldsProvider>
563 <PayPalScriptProvider
564 deferLoading={true}
565 options={getPayPalScriptOptions({isSubscription: isRecurring})}
566 >
567 <PaymentMethodsWrapper />
568 </PayPalScriptProvider>
569 </FormFieldsProvider>
570 );
571 },
572 };
573
574 window.givewp.gateways.register(payPalCommerceGateway);
575 })();
576