import {
PayPalButtons,
PayPalHostedField,
PayPalHostedFieldsProvider,
PayPalScriptProvider,
usePayPalHostedFields,
usePayPalScriptReducer
} from '@paypal/react-paypal-js';
import type {PayPalButtonsComponentProps, PayPalHostedFieldsComponentProps} from '@paypal/react-paypal-js';
import {__, sprintf} from '@wordpress/i18n';
import {Flex, TextControl} from '@wordpress/components';
import {CSSProperties, useEffect, useState} from 'react';
import {PayPalCommerceGateway, PayPalSubscriber} from './types';
import handleValidationRequest from '@givewp/forms/app/utilities/handleValidationRequest';
import createOrder from './resources/js/createOrder';
import type {
CreateSubscriptionRequestBody,
PayPalButtonsComponentOptions
} from '@paypal/paypal-js';
import createSubscriptionPlan from './resources/js/createSubscriptionPlan';
(() => {
/**
* Hoisted values are used to pass data in contexts where hooks cannot be used.
* For example, the form uses hooks to get the values of the form fields,
* but the callbacks for PayPal cannot use hooks to access those values.
*
* The component is used to hoist the form field values
* which are then collected in createOrderHandler(). This callback is not
* a component, so it cannot use hooks to access the form field values.
*/
let amount;
let feeRecovery;
let firstName;
let lastName;
let email;
let cardholderName;
let hostedField;
let payPalDonationsSettings;
let payPalOrderId;
let payPalSubscriptionId;
let subscriptionFrequency;
let subscriptionInstallments;
let subscriptionPeriod;
let country;
let state;
let city;
let addressLine1;
let addressLine2;
let postalCode;
let currency;
let eventTickets;
let submitButton;
/**
* @since 3.12.2
*/
const getEventTicketsTotalAmount = (
eventTickets: Array<{
ticketId: number;
quantity: number;
amount: number;
}>
) => {
const totalAmount = eventTickets.reduce((accumulator, eventTicket) => accumulator + eventTicket.amount, 0);
if (totalAmount > 0) {
return totalAmount / 100;
} else {
return 0;
}
};
const buttonsStyle = {
color: 'gold' as 'gold' | 'blue' | 'silver' | 'white' | 'black',
label: 'paypal' as 'paypal' | 'checkout' | 'buynow' | 'pay' | 'installment' | 'subscribe' | 'donate',
layout: 'vertical' as 'vertical' | 'horizontal',
shape: 'rect' as 'rect' | 'pill',
tagline: false,
};
const CUSTOM_FIELD_STYLE = {
height: '50px', // @todo Magic number, but it works
borderWidth: '.078rem',
borderStyle: 'solid',
borderColor: '#666',
borderRadius: '.25rem',
padding: '0 1.1875rem',
width: '100%',
marginBottom: '.5rem',
boxSizing: 'inherit',
inlineSize: '100%',
backgroundColor: '#fff',
color: '#4d4d4d',
fontSize: '1rem',
fontFamily: 'inherit',
fontWeight: '500',
lineHeight: '1.2',
} as CSSProperties;
/**
* Get PayPal script options.
*
* This function return the paypal script options on basis of context.
* - If donation type is subscription then remove hosted fields from components.
*
* @return {object} PayPal script options.
*/
const getPayPalScriptOptions = ({isSubscription}) => {
let paypalScriptOptions = {...payPalDonationsSettings.sdkOptions};
// Remove hosted fields from components if subscription.
if (isSubscription && -1 !== paypalScriptOptions.components.indexOf('hosted-fields')) {
paypalScriptOptions.components = paypalScriptOptions.components
.split(',')
.filter((component) => component !== 'hosted-fields')
.join(',');
}
return paypalScriptOptions;
};
/**
* Get amount with fee (if any).
*
* @since 3.6.1 Append 'give-cs-form-currency' to formData
* @since 3.2.0
* @return {number} Amount with fee.
*/
const getAmount = () => {
const feeAmount = feeRecovery ? feeRecovery : 0;
let amountWithFee = amount + feeAmount;
amountWithFee = Math.round(amountWithFee * 100) / 100;
return amountWithFee;
};
const getFormData = () => {
const formData = new FormData();
formData.append('give-form-id', payPalDonationsSettings.donationFormId);
formData.append('give-form-hash', payPalDonationsSettings.donationFormNonce);
formData.append('give_payment_mode', 'paypal-commerce');
const eventTicketsTotalAmount = eventTickets ? getEventTicketsTotalAmount(JSON.parse(eventTickets)) : 0;
const isSubscription = subscriptionPeriod ? subscriptionPeriod !== 'one-time' : false;
if (!isSubscription) {
formData.append('give-amount', getAmount() + eventTicketsTotalAmount);
} else {
formData.append('give-amount', getAmount()); // We don't want to charge the event tickets for each subscription renewal
}
formData.append('give-event-tickets-total-amount', String(eventTicketsTotalAmount));
formData.append('give-recurring-period', subscriptionPeriod);
formData.append('period', subscriptionPeriod);
formData.append('frequency', subscriptionFrequency);
formData.append('times', subscriptionInstallments);
formData.append('give_first', firstName);
formData.append('give_last', lastName);
formData.append('give_email', email);
if (country) {
formData.append('card_address', addressLine1);
formData.append('card_address_2', addressLine2);
formData.append('card_city', city);
formData.append('card_state', state);
formData.append('card_zip', postalCode);
formData.append('billing_country', country);
}
/**
* Ensure the proper currency will be used when using the Currency Switcher add-on.
*/
formData.append('give-cs-form-currency', currency);
return formData;
};
const validateHostedFields = () => {
return Object.values(hostedField.cardFields.getState().fields).some(
(field: {isValid: boolean}) => field.isValid
);
};
const createOrderHandler: PayPalHostedFieldsComponentProps['createOrder'] = async (): Promise => {
return await createOrder(`${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_create_order`, payPalCommerceGateway, getFormData());
};
const createSubscriptionHandler: PayPalButtonsComponentOptions['createSubscription'] = async (data, actions) => {
const {planId, userAction} = await createSubscriptionPlan(`${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_create_plan_id`, payPalCommerceGateway, getFormData());
const subscriberData: PayPalSubscriber = {
name: {
given_name: firstName,
surname: lastName,
},
email_address: email,
};
if (country) {
subscriberData.shipping_address = {
name: {
full_name: `${firstName} ${lastName}`.trim(),
},
address: {
address_line_1: addressLine1,
address_line_2: addressLine2,
admin_area_2: city,
admin_area_1: state,
postal_code: postalCode,
country_code: country,
},
};
}
const createSubscriptionPayload: CreateSubscriptionRequestBody = {
plan_id: planId,
subscriber: subscriberData,
};
if (userAction) {
createSubscriptionPayload.application_context = {
user_action: userAction
}
}
return actions.subscription
.create(createSubscriptionPayload)
.then((orderId) => {
return (payPalSubscriptionId = orderId);
});
};
const Divider = ({label, style = {}}) => {
const styles = {
container: {
fontSize: '16px',
fontStyle: 'italic',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
...style,
},
dashedLine: {
border: '1px solid #d4d4d4',
flexGrow: 1,
},
label: {
padding: '0 6px',
fontSize: '14px',
color: '#8d8e8e',
},
};
return (
{label}
);
};
const HoistHostedFieldContext = () => {
hostedField = usePayPalHostedFields();
return <>>;
};
const FormFieldsProvider = ({children}) => {
const {useWatch} = window.givewp.form.hooks;
amount = useWatch({name: 'amount'});
feeRecovery = useWatch({name: 'feeRecovery'});
firstName = useWatch({name: 'firstName'});
lastName = useWatch({name: 'lastName'});
email = useWatch({name: 'email'});
subscriptionFrequency = useWatch({name: 'subscriptionFrequency'});
subscriptionInstallments = useWatch({name: 'subscriptionInstallments'});
subscriptionPeriod = useWatch({name: 'subscriptionPeriod'});
addressLine1 = useWatch({name: 'address1'});
addressLine2 = useWatch({name: 'address2'});
city = useWatch({name: 'city'});
state = useWatch({name: 'state'});
postalCode = useWatch({name: 'zip'});
country = useWatch({name: 'country'});
currency = useWatch({name: 'currency'});
eventTickets = useWatch({name: 'event-tickets'});
return children;
};
const SmartButtonsContainer = () => {
const {useWatch, useFormState} = window.givewp.form.hooks;
const donationType = useWatch({name: 'donationType'});
const {isSubmitting, isSubmitSuccessful} = useFormState();
const {useFormContext} = window.givewp.form.hooks;
const {
getFieldState,
setFocus,
getValues,
formState: {errors},
trigger,
setError,
} = useFormContext();
const gateway = window.givewp.gateways.get('paypal-commerce') as PayPalCommerceGateway;
const props: PayPalButtonsComponentProps = {
style: buttonsStyle,
disabled: isSubmitting || isSubmitSuccessful,
forceReRender: [donationType, amount, feeRecovery, firstName, lastName, email, currency],
onClick: async (data, actions) => {
// Validate whether payment gateway support subscriptions.
if (donationType === 'subscription' && !gateway.supportsSubscriptions) {
setError(
'FORM_ERROR',
{
message: __(
'This payment gateway does not support recurring payments, please try selecting another payment gateway.',
'give'
),
},
{shouldFocus: true}
);
// Scroll to the top of the form.
// Add this moment we do not have a way to scroll to the error message.
// In the future we can add a way to scroll to the error message and remove this code.
submitButton.scrollIntoView({behavior: 'smooth'});
return actions.reject();
}
// Validate the form values in the client side before proceeding.
const isClientValidationSuccessful = await trigger();
if (!isClientValidationSuccessful) {
// Set focus on first invalid field.
for (const fieldName in getValues()) {
if (getFieldState(fieldName).invalid) {
setFocus(fieldName);
}
}
return actions.reject();
}
/**
* Validate the form values in the server side before proceeding - this is important to prevent problems with some blocks.
*
* Ideally, the client-side validations should be enough. However, in some cases, these validations are reached
* later when the donation is already created on the PayPal side. This way, we need the request below to check
* it earlier and prevent the donation creation on the PayPal side if the required fields are missing.
*
* Know cases:
*
* #1 - Billing Address Block: depending on the selected country, the city, state, and zip fields
* can be required or not and there are custom validation rules on the server side that check it.
*
* #2 - Gift Aid Block: when users opt-in to the gift aid checkbox, it will display some
* required fields that should be filled, but as this block is not a group and even so has
* "children" fields, the validation rules for it live only on the server.
*/
const isServerValidationSuccessful = await handleValidationRequest(
payPalDonationsSettings.validateUrl,
getValues(),
setError,
gateway
);
if (!isServerValidationSuccessful) {
return actions.reject();
}
return actions.resolve();
},
onApprove: async (data, actions) => {
const orderId = data.orderID;
const subscriptionId = data?.subscriptionID;
const submitButtonDefaultText = submitButton.textContent;
submitButton.textContent = __('Waiting for PayPal...', 'give');
submitButton.disabled = true;
if (subscriptionId && donationType === 'subscription') {
payPalSubscriptionId = subscriptionId;
submitButton.disabled = false;
submitButton.textContent = submitButtonDefaultText;
submitButton.click();
return;
}
if (orderId) {
payPalOrderId = orderId;
}
submitButton.disabled = false;
submitButton.textContent = submitButtonDefaultText;
submitButton.click();
return;
},
};
return donationType === 'subscription' ? (
) : (
);
};
const HostedFieldsContainer = () => {
const {useWatch} = window.givewp.form.hooks;
const firstName = useWatch({name: 'firstName'});
const lastName = useWatch({name: 'lastName'});
const cardholderDefault = [firstName ?? '', lastName ?? ''].filter((x) => x).join(' ');
const [_cardholderName, setCardholderName] = useState(null);
useEffect(() => {
cardholderName = _cardholderName ?? cardholderDefault;
});
return (