woocommerce-paypal-payments/modules/ppcp-blocks/resources/js/checkout-block.js
Emili Castells 3aba8d7dea
Merge pull request #2259 from woocommerce/PCP-3136-enable-shipping-callback-for-wc-subscriptions
Enable shipping callback for WC subscriptions (3136)
2024-06-03 14:25:36 +02:00

676 lines
23 KiB
JavaScript

import {useEffect, useState} from '@wordpress/element';
import {registerExpressPaymentMethod, registerPaymentMethod} from '@woocommerce/blocks-registry';
import {
mergeWcAddress,
paypalAddressToWc,
paypalOrderToWcAddresses,
paypalSubscriptionToWcAddresses
} from "./Helper/Address";
import {
convertKeysToSnakeCase
} from "./Helper/Helper";
import {
cartHasSubscriptionProducts,
isPayPalSubscription
} from "./Helper/Subscription";
import {
loadPaypalScriptPromise
} from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading'
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
import {
normalizeStyleForFundingSource
} from '../../../ppcp-button/resources/js/modules/Helper/Style'
import buttonModuleWatcher from "../../../ppcp-button/resources/js/modules/ButtonModuleWatcher";
import BlockCheckoutMessagesBootstrap from "./Bootstrap/BlockCheckoutMessagesBootstrap";
import {keysToCamelCase} from "../../../ppcp-button/resources/js/modules/Helper/Utils";
import {
handleShippingOptionsChange
} from "../../../ppcp-button/resources/js/modules/Helper/ShippingHandler";
const config = wc.wcSettings.getSetting('ppcp-gateway_data');
window.ppcpFundingSource = config.fundingSource;
let registeredContext = false;
let paypalScriptPromise = null;
const PayPalComponent = ({
onClick,
onClose,
onSubmit,
onError,
eventRegistration,
emitResponse,
activePaymentMethod,
shippingData,
isEditing,
fundingSource,
}) => {
const {onPaymentSetup, onCheckoutFail, onCheckoutValidation} = eventRegistration;
const {responseTypes} = emitResponse;
const [paypalOrder, setPaypalOrder] = useState(null);
const [gotoContinuationOnError, setGotoContinuationOnError] = useState(false);
const [paypalScriptLoaded, setPaypalScriptLoaded] = useState(false);
if (!paypalScriptLoaded) {
if (!paypalScriptPromise) {
// for editor, since canMakePayment was not called
paypalScriptPromise = loadPaypalScriptPromise(config.scriptData)
}
paypalScriptPromise.then(() => setPaypalScriptLoaded(true));
}
const methodId = fundingSource ? `${config.id}-${fundingSource}` : config.id;
useEffect(() => {
// fill the form if in continuation (for product or mini-cart buttons)
if (!config.scriptData.continuation || !config.scriptData.continuation.order || window.ppcpContinuationFilled) {
return;
}
try {
const paypalAddresses = paypalOrderToWcAddresses(config.scriptData.continuation.order);
const wcAddresses = wp.data.select('wc/store/cart').getCustomerData();
const addresses = mergeWcAddress(wcAddresses, paypalAddresses);
wp.data.dispatch('wc/store/cart').setBillingAddress(addresses.billingAddress);
if (shippingData.needsShipping) {
wp.data.dispatch('wc/store/cart').setShippingAddress(addresses.shippingAddress);
}
} catch (err) {
// sometimes the PayPal address is missing, skip in this case.
console.log(err);
}
// this useEffect should run only once, but adding this in case of some kind of full re-rendering
window.ppcpContinuationFilled = true;
}, [])
const createOrder = async (data, actions) => {
try {
const res = await fetch(config.scriptData.ajax.create_order.endpoint, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
nonce: config.scriptData.ajax.create_order.nonce,
bn_code: '',
context: config.scriptData.context,
payment_method: 'ppcp-gateway',
funding_source: window.ppcpFundingSource ?? 'paypal',
createaccount: false,
payment_source: data.paymentSource
}),
});
const json = await res.json();
if (!json.success) {
if (json.data?.details?.length > 0) {
throw new Error(json.data.details.map(d => `${d.issue} ${d.description}`).join('<br/>'));
} else if (json.data?.message) {
throw new Error(json.data.message);
}
throw new Error(config.scriptData.labels.error.generic);
}
return json.data.id;
} catch (err) {
console.error(err);
onError(err.message);
onClose();
throw err;
}
};
const createSubscription = async (data, actions) => {
let planId = config.scriptData.subscription_plan_id;
if (config.scriptData.variable_paypal_subscription_variation_from_cart !== '') {
planId = config.scriptData.variable_paypal_subscription_variation_from_cart;
}
return actions.subscription.create({
'plan_id': planId
});
};
const handleApproveSubscription = async (data, actions) => {
try {
const subscription = await actions.subscription.get();
if (subscription) {
const addresses = paypalSubscriptionToWcAddresses(subscription);
let promises = [
// save address on server
wp.data.dispatch('wc/store/cart').updateCustomerData({
billing_address: addresses.billingAddress,
shipping_address: addresses.shippingAddress,
}),
];
if (shouldHandleShippingInPayPal()) {
// set address in UI
promises.push(wp.data.dispatch('wc/store/cart').setBillingAddress(addresses.billingAddress));
if (shippingData.needsShipping) {
promises.push(wp.data.dispatch('wc/store/cart').setShippingAddress(addresses.shippingAddress))
}
}
await Promise.all(promises);
}
setPaypalOrder(subscription);
const res = await fetch(config.scriptData.ajax.approve_subscription.endpoint, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
nonce: config.scriptData.ajax.approve_subscription.nonce,
order_id: data.orderID,
subscription_id: data.subscriptionID
})
});
const json = await res.json();
if (!json.success) {
if (typeof actions !== 'undefined' && typeof actions.restart !== 'undefined') {
return actions.restart();
}
if (json.data?.message) {
throw new Error(json.data.message);
}
throw new Error(config.scriptData.labels.error.generic)
}
if (!shouldHandleShippingInPayPal()) {
location.href = getCheckoutRedirectUrl();
} else {
setGotoContinuationOnError(true);
onSubmit();
}
} catch (err) {
console.error(err);
onError(err.message);
onClose();
throw err;
}
};
const getCheckoutRedirectUrl = () => {
const checkoutUrl = new URL(config.scriptData.redirect);
// sometimes some browsers may load some kind of cached version of the page,
// so adding a parameter to avoid that
checkoutUrl.searchParams.append('ppcp-continuation-redirect', (new Date()).getTime().toString());
return checkoutUrl.toString();
}
const handleApprove = async (data, actions) => {
try {
const order = await actions.order.get();
if (order) {
const addresses = paypalOrderToWcAddresses(order);
let promises = [
// save address on server
wp.data.dispatch('wc/store/cart').updateCustomerData({
billing_address: addresses.billingAddress,
shipping_address: addresses.shippingAddress,
}),
];
if (shouldHandleShippingInPayPal()) {
// set address in UI
promises.push(wp.data.dispatch('wc/store/cart').setBillingAddress(addresses.billingAddress));
if (shippingData.needsShipping) {
promises.push(wp.data.dispatch('wc/store/cart').setShippingAddress(addresses.shippingAddress))
}
}
await Promise.all(promises);
}
setPaypalOrder(order);
const res = await fetch(config.scriptData.ajax.approve_order.endpoint, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
nonce: config.scriptData.ajax.approve_order.nonce,
order_id: data.orderID,
funding_source: window.ppcpFundingSource ?? 'paypal',
})
});
const json = await res.json();
if (!json.success) {
if (typeof actions !== 'undefined' && typeof actions.restart !== 'undefined') {
return actions.restart();
}
if (json.data?.message) {
throw new Error(json.data.message);
}
throw new Error(config.scriptData.labels.error.generic)
}
if (!shouldHandleShippingInPayPal()) {
location.href = getCheckoutRedirectUrl();
} else {
setGotoContinuationOnError(true);
onSubmit();
}
} catch (err) {
console.error(err);
onError(err.message);
onClose();
throw err;
}
};
useEffect(() => {
const unsubscribe = onCheckoutValidation(() => {
if (config.scriptData.continuation) {
return true;
}
if (gotoContinuationOnError && wp.data.select('wc/store/validation').hasValidationErrors()) {
location.href = getCheckoutRedirectUrl();
return { type: responseTypes.ERROR };
}
return true;
});
return unsubscribe;
}, [onCheckoutValidation, gotoContinuationOnError] );
const handleClick = (data, actions) => {
if (isEditing) {
return actions.reject();
}
window.ppcpFundingSource = data.fundingSource;
onClick();
};
const shouldHandleShippingInPayPal = () => {
if (config.finalReviewEnabled) {
return false;
}
return window.ppcpFundingSource !== 'venmo' || !config.scriptData.vaultingEnabled;
}
let handleShippingOptionsChange = null;
let handleShippingAddressChange = null;
let handleSubscriptionShippingOptionsChange = null;
let handleSubscriptionShippingAddressChange = null;
if (shippingData.needsShipping && shouldHandleShippingInPayPal()) {
handleShippingOptionsChange = async (data, actions) => {
try {
const shippingOptionId = data.selectedShippingOption?.id;
if (shippingOptionId) {
await wp.data.dispatch('wc/store/cart').selectShippingRate(shippingOptionId);
await shippingData.setSelectedRates(shippingOptionId);
}
const res = await fetch(config.ajax.update_shipping.endpoint, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
nonce: config.ajax.update_shipping.nonce,
order_id: data.orderID,
})
});
const json = await res.json();
if (!json.success) {
throw new Error(json.data.message);
}
} catch (e) {
console.error(e);
actions.reject();
}
};
handleShippingAddressChange = async (data, actions) => {
try {
const address = paypalAddressToWc(convertKeysToSnakeCase(data.shippingAddress));
await wp.data.dispatch('wc/store/cart').updateCustomerData({
shipping_address: address,
});
await shippingData.setShippingAddress(address);
const res = await fetch(config.ajax.update_shipping.endpoint, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
nonce: config.ajax.update_shipping.nonce,
order_id: data.orderID,
})
});
const json = await res.json();
if (!json.success) {
throw new Error(json.data.message);
}
} catch (e) {
console.error(e);
actions.reject();
}
};
handleSubscriptionShippingOptionsChange = async (data, actions) => {
try {
const shippingOptionId = data.selectedShippingOption?.id;
if (shippingOptionId) {
await wp.data.dispatch('wc/store/cart').selectShippingRate(shippingOptionId);
await shippingData.setSelectedRates(shippingOptionId);
}
} catch (e) {
console.error(e);
actions.reject();
}
};
handleSubscriptionShippingAddressChange = async (data, actions) => {
try {
const address = paypalAddressToWc(convertKeysToSnakeCase(data.shippingAddress));
await wp.data.dispatch('wc/store/cart').updateCustomerData({
shipping_address: address,
});
await shippingData.setShippingAddress(address);
const res = await fetch(config.ajax.update_shipping.endpoint, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
nonce: config.ajax.update_shipping.nonce,
order_id: data.orderID,
})
});
const json = await res.json();
if (!json.success) {
throw new Error(json.data.message);
}
} catch (e) {
console.error(e);
actions.reject();
}
};
}
useEffect(() => {
if (activePaymentMethod !== methodId) {
return;
}
const unsubscribeProcessing = onPaymentSetup(() => {
if (config.scriptData.continuation) {
return {
type: responseTypes.SUCCESS,
meta: {
paymentMethodData: {
'paypal_order_id': config.scriptData.continuation.order_id,
'funding_source': window.ppcpFundingSource ?? 'paypal',
}
},
};
}
const addresses = paypalOrderToWcAddresses(paypalOrder);
return {
type: responseTypes.SUCCESS,
meta: {
paymentMethodData: {
'paypal_order_id': paypalOrder.id,
'funding_source': window.ppcpFundingSource ?? 'paypal',
},
...addresses,
},
};
});
return () => {
unsubscribeProcessing();
};
}, [onPaymentSetup, paypalOrder, activePaymentMethod]);
useEffect(() => {
if (activePaymentMethod !== methodId) {
return;
}
const unsubscribe = onCheckoutFail(({ processingResponse }) => {
console.error(processingResponse)
if (onClose) {
onClose();
}
if (config.scriptData.continuation) {
return true;
}
if (shouldHandleShippingInPayPal()) {
location.href = getCheckoutRedirectUrl();
}
return true;
});
return unsubscribe;
}, [onCheckoutFail, onClose, activePaymentMethod]);
if (config.scriptData.continuation) {
return (
<div dangerouslySetInnerHTML={{__html: config.scriptData.continuation.cancel.html}}>
</div>
)
}
if (!registeredContext) {
buttonModuleWatcher.registerContextBootstrap(config.scriptData.context, {
createOrder: () => {
return createOrder();
},
onApprove: (data, actions) => {
return handleApprove(data, actions);
},
});
registeredContext = true;
}
const style = normalizeStyleForFundingSource(config.scriptData.button.style, fundingSource);
if (!paypalScriptLoaded) {
return null;
}
const PayPalButton = paypal.Buttons.driver("react", { React, ReactDOM });
if(isPayPalSubscription(config.scriptData)) {
return (
<PayPalButton
fundingSource={fundingSource}
style={style}
onClick={handleClick}
onCancel={onClose}
onError={onClose}
createSubscription={createSubscription}
onApprove={handleApproveSubscription}
onShippingOptionsChange={(data, actions) => {
shouldHandleShippingInPayPal()
? handleSubscriptionShippingOptionsChange(data, actions)
: null;
}}
onShippingAddressChange={(data, actions) => {
shouldHandleShippingInPayPal()
? handleSubscriptionShippingAddressChange(data, actions)
: null;
}}
/>
);
}
return (
<PayPalButton
fundingSource={fundingSource}
style={style}
onClick={handleClick}
onCancel={onClose}
onError={onClose}
createOrder={createOrder}
onApprove={handleApprove}
onShippingOptionsChange={(data, actions) => {
shouldHandleShippingInPayPal()
? handleShippingOptionsChange(data, actions)
: null;
}}
onShippingAddressChange={(data, actions) => {
shouldHandleShippingInPayPal()
? handleShippingAddressChange(data, actions)
: null;
}}
/>
);
}
const BlockEditorPayPalComponent = () => {
const urlParams = {
clientId: 'test',
...config.scriptData.url_params,
dataNamespace: 'ppcp-blocks-editor-paypal-buttons',
components: 'buttons',
}
return (
<PayPalScriptProvider
options={urlParams}
>
<PayPalButtons
onClick={(data, actions) => {
return false;
}}
/>
</PayPalScriptProvider>
)
}
const features = ['products'];
let block_enabled = true;
if(cartHasSubscriptionProducts(config.scriptData)) {
// Don't show buttons on block cart page if using vault v2 and user is not logged in
if (
! config.scriptData.user.is_logged
&& config.scriptData.context === "cart-block"
&& ! isPayPalSubscription(config.scriptData) // using vaulting
&& ! config.scriptData?.save_payment_methods?.id_token // not vault v3
) {
block_enabled = false;
}
// Don't render if vaulting disabled and is in vault subscription mode
if(
! isPayPalSubscription(config.scriptData)
&& ! config.scriptData.can_save_vault_token
) {
block_enabled = false;
}
// Don't render buttons if in subscription mode and product not associated with a PayPal subscription
if(
isPayPalSubscription(config.scriptData)
&& !config.scriptData.subscription_product_allowed
) {
block_enabled = false;
}
features.push('subscriptions');
}
if (block_enabled && config.enabled) {
if ((config.addPlaceOrderMethod || config.usePlaceOrder) && !config.scriptData.continuation) {
let descriptionElement = <div dangerouslySetInnerHTML={{__html: config.description}}></div>;
if (config.placeOrderButtonDescription) {
descriptionElement = <div>
<p dangerouslySetInnerHTML={{__html: config.description}}></p>
<p style={{textAlign: 'center'}} className={'ppcp-place-order-description'} dangerouslySetInnerHTML={{__html: config.placeOrderButtonDescription}}></p>
</div>;
}
registerPaymentMethod({
name: config.id,
label: <div dangerouslySetInnerHTML={{__html: config.title}}/>,
content: descriptionElement,
edit: descriptionElement,
placeOrderButtonLabel: config.placeOrderButtonText,
ariaLabel: config.title,
canMakePayment: () => {
return config.enabled;
},
supports: {
features: features,
},
});
}
if (config.scriptData.continuation) {
registerPaymentMethod({
name: config.id,
label: <div dangerouslySetInnerHTML={{__html: config.title}}/>,
content: <PayPalComponent isEditing={false}/>,
edit: <BlockEditorPayPalComponent />,
ariaLabel: config.title,
canMakePayment: () => {
return true;
},
supports: {
features: [...features, 'ppcp_continuation'],
},
});
} else if (!config.usePlaceOrder) {
for (const fundingSource of ['paypal', ...config.enabledFundingSources]) {
registerExpressPaymentMethod({
name: `${config.id}-${fundingSource}`,
paymentMethodId: config.id,
label: <div dangerouslySetInnerHTML={{__html: config.title}}/>,
content: <PayPalComponent isEditing={false} fundingSource={fundingSource}/>,
edit: <BlockEditorPayPalComponent />,
ariaLabel: config.title,
canMakePayment: async () => {
if (!paypalScriptPromise) {
paypalScriptPromise = loadPaypalScriptPromise(config.scriptData);
paypalScriptPromise.then(() => {
const messagesBootstrap = new BlockCheckoutMessagesBootstrap(config.scriptData);
messagesBootstrap.init();
});
}
await paypalScriptPromise;
return paypal.Buttons({fundingSource}).isEligible();
},
supports: {
features: features,
},
});
}
}
}