Create sidebar for styling screen and initalize preview - no success

This commit is contained in:
inpsyde-maticluznar 2024-11-26 13:28:03 +01:00
parent 06e74e74f4
commit 5f83517509
No known key found for this signature in database
GPG key ID: D005973F231309F6
44 changed files with 3070 additions and 90 deletions

View file

@ -13,6 +13,7 @@
"@wordpress/scripts": "^30.3.0" "@wordpress/scripts": "^30.3.0"
}, },
"dependencies": { "dependencies": {
"@paypal/paypal-js": "^8.1.2",
"@woocommerce/settings": "^1.0.0", "@woocommerce/settings": "^1.0.0",
"react-select": "^5.8.3" "react-select": "^5.8.3"
} }

View file

@ -18,6 +18,7 @@ button.components-button, a.components-button {
&:not(:disabled) { &:not(:disabled) {
background-color: $color-blueberry; background-color: $color-blueberry;
color:$color-white;
} }
} }

View file

@ -21,23 +21,25 @@
} }
} }
&__checkbox-value { &__checkbox {
@include hide-input-field; position: relative;
&:not(:checked) + .ppcp-r__checkbox-presentation img { input {
display: none; margin: 0;
border-color: $color-gray-600;
&:checked {
background-color: $color-blueberry;
border-color:$color-blueberry;
}
} }
&:checked { .components-checkbox-control__input-container {
+ .ppcp-r__checkbox-presentation { margin: 0;
width: 20px; }
height: 20px;
border: none;
img { .components-base-control__field {
border-radius: 2px; margin: 0;
}
}
} }
} }

View file

@ -15,7 +15,7 @@
&__content { &__content {
margin-top: 60px; margin-top: 60px;
padding:0 24px 28px 24px; padding: 0 24px 28px 24px;
} }
} }
@ -96,6 +96,36 @@
margin-top: 2px; margin-top: 2px;
} }
} }
.components-radio-control {
.components-flex {
gap: 18px;
}
label {
@include font(14, 20, 400);
color: $color-black;
}
&__option {
gap: 18px;
}
&__input {
border-color: $color-gray-700;
margin-right: 0;
&:checked {
border: 1px solid $color-gray-700;
background-color: $color-white;
&::before {
transform: translate(3px, 3px);
border-width: 6px;
border-color: $color-blueberry;
}
}
}
}
} }
.ppcp-r-modal__field-row--save button.is-primary { .ppcp-r-modal__field-row--save button.is-primary {

View file

@ -18,10 +18,6 @@
padding-top: 24px; padding-top: 24px;
} }
p {
@include font(14, 20, 400);
}
&__inner { &__inner {
position: relative; position: relative;
display: flex; display: flex;
@ -37,6 +33,16 @@
color: $color-blueberry; color: $color-blueberry;
} }
} }
.ppcp-r__checkbox {
.components-flex {
gap: 12px;
}
label{
@include font(13, 20, 400);
color:$color-blueberry;
}
}
} }
.ppcp-r-feature-item { .ppcp-r-feature-item {
@ -108,18 +114,20 @@
gap: 12px; gap: 12px;
} }
&__status-toggle--toggled{ &__status-toggle--toggled {
.ppcp-r-connection-status__show-all-data{ .ppcp-r-connection-status__show-all-data {
transform:rotate(180deg); transform: rotate(180deg);
} }
} }
&__status-row { &__status-row {
display: flex; display: flex;
align-items: center; align-items: center;
*{
* {
user-select: none; user-select: none;
} }
strong { strong {
@include font(14, 24, 600); @include font(14, 24, 600);
color: $color-gray-800; color: $color-gray-800;
@ -131,11 +139,13 @@
@include font(14, 24, 400); @include font(14, 24, 400);
color: $color-gray-800; color: $color-gray-800;
} }
.ppcp-r-connection-status__status-toggle{
.ppcp-r-connection-status__status-toggle {
line-height: 0; line-height: 0;
} }
&--first{
&:hover{ &--first {
&:hover {
cursor: pointer; cursor: pointer;
} }
} }
@ -148,11 +158,13 @@
} }
&__status-row { &__status-row {
flex-wrap: wrap; flex-wrap: wrap;
strong{
strong {
width: 100%; width: 100%;
} }
span{
word-break:break-all; span {
word-break: break-all;
} }
} }
} }

View file

@ -0,0 +1,108 @@
.ppcp-r-styling {
display: flex;
border: 1px solid $color-gray-200;
border-radius: 8px;
overflow: hidden;
&__section:not(:last-child) {
border-bottom: 1px solid black;
padding-bottom: 24px;
margin-bottom: 28px;
border-bottom: 1px solid $color-gray-600;
}
&__main-title {
@include font(14, 20, 600);
color: $color-gray-800;
margin: 0 0 8px 0;
display: block;
}
&__description {
@include font(13, 20, 400);
color: $color-gray-800;
margin: 0 0 18px 0;
}
&__settings {
width: 422px;
background-color: $color-white;
padding: 48px;
}
&__preview {
width: calc(100% - 422px);
background-color: #FAF8F5;
}
&__section--rc{
.ppcp-r-styling__title {
@include font(13, 20, 600);
color: $color-black;
display: block;
margin: 0 0 18px 0;
}
}
&__select {
label {
@include font(13, 16, 600);
color: $color-black;
margin: 0;
text-transform: none;
}
select {
@include font(13, 20, 400);
}
}
.ppcp-r__checkbox {
.components-checkbox-control {
&__label {
@include font(13, 20, 400);
color: $color-black;
}
}
.components-flex {
gap: 12px;
}
}
&__payment-method-checkboxes {
display: flex;
flex-direction: column;
gap: 24px;
}
}
.ppcp-r {
&__horizontal-control {
.components-flex {
flex-direction: row;
justify-content: flex-start;
gap: 32px;
}
.components-radio-control {
&__option {
gap: 12px;
input {
margin: 0;
}
label {
@include font(13, 20, 400);
color: $color-black;
}
}
input {
margin: 0;
}
}
}
}

View file

@ -24,6 +24,7 @@
@import './components/screens/overview/tab-overview'; @import './components/screens/overview/tab-overview';
@import './components/screens/overview/tab-payment-methods'; @import './components/screens/overview/tab-payment-methods';
@import './components/screens/overview/tab-settings'; @import './components/screens/overview/tab-settings';
@import './components/screens/overview/tab-styling';
} }
@import './components/reusable-components/payment-method-modal'; @import './components/reusable-components/payment-method-modal';

View file

@ -1,25 +1,38 @@
import data from '../../utils/data'; import { CheckboxControl } from '@wordpress/components';
export const PayPalCheckbox = ( props ) => { export const PayPalCheckbox = ( props ) => {
return ( return (
<div className="ppcp-r__checkbox"> <div className="ppcp-r__checkbox">
<input <CheckboxControl
className="ppcp-r__checkbox-value" label={ props?.label ? props.label : '' }
type="checkbox"
checked={ props.currentValue.includes( props.value ) }
name={ props.name }
value={ props.value } value={ props.value }
onChange={ ( e ) => checked={ props.currentValue.includes( props.value ) }
props.handleCheckboxState( e.target.checked, props ) onChange={ ( checked ) =>
handleCheckboxState( checked, props )
} }
/> />
<span className="ppcp-r__checkbox-presentation">
{ data().getImage( 'icon-checkbox.svg' ) }
</span>
</div> </div>
); );
}; };
export const PayPalCheckboxGroup = ( props ) => {
const renderCheckboxGroup = () => {
return props.value.map( ( checkbox ) => {
return (
<PayPalCheckbox
label={ checkbox.label }
value={ checkbox.value }
key={ checkbox.value }
currentValue={ props.currentValue }
changeCallback={ props.changeCallback }
/>
);
} );
};
return <>{ renderCheckboxGroup() }</>;
};
export const PayPalRdb = ( props ) => { export const PayPalRdb = ( props ) => {
return ( return (
<div className="ppcp-r__radio"> <div className="ppcp-r__radio">
@ -75,7 +88,6 @@ export const handleCheckboxState = ( checked, props ) => {
let newValue = null; let newValue = null;
if ( checked ) { if ( checked ) {
newValue = [ ...props.currentValue, props.value ]; newValue = [ ...props.currentValue, props.value ];
props.changeCallback( newValue );
} else { } else {
newValue = props.currentValue.filter( newValue = props.currentValue.filter(
( value ) => value !== props.value ( value ) => value !== props.value

View file

@ -1,5 +1,5 @@
import data from '../../utils/data'; import data from '../../utils/data';
import { PayPalCheckbox, PayPalRdb, handleCheckboxState } from './Fields'; import { PayPalCheckbox, PayPalRdb } from './Fields';
const SelectBox = ( props ) => { const SelectBox = ( props ) => {
let boxClassName = 'ppcp-r-select-box'; let boxClassName = 'ppcp-r-select-box';
@ -24,10 +24,7 @@ const SelectBox = ( props ) => {
) } ) }
{ props.type === 'checkbox' && ( { props.type === 'checkbox' && (
<PayPalCheckbox <PayPalCheckbox
{ ...{ { ...props }
...props,
handleCheckboxState,
} }
/> />
) } ) }
<div className="ppcp-r-select-box__content"> <div className="ppcp-r-select-box__content">

View file

@ -1,15 +1,28 @@
import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components'; import { Button } from '@wordpress/components';
import {
PayPalRdb,
PayPalRdbWithContent,
} from '../../../ReusableComponents/Fields';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { RadioControl } from '@wordpress/components';
const THREED_SECURE_GROUP_NAME = 'threed-secure';
const ModalAcdc = ( { setModalIsVisible } ) => { const ModalAcdc = ( { setModalIsVisible } ) => {
const [ threeDSecure, setThreeDSecure ] = useState( 'no-3d-secure' ); const [ threeDSecure, setThreeDSecure ] = useState( 'no-3d-secure' );
const acdcOptions = [
{
label: __( 'No 3D Secure', 'woocommerce-paypal-payments' ),
value: 'no-3d-secure',
},
{
label: __( 'Only when required', 'woocommerce-paypal-payments' ),
value: 'only-required-3d-secure',
},
{
label: __(
'Always require 3D Secure',
'woocommerce-paypal-payments'
),
value: 'always-3d-secure',
},
];
return ( return (
<PaymentMethodModal <PaymentMethodModal
@ -30,39 +43,10 @@ const ModalAcdc = ( { setModalIsVisible } ) => {
) } ) }
</p> </p>
<div className="ppcp-r-modal__field-rows ppcp-r-modal__field-rows--acdc"> <div className="ppcp-r-modal__field-rows ppcp-r-modal__field-rows--acdc">
<PayPalRdbWithContent <RadioControl
id="no-3d-secure" onChange={ setThreeDSecure }
name={ THREED_SECURE_GROUP_NAME } selected={ threeDSecure }
value="no-3d-secure" options={ acdcOptions }
currentValue={ threeDSecure }
handleRdbState={ setThreeDSecure }
label={ __(
'No 3D Secure',
'woocommerce-paypal-payments'
) }
/>
<PayPalRdbWithContent
id="only-required-3d-secure"
name={ THREED_SECURE_GROUP_NAME }
value="only-required-3d-secure"
currentValue={ threeDSecure }
handleRdbState={ setThreeDSecure }
label={ __(
'Only when required',
'woocommerce-paypal-payments'
) }
/>
<PayPalRdbWithContent
id="always-3d-secure"
name={ THREED_SECURE_GROUP_NAME }
value="always-3d-secure"
currentValue={ threeDSecure }
handleRdbState={ setThreeDSecure }
label={ __(
'Always require 3D Secure',
'woocommerce-paypal-payments'
) }
/> />
<div className="ppcp-r-modal__field-row ppcp-r-modal__field-row--save"> <div className="ppcp-r-modal__field-row ppcp-r-modal__field-row--save">

View file

@ -2,7 +2,6 @@ import SettingsCard from '../../ReusableComponents/SettingsCard';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { import {
PayPalCheckbox, PayPalCheckbox,
handleCheckboxState,
} from '../../ReusableComponents/Fields'; } from '../../ReusableComponents/Fields';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import data from '../../../utils/data'; import data from '../../../utils/data';
@ -41,7 +40,7 @@ const TabOverview = () => {
value={ todo.value } value={ todo.value }
currentValue={ todos } currentValue={ todos }
changeCallback={ setTodos } changeCallback={ setTodos }
description={ todo.description } label={ todo.description }
changeTodos={ setTodosData } changeTodos={ setTodosData }
todosData={ todosData } todosData={ todosData }
/> />
@ -144,10 +143,8 @@ const TodoItem = ( props ) => {
<PayPalCheckbox <PayPalCheckbox
{ ...{ { ...{
...props, ...props,
handleCheckboxState,
} } } }
/>{ ' ' } />{ ' ' }
<p>{ props.description }</p>
</div> </div>
<div <div
className="ppcp-r-todo-item__close" className="ppcp-r-todo-item__close"

View file

@ -24,7 +24,6 @@ const TabSettings = () => {
buttonLanguage: '', buttonLanguage: '',
} ); } );
const updateFormValue = ( key, value ) => { const updateFormValue = ( key, value ) => {
console.log( key, value );
setSettings( { ...settings, [ key ]: value } ); setSettings( { ...settings, [ key ]: value } );
}; };

View file

@ -1,5 +1,256 @@
import { __, sprintf } from '@wordpress/i18n';
import { SelectControl, RadioControl } from '@wordpress/components';
import { PayPalCheckboxGroup } from '../../ReusableComponents/Fields';
import { useState, useMemo, useEffect } from '@wordpress/element';
import {
defaultLocationSettings,
paymentMethodOptions,
colorOptions,
shapeOptions,
buttonLayoutOptions,
buttonLabelOptions,
} from '../../../data/settings/tab-styling-data';
import Renderer from '../../../utils/renderer';
const TabStyling = () => { const TabStyling = () => {
return <div>Styling tab</div>; useEffect( () => {
generatePreview();
}, [] );
const [ location, setLocation ] = useState( 'cart' );
const [ locationSettings, setLocationSettings ] = useState( {
...defaultLocationSettings,
} );
const currentLocationSettings = useMemo( () => {
return locationSettings[ location ];
}, [ location, locationSettings ] );
const locationOptions = useMemo( () => {
return Object.keys( locationSettings ).reduce(
( locationOptionsData, key ) => {
locationOptionsData.push( {
value: locationSettings[ key ].value,
label: locationSettings[ key ].label,
} );
return locationOptionsData;
},
[]
);
}, [] );
const updateButtonSettings = ( key, value ) => {
const updatedLocationSettings = { ...currentLocationSettings };
updatedLocationSettings.settings = {
...updatedLocationSettings.settings,
...{ [ key ]: value },
};
setLocationSettings( {
...locationSettings,
[ location ]: { ...updatedLocationSettings },
} );
};
return (
<div className="ppcp-r-styling">
<div className="ppcp-r-styling__settings">
<SectionIntro />
<TabStylingSection className="ppcp-r-styling__section--rc">
<SelectControl
className="ppcp-r-styling__select"
value={ location }
onChange={ ( newLocation ) =>
setLocation( newLocation )
}
label={ __(
'Locations',
'woocommerce-paypal-payments'
) }
options={ locationOptions }
/>
</TabStylingSection>
<TabStylingSection
title={ __(
'Payment Methods',
'woocommerce-paypal-payments'
) }
className="ppcp-r-styling__section--rc"
>
<div className="ppcp-r-styling__payment-method-checkboxes">
<PayPalCheckboxGroup
value={ paymentMethodOptions }
changeCallback={ ( newValue ) =>
updateButtonSettings(
'paymentMethods',
newValue
)
}
currentValue={
currentLocationSettings.settings.paymentMethods
}
/>
</div>
</TabStylingSection>
{ currentLocationSettings.settings?.buttonLayout && (
<TabStylingSection
className="ppcp-r-styling__section--rc"
title={ __(
'Button Layout',
'woocommerce-paypal-payments'
) }
>
<RadioControl
className="ppcp-r__horizontal-control"
onChange={ ( newValue ) =>
updateButtonSettings( 'buttonLayout', newValue )
}
selected={
currentLocationSettings.settings.buttonLayout
}
options={ buttonLayoutOptions }
/>
</TabStylingSection>
) }
<TabStylingSection
title={ __( 'Shape', 'woocommerce-paypal-payments' ) }
className="ppcp-r-styling__section--rc"
>
<RadioControl
className="ppcp-r__horizontal-control"
onChange={ ( newValue ) =>
updateButtonSettings( 'shape', newValue )
}
selected={ currentLocationSettings.settings.shape }
options={ shapeOptions }
/>
</TabStylingSection>
<TabStylingSection>
<SelectControl
className="ppcp-r-styling__select"
onChange={ ( newValue ) =>
updateButtonSettings( 'buttonLabel', newValue )
}
selected={
currentLocationSettings.settings.buttonLabel
}
label={ __(
'Button Label',
'woocommerce-paypal-payments'
) }
options={ buttonLabelOptions }
/>
</TabStylingSection>
<TabStylingSection>
<SelectControl
className="ppcp-r-styling__select"
label={ __(
'Button Color',
'woocommerce-paypal-payments'
) }
options={ colorOptions }
/>
</TabStylingSection>
{ currentLocationSettings.settings?.tagLine && (
<TabStylingSection
title={ __( 'Tagline', 'woocommerce-paypal-payments' ) }
className="ppcp-r-styling__section--rc"
>
<PayPalCheckboxGroup
value={ [
{
value: 'enable-tagline',
label: __(
'Enable Tagline',
'woocommerce-paypal-payments'
),
},
] }
changeCallback={ ( newValue ) =>
updateButtonSettings( 'tagLine', newValue )
}
currentValue={
currentLocationSettings.settings.tagLine
}
/>
</TabStylingSection>
) }
</div>
<div
id="ppcp-r-styling-preview"
className="ppcp-r-styling__preview"
>
<div className="ppcp-preview"></div>
</div>
</div>
);
};
const TabStylingSection = ( props ) => {
let sectionTitleClassName = 'ppcp-r-styling__section';
if ( props?.className ) {
sectionTitleClassName += ` ${ props.className }`;
}
return (
<div className={ sectionTitleClassName }>
<span className="ppcp-r-styling__title">{ props.title }</span>
{ props?.description && (
<p
dangerouslySetInnerHTML={ {
__html: props.description,
} }
className="ppcp-r-styling__description"
/>
) }
{ props.children }
</div>
);
};
const SectionIntro = () => {
const buttonStyleDescription = sprintf(
// translators: %s: Link to Classic checkout page
__(
'Customize the appearance of the PayPal smart buttons on the <a href="%s">[MISSING LINK]Classic Checkout page</a>. Checkout Buttons must be enabled to display the PayPal gateway on the Checkout page.'
),
'#'
);
return (
<TabStylingSection
className="ppcp-r-styling__section--rc ppcp-r-styling__section--empty"
title={ __( 'Button Styling', 'wooocommerce-paypal-payments' ) }
description={ buttonStyleDescription }
></TabStylingSection>
);
};
const generatePreview = () => {
const settings = {
button: {
wrapper: '#ppcp-r-styling-preview',
style: {
color: 'gold',
shape: 'rect',
label: 'paypal',
tagline: false,
layout: 'horizontal',
},
},
separate_buttons: {},
};
const renderer = new Renderer(
null,
settings,
( data, actions ) => actions.reject(),
null
);
jQuery( document ).trigger( 'ppcp_paypal_render_preview', settings );
renderer.render( {} );
}; };
export default TabStyling; export default TabStyling;

View file

@ -0,0 +1,132 @@
import { __ } from '@wordpress/i18n';
const settings = {
paymentMethods: [],
buttonLayout: 'horizontal',
shape: null,
buttonLabel: 'paypal',
buttonColor: 'gold',
tagLine: [],
};
const cartAndExpressCheckoutSettings = {
paymentMethods: [],
shape: null,
buttonLabel: 'paypal',
buttonColor: 'gold',
};
export const defaultLocationSettings = {
cart: {
value: 'cart',
label: __( 'Cart', 'woocommerce-paypal-payments' ),
settings: { ...cartAndExpressCheckoutSettings },
},
'classic-checkout': {
value: 'classic-checkout',
label: __( 'Classic Checkout', 'woocommerce-paypal-payments' ),
settings: { ...settings },
},
'express-checkout': {
value: 'express-checkout',
label: __( 'Express Checkout', 'woocommerce-paypal-payments' ),
settings: { ...cartAndExpressCheckoutSettings },
},
'mini-cart': {
value: 'mini-cart',
label: __( 'Mini Cart', 'woocommerce-paypel-payements' ),
settings: { ...settings },
},
'product-page': {
value: 'product-page',
label: __( 'Product Page', 'woocommerce-paypal-payments' ),
settings: { ...settings },
},
};
export const paymentMethodOptions = [
{
value: 'venmo',
label: __( 'Venmo', 'woocommerce-paypal-payments' ),
},
{
value: 'pay-later',
label: __( 'Pay Later', 'woocommerce-paypal-payments' ),
},
{
value: 'debit-or-credit-card',
label: __( 'Debit or Credit Card', 'woocommerce-paypal-payments' ),
},
{
value: 'google-pay',
label: __( 'Google Pay', 'woocommerce-paypal-payments' ),
},
{
value: 'apple-pay',
label: __( 'Apple Pay', 'woocommerce-paypal-payments' ),
},
];
export const buttonLabelOptions = [
{
value: 'paypal',
label: __( 'PayPal', 'woocommerce-paypal-payments' ),
},
{
value: 'checkout',
label: __( 'Checkout', 'woocommerce-paypal-payments' ),
},
{
value: 'paypal-buy-now',
label: __( 'PayPal Buy Now', 'woocommerce-paypal-payments' ),
},
{
value: 'pay-with-paypal',
label: __( 'Pay with PayPal', 'woocommerce-paypal-payments' ),
},
];
export const colorOptions = [
{
value: 'gold',
label: __( 'Gold (Recommended)', 'woocommerce-paypal-payments' ),
},
{
value: 'blue',
label: __( 'Blue', 'woocommerce-paypal-payments' ),
},
{
value: 'silver',
label: __( 'Silver', 'woocommerce-paypal-payments' ),
},
{
value: 'black',
label: __( 'Black', 'woocommerce-paypal-payments' ),
},
{
value: 'white',
label: __( 'White', 'woocommerce-paypal-payments' ),
},
];
export const buttonLayoutOptions = [
{
label: __( 'Vertical', 'woocommerce-paypal-payments' ),
value: 'vertical',
},
{
label: __( 'Horizontal', 'woocommerce-paypal-payments' ),
value: 'horizontal',
},
];
export const shapeOptions = [
{
value: 'pill',
label: __( 'Pill', 'woocommerce-paypal-payments' ),
},
{
value: 'rectangle',
label: __( 'Rectangle', 'woocommerce-paypal-payments' ),
},
];

View file

@ -0,0 +1,129 @@
export const apmButtonsInit = ( config, selector = '.ppcp-button-apm' ) => {
let selectorInContainer = selector;
if ( window.ppcpApmButtons ) {
return;
}
if ( config && config.button ) {
// If it's separate gateways, modify wrapper to account for the individual buttons as individual APMs.
const wrapper = config.button.wrapper;
const isSeparateGateways =
jQuery( wrapper ).children( 'div[class^="item-"]' ).length > 0;
if ( isSeparateGateways ) {
selector += `, ${ wrapper } div[class^="item-"]`;
selectorInContainer += `, div[class^="item-"]`;
}
}
window.ppcpApmButtons = new ApmButtons( selector, selectorInContainer );
};
export class ApmButtons {
constructor( selector, selectorInContainer ) {
this.selector = selector;
this.selectorInContainer = selectorInContainer;
this.containers = [];
// Reloads button containers.
this.reloadContainers();
// Refresh button layout.
jQuery( window )
.resize( () => {
this.refresh();
} )
.resize();
jQuery( document ).on( 'ppcp-smart-buttons-init', () => {
this.refresh();
} );
jQuery( document ).on(
'ppcp-shown ppcp-hidden ppcp-enabled ppcp-disabled',
( ev, data ) => {
this.refresh();
setTimeout( this.refresh.bind( this ), 200 );
}
);
// Observes for new buttons.
new MutationObserver(
this.observeElementsCallback.bind( this )
).observe( document.body, { childList: true, subtree: true } );
}
observeElementsCallback( mutationsList, observer ) {
const observeSelector =
this.selector +
', .widget_shopping_cart, .widget_shopping_cart_content';
let shouldReload = false;
for ( const mutation of mutationsList ) {
if ( mutation.type === 'childList' ) {
mutation.addedNodes.forEach( ( node ) => {
if ( node.matches && node.matches( observeSelector ) ) {
shouldReload = true;
}
} );
}
}
if ( shouldReload ) {
this.reloadContainers();
this.refresh();
}
}
reloadContainers() {
jQuery( this.selector ).each( ( index, el ) => {
const parent = jQuery( el ).parent();
if ( ! this.containers.some( ( $el ) => $el.is( parent ) ) ) {
this.containers.push( parent );
}
} );
}
refresh() {
for ( const container of this.containers ) {
const $container = jQuery( container );
// Check width and add classes
const width = $container.width();
$container.removeClass(
'ppcp-width-500 ppcp-width-300 ppcp-width-min'
);
if ( width >= 500 ) {
$container.addClass( 'ppcp-width-500' );
} else if ( width >= 300 ) {
$container.addClass( 'ppcp-width-300' );
} else {
$container.addClass( 'ppcp-width-min' );
}
// Check first apm button
const $firstElement = $container.children( ':visible' ).first();
// Assign margins to buttons
$container.find( this.selectorInContainer ).each( ( index, el ) => {
const $el = jQuery( el );
if ( $el.is( $firstElement ) ) {
$el.css( 'margin-top', `0px` );
return true;
}
const minMargin = 11; // Minimum margin.
const height = $el.height();
const margin = Math.max(
minMargin,
Math.round( height * 0.3 )
);
$el.css( 'margin-top', `${ margin }px` );
} );
}
}
}

View file

@ -0,0 +1,54 @@
import { disable, enable, isDisabled } from './ButtonDisabler';
import merge from 'deepmerge';
/**
* Common Bootstrap methods to avoid code repetition.
*/
export default class BootstrapHelper {
static handleButtonStatus( bs, options ) {
options = options || {};
options.wrapper = options.wrapper || bs.gateway.button.wrapper;
const wasDisabled = isDisabled( options.wrapper );
const shouldEnable = bs.shouldEnable();
// Handle enable / disable
if ( shouldEnable && wasDisabled ) {
bs.renderer.enableSmartButtons( options.wrapper );
enable( options.wrapper );
} else if ( ! shouldEnable && ! wasDisabled ) {
bs.renderer.disableSmartButtons( options.wrapper );
disable( options.wrapper, options.formSelector || null );
}
if ( wasDisabled !== ! shouldEnable ) {
jQuery( options.wrapper ).trigger( 'ppcp_buttons_enabled_changed', [
shouldEnable,
] );
}
}
static shouldEnable( bs, options ) {
options = options || {};
if ( typeof options.isDisabled === 'undefined' ) {
options.isDisabled = bs.gateway.button.is_disabled;
}
return bs.shouldRender() && options.isDisabled !== true;
}
static updateScriptData( bs, newData ) {
const newObj = merge( bs.gateway, newData );
const isChanged =
JSON.stringify( bs.gateway ) !== JSON.stringify( newObj );
bs.gateway = newObj;
if ( isChanged ) {
jQuery( document.body ).trigger( 'ppcp_script_data_changed', [
newObj,
] );
}
}
}

View file

@ -0,0 +1,86 @@
/**
* @param selectorOrElement
* @return {Element}
*/
const getElement = ( selectorOrElement ) => {
if ( typeof selectorOrElement === 'string' ) {
return document.querySelector( selectorOrElement );
}
return selectorOrElement;
};
const triggerEnabled = ( selectorOrElement, element ) => {
jQuery( document ).trigger( 'ppcp-enabled', {
handler: 'ButtonsDisabler.setEnabled',
action: 'enable',
selector: selectorOrElement,
element,
} );
};
const triggerDisabled = ( selectorOrElement, element ) => {
jQuery( document ).trigger( 'ppcp-disabled', {
handler: 'ButtonsDisabler.setEnabled',
action: 'disable',
selector: selectorOrElement,
element,
} );
};
export const setEnabled = ( selectorOrElement, enable, form = null ) => {
const element = getElement( selectorOrElement );
if ( ! element ) {
return;
}
if ( enable ) {
jQuery( element )
.removeClass( 'ppcp-disabled' )
.off( 'mouseup' )
.find( '> *' )
.css( 'pointer-events', '' );
triggerEnabled( selectorOrElement, element );
} else {
jQuery( element )
.addClass( 'ppcp-disabled' )
.on( 'mouseup', function ( event ) {
event.stopImmediatePropagation();
if ( form ) {
// Trigger form submit to show the error message
const $form = jQuery( form );
if (
$form
.find( '.single_add_to_cart_button' )
.hasClass( 'disabled' )
) {
$form.find( ':submit' ).trigger( 'click' );
}
}
} )
.find( '> *' )
.css( 'pointer-events', 'none' );
triggerDisabled( selectorOrElement, element );
}
};
export const isDisabled = ( selectorOrElement ) => {
const element = getElement( selectorOrElement );
if ( ! element ) {
return false;
}
return jQuery( element ).hasClass( 'ppcp-disabled' );
};
export const disable = ( selectorOrElement, form = null ) => {
setEnabled( selectorOrElement, false, form );
};
export const enable = ( selectorOrElement ) => {
setEnabled( selectorOrElement, true );
};

View file

@ -0,0 +1,46 @@
import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce';
const REFRESH_BUTTON_EVENT = 'ppcp_refresh_payment_buttons';
/**
* Triggers a refresh of the payment buttons.
* This function dispatches a custom event that the button components listen for.
*
* Use this function on the front-end to update payment buttons after the checkout form was updated.
*/
export function refreshButtons() {
document.dispatchEvent( new Event( REFRESH_BUTTON_EVENT ) );
}
/**
* Sets up event listeners for various cart and checkout update events.
* When these events occur, it triggers a refresh of the payment buttons.
*
* @param {Function} refresh - Callback responsible to re-render the payment button.
*/
export function setupButtonEvents( refresh ) {
const miniCartInitDelay = 1000;
const debouncedRefresh = debounce( refresh, 50 );
// Listen for our custom refresh event.
document.addEventListener( REFRESH_BUTTON_EVENT, debouncedRefresh );
// Listen for cart and checkout update events.
// Note: we need jQuery here, because WooCommerce uses jQuery.trigger() to dispatch the events.
window
.jQuery( 'body' )
.on( 'updated_cart_totals', debouncedRefresh )
.on( 'updated_checkout', debouncedRefresh );
// Use setTimeout for fragment events to avoid unnecessary refresh on initial render.
setTimeout( () => {
document.body.addEventListener(
'wc_fragments_loaded',
debouncedRefresh
);
document.body.addEventListener(
'wc_fragments_refreshed',
debouncedRefresh
);
}, miniCartInitDelay );
}

View file

@ -0,0 +1,77 @@
class CartHelper {
constructor( cartItemKeys = [] ) {
this.cartItemKeys = cartItemKeys;
}
getEndpoint() {
let ajaxUrl = '/?wc-ajax=%%endpoint%%';
if (
typeof wc_cart_fragments_params !== 'undefined' &&
wc_cart_fragments_params.wc_ajax_url
) {
ajaxUrl = wc_cart_fragments_params.wc_ajax_url;
}
return ajaxUrl.toString().replace( '%%endpoint%%', 'remove_from_cart' );
}
addFromPurchaseUnits( purchaseUnits ) {
for ( const purchaseUnit of purchaseUnits || [] ) {
for ( const item of purchaseUnit.items || [] ) {
if ( ! item.cart_item_key ) {
continue;
}
this.cartItemKeys.push( item.cart_item_key );
}
}
return this;
}
removeFromCart() {
return new Promise( ( resolve, reject ) => {
if ( ! this.cartItemKeys || ! this.cartItemKeys.length ) {
resolve();
return;
}
const numRequests = this.cartItemKeys.length;
let numResponses = 0;
const tryToResolve = () => {
numResponses++;
if ( numResponses >= numRequests ) {
resolve();
}
};
for ( const cartItemKey of this.cartItemKeys ) {
const params = new URLSearchParams();
params.append( 'cart_item_key', cartItemKey );
if ( ! cartItemKey ) {
tryToResolve();
continue;
}
fetch( this.getEndpoint(), {
method: 'POST',
credentials: 'same-origin',
body: params,
} )
.then( function ( res ) {
return res.json();
} )
.then( () => {
tryToResolve();
} )
.catch( () => {
tryToResolve();
} );
}
} );
}
}
export default CartHelper;

View file

@ -0,0 +1,55 @@
import Spinner from './Spinner';
import FormValidator from './FormValidator';
import ErrorHandler from '../ErrorHandler';
const validateCheckoutForm = function ( config ) {
return new Promise( async ( resolve, reject ) => {
try {
const spinner = new Spinner();
const errorHandler = new ErrorHandler(
config.labels.error.generic,
document.querySelector( '.woocommerce-notices-wrapper' )
);
const formSelector =
config.context === 'checkout'
? 'form.checkout'
: 'form#order_review';
const formValidator = config.early_checkout_validation_enabled
? new FormValidator(
config.ajax.validate_checkout.endpoint,
config.ajax.validate_checkout.nonce
)
: null;
if ( ! formValidator ) {
resolve();
return;
}
formValidator
.validate( document.querySelector( formSelector ) )
.then( ( errors ) => {
if ( errors.length > 0 ) {
spinner.unblock();
errorHandler.clear();
errorHandler.messages( errors );
// fire WC event for other plugins
jQuery( document.body ).trigger( 'checkout_error', [
errorHandler.currentHtml(),
] );
reject();
} else {
resolve();
}
} );
} catch ( error ) {
console.error( error );
reject();
}
} );
};
export default validateCheckoutForm;

View file

@ -0,0 +1,48 @@
export const PaymentMethods = {
PAYPAL: 'ppcp-gateway',
CARDS: 'ppcp-credit-card-gateway',
OXXO: 'ppcp-oxxo-gateway',
CARD_BUTTON: 'ppcp-card-button-gateway',
GOOGLEPAY: 'ppcp-googlepay',
APPLEPAY: 'ppcp-applepay',
};
/**
* List of valid context values that the button can have.
*
* The "context" describes the placement or page where a payment button might be displayed.
*
* @type {Object}
*/
export const PaymentContext = {
Cart: 'cart', // Classic cart.
Checkout: 'checkout', // Classic checkout.
BlockCart: 'cart-block', // Block cart.
BlockCheckout: 'checkout-block', // Block checkout.
Product: 'product', // Single product page.
MiniCart: 'mini-cart', // Mini cart available on all pages except checkout & cart.
PayNow: 'pay-now', // Pay for order, via admin generated link.
Preview: 'preview', // Layout preview on settings page.
// Contexts that use blocks to render payment methods.
Blocks: [ 'cart-block', 'checkout-block' ],
// Contexts that display "classic" payment gateways.
Gateways: [ 'checkout', 'pay-now' ],
};
export const ORDER_BUTTON_SELECTOR = '#place_order';
export const getCurrentPaymentMethod = () => {
const el = document.querySelector( 'input[name="payment_method"]:checked' );
if ( ! el ) {
return null;
}
return el.value;
};
export const isSavedCardSelected = () => {
const savedCardList = document.querySelector( '#saved-credit-card' );
return savedCardList && savedCardList.value !== '';
};

View file

@ -0,0 +1,31 @@
import merge from 'deepmerge';
import { v4 as uuidv4 } from 'uuid';
import { keysToCamelCase } from './Utils';
const processAxoConfig = ( config ) => {
const scriptOptions = {};
const sdkClientToken = config?.axo?.sdk_client_token;
const uuid = uuidv4().replace( /-/g, '' );
if ( sdkClientToken && config?.user?.is_logged !== true ) {
scriptOptions[ 'data-sdk-client-token' ] = sdkClientToken;
scriptOptions[ 'data-client-metadata-id' ] = uuid;
}
return scriptOptions;
};
const processUserIdToken = ( config ) => {
const userIdToken = config?.save_payment_methods?.id_token;
return userIdToken && config?.user?.is_logged === true
? { 'data-user-id-token': userIdToken }
: {};
};
export const processConfig = ( config ) => {
let scriptOptions = keysToCamelCase( config.url_params );
if ( config.script_attributes ) {
scriptOptions = merge( scriptOptions, config.script_attributes );
}
const axoOptions = processAxoConfig( config );
const userIdTokenOptions = processUserIdToken( config );
return merge.all( [ scriptOptions, axoOptions, userIdTokenOptions ] );
};

View file

@ -0,0 +1,21 @@
const dccInputFactory = ( original ) => {
const styles = window.getComputedStyle( original );
const newElement = document.createElement( 'span' );
newElement.setAttribute( 'id', original.id );
newElement.setAttribute( 'class', original.className );
Object.values( styles ).forEach( ( prop ) => {
if (
! styles[ prop ] ||
! isNaN( prop ) ||
prop === 'background-image'
) {
return;
}
newElement.style.setProperty( prop, '' + styles[ prop ] );
} );
return newElement;
};
export default dccInputFactory;

View file

@ -0,0 +1,52 @@
/**
* Common Form utility methods
*/
export default class FormHelper {
static getPrefixedFields( formElement, prefix ) {
const formData = new FormData( formElement );
const fields = {};
for ( const [ name, value ] of formData.entries() ) {
if ( ! prefix || name.startsWith( prefix ) ) {
fields[ name ] = value;
}
}
return fields;
}
static getFilteredFields( formElement, exactFilters, prefixFilters ) {
const formData = new FormData( formElement );
const fields = {};
const counters = {};
for ( let [ name, value ] of formData.entries() ) {
// Handle array format
if ( name.indexOf( '[]' ) !== -1 ) {
const k = name;
counters[ k ] = counters[ k ] || 0;
name = name.replace( '[]', `[${ counters[ k ] }]` );
counters[ k ]++;
}
if ( ! name ) {
continue;
}
if ( exactFilters && exactFilters.indexOf( name ) !== -1 ) {
continue;
}
if (
prefixFilters &&
prefixFilters.some( ( prefixFilter ) =>
name.startsWith( prefixFilter )
)
) {
continue;
}
fields[ name ] = value;
}
return fields;
}
}

View file

@ -0,0 +1,28 @@
export default class FormSaver {
constructor( url, nonce ) {
this.url = url;
this.nonce = nonce;
}
async save( form ) {
const formData = new FormData( form );
const res = await fetch( this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify( {
nonce: this.nonce,
form_encoded: new URLSearchParams( formData ).toString(),
} ),
} );
const data = await res.json();
if ( ! data.success ) {
throw Error( data.data.message );
}
}
}

View file

@ -0,0 +1,37 @@
export default class FormValidator {
constructor( url, nonce ) {
this.url = url;
this.nonce = nonce;
}
async validate( form ) {
const formData = new FormData( form );
const res = await fetch( this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify( {
nonce: this.nonce,
form_encoded: new URLSearchParams( formData ).toString(),
} ),
} );
const data = await res.json();
if ( ! data.success ) {
if ( data.data.refresh ) {
jQuery( document.body ).trigger( 'update_checkout' );
}
if ( data.data.errors ) {
return data.data.errors;
}
throw Error( data.data.message );
}
return [];
}
}

View file

@ -0,0 +1,92 @@
/**
* @param selectorOrElement
* @return {Element}
*/
const getElement = ( selectorOrElement ) => {
if ( typeof selectorOrElement === 'string' ) {
return document.querySelector( selectorOrElement );
}
return selectorOrElement;
};
const triggerHidden = ( handler, selectorOrElement, element ) => {
jQuery( document ).trigger( 'ppcp-hidden', {
handler,
action: 'hide',
selector: selectorOrElement,
element,
} );
};
const triggerShown = ( handler, selectorOrElement, element ) => {
jQuery( document ).trigger( 'ppcp-shown', {
handler,
action: 'show',
selector: selectorOrElement,
element,
} );
};
export const isVisible = ( element ) => {
return !! (
element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length
);
};
export const setVisible = ( selectorOrElement, show, important = false ) => {
const element = getElement( selectorOrElement );
if ( ! element ) {
return;
}
const currentValue = element.style.getPropertyValue( 'display' );
if ( ! show ) {
if ( currentValue === 'none' ) {
return;
}
element.style.setProperty(
'display',
'none',
important ? 'important' : ''
);
triggerHidden( 'Hiding.setVisible', selectorOrElement, element );
} else {
if ( currentValue === 'none' ) {
element.style.removeProperty( 'display' );
triggerShown( 'Hiding.setVisible', selectorOrElement, element );
}
// still not visible (if something else added display: none in CSS)
if ( ! isVisible( element ) ) {
element.style.setProperty( 'display', 'block' );
triggerShown( 'Hiding.setVisible', selectorOrElement, element );
}
}
};
export const setVisibleByClass = ( selectorOrElement, show, hiddenClass ) => {
const element = getElement( selectorOrElement );
if ( ! element ) {
return;
}
if ( show ) {
element.classList.remove( hiddenClass );
triggerShown( 'Hiding.setVisibleByClass', selectorOrElement, element );
} else {
element.classList.add( hiddenClass );
triggerHidden( 'Hiding.setVisibleByClass', selectorOrElement, element );
}
};
export const hide = ( selectorOrElement, important = false ) => {
setVisible( selectorOrElement, false, important );
};
export const show = ( selectorOrElement ) => {
setVisible( selectorOrElement, true );
};

View file

@ -0,0 +1,179 @@
/* global localStorage */
function checkLocalStorageAvailability() {
try {
const testKey = '__ppcp_test__';
localStorage.setItem( testKey, 'test' );
localStorage.removeItem( testKey );
return true;
} catch ( e ) {
return false;
}
}
function sanitizeKey( name ) {
return name
.toLowerCase()
.trim()
.replace( /[^a-z0-9_-]/g, '_' );
}
function deserializeEntry( serialized ) {
try {
const payload = JSON.parse( serialized );
return {
data: payload.data,
expires: payload.expires || 0,
};
} catch ( e ) {
return null;
}
}
function serializeEntry( data, timeToLive ) {
const payload = {
data,
expires: calculateExpiration( timeToLive ),
};
return JSON.stringify( payload );
}
function calculateExpiration( timeToLive ) {
return timeToLive ? Date.now() + timeToLive * 1000 : 0;
}
/**
* A reusable class for handling data storage in the browser's local storage,
* with optional expiration.
*
* Can be extended for module specific logic.
*
* @see GooglePaySession
*/
export class LocalStorage {
/**
* @type {string}
*/
#group = '';
/**
* @type {null|boolean}
*/
#canUseLocalStorage = null;
/**
* @param {string} group - Group name for all storage keys managed by this instance.
*/
constructor( group ) {
this.#group = sanitizeKey( group ) + ':';
this.#removeExpired();
}
/**
* Removes all items in the current group that have reached the expiry date.
*/
#removeExpired() {
if ( ! this.canUseLocalStorage ) {
return;
}
Object.keys( localStorage ).forEach( ( key ) => {
if ( ! key.startsWith( this.#group ) ) {
return;
}
const entry = deserializeEntry( localStorage.getItem( key ) );
if ( entry && entry.expires > 0 && entry.expires < Date.now() ) {
localStorage.removeItem( key );
}
} );
}
/**
* Sanitizes the given entry name and adds the group prefix.
*
* @throws {Error} If the name is empty after sanitization.
* @param {string} name - Entry name.
* @return {string} Prefixed and sanitized entry name.
*/
#entryKey( name ) {
const sanitizedName = sanitizeKey( name );
if ( sanitizedName.length === 0 ) {
throw new Error( 'Name cannot be empty after sanitization' );
}
return `${ this.#group }${ sanitizedName }`;
}
/**
* Indicates, whether localStorage is available.
*
* @return {boolean} True means the localStorage API is available.
*/
get canUseLocalStorage() {
if ( null === this.#canUseLocalStorage ) {
this.#canUseLocalStorage = checkLocalStorageAvailability();
}
return this.#canUseLocalStorage;
}
/**
* Stores data in the browser's local storage, with an optional timeout.
*
* @param {string} name - Name of the item in the storage.
* @param {any} data - The data to store.
* @param {number} [timeToLive=0] - Lifespan in seconds. 0 means the data won't expire.
* @throws {Error} If local storage is not available.
*/
set( name, data, timeToLive = 0 ) {
if ( ! this.canUseLocalStorage ) {
throw new Error( 'Local storage is not available' );
}
const entry = serializeEntry( data, timeToLive );
const entryKey = this.#entryKey( name );
localStorage.setItem( entryKey, entry );
}
/**
* Retrieves previously stored data from the browser's local storage.
*
* @param {string} name - Name of the stored item.
* @return {any|null} The stored data, or null when no valid entry is found or it has expired.
* @throws {Error} If local storage is not available.
*/
get( name ) {
if ( ! this.canUseLocalStorage ) {
throw new Error( 'Local storage is not available' );
}
const itemKey = this.#entryKey( name );
const entry = deserializeEntry( localStorage.getItem( itemKey ) );
if ( ! entry ) {
return null;
}
return entry.data;
}
/**
* Removes the specified entry from the browser's local storage.
*
* @param {string} name - Name of the stored item.
* @throws {Error} If local storage is not available.
*/
clear( name ) {
if ( ! this.canUseLocalStorage ) {
throw new Error( 'Local storage is not available' );
}
const itemKey = this.#entryKey( name );
localStorage.removeItem( itemKey );
}
}

View file

@ -0,0 +1,123 @@
import { refreshButtons } from './ButtonRefreshHelper';
const DEFAULT_TRIGGER_ELEMENT_SELECTOR = '.woocommerce-checkout-payment';
/**
* The MultistepCheckoutHelper class ensures the initialization of payment buttons
* on websites using a multistep checkout plugin. These plugins usually hide the
* payment button on page load up and reveal it later using JS. During the
* invisibility period of wrappers, some payment buttons fail to initialize,
* so we wait for the payment element to be visible.
*
* @property {HTMLElement} form - Checkout form element.
* @property {HTMLElement} triggerElement - Element, which visibility we need to detect.
* @property {boolean} isVisible - Whether the triggerElement is visible.
*/
class MultistepCheckoutHelper {
/**
* Selector that defines the HTML element we are waiting to become visible.
* @type {string}
*/
#triggerElementSelector;
/**
* Interval (in milliseconds) in which the visibility of the trigger element is checked.
* @type {number}
*/
#intervalTime = 150;
/**
* The interval ID returned by the setInterval() method.
* @type {number|false}
*/
#intervalId;
/**
* Selector passed to the constructor that identifies the checkout form
* @type {string}
*/
#formSelector;
/**
* @param {string} formSelector - Selector of the checkout form
* @param {string} triggerElementSelector - Optional. Selector of the dependant element.
*/
constructor( formSelector, triggerElementSelector = '' ) {
this.#formSelector = formSelector;
this.#triggerElementSelector =
triggerElementSelector || DEFAULT_TRIGGER_ELEMENT_SELECTOR;
this.#intervalId = false;
/*
Start the visibility checker after a brief delay. This allows eventual multistep plugins to
dynamically prepare the checkout page, so we can decide whether this helper is needed.
*/
setTimeout( () => {
if ( this.form && ! this.isVisible ) {
this.start();
}
}, 250 );
}
/**
* The checkout form element.
* @return {Element|null} - Form element or null.
*/
get form() {
return document.querySelector( this.#formSelector );
}
/**
* The element which must be visible before payment buttons should be initialized.
* @return {Element|null} - Trigger element or null.
*/
get triggerElement() {
return this.form?.querySelector( this.#triggerElementSelector );
}
/**
* Checks the visibility of the payment button wrapper.
* @return {boolean} - returns boolean value on the basis of visibility of element.
*/
get isVisible() {
const box = this.triggerElement?.getBoundingClientRect();
return !! ( box && box.width && box.height );
}
/**
* Starts the observation of the DOM, initiates monitoring the checkout form.
* To ensure multiple calls to start don't create multiple intervals, we first call stop.
*/
start() {
this.stop();
this.#intervalId = setInterval(
() => this.checkElement(),
this.#intervalTime
);
}
/**
* Stops the observation of the checkout form.
* Multiple calls to stop are safe as clearInterval doesn't throw if provided ID doesn't exist.
*/
stop() {
if ( this.#intervalId ) {
clearInterval( this.#intervalId );
this.#intervalId = false;
}
}
/**
* Checks if the trigger element is visible.
* If visible, it initialises the payment buttons and stops the observation.
*/
checkElement() {
if ( this.isVisible ) {
refreshButtons();
this.stop();
}
}
}
export default MultistepCheckoutHelper;

View file

@ -0,0 +1,101 @@
import { loadScript } from '@paypal/paypal-js';
import dataClientIdAttributeHandler from '../DataClientIdAttributeHandler';
import widgetBuilder from '../Renderer/WidgetBuilder';
import { processConfig } from './ConfigProcessor';
const loadedScripts = new Map();
const scriptPromises = new Map();
const handleDataClientIdAttribute = async ( scriptOptions, config ) => {
if (
config.data_client_id?.set_attribute &&
config.vault_v3_enabled !== true
) {
return new Promise( ( resolve, reject ) => {
dataClientIdAttributeHandler(
scriptOptions,
config.data_client_id,
( paypal ) => {
widgetBuilder.setPaypal( paypal );
resolve( paypal );
},
reject
);
} );
}
return null;
};
export const loadPayPalScript = async ( namespace, config ) => {
if ( ! namespace ) {
throw new Error( 'Namespace is required' );
}
if ( loadedScripts.has( namespace ) ) {
console.log( `Script already loaded for namespace: ${ namespace }` );
return loadedScripts.get( namespace );
}
if ( scriptPromises.has( namespace ) ) {
console.log(
`Script loading in progress for namespace: ${ namespace }`
);
return scriptPromises.get( namespace );
}
const scriptOptions = {
...processConfig( config ),
'data-namespace': namespace,
};
const dataClientIdResult = await handleDataClientIdAttribute(
scriptOptions,
config
);
if ( dataClientIdResult ) {
return dataClientIdResult;
}
const scriptPromise = new Promise( ( resolve, reject ) => {
loadScript( scriptOptions )
.then( ( script ) => {
widgetBuilder.setPaypal( script );
loadedScripts.set( namespace, script );
console.log( `Script loaded for namespace: ${ namespace }` );
resolve( script );
} )
.catch( ( error ) => {
console.error(
`Failed to load script for namespace: ${ namespace }`,
error
);
reject( error );
} )
.finally( () => {
scriptPromises.delete( namespace );
} );
} );
scriptPromises.set( namespace, scriptPromise );
return scriptPromise;
};
export const loadAndRenderPayPalScript = async (
namespace,
options,
renderFunction,
renderTarget
) => {
if ( ! namespace ) {
throw new Error( 'Namespace is required' );
}
const scriptOptions = {
...options,
'data-namespace': namespace,
};
const script = await loadScript( scriptOptions );
widgetBuilder.setPaypal( script );
await renderFunction( script, renderTarget );
};

View file

@ -0,0 +1,196 @@
/**
* Name details.
*
* @typedef {Object} NameDetails
* @property {string} [given_name] - First name, e.g. "John".
* @property {string} [surname] - Last name, e.g. "Doe".
*/
/**
* Postal address details.
*
* @typedef {Object} AddressDetails
* @property {string} [country_code] - Country code (2-letter).
* @property {string} [address_line_1] - Address details, line 1 (street, house number).
* @property {string} [address_line_2] - Address details, line 2.
* @property {string} [admin_area_1] - State or region.
* @property {string} [admin_area_2] - State or region.
* @property {string} [postal_code] - Zip code.
*/
/**
* Phone details.
*
* @typedef {Object} PhoneDetails
* @property {string} [phone_type] - Type, usually 'HOME'
* @property {{national_number: string}} [phone_number] - Phone number details.
*/
/**
* Payer details.
*
* @typedef {Object} PayerDetails
* @property {string} [email_address] - Email address for billing communication.
* @property {PhoneDetails} [phone] - Phone number for billing communication.
* @property {NameDetails} [name] - Payer's name.
* @property {AddressDetails} [address] - Postal billing address.
*/
// Map checkout fields to PayerData object properties.
const FIELD_MAP = {
'#billing_email': [ 'email_address' ],
'#billing_last_name': [ 'name', 'surname' ],
'#billing_first_name': [ 'name', 'given_name' ],
'#billing_country': [ 'address', 'country_code' ],
'#billing_address_1': [ 'address', 'address_line_1' ],
'#billing_address_2': [ 'address', 'address_line_2' ],
'#billing_state': [ 'address', 'admin_area_1' ],
'#billing_city': [ 'address', 'admin_area_2' ],
'#billing_postcode': [ 'address', 'postal_code' ],
'#billing_phone': [ 'phone' ],
};
function normalizePayerDetails( details ) {
return {
email_address: details.email_address,
phone: details.phone,
name: {
surname: details.name?.surname,
given_name: details.name?.given_name,
},
address: {
country_code: details.address?.country_code,
address_line_1: details.address?.address_line_1,
address_line_2: details.address?.address_line_2,
admin_area_1: details.address?.admin_area_1,
admin_area_2: details.address?.admin_area_2,
postal_code: details.address?.postal_code,
},
};
}
function mergePayerDetails( firstPayer, secondPayer ) {
const mergeNestedObjects = ( target, source ) => {
for ( const [ key, value ] of Object.entries( source ) ) {
if ( null !== value && undefined !== value ) {
if ( 'object' === typeof value ) {
target[ key ] = mergeNestedObjects(
target[ key ] || {},
value
);
} else {
target[ key ] = value;
}
}
}
return target;
};
return mergeNestedObjects(
normalizePayerDetails( firstPayer ),
normalizePayerDetails( secondPayer )
);
}
function getCheckoutBillingDetails() {
const getElementValue = ( selector ) =>
document.querySelector( selector )?.value;
const setNestedValue = ( obj, path, value ) => {
let current = obj;
for ( let i = 0; i < path.length - 1; i++ ) {
current = current[ path[ i ] ] = current[ path[ i ] ] || {};
}
current[ path[ path.length - 1 ] ] = value;
};
const data = {};
Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => {
const value = getElementValue( selector );
if ( value ) {
setNestedValue( data, path, value );
}
} );
if ( data.phone && 'string' === typeof data.phone ) {
data.phone = {
phone_type: 'HOME',
phone_number: { national_number: data.phone },
};
}
return data;
}
function setCheckoutBillingDetails( payer ) {
const setValue = ( path, field, value ) => {
if ( null === value || undefined === value || ! field ) {
return;
}
if ( 'phone' === path[ 0 ] && 'object' === typeof value ) {
value = value.phone_number?.national_number;
}
field.value = value;
};
const getNestedValue = ( obj, path ) =>
path.reduce( ( current, key ) => current?.[ key ], obj );
Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => {
const value = getNestedValue( payer, path );
const element = document.querySelector( selector );
setValue( path, element, value );
} );
}
export function getWooCommerceCustomerDetails() {
// Populated on server-side with details about the current WooCommerce customer.
return window?.PayPalCommerceGateway?.payer;
}
export function getSessionBillingDetails() {
// Populated by JS via `setSessionBillingDetails()`
return window._PpcpPayerSessionDetails;
}
/**
* Stores customer details in the current JS context for use in the same request.
* Details that are set are not persisted during navigation.
*
* @param {unknown} details - New payer details
*/
export function setSessionBillingDetails( details ) {
if ( ! details || 'object' !== typeof details ) {
return;
}
window._PpcpPayerSessionDetails = normalizePayerDetails( details );
}
export function payerData() {
const payer = getWooCommerceCustomerDetails() ?? getSessionBillingDetails();
if ( ! payer ) {
return null;
}
const formData = getCheckoutBillingDetails();
if ( formData ) {
return mergePayerDetails( payer, formData );
}
return normalizePayerDetails( payer );
}
export function setPayerData( payerDetails, updateCheckoutForm = false ) {
setSessionBillingDetails( payerDetails );
if ( updateCheckoutForm ) {
setCheckoutBillingDetails( payerDetails );
}
}

View file

@ -0,0 +1,117 @@
/**
* Helper function used by PaymentButton instances.
*
* @file
*/
/**
* Collection of recognized event names for payment button events.
*
* @type {Object}
*/
export const ButtonEvents = Object.freeze( {
INVALIDATE: 'ppcp_invalidate_methods',
RENDER: 'ppcp_render_method',
REDRAW: 'ppcp_redraw_method',
} );
/**
*
* @param {string} defaultId - Default wrapper ID.
* @param {string} miniCartId - Wrapper inside the mini-cart.
* @param {string} smartButtonId - ID of the smart button wrapper.
* @param {string} blockId - Block wrapper ID (express checkout, block cart).
* @param {string} gatewayId - Gateway wrapper ID (classic checkout).
* @return {{MiniCart, Gateway, Block, SmartButton, Default}} List of all wrapper IDs, by context.
*/
export function combineWrapperIds(
defaultId = '',
miniCartId = '',
smartButtonId = '',
blockId = '',
gatewayId = ''
) {
const sanitize = ( id ) => id.replace( /^#/, '' );
return {
Default: sanitize( defaultId ),
SmartButton: sanitize( smartButtonId ),
Block: sanitize( blockId ),
Gateway: sanitize( gatewayId ),
MiniCart: sanitize( miniCartId ),
};
}
/**
* Returns full payment button styles by combining the global ppcpConfig with
* payment-method-specific styling provided via buttonConfig.
*
* @param {Object} ppcpConfig - Global plugin configuration.
* @param {Object} buttonConfig - Payment method specific configuration.
* @return {{MiniCart: (*), Default: (*)}} Combined styles, separated by context.
*/
export function combineStyles( ppcpConfig, buttonConfig ) {
return {
Default: {
...ppcpConfig.style,
...buttonConfig.style,
},
MiniCart: {
...ppcpConfig.mini_cart_style,
...buttonConfig.mini_cart_style,
},
};
}
/**
* Verifies if the given event name is a valid Payment Button event.
*
* @param {string} event - The event name to verify.
* @return {boolean} True, if the event name is valid.
*/
export function isValidButtonEvent( event ) {
const buttonEventValues = Object.values( ButtonEvents );
return buttonEventValues.includes( event );
}
/**
* Dispatches a payment button event.
*
* @param {Object} options - The options for dispatching the event.
* @param {string} options.event - Event to dispatch.
* @param {string} [options.paymentMethod] - Optional. Name of payment method, to target a specific button only.
* @throws {Error} Throws an error if the event is invalid.
*/
export function dispatchButtonEvent( { event, paymentMethod = '' } ) {
if ( ! isValidButtonEvent( event ) ) {
throw new Error( `Invalid event: ${ event }` );
}
const fullEventName = paymentMethod
? `${ event }-${ paymentMethod }`
: event;
document.body.dispatchEvent( new Event( fullEventName ) );
}
/**
* Adds an event listener for the provided button event.
*
* @param {Object} options - The options for the event listener.
* @param {string} options.event - Event to observe.
* @param {string} [options.paymentMethod] - The payment method name (optional).
* @param {Function} options.callback - The callback function to execute when the event is triggered.
* @throws {Error} Throws an error if the event is invalid.
*/
export function observeButtonEvent( { event, paymentMethod = '', callback } ) {
if ( ! isValidButtonEvent( event ) ) {
throw new Error( `Invalid event: ${ event }` );
}
const fullEventName = paymentMethod
? `${ event }-${ paymentMethod }`
: event;
document.body.addEventListener( fullEventName, callback );
}

View file

@ -0,0 +1,128 @@
import dataClientIdAttributeHandler from '../DataClientIdAttributeHandler';
import { loadScript } from '@paypal/paypal-js';
import widgetBuilder from '../Renderer/WidgetBuilder';
import merge from 'deepmerge';
import { keysToCamelCase } from './Utils';
import { getCurrentPaymentMethod } from './CheckoutMethodState';
import { v4 as uuidv4 } from 'uuid';
// This component may be used by multiple modules. This assures that options are shared between all instances.
const scriptOptionsMap = {};
const getNamespaceOptions = ( namespace ) => {
if ( ! scriptOptionsMap[ namespace ] ) {
scriptOptionsMap[ namespace ] = {
isLoading: false,
onLoadedCallbacks: [],
onErrorCallbacks: [],
};
}
return scriptOptionsMap[ namespace ];
};
export const loadPaypalScript = ( config, onLoaded, onError = null ) => {
const dataNamespace = config?.data_namespace || '';
const options = getNamespaceOptions( dataNamespace );
// If PayPal is already loaded for this namespace, call the onLoaded callback and return.
if ( typeof window.paypal !== 'undefined' && ! dataNamespace ) {
onLoaded();
return;
}
// Add the onLoaded callback to the onLoadedCallbacks stack.
options.onLoadedCallbacks.push( onLoaded );
if ( onError ) {
options.onErrorCallbacks.push( onError );
}
// Return if it's still loading.
if ( options.isLoading ) {
return;
}
options.isLoading = true;
const resetState = () => {
options.isLoading = false;
options.onLoadedCallbacks = [];
options.onErrorCallbacks = [];
};
// Callback to be called once the PayPal script is loaded.
const callback = ( paypal ) => {
widgetBuilder.setPaypal( paypal );
for ( const onLoadedCallback of options.onLoadedCallbacks ) {
onLoadedCallback();
}
resetState();
};
const errorCallback = ( err ) => {
for ( const onErrorCallback of options.onErrorCallbacks ) {
onErrorCallback( err );
}
resetState();
};
// Build the PayPal script options.
let scriptOptions = keysToCamelCase( config.url_params );
if ( config.script_attributes ) {
scriptOptions = merge( scriptOptions, config.script_attributes );
}
// Axo SDK options
const sdkClientToken = config?.axo?.sdk_client_token;
const uuid = uuidv4().replace( /-/g, '' );
if ( sdkClientToken && config?.user?.is_logged !== true ) {
scriptOptions[ 'data-sdk-client-token' ] = sdkClientToken;
scriptOptions[ 'data-client-metadata-id' ] = uuid;
}
// Load PayPal script for special case with data-client-token
if (
config.data_client_id?.set_attribute &&
config.vault_v3_enabled !== '1'
) {
dataClientIdAttributeHandler(
scriptOptions,
config.data_client_id,
callback,
errorCallback
);
return;
}
// Adds data-user-id-token to script options.
const userIdToken = config?.save_payment_methods?.id_token;
if ( userIdToken && config?.user?.is_logged === true ) {
scriptOptions[ 'data-user-id-token' ] = userIdToken;
}
// Adds data-namespace to script options.
if ( dataNamespace ) {
scriptOptions.dataNamespace = dataNamespace;
}
// Load PayPal script
loadScript( scriptOptions ).then( callback ).catch( errorCallback );
};
export const loadPaypalScriptPromise = ( config ) => {
return new Promise( ( resolve, reject ) => {
loadPaypalScript( config, resolve, reject );
} );
};
export const loadPaypalJsScript = ( options, buttons, container ) => {
loadScript( options ).then( ( paypal ) => {
paypal.Buttons( buttons ).render( container );
} );
};
export const loadPaypalJsScriptPromise = ( options ) => {
return new Promise( ( resolve, reject ) => {
loadScript( options ).then( resolve ).catch( reject );
} );
};

View file

@ -0,0 +1,151 @@
import { paypalAddressToWc } from '../../../../../ppcp-blocks/resources/js/Helper/Address.js';
import { convertKeysToSnakeCase } from '../../../../../ppcp-blocks/resources/js/Helper/Helper.js';
/**
* Handles the shipping option change in PayPal.
*
* @param data
* @param actions
* @param config
* @return {Promise<void>}
*/
export const handleShippingOptionsChange = async ( data, actions, config ) => {
try {
const shippingOptionId = data.selectedShippingOption?.id;
if ( shippingOptionId ) {
await fetch(
config.ajax.update_customer_shipping.shipping_options.endpoint,
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WC-Store-API-Nonce':
config.ajax.update_customer_shipping.wp_rest_nonce,
},
body: JSON.stringify( {
rate_id: shippingOptionId,
} ),
}
)
.then( ( response ) => {
return response.json();
} )
.then( ( cardData ) => {
const shippingMethods =
document.querySelectorAll( '.shipping_method' );
shippingMethods.forEach( function ( method ) {
if ( method.value === shippingOptionId ) {
method.checked = true;
}
} );
} );
}
if ( ! config.data_client_id.has_subscriptions ) {
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();
}
};
/**
* Handles the shipping address change in PayPal.
*
* @param data
* @param actions
* @param config
* @return {Promise<void>}
*/
export const handleShippingAddressChange = async ( data, actions, config ) => {
try {
const address = paypalAddressToWc(
convertKeysToSnakeCase( data.shippingAddress )
);
// Retrieve current cart contents
await fetch(
config.ajax.update_customer_shipping.shipping_address.cart_endpoint
)
.then( ( response ) => {
return response.json();
} )
.then( ( cartData ) => {
// Update shipping address in the cart data
cartData.shipping_address.address_1 = address.address_1;
cartData.shipping_address.address_2 = address.address_2;
cartData.shipping_address.city = address.city;
cartData.shipping_address.state = address.state;
cartData.shipping_address.postcode = address.postcode;
cartData.shipping_address.country = address.country;
// Send update request
return fetch(
config.ajax.update_customer_shipping.shipping_address
.update_customer_endpoint,
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WC-Store-API-Nonce':
config.ajax.update_customer_shipping
.wp_rest_nonce,
},
body: JSON.stringify( {
shipping_address: cartData.shipping_address,
} ),
}
)
.then( function ( res ) {
return res.json();
} )
.then( function ( customerData ) {
jQuery( '.cart_totals .shop_table' ).load(
location.href +
' ' +
'.cart_totals .shop_table' +
'>*',
''
);
} );
} );
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();
}
};

View file

@ -0,0 +1,42 @@
class SimulateCart {
constructor( endpoint, nonce ) {
this.endpoint = endpoint;
this.nonce = nonce;
}
/**
*
* @param onResolve
* @param {Product[]} products
* @return {Promise<unknown>}
*/
simulate( onResolve, products ) {
return new Promise( ( resolve, reject ) => {
fetch( this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify( {
nonce: this.nonce,
products,
} ),
} )
.then( ( result ) => {
return result.json();
} )
.then( ( result ) => {
if ( ! result.success ) {
reject( result.data );
return;
}
const resolved = onResolve( result.data );
resolve( resolved );
} );
} );
}
}
export default SimulateCart;

View file

@ -0,0 +1,25 @@
class Spinner {
constructor( target = 'form.woocommerce-checkout' ) {
this.target = target;
}
setTarget( target ) {
this.target = target;
}
block() {
jQuery( this.target ).block( {
message: null,
overlayCSS: {
background: '#fff',
opacity: 0.6,
},
} );
}
unblock() {
jQuery( this.target ).unblock();
}
}
export default Spinner;

View file

@ -0,0 +1,20 @@
export const normalizeStyleForFundingSource = ( style, fundingSource ) => {
const commonProps = {};
[ 'shape', 'height' ].forEach( ( prop ) => {
if ( style[ prop ] ) {
commonProps[ prop ] = style[ prop ];
}
} );
switch ( fundingSource ) {
case 'paypal':
return style;
case 'paylater':
return {
color: style.color,
...commonProps,
};
default:
return commonProps;
}
};

View file

@ -0,0 +1,28 @@
export const isChangePaymentPage = () => {
const urlParams = new URLSearchParams( window.location.search );
return urlParams.has( 'change_payment_method' );
};
export const getPlanIdFromVariation = ( variation ) => {
let subscription_plan = '';
PayPalCommerceGateway.variable_paypal_subscription_variations.forEach(
( element ) => {
const obj = {};
variation.forEach( ( { name, value } ) => {
Object.assign( obj, {
[ name.replace( 'attribute_', '' ) ]: value,
} );
} );
if (
JSON.stringify( obj ) ===
JSON.stringify( element.attributes ) &&
element.subscription_plan !== ''
) {
subscription_plan = element.subscription_plan;
}
}
);
return subscription_plan;
};

View file

@ -0,0 +1,45 @@
import Product from '../Entity/Product';
class UpdateCart {
constructor( endpoint, nonce ) {
this.endpoint = endpoint;
this.nonce = nonce;
}
/**
*
* @param onResolve
* @param {Product[]} products
* @param {Object} options
* @return {Promise<unknown>}
*/
update( onResolve, products, options = {} ) {
return new Promise( ( resolve, reject ) => {
fetch( this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify( {
nonce: this.nonce,
products,
...options,
} ),
} )
.then( ( result ) => {
return result.json();
} )
.then( ( result ) => {
if ( ! result.success ) {
reject( result.data );
return;
}
const resolved = onResolve( result.data );
resolve( resolved );
} );
} );
}
}
export default UpdateCart;

View file

@ -0,0 +1,69 @@
export const toCamelCase = ( str ) => {
return str.replace( /([-_]\w)/g, function ( match ) {
return match[ 1 ].toUpperCase();
} );
};
export const keysToCamelCase = ( obj ) => {
const output = {};
for ( const key in obj ) {
if ( Object.prototype.hasOwnProperty.call( obj, key ) ) {
output[ toCamelCase( key ) ] = obj[ key ];
}
}
return output;
};
export const strAddWord = ( str, word, separator = ',' ) => {
const arr = str.split( separator );
if ( ! arr.includes( word ) ) {
arr.push( word );
}
return arr.join( separator );
};
export const strRemoveWord = ( str, word, separator = ',' ) => {
const arr = str.split( separator );
const index = arr.indexOf( word );
if ( index !== -1 ) {
arr.splice( index, 1 );
}
return arr.join( separator );
};
export const throttle = ( func, limit ) => {
let inThrottle, lastArgs, lastContext;
function execute() {
inThrottle = true;
func.apply( this, arguments );
setTimeout( () => {
inThrottle = false;
if ( lastArgs ) {
const nextArgs = lastArgs;
const nextContext = lastContext;
lastArgs = lastContext = null;
execute.apply( nextContext, nextArgs );
}
}, limit );
}
return function () {
if ( ! inThrottle ) {
execute.apply( this, arguments );
} else {
lastArgs = arguments;
lastContext = this;
}
};
};
const Utils = {
toCamelCase,
keysToCamelCase,
strAddWord,
strRemoveWord,
throttle,
};
export default Utils;

View file

@ -0,0 +1,264 @@
import merge from 'deepmerge';
import { loadScript } from '@paypal/paypal-js';
import { keysToCamelCase } from './Helper/Utils';
import widgetBuilder from './widget-builder';
import { normalizeStyleForFundingSource } from './Helper/Style';
class Renderer {
constructor(
creditCardRenderer,
defaultSettings,
onSmartButtonClick,
onSmartButtonsInit
) {
this.defaultSettings = defaultSettings;
this.creditCardRenderer = creditCardRenderer;
this.onSmartButtonClick = onSmartButtonClick;
this.onSmartButtonsInit = onSmartButtonsInit;
this.buttonsOptions = {};
this.onButtonsInitListeners = {};
this.renderedSources = new Set();
this.reloadEventName = 'ppcp-reload-buttons';
}
render(
contextConfig,
settingsOverride = {},
contextConfigOverride = () => {}
) {
const settings = merge( this.defaultSettings, settingsOverride );
const enabledSeparateGateways = Object.fromEntries(
Object.entries( settings.separate_buttons ).filter(
( [ s, data ] ) => document.querySelector( data.wrapper )
)
);
const hasEnabledSeparateGateways =
Object.keys( enabledSeparateGateways ).length !== 0;
if ( ! hasEnabledSeparateGateways ) {
console.log( 'RENDER 1', settings );
this.renderButtons(
settings.button.wrapper,
settings.button.style,
contextConfig,
hasEnabledSeparateGateways
);
} else {
// render each button separately
for ( const fundingSource of paypal
.getFundingSources()
.filter( ( s ) => ! ( s in enabledSeparateGateways ) ) ) {
const style = normalizeStyleForFundingSource(
settings.button.style,
fundingSource
);
console.log( 'RENDER' );
this.renderButtons(
settings.button.wrapper,
style,
contextConfig,
hasEnabledSeparateGateways,
fundingSource
);
}
}
if ( this.creditCardRenderer ) {
this.creditCardRenderer.render(
settings.hosted_fields.wrapper,
contextConfigOverride
);
}
for ( const [ fundingSource, data ] of Object.entries(
enabledSeparateGateways
) ) {
this.renderButtons(
data.wrapper,
data.style,
contextConfig,
hasEnabledSeparateGateways,
fundingSource
);
}
}
renderButtons(
wrapper,
style,
contextConfig,
hasEnabledSeparateGateways,
fundingSource = null
) {
if (
! document.querySelector( wrapper ) ||
this.isAlreadyRendered(
wrapper,
fundingSource,
hasEnabledSeparateGateways
)
) {
// Try to render registered buttons again in case they were removed from the DOM by an external source.
widgetBuilder.renderButtons( [ wrapper, fundingSource ] );
return;
}
if ( fundingSource ) {
contextConfig.fundingSource = fundingSource;
}
let venmoButtonClicked = false;
const buttonsOptions = () => {
const options = {
style,
...contextConfig,
onClick: ( data, actions ) => {
if ( this.onSmartButtonClick ) {
this.onSmartButtonClick( data, actions );
}
venmoButtonClicked = false;
if ( data.fundingSource === 'venmo' ) {
venmoButtonClicked = true;
}
},
onInit: ( data, actions ) => {
if ( this.onSmartButtonsInit ) {
this.onSmartButtonsInit( data, actions );
}
this.handleOnButtonsInit( wrapper, data, actions );
},
};
return options;
};
jQuery( document )
.off( this.reloadEventName, wrapper )
.on(
this.reloadEventName,
wrapper,
( event, settingsOverride = {}, triggeredFundingSource ) => {
// Only accept events from the matching funding source
if (
fundingSource &&
triggeredFundingSource &&
triggeredFundingSource !== fundingSource
) {
return;
}
const settings = merge(
this.defaultSettings,
settingsOverride
);
let scriptOptions = keysToCamelCase( settings.url_params );
scriptOptions = merge(
scriptOptions,
settings.script_attributes
);
loadScript( scriptOptions ).then( ( paypal ) => {
widgetBuilder.setPaypal( paypal );
widgetBuilder.registerButtons(
[ wrapper, fundingSource ],
buttonsOptions()
);
widgetBuilder.renderAll();
} );
}
);
this.renderedSources.add( wrapper + ( fundingSource ?? '' ) );
if (
typeof paypal !== 'undefined' &&
typeof paypal.Buttons !== 'undefined'
) {
widgetBuilder.registerButtons(
[ wrapper, fundingSource ],
buttonsOptions()
);
widgetBuilder.renderButtons( [ wrapper, fundingSource ] );
}
}
isVenmoButtonClickedWhenVaultingIsEnabled = ( venmoButtonClicked ) => {
return venmoButtonClicked && this.defaultSettings.vaultingEnabled;
};
shouldEnableShippingCallback = () => {
const needShipping =
this.defaultSettings.needShipping ||
this.defaultSettings.context === 'product';
return (
this.defaultSettings.should_handle_shipping_in_paypal &&
needShipping
);
};
isAlreadyRendered( wrapper, fundingSource ) {
return this.renderedSources.has( wrapper + ( fundingSource ?? '' ) );
}
disableCreditCardFields() {
this.creditCardRenderer.disableFields();
}
enableCreditCardFields() {
this.creditCardRenderer.enableFields();
}
onButtonsInit( wrapper, handler, reset ) {
this.onButtonsInitListeners[ wrapper ] = reset
? []
: this.onButtonsInitListeners[ wrapper ] || [];
this.onButtonsInitListeners[ wrapper ].push( handler );
}
handleOnButtonsInit( wrapper, data, actions ) {
this.buttonsOptions[ wrapper ] = {
data,
actions,
};
if ( this.onButtonsInitListeners[ wrapper ] ) {
for ( const handler of this.onButtonsInitListeners[ wrapper ] ) {
if ( typeof handler === 'function' ) {
handler( {
wrapper,
...this.buttonsOptions[ wrapper ],
} );
}
}
}
}
disableSmartButtons( wrapper ) {
if ( ! this.buttonsOptions[ wrapper ] ) {
return;
}
try {
this.buttonsOptions[ wrapper ].actions.disable();
} catch ( err ) {
console.log( 'Failed to disable buttons: ' + err );
}
}
enableSmartButtons( wrapper ) {
if ( ! this.buttonsOptions[ wrapper ] ) {
return;
}
try {
this.buttonsOptions[ wrapper ].actions.enable();
} catch ( err ) {
console.log( 'Failed to enable buttons: ' + err );
}
}
}
export default Renderer;

View file

@ -0,0 +1,179 @@
/**
* Handles the registration and rendering of PayPal widgets: Buttons and Messages.
* To have several Buttons per wrapper, an array should be provided, ex: [wrapper, fundingSource].
*/
class WidgetBuilder {
constructor() {
this.paypal = null;
this.buttons = new Map();
this.messages = new Map();
this.renderEventName = 'ppcp-render';
document.ppcpWidgetBuilderStatus = () => {
console.log( {
buttons: this.buttons,
messages: this.messages,
} );
};
jQuery( document )
.off( this.renderEventName )
.on( this.renderEventName, () => {
this.renderAll();
} );
}
setPaypal( paypal ) {
this.paypal = paypal;
jQuery( document ).trigger( 'ppcp-paypal-loaded', paypal );
}
registerButtons( wrapper, options ) {
wrapper = this.sanitizeWrapper( wrapper );
this.buttons.set( this.toKey( wrapper ), {
wrapper,
options,
} );
}
renderButtons( wrapper ) {
wrapper = this.sanitizeWrapper( wrapper );
if ( ! this.buttons.has( this.toKey( wrapper ) ) ) {
return;
}
if ( this.hasRendered( wrapper ) ) {
return;
}
const entry = this.buttons.get( this.toKey( wrapper ) );
const btn = this.paypal.Buttons( entry.options );
if ( ! btn.isEligible() ) {
this.buttons.delete( this.toKey( wrapper ) );
return;
}
const target = this.buildWrapperTarget( wrapper );
if ( ! target ) {
return;
}
btn.render( target );
}
renderAllButtons() {
for ( const [ wrapper, entry ] of this.buttons ) {
this.renderButtons( wrapper );
}
}
registerMessages( wrapper, options ) {
this.messages.set( wrapper, {
wrapper,
options,
} );
}
renderMessages( wrapper ) {
if ( ! this.messages.has( wrapper ) ) {
return;
}
const entry = this.messages.get( wrapper );
if ( this.hasRendered( wrapper ) ) {
const element = document.querySelector( wrapper );
element.setAttribute( 'data-pp-amount', entry.options.amount );
return;
}
const btn = this.paypal.Messages( entry.options );
btn.render( entry.wrapper );
// watchdog to try to handle some strange cases where the wrapper may not be present
setTimeout( () => {
if ( ! this.hasRendered( wrapper ) ) {
btn.render( entry.wrapper );
}
}, 100 );
}
renderAllMessages() {
for ( const [ wrapper, entry ] of this.messages ) {
this.renderMessages( wrapper );
}
}
renderAll() {
this.renderAllButtons();
this.renderAllMessages();
}
hasRendered( wrapper ) {
let selector = wrapper;
if ( Array.isArray( wrapper ) ) {
selector = wrapper[ 0 ];
for ( const item of wrapper.slice( 1 ) ) {
selector += ' .item-' + item;
}
}
const element = document.querySelector( selector );
return element && element.hasChildNodes();
}
sanitizeWrapper( wrapper ) {
if ( Array.isArray( wrapper ) ) {
wrapper = wrapper.filter( ( item ) => !! item );
if ( wrapper.length === 1 ) {
wrapper = wrapper[ 0 ];
}
}
return wrapper;
}
buildWrapperTarget( wrapper ) {
let target = wrapper;
if ( Array.isArray( wrapper ) ) {
const $wrapper = jQuery( wrapper[ 0 ] );
if ( ! $wrapper.length ) {
return;
}
const itemClass = 'item-' + wrapper[ 1 ];
// Check if the parent element exists and it doesn't already have the div with the class
let $item = $wrapper.find( '.' + itemClass );
if ( ! $item.length ) {
$item = jQuery( `<div class="${ itemClass }"></div>` );
$wrapper.append( $item );
}
target = $item.get( 0 );
}
if ( ! jQuery( target ).length ) {
return null;
}
return target;
}
toKey( wrapper ) {
if ( Array.isArray( wrapper ) ) {
return JSON.stringify( wrapper );
}
return wrapper;
}
}
export default window.widgetBuilder;

View file

@ -7,8 +7,26 @@ module.exports = {
...{ ...{
entry: { entry: {
index: path.resolve( process.cwd(), 'resources/js', 'index.js' ), index: path.resolve( process.cwd(), 'resources/js', 'index.js' ),
switchSettingsUi: path.resolve( process.cwd(), 'resources/js', 'switchSettingsUi.js' ), switchSettingsUi: path.resolve(
process.cwd(),
'resources/js',
'switchSettingsUi.js'
),
style: path.resolve( process.cwd(), 'resources/css', 'style.scss' ), style: path.resolve( process.cwd(), 'resources/css', 'style.scss' ),
}, },
resolve: {
...defaultConfig.resolve,
...{
alias: {
...defaultConfig.resolve.alias,
...{
ppcpButton: path.resolve(
__dirname,
'../ppcp-button/resources/js'
),
},
},
},
},
}, },
}; };

View file

@ -1874,6 +1874,13 @@
"@parcel/watcher-win32-ia32" "2.5.0" "@parcel/watcher-win32-ia32" "2.5.0"
"@parcel/watcher-win32-x64" "2.5.0" "@parcel/watcher-win32-x64" "2.5.0"
"@paypal/paypal-js@^8.1.2":
version "8.1.2"
resolved "https://registry.yarnpkg.com/@paypal/paypal-js/-/paypal-js-8.1.2.tgz#3b6199e1c9e3b2a3e444309e0d0ff63556e3ef86"
integrity sha512-EKshGSWRxLWU1NyPB9P1TiOkPajVmpTo5I9HuZKoSv8y2uk0XIskXqMkAJ/Y9qAg9iJyP102Jb/atX63tTy24w==
dependencies:
promise-polyfill "^8.3.0"
"@pkgr/core@^0.1.0": "@pkgr/core@^0.1.0":
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
@ -9365,6 +9372,11 @@ progress@2.0.3, progress@^2.0.3:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
promise-polyfill@^8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63"
integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==
prompts@^2.0.1, prompts@^2.4.2: prompts@^2.0.1, prompts@^2.4.2:
version "2.4.2" version "2.4.2"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"