This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,108 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetpack/simple-payments",
"title": "Pay with PayPal",
"description": "Add credit and debit card payment buttons with minimal setup. Good for collecting donations or payments for products and services.",
"keywords": [
"buy",
"commerce",
"credit card",
"debit card",
"monetize",
"earn",
"ecommerce",
"money",
"paid",
"payments",
"products",
"purchase",
"sell",
"shop",
"square",
"payments"
],
"version": "12.5.0",
"textdomain": "jetpack-paypal-payments",
"category": "monetize",
"icon": "<svg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'><path fill='none' d='M0 0h24v24H0V0z' /><path d='M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z' /></svg>",
"supports": {
"align": false,
"className": false,
"customClassName": false,
"html": false,
"inserter": false,
"reusable": false
},
"attributes": {
"currency": {
"type": "string",
"default": "USD"
},
"content": {
"type": "string",
"source": "html",
"selector": ".jetpack-simple-payments-description p",
"default": ""
},
"email": {
"type": "string",
"default": ""
},
"featuredMediaId": {
"type": "number",
"default": 0
},
"featuredMediaUrl": {
"type": "string",
"source": "attribute",
"selector": ".jetpack-simple-payments-image img",
"attribute": "src",
"default": null
},
"featuredMediaTitle": {
"type": "string",
"source": "attribute",
"selector": ".jetpack-simple-payments-image img",
"attribute": "alt",
"default": null
},
"multiple": {
"type": "boolean",
"default": false
},
"postLinkUrl": {
"type": "string",
"source": "attribute",
"selector": ".jetpack-simple-payments-purchase",
"attribute": "href"
},
"postLinkText": {
"type": "string",
"source": "html",
"selector": ".jetpack-simple-payments-purchase",
"default": "Click here to purchase."
},
"price": {
"type": "number"
},
"productId": {
"type": "number"
},
"title": {
"type": "string",
"source": "html",
"selector": ".jetpack-simple-payments-title p",
"default": ""
}
},
"example": {
"attributes": {
"price": 25.0,
"title": "Jetpack t-shirt",
"content": "Take flight in ultimate comfort with this stylish t-shirt featuring the Jetpack logo.",
"email": "jetpack@jetpack.com",
"featuredMediaUrl": "./simple-payments_example-1.jpg"
}
}
}
@@ -0,0 +1,159 @@
<?php
/**
* Pay with PayPal block (aka Simple Payments).
*
* @since jetpack-9.0.0
*
* @package automattic/jetpack-paypal-payments
*/
namespace Automattic\Jetpack\PaypalPayments\SimplePayments;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Blocks;
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\Paypal_Payments\Simple_Payments;
use Automattic\Jetpack\Status\Request;
use WP_Post;
/**
* Register and render the block.
*/
class Block {
/**
* The block full slugname.
*
* @var string
*/
const BLOCK_NAME = 'jetpack/simple-payments';
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
public static function register_block() {
if ( ! Jetpack_Plan::supports( 'simple-payments' ) ) {
return;
}
Blocks::jetpack_register_block(
__DIR__,
array( 'render_callback' => array( __CLASS__, 'render_block' ) )
);
}
/**
* Pay with PayPal block dynamic rendering.
*
* @param array $attr Array containing the block attributes.
* @param string $content String containing the block content.
*
* @return string
*/
public static function render_block( $attr, $content ) {
// Do nothing if block content is a `simple-payment` shortcode.
if ( preg_match( '/\[simple-payment(.*)]/', $content ) ) {
return $content;
}
// Keep content as-is if rendered in other contexts than frontend (i.e. feed, emails, API, etc.).
if ( ! Request::is_frontend() ) {
return $content;
}
$simple_payments = Simple_Payments::get_instance();
if ( ! $simple_payments->is_valid( $attr ) ) {
return '';
}
$simple_payments->enqueue_frontend_assets();
// For AMP requests, make sure the purchase link redirects to the non-AMP post URL.
if ( Blocks::is_amp_request() ) {
$content = preg_replace(
'#(<a class="jetpack-simple-payments-purchase".*)rel="(.*)"(.*>.*</a>)#i',
'$1rel="$2 noamphtml"$3',
$content
);
return $content;
}
// Augment block UI with a PayPal button if rendered on the frontend.
$product_id = $attr['productId'];
$dom_id = wp_unique_id( "jetpack-simple-payments-{$product_id}_" );
$is_multiple = get_post_meta( $product_id, 'spay_multiple', true ) || '0';
$simple_payments->setup_paypal_checkout_button( $product_id, $dom_id, $is_multiple );
$purchase_box = $simple_payments->output_purchase_box( $dom_id, $is_multiple );
$content = preg_replace( '#<a class="jetpack-simple-payments-purchase(.*)</a>#i', $purchase_box, $content );
return $content;
}
/**
* Load editor styles for the block.
* These are loaded via enqueue_block_assets to ensure proper loading in the editor iframe context.
*/
public static function load_editor_styles() {
$handle = 'jp-paypal-payments-blocks';
Assets::register_script(
$handle,
'../../dist/block/editor.js',
__FILE__,
array(
'css_path' => '../../dist/block/editor.css',
'textdomain' => 'jetpack-paypal-payments',
)
);
wp_enqueue_style( $handle );
}
/**
* Loads scripts
*/
public static function load_editor_scripts() {
Assets::register_script(
'jp-paypal-payments-blocks',
'../../dist/block/editor.js',
__FILE__,
array(
'in_footer' => true,
'textdomain' => 'jetpack-paypal-payments',
'enqueue' => true,
// Editor styles are loaded separately, see load_editor_styles().
'css_path' => null,
)
);
}
/**
* Determine if AMP should be disabled on posts having "Pay with PayPal" blocks.
*
* @param bool $skip Skipped.
* @param int $post_id Post ID.
* @param WP_Post $post Post.
*
* @return bool Whether to skip the post from AMP.
*/
public static function amp_skip_post( $skip, $post_id, $post ) {
/*
* When AMP is on standard mode,
* there are no non-AMP posts to link to where
* the purchase can be completed,
* so let's prevent the post from being available in AMP.
*/
if (
function_exists( 'amp_is_canonical' )
&& \amp_is_canonical()
&& has_block( self::BLOCK_NAME, $post->post_content )
) {
return true;
}
return $skip;
}
}
@@ -0,0 +1,55 @@
import { RawHTML } from '@wordpress/element';
export default {
attributes: {
currency: {
type: 'string',
default: 'USD',
},
content: {
type: 'string',
default: '',
},
email: {
type: 'string',
default: '',
},
featuredMediaId: {
type: 'number',
default: 0,
},
featuredMediaUrl: {
type: 'string',
default: null,
},
featuredMediaTitle: {
type: 'string',
default: null,
},
multiple: {
type: 'boolean',
default: false,
},
price: {
type: 'number',
},
productId: {
type: 'number',
},
title: {
type: 'string',
default: '',
},
},
supports: {
align: false,
className: false,
customClassName: false,
html: false,
reusable: false,
},
save: ( { attributes } ) => {
const { productId } = attributes;
return productId ? <RawHTML>{ `[simple-payment id="${ productId }"]` }</RawHTML> : null;
},
};
@@ -0,0 +1,624 @@
import { InspectorControls } from '@wordpress/block-editor';
import {
Disabled,
PanelBody,
SelectControl,
TextareaControl,
TextControl,
ToggleControl,
} from '@wordpress/components';
import { compose, withInstanceId } from '@wordpress/compose';
import { dispatch, withSelect } from '@wordpress/data';
import { Component } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
import { Link } from '@wordpress/ui';
import clsx from 'clsx';
import { validate as emailValidatorValidate } from 'email-validator';
import { isEmpty, isEqual, pick, trimEnd } from 'lodash';
import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, SUPPORTED_CURRENCY_LIST } from '../../constants';
import FeaturedMedia from '../../featured-media';
import HelpMessage from '../../help-message';
import ProductPlaceholder from '../../product-placeholder';
import { decimalPlaces, formatPriceFallback, getCurrencyDefaults } from '../../utils';
class SimplePaymentsEdit extends Component {
state = {
fieldEmailError: null,
fieldPriceError: null,
fieldTitleError: null,
isSavingProduct: false,
};
/**
* We'll use this flag to inject attributes one time when the product entity is loaded.
*
* It is based on the presence of a `productId` attribute.
*
* If present, initially we are waiting for attributes to be injected.
* If absent, we may save the product in the future but do not need to inject attributes based
* on the response as they will have come from our product submission.
*/
shouldInjectPaymentAttributes = !! this.props.attributes.productId;
componentDidMount() {
// Try to get the simplePayment loaded into attributes if possible.
this.injectPaymentAttributes();
const { attributes, hasPublishAction, postLinkUrl, setAttributes } = this.props;
const { productId } = attributes;
// If the user can publish save an empty product so that we have an ID and can save
// concurrently with the post that contains the Simple Payment.
if ( ! productId && hasPublishAction ) {
this.saveProduct();
}
const shouldUpdatePostLinkUrl =
postLinkUrl && postLinkUrl !== this.props.attributes.postLinkUrl;
const shouldUpdatePostLinkText = ! this.props.attributes.postLinkText;
if ( shouldUpdatePostLinkUrl || shouldUpdatePostLinkText ) {
setAttributes( {
...( shouldUpdatePostLinkUrl && { postLinkUrl } ),
...( shouldUpdatePostLinkText && {
postLinkText: __( 'Click here to purchase.', 'jetpack-paypal-payments' ),
} ),
} );
}
}
componentDidUpdate( prevProps ) {
const { hasPublishAction, isSelected, postLinkUrl, setAttributes } = this.props;
if ( ! isEqual( prevProps.simplePayment, this.props.simplePayment ) ) {
this.injectPaymentAttributes();
}
if (
! prevProps.isSaving &&
this.props.isSaving &&
hasPublishAction &&
this.validateAttributes()
) {
// Validate and save product on post save
this.saveProduct();
} else if ( prevProps.isSelected && ! isSelected ) {
// Validate on block deselect
this.validateAttributes();
}
const shouldUpdatePostLinkUrl =
postLinkUrl && postLinkUrl !== this.props.attributes.postLinkUrl;
const shouldUpdatePostLinkText = ! this.props.attributes.postLinkText;
if ( shouldUpdatePostLinkUrl || shouldUpdatePostLinkText ) {
setAttributes( {
...( shouldUpdatePostLinkUrl && { postLinkUrl } ),
...( shouldUpdatePostLinkText && {
postLinkText: __( 'Click here to purchase.', 'jetpack-paypal-payments' ),
} ),
} );
}
}
injectPaymentAttributes() {
/**
* Prevent injecting the product attributes when not desired.
*
* When we first load a product, we should inject its attributes as our initial form state.
* When subsequent saves occur, we should avoid injecting attributes so that we do not
* overwrite changes that the user has made with stale state from the previous save.
*/
const { simplePayment, featuredMedia } = this.props;
if ( ! this.shouldInjectPaymentAttributes || isEmpty( simplePayment ) ) {
return;
}
const { attributes, setAttributes } = this.props;
const {
content,
currency,
email,
featuredMediaId,
featuredMediaUrl,
featuredMediaTitle,
multiple,
price,
title,
} = attributes;
setAttributes( {
content: simplePayment?.content?.raw ?? content,
currency: simplePayment?.meta?.spay_currency ?? currency,
email: simplePayment?.meta?.spay_email ?? email,
featuredMediaId: simplePayment?.featured_media ?? featuredMediaId,
featuredMediaUrl: featuredMedia?.url ?? featuredMediaUrl,
featuredMediaTitle: featuredMedia?.title ?? featuredMediaTitle,
multiple: Boolean( simplePayment?.meta?.spay_multiple ?? multiple ),
price: simplePayment?.meta?.spay_price ?? ( price || undefined ),
title: simplePayment?.title?.raw ?? title,
} );
this.shouldInjectPaymentAttributes = ! this.shouldInjectPaymentAttributes;
}
toApi() {
const { attributes } = this.props;
const { content, currency, email, featuredMediaId, multiple, price, productId, title } =
attributes;
return {
id: productId,
content,
featured_media: featuredMediaId,
meta: {
spay_currency: currency,
spay_email: email,
spay_multiple: multiple,
spay_price: price,
},
status: productId ? 'publish' : 'draft',
title,
};
}
saveProduct() {
if ( this.state.isSavingProduct ) {
return;
}
const { attributes, setAttributes } = this.props;
const { email } = attributes;
const { saveEntityRecord } = dispatch( 'core' );
this.setState( { isSavingProduct: true }, () => {
saveEntityRecord( 'postType', SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, this.toApi() )
.then( record => {
if ( record ) {
setAttributes( { productId: record.id } );
}
return record;
} )
.catch( error => {
// Nothing we can do about errors without details at the moment
if ( ! error || ! error.data ) {
return;
}
const {
data: { key: apiErrorKey },
} = error;
// @TODO errors in other fields
this.setState( {
fieldEmailError:
apiErrorKey === 'spay_email'
? sprintf(
/* translators: %s: an email address. */
__( '%s is not a valid email address.', 'jetpack-paypal-payments' ),
email
)
: null,
fieldPriceError:
apiErrorKey === 'spay_price'
? __( 'Invalid price.', 'jetpack-paypal-payments' )
: null,
} );
} )
.finally( () => {
this.setState( {
isSavingProduct: false,
} );
} );
} );
}
validateAttributes = () => {
const isPriceValid = this.validatePrice();
const isTitleValid = this.validateTitle();
const isEmailValid = this.validateEmail();
const isCurrencyValid = this.validateCurrency();
return isPriceValid && isTitleValid && isEmailValid && isCurrencyValid;
};
/**
* Validate currency
*
* This method does not include validation UI. Currency selection should not allow for invalid
* values. It is primarily to ensure that the currency is valid to save.
*
* @return {boolean} True if currency is valid
*/
validateCurrency = () => {
const { currency } = this.props.attributes;
return SUPPORTED_CURRENCY_LIST.includes( currency );
};
/**
* Validate price
*
* Stores error message in state.fieldPriceError
*
* @return {boolean} True when valid, false when invalid
*/
validatePrice = () => {
const { currency, price } = this.props.attributes;
if ( ! price || parseFloat( price ) === 0 ) {
this.setState( {
fieldPriceError: __(
'If youre selling something, you need a price tag. Add yours here.',
'jetpack-paypal-payments'
),
} );
return false;
}
if ( Number.isNaN( parseFloat( price ) ) ) {
this.setState( {
fieldPriceError: __( 'Invalid price', 'jetpack-paypal-payments' ),
} );
return false;
}
if ( parseFloat( price ) < 0 ) {
this.setState( {
fieldPriceError: __(
'Your price is negative — enter a positive number so people can pay the right amount.',
'jetpack-paypal-payments'
),
} );
return false;
}
const { precision } = getCurrencyDefaults( currency );
if ( decimalPlaces( price ) > precision ) {
if ( precision === 0 ) {
this.setState( {
fieldPriceError: __(
'We know every penny counts, but prices in this currency cant contain decimal values.',
'jetpack-paypal-payments'
),
} );
return false;
}
this.setState( {
fieldPriceError: sprintf(
/* translators: %d: the number of decimals in a number. */
_n(
'The price cannot have more than %d decimal place.',
'The price cannot have more than %d decimal places.',
precision,
'jetpack-paypal-payments'
),
precision
),
} );
return false;
}
if ( this.state.fieldPriceError ) {
this.setState( { fieldPriceError: null } );
}
return true;
};
/**
* Validate email
*
* Stores error message in state.fieldEmailError
*
* @return {boolean} True when valid, false when invalid
*/
validateEmail = () => {
const { email } = this.props.attributes;
if ( ! email ) {
this.setState( {
fieldEmailError: __(
'We want to make sure payments reach you, so please add an email address.',
'jetpack-paypal-payments'
),
} );
return false;
}
if ( ! emailValidatorValidate( email ) ) {
this.setState( {
fieldEmailError: sprintf(
/* translators: %s: an email address. */
__( '%s is not a valid email address.', 'jetpack-paypal-payments' ),
email
),
} );
return false;
}
if ( this.state.fieldEmailError ) {
this.setState( { fieldEmailError: null } );
}
return true;
};
/**
* Validate title
*
* Stores error message in state.fieldTitleError
*
* @return {boolean} True when valid, false when invalid
*/
validateTitle = () => {
const { title } = this.props.attributes;
if ( ! title ) {
this.setState( {
fieldTitleError: __(
'Please add a brief title so that people know what theyre paying for.',
'jetpack-paypal-payments'
),
} );
return false;
}
if ( this.state.fieldTitleError ) {
this.setState( { fieldTitleError: null } );
}
return true;
};
handleEmailChange = email => {
this.props.setAttributes( { email } );
this.setState( { fieldEmailError: null } );
};
handleContentChange = content => {
this.props.setAttributes( { content } );
};
handlePriceChange = price => {
price = parseFloat( price );
if ( ! isNaN( price ) ) {
this.props.setAttributes( { price } );
} else {
this.props.setAttributes( { price: undefined } );
}
this.setState( { fieldPriceError: null } );
};
handleCurrencyChange = currency => {
this.props.setAttributes( { currency } );
};
handleMultipleChange = multiple => {
this.props.setAttributes( { multiple: !! multiple } );
};
handleTitleChange = title => {
this.props.setAttributes( { title } );
this.setState( { fieldTitleError: null } );
};
getCurrencyList = SUPPORTED_CURRENCY_LIST.map( value => {
const { symbol } = getCurrencyDefaults( value );
// if symbol is equal to the code (e.g., 'CHF' === 'CHF'), don't duplicate it.
// trim the dot at the end, e.g., 'kr.' becomes 'kr'
const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`;
return { value, label };
} );
renderSettings = () => (
<InspectorControls>
<PanelBody title={ __( 'Settings', 'jetpack-paypal-payments' ) } initialOpen={ false }>
<TextControl
label={ __( 'Purchase link text', 'jetpack-paypal-payments' ) }
help={ __(
'Enter the text you want to display on a purchase link used as fallback when the PayPal button cannot be used (e.g. emails, AMP, etc.)',
'jetpack-paypal-payments'
) }
className="jetpack-simple-payments__purchase-link-text"
placeholder={ __( 'Click here to purchase', 'jetpack-paypal-payments' ) }
onChange={ newPostLinkText =>
this.props.setAttributes( { postLinkText: newPostLinkText } )
}
value={ this.props.attributes.postLinkText }
/>
</PanelBody>
</InspectorControls>
);
render() {
const { fieldEmailError, fieldPriceError, fieldTitleError } = this.state;
const { attributes, instanceId, isSelected, setAttributes, simplePayment } = this.props;
const {
content,
currency,
email,
featuredMediaId,
featuredMediaUrl,
featuredMediaTitle,
multiple,
price,
productId,
title,
} = attributes;
/**
* The only disabled state that concerns us is when we expect a product but don't have it in
* local state.
*/
const isDisabled = productId && isEmpty( simplePayment );
if ( ! isSelected && isDisabled ) {
return (
<div className="simple-payments__loading">
<ProductPlaceholder
aria-busy="true"
content="█████"
formattedPrice="█████"
title="█████"
/>
</div>
);
}
if (
! isSelected &&
email &&
price &&
title &&
! fieldEmailError &&
! fieldPriceError &&
! fieldTitleError
) {
return (
<ProductPlaceholder
aria-busy="false"
content={ content }
featuredMediaUrl={ featuredMediaUrl }
featuredMediaTitle={ featuredMediaTitle }
formattedPrice={ formatPriceFallback( price, currency ) }
multiple={ multiple }
title={ title }
/>
);
}
const Wrapper = isDisabled ? Disabled : 'div';
return (
<Wrapper className="wp-block-jetpack-simple-payments">
{ this.renderSettings() }
<FeaturedMedia
{ ...{ featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } }
/>
<div>
<TextControl
aria-describedby={ `${ instanceId }-title-error` }
className={ clsx( 'simple-payments__field', 'simple-payments__field-title', {
'simple-payments__field-has-error': fieldTitleError,
} ) }
label={ __( 'Item name', 'jetpack-paypal-payments' ) }
onChange={ this.handleTitleChange }
placeholder={ __( 'Item name', 'jetpack-paypal-payments' ) }
required
type="text"
value={ title }
/>
<HelpMessage id={ `${ instanceId }-title-error` } isError>
{ fieldTitleError }
</HelpMessage>
<TextareaControl
className="simple-payments__field simple-payments__field-content"
label={ __( 'Describe your item in a few words', 'jetpack-paypal-payments' ) }
onChange={ this.handleContentChange }
placeholder={ __( 'Describe your item in a few words', 'jetpack-paypal-payments' ) }
value={ content }
/>
<div className="simple-payments__price-container">
<SelectControl
__next40pxDefaultSize
className="simple-payments__field simple-payments__field-currency"
label={ __( 'Currency', 'jetpack-paypal-payments' ) }
onChange={ this.handleCurrencyChange }
options={ this.getCurrencyList }
value={ currency }
/>
<TextControl
aria-describedby={ `${ instanceId }-price-error` }
className={ clsx( 'simple-payments__field', 'simple-payments__field-price', {
'simple-payments__field-has-error': fieldPriceError,
} ) }
label={ __( 'Price', 'jetpack-paypal-payments' ) }
onChange={ this.handlePriceChange }
placeholder={ formatPriceFallback( 0, currency, false ) }
required
step="1"
type="number"
value={ price || '' }
/>
<HelpMessage id={ `${ instanceId }-price-error` } isError>
{ fieldPriceError }
</HelpMessage>
</div>
<div className="simple-payments__field-multiple">
<ToggleControl
checked={ Boolean( multiple ) }
label={ __(
'Allow people to buy more than one item at a time',
'jetpack-paypal-payments'
) }
onChange={ this.handleMultipleChange }
/>
</div>
<TextControl
aria-describedby={ `${ instanceId }-email-${ fieldEmailError ? 'error' : 'help' }` }
className={ clsx( 'simple-payments__field', 'simple-payments__field-email', {
'simple-payments__field-has-error': fieldEmailError,
} ) }
label={ __( 'Email', 'jetpack-paypal-payments' ) }
onChange={ this.handleEmailChange }
placeholder={ __( 'Email', 'jetpack-paypal-payments' ) }
required
// TODO: switch this back to type="email" once Gutenberg paste handler ignores inputs of type email
type="text"
value={ email }
/>
<HelpMessage id={ `${ instanceId }-email-error` } isError>
{ fieldEmailError }
</HelpMessage>
<HelpMessage id={ `${ instanceId }-email-help` }>
{ __(
'Enter the email address associated with your PayPal account. Dont have an account?',
'jetpack-paypal-payments'
) + ' ' }
<Link openInNewTab href="https://www.paypal.com/">
{ __( 'Create one on PayPal', 'jetpack-paypal-payments' ) }
</Link>
</HelpMessage>
</div>
</Wrapper>
);
}
}
const mapSelectToProps = withSelect( ( select, props ) => {
const { getEntityRecord } = select( 'core' );
const { isSavingPost, getCurrentPost } = select( 'core/editor' );
const { productId, featuredMediaId } = props.attributes;
const fields = [
[ 'content' ],
[ 'meta', 'spay_currency' ],
[ 'meta', 'spay_email' ],
[ 'meta', 'spay_multiple' ],
[ 'meta', 'spay_price' ],
[ 'title', 'raw' ],
[ 'featured_media' ],
];
const simplePayment = productId
? pick( getEntityRecord( 'postType', SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, productId ), fields )
: undefined;
const post = getCurrentPost();
return {
hasPublishAction: !! post?._links?.[ 'wp:action-publish' ],
isSaving: !! isSavingPost(),
simplePayment,
featuredMedia: featuredMediaId
? getEntityRecord( 'postType', 'attachment', featuredMediaId )
: null,
postLinkUrl: post.link,
};
} );
export default compose( mapSelectToProps, withInstanceId )( SimplePaymentsEdit );
@@ -0,0 +1,164 @@
import { isWpcomPlatformSite } from '@automattic/jetpack-script-data';
import { Path, SVG } from '@wordpress/components';
import { Fragment } from '@wordpress/element';
import { __, _x } from '@wordpress/i18n';
import { Link } from '@wordpress/ui';
import { DEFAULT_CURRENCY } from '../../constants';
import edit from './edit';
import save from './save';
export const icon = (
<SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<Path fill="none" d="M0 0h24v24H0V0z" />
<Path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" />
</SVG>
);
const supportLink = isWpcomPlatformSite()
? 'https://wordpress.com/support/pay-with-paypal/'
: 'https://jetpack.com/support/jetpack-blocks/pay-with-paypal/';
const settings = {
title: __( 'Pay with PayPal', 'jetpack-paypal-payments' ),
description: (
<Fragment>
<p>
{ __(
'Lets you add credit and debit card payment buttons with minimal setup.',
'jetpack-paypal-payments'
) }
</p>
<p>
{ __(
'Good for collecting donations or payments for products and services.',
'jetpack-paypal-payments'
) }
</p>
<Link openInNewTab href={ supportLink }>
{ __( 'Support reference', 'jetpack-paypal-payments' ) }
</Link>
</Fragment>
),
icon: {
src: icon,
},
category: 'monetize',
keywords: [
_x( 'buy', 'block search term', 'jetpack-paypal-payments' ),
_x( 'commerce', 'block search term', 'jetpack-paypal-payments' ),
_x( 'products', 'block search term', 'jetpack-paypal-payments' ),
_x( 'purchase', 'block search term', 'jetpack-paypal-payments' ),
_x( 'sell', 'block search term', 'jetpack-paypal-payments' ),
_x( 'shop', 'block search term', 'jetpack-paypal-payments' ),
_x( 'simple', 'block search term', 'jetpack-paypal-payments' ),
_x( 'payments', 'block search term', 'jetpack-paypal-payments' ),
'PayPal',
],
attributes: {
currency: {
type: 'string',
default: DEFAULT_CURRENCY,
},
content: {
type: 'string',
source: 'html',
selector: '.jetpack-simple-payments-description p',
default: '',
},
email: {
type: 'string',
default: '',
},
featuredMediaId: {
type: 'number',
default: 0,
},
featuredMediaUrl: {
type: 'string',
source: 'attribute',
selector: '.jetpack-simple-payments-image img',
attribute: 'src',
default: null,
},
featuredMediaTitle: {
type: 'string',
source: 'attribute',
selector: '.jetpack-simple-payments-image img',
attribute: 'alt',
default: null,
},
multiple: {
type: 'boolean',
default: false,
},
postLinkUrl: {
type: 'string',
source: 'attribute',
selector: '.jetpack-simple-payments-purchase',
attribute: 'href',
},
postLinkText: {
type: 'string',
source: 'html',
selector: '.jetpack-simple-payments-purchase',
default: __( 'Click here to purchase.', 'jetpack-paypal-payments' ),
},
price: {
type: 'number',
},
productId: {
type: 'number',
},
title: {
type: 'string',
source: 'html',
selector: '.jetpack-simple-payments-title p',
default: '',
},
},
transforms: {
from: [
{
type: 'shortcode',
tag: 'simple-payment',
attributes: {
productId: {
type: 'number',
shortcode: ( { named: { id } } ) => {
if ( ! id ) {
return;
}
const result = parseInt( id, 10 );
if ( result ) {
return result;
}
},
},
},
},
],
},
edit,
save,
supports: {
align: false,
className: false,
customClassName: false,
html: false,
// Disabled due several problems because the block uses custom post type to store information
// https://github.com/Automattic/jetpack/issues/11789
reusable: false,
},
};
export default settings;
@@ -0,0 +1,53 @@
import { formatPriceFallback } from '../../utils';
export default function Save( { attributes } ) {
const {
content,
currency,
featuredMediaUrl,
featuredMediaTitle,
postLinkUrl,
postLinkText,
price,
productId,
title,
} = attributes;
if ( ! productId ) {
return null;
}
return (
<div className={ `jetpack-simple-payments-wrapper jetpack-simple-payments-${ productId }` }>
<div className="jetpack-simple-payments-product">
{ featuredMediaUrl && (
<div className="jetpack-simple-payments-product-image">
<div className="jetpack-simple-payments-image">
<figure>
<img src={ featuredMediaUrl } alt={ featuredMediaTitle } />
</figure>
</div>
</div>
) }
<div className="jetpack-simple-payments-details">
<div className="jetpack-simple-payments-title">
<p>{ title }</p>
</div>
<div className="jetpack-simple-payments-description">
<p>{ content }</p>
</div>
<div className="jetpack-simple-payments-price">
<p>{ formatPriceFallback( price, currency ) }</p>
</div>
<a
className="jetpack-simple-payments-purchase"
href={ postLinkUrl }
target="_blank"
rel="noopener noreferrer"
>
{ postLinkText }
</a>
</div>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,16 @@
<?php
/**
* PayPal Payments package.
*
* @package automattic/jetpack-paypal-payments
*/
namespace Automattic\Jetpack;
/**
* Class PayPal_Payments.
*/
class PayPal_Payments {
const PACKAGE_VERSION = '0.7.6';
}
@@ -0,0 +1,62 @@
<?php
/**
* Read-only REST controller for jp_pay_order.
*
* Orders should only be created through the internal payment processing flow,
* not directly via the REST API.
*
* @package automattic/jetpack-paypal-payments
*/
namespace Automattic\Jetpack\Paypal_Payments;
use WP_Error;
use WP_REST_Posts_Controller;
/**
* Extends WP_REST_Posts_Controller to disable create, update, and delete operations.
*/
class Order_REST_Controller extends WP_REST_Posts_Controller {
/**
* Deny order creation via the REST API.
*
* @param \WP_REST_Request $request Full details about the request.
* @return WP_Error
*/
public function create_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return new WP_Error(
'rest_cannot_create',
__( 'Orders can only be created through the payment processing flow.', 'jetpack-paypal-payments' ),
array( 'status' => 403 )
);
}
/**
* Deny order updates via the REST API.
*
* @param \WP_REST_Request $request Full details about the request.
* @return WP_Error
*/
public function update_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return new WP_Error(
'rest_cannot_update',
__( 'Orders cannot be modified via the REST API.', 'jetpack-paypal-payments' ),
array( 'status' => 403 )
);
}
/**
* Deny order deletion via the REST API.
*
* @param \WP_REST_Request $request Full details about the request.
* @return WP_Error
*/
public function delete_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return new WP_Error(
'rest_cannot_delete',
__( 'Orders cannot be deleted via the REST API.', 'jetpack-paypal-payments' ),
array( 'status' => 403 )
);
}
}
@@ -0,0 +1,186 @@
<?php
/**
* PayPal_Payments_Currencies: Utils for displaying and managing currencies.
*
* @package automattic/jetpack-paypal-payments
*/
/**
* General currencies specific functionality
*/
class PayPal_Payments_Currencies {
/**
* Currencies definition
*/
const CURRENCIES = array(
'USD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '$',
'decimal' => 2,
),
'GBP' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '&#163;',
'decimal' => 2,
),
'JPY' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '&#165;',
'decimal' => 0,
),
'BRL' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'R$',
'decimal' => 2,
),
'EUR' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '&#8364;',
'decimal' => 2,
),
'NZD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'NZ$',
'decimal' => 2,
),
'AUD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'A$',
'decimal' => 2,
),
'CAD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'C$',
'decimal' => 2,
),
'ILS' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₪',
'decimal' => 2,
),
'RUB' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₽',
'decimal' => 2,
),
'MXN' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'MX$',
'decimal' => 2,
),
'MYR' => array(
'format' => '%2$s%1$s', // 1: Symbol 2: currency value
'symbol' => 'RM',
'decimal' => 2,
),
'SEK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Skr',
'decimal' => 2,
),
'HUF' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Ft',
'decimal' => 0, // Decimals are supported by Stripe but not by PayPal.
),
'CHF' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'CHF',
'decimal' => 2,
),
'CZK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Kč',
'decimal' => 2,
),
'DKK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Dkr',
'decimal' => 2,
),
'HKD' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'HK$',
'decimal' => 2,
),
'NOK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Kr',
'decimal' => 2,
),
'PHP' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₱',
'decimal' => 2,
),
'PLN' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'PLN',
'decimal' => 2,
),
'SGD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'S$',
'decimal' => 2,
),
'TWD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'NT$',
'decimal' => 0, // Decimals are supported by Stripe but not by PayPal.
),
'THB' => array(
'format' => '%2$s%1$s', // 1: Symbol 2: currency value
'symbol' => '฿',
'decimal' => 2,
),
'INR' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₹',
'decimal' => 0,
),
);
/**
* Format a price with currency.
*
* Uses currency-aware formatting to output a formatted price with a simple fallback.
*
* Largely inspired by WordPress.com's Store_Price::display_currency
*
* @param string $price Price.
* @param string $currency Currency.
* @param bool $symbol Whether to display the currency symbol.
* @return string Formatted price.
*/
public static function format_price( $price, $currency, $symbol = true ) {
$price = floatval( $price );
// Try to parse using NumberFormatter if available
if ( class_exists( 'NumberFormatter' ) ) {
$formatter = new NumberFormatter( get_locale(), NumberFormatter::DECIMAL );
$parsed = $formatter->parse( (string) $price );
if ( false !== $parsed ) {
$price = (float) $parsed;
}
}
// Fall back to unspecified currency symbol like `¤1,234.05`.
// @link https://en.wikipedia.org/wiki/Currency_sign_(typography).
if ( ! array_key_exists( $currency, self::CURRENCIES ) ) {
return ( $symbol ? '¤' : '' ) . number_format_i18n( $price, 2 );
}
$currency_details = self::CURRENCIES[ $currency ];
// Ensure USD displays as 1234.56 even in non-US locales.
$amount = 'USD' === $currency
? number_format( $price, $currency_details['decimal'], '.', ',' )
: number_format_i18n( $price, $currency_details['decimal'] );
return sprintf(
$currency_details['format'],
$symbol ? $currency_details['symbol'] : '',
$amount
);
}
}
@@ -0,0 +1,800 @@
<?php
/**
* Simple Payments lets users embed a PayPal button fully integrated with wpcom to sell products on the site.
* This is not a proper module yet, because not all the pieces are in place. Until everything is shipped, it can be turned
* into module that can be enabled/disabled.
*
* @package automattic/jetpack-paypal-payments
*/
namespace Automattic\Jetpack\Paypal_Payments;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\PayPal_Payments;
use PayPal_Payments_Currencies;
use WP_Post;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Simple_Payments
*/
class Simple_Payments {
const PACKAGE_VERSION = PayPal_Payments::PACKAGE_VERSION;
/**
* Post type order.
*
* @var string
*/
public static $post_type_order = 'jp_pay_order';
/**
* Post type product.
*
* @var string
*/
public static $post_type_product = 'jp_pay_product';
/**
* Define simple payment shortcode.
*
* @var string
*/
public static $shortcode = 'simple-payment';
/**
* Define simple payment CSS prefix.
*
* @var string
*/
public static $css_classname_prefix = 'jetpack-simple-payments';
/**
* Which plan the user is on.
*
* @var string value_bundle or jetpack_premium
*/
public static $required_plan;
/**
* Instance of the class.
*
* @var Simple_Payments
*/
private static $instance;
/**
* Construction function.
*/
private function __construct() {}
/**
* Create instance of class.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
self::$instance->register_init_hooks();
self::$required_plan = ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ? 'value_bundle' : 'jetpack_premium';
}
return self::$instance;
}
/**
* Register scripts and styles.
*/
private function register_scripts_and_styles() {
/**
* Paypal heavily discourages putting that script in your own server:
*
* @see https://developer.paypal.com/docs/integration/direct/express-checkout/integration-jsv4/add-paypal-button/
*/
wp_register_script(
'paypal-checkout-js',
'https://www.paypalobjects.com/api/checkout.js',
array(),
null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
true
);
Assets::register_script(
'jetpack-paypal-express-checkout',
'./paypal-express-checkout.js',
__FILE__,
array(
'dependencies' => array(
'jquery',
'paypal-checkout-js',
),
)
);
wp_register_style(
'jetpack-simple-payments',
plugin_dir_url( __FILE__ ) . '/../../../dist/legacy-simple-payments.css',
array( 'dashicons' ),
self::PACKAGE_VERSION,
false /* @phan-suppress-current-line PhanTypeMismatchArgument */
);
}
/**
* Register init hooks.
*/
private function register_init_hooks() {
add_action( 'init', array( $this, 'init_hook_action' ) );
add_action( 'rest_api_init', array( $this, 'register_meta_fields_in_rest_api' ) );
add_filter( 'rest_prepare_' . self::$post_type_product, array( $this, 'redact_spay_email_for_unauthorized' ), 10, 2 );
}
/**
* Register the shortcode.
*/
private function register_shortcode() {
add_shortcode( self::$shortcode, array( $this, 'parse_shortcode' ) );
}
/**
* Actions that are run on init.
*/
public function init_hook_action() {
add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
if ( ! is_admin() ) {
$this->register_scripts_and_styles();
}
$this->register_shortcode();
$this->setup_cpts();
add_filter( 'the_content', array( $this, 'remove_auto_paragraph_from_product_description' ), 0 );
}
/**
* Enqueue the static assets needed in the frontend.
*/
public function enqueue_frontend_assets() {
if ( ! wp_style_is( 'jetpack-simple-payments', 'enqueued' ) ) {
wp_enqueue_style( 'jetpack-simple-payments' );
}
if ( ! wp_script_is( 'jetpack-paypal-express-checkout', 'enqueued' ) ) {
wp_enqueue_script( 'jetpack-paypal-express-checkout' );
}
}
/**
* Add an inline script for setting up the PayPal checkout button.
*
* @param string $id Product ID.
* @param string $dom_id ID of the DOM element with the purchase message.
* @param boolean $is_multiple Whether multiple items of the same product can be purchased.
*/
public function setup_paypal_checkout_button( $id, $dom_id, $is_multiple ) {
wp_add_inline_script(
'jetpack-paypal-express-checkout',
sprintf(
"try{PaypalExpressCheckout.renderButton( '%d', '%d', %s, '%d' );}catch(e){}",
intval( $this->get_blog_id() ),
intval( $id ),
wp_json_encode( $dom_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ),
intval( $is_multiple )
)
);
}
/**
* Remove auto paragraph from product description.
*
* @param string $content - the content of the post.
*/
public function remove_auto_paragraph_from_product_description( $content ) {
if ( get_post_type() === self::$post_type_product ) {
remove_filter( 'the_content', 'wpautop' );
}
return $content;
}
/** Return the blog ID */
public function get_blog_id() {
return ( new Manager() )->get_site_id();
}
/**
* Used to check whether Simple Payments are enabled for given site.
*
* @return bool True if Simple Payments are enabled, false otherwise.
*/
public static function is_enabled_jetpack_simple_payments() {
/**
* Can be used by plugin authors to disable the conflicting output of Simple Payments.
*
* @since 6.3.0
*
* @param bool True if Simple Payments should be disabled, false otherwise.
*/
if ( apply_filters( 'jetpack_disable_simple_payments', false ) ) {
return false;
}
return ( ( defined( 'IS_WPCOM' ) && IS_WPCOM )
|| ( new Manager() )->is_connected()
&& Jetpack_Plan::supports( 'simple-payments' ) );
}
/**
* Get a WP_Post representation of a product
*
* @param int $id The ID of the product.
*
* @return array|false|WP_Post
*/
private function get_product( $id ) {
if ( ! $id ) {
return false;
}
$product = get_post( $id );
if ( ! $product || is_wp_error( $product ) ) {
return false;
}
if ( $product->post_type !== self::$post_type_product || 'publish' !== $product->post_status ) {
return false;
}
return $product;
}
/**
* Creates the content from a shortcode
*
* @param array $attrs Shortcode attributes.
* @param mixed $content unused.
*
* @return string|void
*/
public function parse_shortcode( $attrs, $content = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( empty( $attrs['id'] ) ) {
return;
}
$product = $this->get_product( $attrs['id'] );
if ( ! $product ) {
return;
}
// We allow for overriding the presentation labels.
$data = shortcode_atts(
array(
'blog_id' => $this->get_blog_id(),
'dom_id' => uniqid( self::$css_classname_prefix . '-' . $product->ID . '_', true ),
'class' => self::$css_classname_prefix . '-' . $product->ID,
'title' => get_the_title( $product ),
'description' => $product->post_content,
'cta' => get_post_meta( $product->ID, 'spay_cta', true ),
'multiple' => get_post_meta( $product->ID, 'spay_multiple', true ) || '0',
),
$attrs
);
$data['price'] = $this->format_price(
get_post_meta( $product->ID, 'spay_price', true ),
get_post_meta( $product->ID, 'spay_currency', true )
);
$data['id'] = $attrs['id'];
if ( ! self::is_enabled_jetpack_simple_payments() ) {
return;
}
$this->enqueue_frontend_assets();
$this->setup_paypal_checkout_button( $attrs['id'], $data['dom_id'], $data['multiple'] );
return $this->output_shortcode( $data );
}
/**
* Get the HTML output to use as PayPal purchase box.
*
* @param string $dom_id ID of the DOM element with the purchase message.
* @param boolean $is_multiple Whether multiple items of the same product can be purchased.
*
* @return string
*/
public function output_purchase_box( $dom_id, $is_multiple ) {
$items = '';
$css_prefix = self::$css_classname_prefix;
if ( $is_multiple ) {
$items = sprintf(
'
<div class="%1$s">
<input class="%2$s" type="number" value="1" min="1" id="%3$s" />
</div>
',
esc_attr( "{$css_prefix}-items" ),
esc_attr( "{$css_prefix}-items-number" ),
esc_attr( "{$dom_id}_number" )
);
}
return sprintf(
'<div class="%1$s" id="%2$s"></div><div class="%3$s">%4$s<div class="%5$s" id="%6$s"></div></div>',
esc_attr( "{$css_prefix}-purchase-message" ),
esc_attr( "{$dom_id}-message-container" ),
esc_attr( "{$css_prefix}-purchase-box" ),
$items,
esc_attr( "{$css_prefix}-button" ),
esc_attr( "{$dom_id}_button" )
);
}
/**
* Get the HTML output to replace the `simple-payments` shortcode.
*
* @param array $data Product data.
* @return string
*/
public function output_shortcode( $data ) {
$css_prefix = self::$css_classname_prefix;
$image = '';
if ( has_post_thumbnail( $data['id'] ) ) {
$image = sprintf(
'<div class="%1$s"><div class="%2$s">%3$s</div></div>',
esc_attr( "{$css_prefix}-product-image" ),
esc_attr( "{$css_prefix}-image" ),
get_the_post_thumbnail( $data['id'], 'full' )
);
}
return sprintf(
'
<div class="%1$s">
<div class="%2$s">
%3$s
<div class="%4$s">
<div class="%5$s"><p>%6$s</p></div>
<div class="%7$s"><p>%8$s</p></div>
<div class="%9$s"><p>%10$s</p></div>
%11$s
</div>
</div>
</div>
',
esc_attr( "{$data['class']} {$css_prefix}-wrapper" ),
esc_attr( "{$css_prefix}-product" ),
$image,
esc_attr( "{$css_prefix}-details" ),
esc_attr( "{$css_prefix}-title" ),
esc_html( $data['title'] ),
esc_attr( "{$css_prefix}-description" ),
wp_kses( $data['description'], wp_kses_allowed_html( 'post' ) ),
esc_attr( "{$css_prefix}-price" ),
esc_html( $data['price'] ),
$this->output_purchase_box( $data['dom_id'], $data['multiple'] )
);
}
/**
* Format a price with currency
*
* Uses currency-aware formatting to output a formatted price with a simple fallback.
*
* Largely inspired by WordPress.com's Store_Price::display_currency
*
* @param string $price Price.
* @param string $currency Currency.
* @return string Formatted price.
*/
private function format_price( $price, $currency ) {
return PayPal_Payments_Currencies::format_price( $price, $currency );
}
/**
* Allows custom post types to be used by REST API.
*
* @param array $post_types - the allows post types.
* @see hook 'rest_api_allowed_post_types'
* @return array
*/
public function allow_rest_api_types( $post_types ) {
$post_types[] = self::$post_type_order;
$post_types[] = self::$post_type_product;
return $post_types;
}
/**
* Merge $post_meta with additional meta information.
*
* @param array $post_meta - the post's meta information.
*/
public function allow_sync_post_meta( $post_meta ) {
return array_merge(
$post_meta,
array(
'spay_paypal_id',
'spay_status',
'spay_product_id',
'spay_quantity',
'spay_price',
'spay_customer_email',
'spay_currency',
'spay_cta',
'spay_email',
'spay_multiple',
'spay_formatted_price',
)
);
}
/**
* Enable Simple payments custom meta values for access through the REST API.
* Field's value will be exposed on a .meta key in the endpoint response,
* and WordPress will handle setting up the callbacks for reading and writing
* to that meta key.
*
* @link https://developer.wordpress.org/rest-api/extending-the-rest-api/modifying-responses/
*/
public function register_meta_fields_in_rest_api() {
register_meta(
'post',
'spay_price',
array(
'description' => esc_html__( 'Simple payments; price.', 'jetpack-paypal-payments' ),
'object_subtype' => self::$post_type_product,
'sanitize_callback' => array( $this, 'sanitize_price' ),
'show_in_rest' => true,
'single' => true,
'type' => 'number',
)
);
register_meta(
'post',
'spay_currency',
array(
'description' => esc_html__( 'Simple payments; currency code.', 'jetpack-paypal-payments' ),
'object_subtype' => self::$post_type_product,
'sanitize_callback' => array( $this, 'sanitize_currency' ),
'show_in_rest' => true,
'single' => true,
'type' => 'string',
)
);
register_meta(
'post',
'spay_cta',
array(
'description' => esc_html__( 'Simple payments; text with "Buy" or other CTA', 'jetpack-paypal-payments' ),
'object_subtype' => self::$post_type_product,
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => true,
'single' => true,
'type' => 'string',
)
);
register_meta(
'post',
'spay_multiple',
array(
'description' => esc_html__( 'Simple payments; allow multiple items', 'jetpack-paypal-payments' ),
'object_subtype' => self::$post_type_product,
'sanitize_callback' => 'rest_sanitize_boolean',
'show_in_rest' => true,
'single' => true,
'type' => 'boolean',
)
);
register_meta(
'post',
'spay_email',
array(
'description' => esc_html__( 'Simple payments button; paypal email.', 'jetpack-paypal-payments' ),
'object_subtype' => self::$post_type_product,
'sanitize_callback' => 'sanitize_email',
'show_in_rest' => true,
'single' => true,
'type' => 'string',
)
);
register_meta(
'post',
'spay_status',
array(
'description' => esc_html__( 'Simple payments; status.', 'jetpack-paypal-payments' ),
'object_subtype' => self::$post_type_product,
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => true,
'single' => true,
'type' => 'string',
)
);
}
/**
* Strip the seller's PayPal email (`spay_email`) from REST responses when the
* requester cannot edit the product. The meta stays `show_in_rest => true` so
* the block editor's read/write round-trip keeps working — but unauthenticated
* or read-only callers no longer see the address in collection or single-item
* responses for the `jp_pay_product` post type.
*
* @param mixed $response The response object (expected: \WP_REST_Response).
* @param mixed $post The product post (expected: \WP_Post).
* @return mixed
*/
public function redact_spay_email_for_unauthorized( $response, $post ) {
if ( ! $response instanceof \WP_REST_Response || ! $post instanceof WP_Post ) {
return $response;
}
if ( current_user_can( 'edit_post', $post->ID ) ) {
return $response;
}
$data = $response->get_data();
if ( isset( $data['meta']['spay_email'] ) ) {
unset( $data['meta']['spay_email'] );
$response->set_data( $data );
}
return $response;
}
/**
* Sanitize three-character ISO-4217 Simple payments currency
*
* List has to be in sync with list at the block's client side and widget's backend side:
*
* @param array $currency - list of currencies.
* @link https://github.com/Automattic/jetpack/blob/31efa189ad223c0eb7ad085ac0650a23facf9ef5/extensions/blocks/simple-payments/constants.js#L9-L39
* @link https://github.com/Automattic/jetpack/blob/31efa189ad223c0eb7ad085ac0650a23facf9ef5/modules/widgets/simple-payments.php#L19-L44
*
* Currencies should be supported by PayPal:
* @link https://developer.paypal.com/docs/api/reference/currency-codes/
*
* Indian Rupee (INR) not supported because at the time of the creation of this file
* because it's limited to in-country PayPal India accounts only.
* Discussion: https://github.com/Automattic/wp-calypso/pull/28236
*/
public static function sanitize_currency( $currency ) {
$valid_currencies = array(
'USD',
'EUR',
'AUD',
'BRL',
'CAD',
'CZK',
'DKK',
'HKD',
'HUF',
'ILS',
'JPY',
'MYR',
'MXN',
'TWD',
'NZD',
'NOK',
'PHP',
'PLN',
'GBP',
'RUB',
'SGD',
'SEK',
'CHF',
'THB',
);
return in_array( $currency, $valid_currencies, true ) ? $currency : false;
}
/**
* Sanitize price:
*
* Positive integers and floats
* Supports two decimal places.
* Maximum length: 10.
*
* See `price` from PayPal docs:
*
* @link https://developer.paypal.com/docs/api/orders/v1/#definition-item
*
* @param string $price - the price we want to sanitize.
* @return null|string
*/
public static function sanitize_price( $price ) {
return preg_match( '/^[0-9]{0,10}(\.[0-9]{0,2})?$/', $price ) ? $price : false;
}
/**
* Sets up the custom post types for the module.
*/
public function setup_cpts() {
/*
* ORDER data structure. holds:
* title = customer_name | 4xproduct_name
* excerpt = customer_name + customer contact info + customer notes from paypal form
* metadata:
* spay_paypal_id - paypal id of transaction
* spay_status
* spay_product_id - post_id of bought product
* spay_quantity - quantity of product
* spay_price - item price at the time of purchase
* spay_customer_email - customer email
* ... (WIP)
*/
$order_capabilities = array(
'edit_post' => 'edit_posts',
'read_post' => 'read_private_posts',
'delete_post' => 'delete_posts',
'edit_posts' => 'edit_posts',
'edit_others_posts' => 'edit_others_posts',
'publish_posts' => 'publish_posts',
'read_private_posts' => 'read_private_posts',
);
$order_args = array(
'label' => esc_html_x( 'Order', 'noun: a quantity of goods or items purchased or sold', 'jetpack-paypal-payments' ),
'description' => esc_html__( 'Simple Payments orders', 'jetpack-paypal-payments' ),
'supports' => array( 'custom-fields', 'excerpt' ),
'hierarchical' => false,
'public' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_admin_bar' => false,
'show_in_nav_menus' => false,
'can_export' => true,
'has_archive' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'rewrite' => false,
'capabilities' => $order_capabilities,
'show_in_rest' => true,
'rest_controller_class' => Order_REST_Controller::class,
);
register_post_type( self::$post_type_order, $order_args );
/*
* PRODUCT data structure. Holds:
* title - title
* content - description
* thumbnail - image
* metadata:
* spay_price - price
* spay_formatted_price
* spay_currency - currency code
* spay_cta - text with "Buy" or other CTA
* spay_email - paypal email
* spay_multiple - allow for multiple items
* spay_status - status. { enabled | disabled }
*/
$product_capabilities = array(
'edit_post' => 'edit_posts',
'read_post' => 'read_private_posts',
'delete_post' => 'delete_posts',
'edit_posts' => 'publish_posts',
'edit_others_posts' => 'edit_others_posts',
'publish_posts' => 'publish_posts',
'read_private_posts' => 'read_private_posts',
);
$product_args = array(
'label' => esc_html__( 'Product', 'jetpack-paypal-payments' ),
'description' => esc_html__( 'Simple Payments products', 'jetpack-paypal-payments' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields', 'author' ),
'hierarchical' => false,
'public' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_admin_bar' => false,
'show_in_nav_menus' => false,
'can_export' => true,
'has_archive' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'rewrite' => false,
'capabilities' => $product_capabilities,
'show_in_rest' => true,
);
register_post_type( self::$post_type_product, $product_args );
}
/**
* Validate the block attributes
*
* @param array $attrs The block attributes, expected to contain:
* * email - an email address.
* * price - a float between 0.01 and 9999999999.99.
* * productId - the ID of the product being paid for.
*
* @return bool
*/
public function is_valid( $attrs ) {
if ( ! $this->validate_paypal_email( $attrs ) ) {
return false;
}
if ( ! $this->validate_price( $attrs ) ) {
return false;
}
if ( ! $this->validate_product( $attrs ) ) {
return false;
}
return true;
}
/**
* Check that the email address to make a payment to is valid
*
* @param array $attrs Key-value array of attributes.
*
* @return boolean
*/
private function validate_paypal_email( $attrs ) {
if ( empty( $attrs['email'] ) ) {
return false;
}
return (bool) filter_var( $attrs['email'], FILTER_VALIDATE_EMAIL );
}
/**
* Check that the price is valid
*
* @param array $attrs Key-value array of attributes.
*
* @return bool
*/
private function validate_price( $attrs ) {
if ( empty( $attrs['price'] ) ) {
return false;
}
return (bool) self::sanitize_price( $attrs['price'] );
}
/**
* Check that the stored product is valid
*
* Valid means it has a title, and the currency is accepted.
*
* @param array $attrs Key-value array of attributes.
*
* @return bool
*/
private function validate_product( $attrs ) {
if ( empty( $attrs['productId'] ) ) {
return false;
}
$product = $this->get_product( $attrs['productId'] );
if ( ! $product ) {
return false;
}
// This title is the one used by paypal, it's set from the title set in the block content, unless the block
// content title is blank.
if ( ! get_the_title( $product ) ) {
return false;
}
$currency = get_post_meta( $product->ID, 'spay_currency', true );
return (bool) self::sanitize_currency( $currency );
}
/**
* Register Simple_Payments_Widget widget.
*/
public static function register_widget_simple_payments() {
if ( ! self::is_enabled_jetpack_simple_payments() ) {
return;
}
register_widget( 'Automattic\Jetpack\Paypal_Payments\Widgets\Simple_Payments_Widget' );
}
}
Simple_Payments::get_instance();
@@ -0,0 +1,250 @@
/**
* This PaypalExpressCheckout global is included by wp_enqueue_script( 'jetpack-paypal-express-checkout' );
* It handles communication with Paypal Express checkout and public-api.wordpress.com for the purposes
* of simple-payments module.
*/
/* global paypal */
/* exported PaypalExpressCheckout */
const PaypalExpressCheckout = {
primaryCssClassName: 'jetpack-simple-payments',
messageCssClassName: 'jetpack-simple-payments-purchase-message',
wpRestAPIHost: 'https://public-api.wordpress.com',
wpRestAPIVersion: '/wpcom/v2',
getEnvironment: function () {
if (
localStorage &&
localStorage.getItem &&
localStorage.getItem( 'simple-payments-env' ) === 'sandbox'
) {
return 'sandbox';
}
return 'production';
},
getCreatePaymentEndpoint: function ( blogId ) {
return (
PaypalExpressCheckout.wpRestAPIHost +
PaypalExpressCheckout.wpRestAPIVersion +
'/sites/' +
blogId +
'/simple-payments/paypal/payment'
);
},
getExecutePaymentEndpoint: function ( blogId, paymentId ) {
return (
PaypalExpressCheckout.wpRestAPIHost +
PaypalExpressCheckout.wpRestAPIVersion +
'/sites/' +
blogId +
'/simple-payments/paypal/' +
paymentId +
'/execute'
);
},
getNumberOfItems: function ( field, enableMultiple ) {
if ( enableMultiple !== '1' ) {
return 1;
}
const numberField = document.getElementById( field );
if ( ! numberField ) {
return 1;
}
const number = Number( numberField.value );
if ( isNaN( number ) ) {
return 1;
}
return number;
},
/**
* Get the DOM element-placeholder used to show message
* about the transaction. If it doesn't exist then the function will create a new one.
*
* @param {string} domId - domId id of the payment button placeholder
* @return {Element} the dom element to print the message
*/
getMessageContainer: function ( domId ) {
return document.getElementById( domId + '-message-container' );
},
/**
* Show a messange close to the Paypal button.
* Use this function to give feedback to the user according
* to the transaction result.
*
* @param {string} message - message to show
* @param {string} domId - paypal-button element dom identifier
* @param {boolean} [isError] - defines if it's a message error. Not TRUE as default.
*/
showMessage: function ( message, domId, isError ) {
const domEl = PaypalExpressCheckout.getMessageContainer( domId );
// set css classes
let cssClasses = PaypalExpressCheckout.messageCssClassName + ' show ';
cssClasses += isError ? 'error' : 'success';
// show message 1s after PayPal popup is closed
setTimeout( function () {
domEl.innerHTML = message;
domEl.setAttribute( 'class', cssClasses );
}, 1000 );
},
showError: function ( message, domId ) {
PaypalExpressCheckout.showMessage( message, domId, true );
},
processErrorMessage: function ( errorResponse ) {
const error = errorResponse ? errorResponse.responseJSON : null;
const defaultMessage = 'There was an issue processing your payment.';
if ( ! error ) {
return '<p>' + defaultMessage + '</p>';
}
if ( error.additional_errors ) {
const messages = [];
error.additional_errors.forEach( function ( additionalError ) {
if ( additionalError.message ) {
messages.push( '<p>' + additionalError.message.toString() + '</p>' );
}
} );
return messages.join( '' );
}
return '<p>' + ( error.message || defaultMessage ) + '</p>';
},
processSuccessMessage: function ( successResponse ) {
const message = successResponse.message;
const defaultMessage = 'Thank you. Your purchase was successful!';
if ( ! message ) {
return '<p>' + defaultMessage + '</p>';
}
return '<p>' + message + '</p>';
},
cleanAndHideMessage: function ( domId ) {
const domEl = PaypalExpressCheckout.getMessageContainer( domId );
domEl.setAttribute( 'class', PaypalExpressCheckout.messageCssClassName );
domEl.innerHTML = '';
},
renderButton: function ( blogId, buttonId, domId, enableMultiple ) {
const env = PaypalExpressCheckout.getEnvironment();
if ( ! paypal ) {
throw new Error( 'PayPal module is required by PaypalExpressCheckout' );
}
const buttonDomId = domId + '_button';
paypal.Button.render(
{
env: env,
commit: true,
style: {
label: 'pay',
shape: 'rect',
color: 'silver',
size: 'responsive',
fundingicons: true,
},
payment: function () {
PaypalExpressCheckout.cleanAndHideMessage( domId );
const payload = {
number: PaypalExpressCheckout.getNumberOfItems( domId + '_number', enableMultiple ),
buttonId: buttonId,
env: env,
};
return new paypal.Promise( function ( resolve, reject ) {
// eslint-disable-next-line no-undef
jQuery
.post( PaypalExpressCheckout.getCreatePaymentEndpoint( blogId ), payload )
.done( function ( paymentResponse ) {
if ( ! paymentResponse ) {
PaypalExpressCheckout.showError(
PaypalExpressCheckout.processErrorMessage(),
domId
);
return reject( new Error( 'server_error' ) );
}
resolve( paymentResponse.id );
} )
.fail( function ( paymentError ) {
const paymentErrorMessage =
PaypalExpressCheckout.processErrorMessage( paymentError );
PaypalExpressCheckout.showError( paymentErrorMessage, domId );
const code =
paymentError.responseJSON && paymentError.responseJSON.code
? paymentError.responseJSON.code
: 'server_error';
reject( new Error( code ) );
} );
} );
},
onAuthorize: function ( onAuthData ) {
const payload = {
buttonId: buttonId,
payerId: onAuthData.payerID,
env: env,
};
return new paypal.Promise( function ( resolve, reject ) {
// eslint-disable-next-line no-undef
jQuery
.post(
PaypalExpressCheckout.getExecutePaymentEndpoint( blogId, onAuthData.paymentID ),
payload
)
.done( function ( authResponse ) {
if ( ! authResponse ) {
PaypalExpressCheckout.showError(
PaypalExpressCheckout.processErrorMessage(),
domId
);
return reject( new Error( 'server_error' ) );
}
PaypalExpressCheckout.showMessage(
PaypalExpressCheckout.processSuccessMessage( authResponse ),
domId
);
resolve();
} )
.fail( function ( authError ) {
const authErrorMessage = PaypalExpressCheckout.processErrorMessage( authError );
PaypalExpressCheckout.showError( authErrorMessage, domId );
const code =
authError.responseJSON && authError.responseJSON.code
? authError.responseJSON.code
: 'server_error';
reject( new Error( code ) );
} );
} );
},
},
buttonDomId
);
},
};
@@ -0,0 +1,172 @@
.jetpack-simple-payments-wrapper {
margin-bottom: 1.5em;
}
/* Higher specificity in order to reset paragraph style */
body .jetpack-simple-payments-wrapper .jetpack-simple-payments-details p {
margin: 0 0 1.5em;
padding: 0;
}
.jetpack-simple-payments-product {
display: flex;
flex-direction: column;
}
.jetpack-simple-payments-product-image {
flex: 0 0 30%;
margin-bottom: 1.5em;
}
.jetpack-simple-payments-image {
box-sizing: border-box;
min-width: 70px;
padding-top: 100%;
position: relative;
}
/* Higher specificity in order to trump theme's style */
body .jetpack-simple-payments-wrapper .jetpack-simple-payments-product-image .jetpack-simple-payments-image img {
border: 0;
border-radius: 0;
height: auto;
left: 50%;
margin: 0;
max-height: 100%;
max-width: 100%;
padding: 0;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
}
.jetpack-simple-payments-title p,
.jetpack-simple-payments-price p {
font-weight: 700;
}
.jetpack-simple-payments-purchase-box {
align-items: flex-start;
display: flex;
}
.jetpack-simple-payments-button {
max-width: 340px;
width: 100%;
}
.jetpack-simple-payments-items {
flex: 0 0 auto;
margin-right: 10px;
}
input[type="number"].jetpack-simple-payments-items-number {
font-size: 16px;
line-height: 1;
max-width: 60px;
padding: 4px 8px;
}
input[type="number"].jetpack-simple-payments-items-number::-webkit-inner-spin-button,
input[type="number"].jetpack-simple-payments-items-number::-webkit-outer-spin-button {
opacity: 1;
}
.jetpack-simple-payments-button iframe {
margin: 0;
}
.jetpack-simple-payments-purchase-message {
background-color: rgba(255, 255, 255, 0.7);
border: 2px solid #fff;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(200, 215, 225, 0.5), 0 1px 2px #e9eff3;
display: none;
margin-bottom: 1.5em;
min-height: 48px;
padding: 1em;
position: relative;
}
.jetpack-simple-payments-purchase-message::before {
font-family: dashicons !important;
font-size: 48px !important;
line-height: 1 !important;
position: absolute;
speak: none;
top: 50%;
left: 0;
transform: translateY(-50%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.jetpack-simple-payments-purchase-message.show {
display: block;
}
.jetpack-simple-payments-purchase-message.success::before {
color: #4ab866;
content: "\f147";
}
.jetpack-simple-payments-purchase-message.error::before {
color: #d94f4f;
content: "\f335";
}
/* Higher specificity in order to reset */
body .jetpack-simple-payments-wrapper .jetpack-simple-payments-purchase-message p {
color: #222;
margin: 0 0 0.5em;
padding: 0 0 0 40px;
}
body .jetpack-simple-payments-wrapper .jetpack-simple-payments-purchase-message p:last-child {
margin: 0;
}
.jetpack-simple-payments-description {
white-space: pre-wrap;
}
@media screen and (min-width: 400px) {
.jetpack-simple-payments-product {
flex-direction: row;
}
.jetpack-simple-payments-product-image + .jetpack-simple-payments-details {
flex-basis: 70%;
padding-left: 1em;
}
}
.is-email .jetpack-simple-payments-product {
display: table;
width: 100%;
}
.is-email .jetpack-simple-payments-product-image {
display: table-cell;
width: 30%;
vertical-align: top;
}
.is-email .jetpack-simple-payments-image {
padding-top: 0;
}
.is-email .jetpack-simple-payments-image figure {
margin: 0;
}
.jetpack-simple-payments-details {
width: 100%;
}
.is-email .jetpack-simple-payments-product-image + .jetpack-simple-payments-details {
display: table-cell;
width: 70%;
}
@@ -0,0 +1,31 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetpack/paypal-payment-buttons",
"version": "0.1.0",
"title": "PayPal Payment Buttons",
"category": "monetize",
"icon": "<svg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'><path fill='none' d='M0 0h24v24H0V0z' /><path d='M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z' /></svg>",
"description": "Custom block for displaying PayPal Payment Buttons.",
"example": {},
"attributes": {
"buttonType": {
"type": "string",
"enum": [ "stacked", "single" ],
"default": "stacked"
},
"scriptSrc": {
"type": "string"
},
"hostedButtonId": {
"type": "string"
},
"buttonText": {
"type": "string"
}
},
"supports": {
"html": false
},
"textdomain": "jetpack-paypal-payments"
}
@@ -0,0 +1,248 @@
<?php
/**
* PayPal Payment Buttons block lets users embed a PayPal button to sell products on their site.
*
* @package automattic/jetpack-paypal-payments
*/
namespace Automattic\Jetpack\PaypalPayments;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Blocks;
/**
* Class PayPal_Payment_Buttons
*
* @package Automattic\Jetpack\PaypalPayments
*/
class PayPal_Payment_Buttons {
/**
* The block full slugname.
*
* @var string
*/
public const BLOCK_NAME = 'jetpack/paypal-payment-buttons';
/**
* PayPal partner attribution ID used for tracking.
*
* @var string
*/
public const PAYPAL_PARTNER_ATTRIBUTION_ID = 'WooNCPS_Ecom_Wordpress';
/**
* Validates and sanitizes a script URL to ensure it's from an allowed PayPal domain.
*
* @param string $url The URL to validate and sanitize.
* @return string|false The sanitized URL, or false if URL is not from an allowed PayPal domain.
*/
public static function sanitize_paypal_script_url( $url ) {
if ( empty( $url ) ) {
return false;
}
$parsed_url = wp_parse_url( $url );
if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
return false;
}
// Normalize the host
$host = strtolower( $parsed_url['host'] );
$host = rtrim( $host, '.' );
// Only allow specific PayPal domains
$allowed_hosts = array(
'www.paypal.com',
'paypal.com',
'www.sandbox.paypal.com',
'sandbox.paypal.com',
);
if ( ! in_array( $host, $allowed_hosts, true ) ) {
return false;
}
// Rebuild the URL with HTTPS
$sanitized_url = 'https://' . $host;
if ( isset( $parsed_url['path'] ) ) {
$sanitized_url .= $parsed_url['path'];
}
if ( isset( $parsed_url['query'] ) ) {
// If we have escaped ampersands in the query string, we need to unescape them.
$sanitized_url .= '?' . str_replace( '&amp;', '&', $parsed_url['query'] );
}
return $sanitized_url;
}
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
public static function register_block() {
Blocks::jetpack_register_block(
__DIR__,
array(
'render_callback' => array( __CLASS__, 'render_block' ),
'plan_check' => true,
)
);
}
/**
* Render the block.
*
* @param array $attributes The block attributes.
* @param string $content The block content.
* @return string|void
*/
public static function render_block( $attributes, $content ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$button_type = $attributes['buttonType'] ?? '';
$script_src = $attributes['scriptSrc'] ?? '';
$hosted_button_id = $attributes['hostedButtonId'] ?? '';
$button_text = $attributes['buttonText'] ?? '';
if ( empty( $button_type ) || empty( $hosted_button_id ) ) {
return;
}
// For stacked buttons, we need both scriptSrc and hostedButtonId
if ( 'stacked' === $button_type && empty( $script_src ) ) {
return;
}
// For single buttons, we need buttonText
if ( 'single' === $button_type && empty( $button_text ) ) {
return;
}
if ( 'stacked' === $button_type ) {
// Sanitize the script URL to ensure it's from an allowed PayPal domain
$sanitized_url = self::sanitize_paypal_script_url( $script_src );
if ( false === $sanitized_url ) {
return;
}
// We can't include the version number here. If we do, it is appended to the URL and causes a 400 response.
wp_enqueue_script( 'paypal-payment-buttons-block-head', $sanitized_url, array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
add_filter(
'script_loader_tag',
function ( $tag, $handle, $src ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( 'paypal-payment-buttons-block-head' === $handle ) {
// Add namespace to avoid conflicts with other PayPal SDK versions
if ( ! str_contains( $tag, 'data-namespace' ) ) {
$tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-namespace="paypal_payment_buttons" src=$2', $tag );
}
// Add partner attribution ID
if ( ! str_contains( $tag, 'data-paypal-partner-attribution-id' ) ) {
$tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-paypal-partner-attribution-id="' . self::PAYPAL_PARTNER_ATTRIBUTION_ID . '" src=$2', $tag );
}
}
return $tag;
},
10,
3
);
// Generate the button HTML and inline script
$container_id = 'paypal-container-' . $hosted_button_id;
$button_html = '<div id="' . esc_attr( $container_id ) . '"></div>';
$inline_script = sprintf(
'(window.paypal_payment_buttons || window.paypal).HostedButtons({
hostedButtonId: %s,
}).render(%s);',
wp_json_encode( $hosted_button_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ),
wp_json_encode( '#' . $container_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
);
wp_add_inline_script( 'paypal-payment-buttons-block-head', $inline_script );
return $button_html;
}
// Single button type - generate the complete form HTML
if ( 'single' === $button_type ) {
self::register_hooks();
$payment_id = esc_attr( $hosted_button_id );
$button_text_escaped = esc_attr( $button_text );
$action_url = esc_url( 'https://www.paypal.com/ncp/payment/' . $payment_id . '?at_code=' . self::PAYPAL_PARTNER_ATTRIBUTION_ID );
$button_html = sprintf(
'<style>.pp-%1$s{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style>
<div>
<form action="%2$s" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;">
<input class="pp-%1$s" type="submit" value="%3$s" />
<img src="https://www.paypalobjects.com/images/Debit_Credit_APM.svg" alt="cards" />
<section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="paypal" style="height:0.875rem;vertical-align:middle;"/></section>
</form>
</div>',
$payment_id,
$action_url,
$button_text_escaped
);
return $button_html;
}
}
/**
* Load editor styles for the block.
* These are loaded via enqueue_block_assets to ensure proper loading in the editor iframe context.
*/
public static function load_editor_styles() {
$handle = 'jp-paypal-payments-ncps-blocks';
Assets::register_script(
$handle,
'../../dist/paypal-payment-buttons/editor.js',
__FILE__,
array(
'css_path' => '../../dist/paypal-payment-buttons/editor.css',
'textdomain' => 'jetpack-paypal-payments',
)
);
wp_enqueue_style( $handle );
}
/**
* Loads scripts
*/
public static function load_editor_scripts() {
Assets::register_script(
'jp-paypal-payments-ncps-blocks',
'../../dist/paypal-payment-buttons/editor.js',
__FILE__,
array(
'in_footer' => true,
'textdomain' => 'jetpack-paypal-payments',
'enqueue' => true,
// Editor styles are loaded separately, see load_editor_styles().
'css_path' => null,
)
);
}
/**
* Add display to the allowed styles.
*
* @see https://developer.wordpress.org/reference/hooks/safe_style_css/
*
* @param array $safe_styles The allowed styles.
* @return array The allowed styles.
*/
public static function add_style_display( array $safe_styles ): array {
$safe_styles[] = 'display';
return $safe_styles;
}
/**
* Register hooks.
*/
public static function register_hooks() {
add_filter( 'safe_style_css', array( __CLASS__, 'add_style_display' ) );
}
}
@@ -0,0 +1,428 @@
import { isWpcomPlatformSite } from '@automattic/jetpack-script-data';
import { PlainText, useBlockProps } from '@wordpress/block-editor';
import {
Notice,
Placeholder,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalItemGroup as ItemGroup,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalItem as Item,
} from '@wordpress/components';
import { createInterpolateElement, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Link } from '@wordpress/ui';
import PayPalIcon from './icon';
import './editor.scss';
const BUTTON_ID_PATTERN = '[A-Za-z0-9_-]+';
const extractScriptSrc = codeHead => {
const match = codeHead.match(
/src="(https:\/\/(www\.)?(sandbox\.)?paypal\.com\/sdk\/js\?[^"]+)"/
);
return match ? match[ 1 ] : '';
};
const extractHostedButtonId = codeBody => {
let buttonId = '';
// Try to extract from hostedButtonId property first (stacked buttons)
const hostedButtonMatch = codeBody.match(
new RegExp( `hostedButtonId:\\s*["'](${ BUTTON_ID_PATTERN })["']` )
);
if ( hostedButtonMatch ) {
buttonId = hostedButtonMatch[ 1 ];
}
// Try to extract from form action URL (single buttons)
// Support international domains, protocol-relative URLs, and case-insensitive domain matching
// Extract ID before any query parameters or spaces
if ( ! buttonId ) {
const actionMatch = codeBody.match(
/action\s*=\s*["'](?:https?:)?\/\/(?:www\.)?(?:sandbox\.)?paypal\.[a-z.]+\/ncp\/payment\/([A-Za-z0-9_-]+)\s*(?:\?[^"']*)?["']/i
);
if ( actionMatch ) {
buttonId = actionMatch[ 1 ];
}
}
return buttonId.trim();
};
const extractButtonText = codeBody => {
// Extract button text from input value attribute (single buttons)
// Support spaces around equals sign and both self-closing and non-self-closing tags
const inputMatch = codeBody.match( /<input[^>]*value\s*=\s*["']([^"']+)["'][^>]*\/?>/ );
return inputMatch ? inputMatch[ 1 ].trim() : '';
};
const generateHeadCode = scriptSrc => {
if ( ! scriptSrc ) {
return '';
}
return `<script src="${ scriptSrc }" data-namespace="paypal_payment_buttons"></script>`;
};
const generateBodyCode = ( hostedButtonId, buttonType = 'stacked', buttonText = '' ) => {
if ( ! hostedButtonId ) {
return '';
}
if ( buttonType === 'single' ) {
return `<style>.pp-${ hostedButtonId }{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style>
<form action="https://www.paypal.com/ncp/payment/${ hostedButtonId }" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;">
<input class="pp-${ hostedButtonId }" type="submit" value="${ buttonText || 'Pay Now' }" />
<img src="https://www.paypalobjects.com/images/Debit_Credit_APM.svg" alt="cards" />
<section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="paypal" style="height:0.875rem;vertical-align:middle;"/></section>
</form>`;
}
return `<div id="paypal-container-${ hostedButtonId }"></div>
<script>
(window.paypal_payment_buttons || window.paypal).HostedButtons({
hostedButtonId: "${ hostedButtonId }",
}).render("#paypal-container-${ hostedButtonId }")
</script>`;
};
const validScriptSrc = scriptSrc =>
/^https:\/\/(www\.)?(sandbox\.)?paypal\.com\/sdk\/js\?client-id=/.test( scriptSrc );
const validHostedButtonId = hostedButtonId => {
// Validate the button ID format
return new RegExp( `^${ BUTTON_ID_PATTERN }$` ).test( hostedButtonId );
};
const validButtonText = buttonText =>
buttonText && buttonText.trim().length > 0 && buttonText.length <= 50;
/**
* Get PayPal signup URL with platform-specific tracking parameters
*
* @return {string} The PayPal signup URL
*/
const getPayPalSignupUrl = () => {
const isWpcom = isWpcomPlatformSite();
const utmSource = isWpcom ? 'wp_com' : 'wp_org';
const atCode = isWpcom ? 'wp_com' : 'wp_org';
return `https://www.paypal.com/bizsignup/entry?product=payment_button&utm_source=${ utmSource }&at_code=${ atCode }`;
};
/**
* Get PayPal login URL with platform-specific tracking parameters
*
* @return {string} The PayPal login URL
*/
const getPayPalLoginUrl = () => {
const isWpcom = isWpcomPlatformSite();
const utmSource = isWpcom ? 'wp_com' : 'wp_org';
const atCode = isWpcom ? 'wp_com' : 'wp_org';
return `https://www.paypal.com/ncp/buttons/create?utm_source=${ utmSource }&at_code=${ atCode }`;
};
/**
* PayPal Single Button Preview component (rendered directly)
*
* @param {object} root0 - The component props
* @param {string} root0.buttonText - The button text
* @return {Element} The PayPal single button preview component
*/
const PayPalSingleButtonPreview = ( { buttonText } ) => {
const paypalButtonStyles = {
textAlign: 'center',
border: 'none',
borderRadius: '0.25rem',
minWidth: '11.625rem',
padding: '0 2rem',
height: '2.625rem',
fontWeight: 'bold',
backgroundColor: '#FFD140',
color: '#000000',
fontFamily: '"Helvetica Neue", Arial, sans-serif',
fontSize: '1rem',
lineHeight: '1.25rem',
cursor: 'pointer',
pointerEvents: 'none', // Prevent clicking in editor
};
return (
<div style={ { textAlign: 'center' } }>
<form
style={ {
display: 'inline-grid',
justifyItems: 'center',
alignContent: 'start',
gap: '0.5rem',
} }
>
<input type="button" value={ buttonText } style={ paypalButtonStyles } />
<img src="https://www.paypalobjects.com/images/Debit_Credit_APM.svg" alt="cards" />
<section style={ { fontSize: '0.75rem' } }>
Powered by{ ' ' }
<img
src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg"
alt="paypal"
style={ { height: '0.875rem', verticalAlign: 'middle' } }
/>
</section>
</form>
</div>
);
};
/**
* Check if we have the required data for a preview (only single buttons)
*
* @param {object} attributes - The block attributes
* @return {boolean} Whether preview can be shown
*/
const canShowPreview = attributes => {
const { buttonType, hostedButtonId, buttonText } = attributes;
if ( ! hostedButtonId || ! validHostedButtonId( hostedButtonId ) ) {
return false;
}
if ( buttonType === 'single' ) {
return buttonText && validButtonText( buttonText );
}
return false;
};
/**
* PayPal Preview component router
*
* @param {object} root0 - The component props
* @param {object} root0.attributes - The block attributes
* @return {Element|null} The PayPal preview component or null
*/
const PayPalPreview = ( { attributes } ) => {
const { buttonType, buttonText } = attributes;
if ( ! canShowPreview( attributes ) ) {
return null;
}
// Only render preview for single button type
if ( buttonType === 'single' ) {
return <PayPalSingleButtonPreview buttonText={ buttonText } />;
}
return null;
};
export default function Edit( { attributes, setAttributes, isSelected } ) {
const { buttonType, scriptSrc, hostedButtonId, buttonText } = attributes;
const [ notice, setNotice ] = useState( null );
const [ rawHeadCode, setRawHeadCode ] = useState( '' );
const [ rawBodyCode, setRawBodyCode ] = useState( '' );
const stackedInstructions = __(
'Stacked Buttons (Recommended): This option lets you present all of your product information and PayPal payment method upfront on your website.',
'jetpack-paypal-payments'
);
const singleInstructions = __(
'Single Button: This option lets you quickly paste a single button on your site, with no product information.',
'jetpack-paypal-payments'
);
// Initialize raw code when valid extracted values exist
useEffect( () => {
if ( ! rawHeadCode && scriptSrc && buttonType === 'stacked' ) {
setRawHeadCode( generateHeadCode( scriptSrc ) );
}
}, [ scriptSrc, rawHeadCode, buttonType ] );
useEffect( () => {
if ( ! rawBodyCode && hostedButtonId ) {
setRawBodyCode( generateBodyCode( hostedButtonId, buttonType, buttonText ) );
}
}, [ hostedButtonId, rawBodyCode, buttonType, buttonText ] );
useEffect( () => {
// Check if user has pasted invalid code that couldn't be extracted
if ( 'stacked' === buttonType && rawHeadCode && rawHeadCode.trim() && ! scriptSrc ) {
return setNotice(
<Notice status="error" isDismissible={ false }>
{ __(
'Invalid PayPal script URL. Please paste code from PayPal.com.',
'jetpack-paypal-payments'
) }
</Notice>
);
}
if ( rawBodyCode && rawBodyCode.trim() && ! hostedButtonId ) {
return setNotice(
<Notice status="error" isDismissible={ false }>
{ __(
'Invalid PayPal button code. Please paste code from PayPal.com.',
'jetpack-paypal-payments'
) }
</Notice>
);
}
// Validate extracted values
if ( 'stacked' === buttonType && scriptSrc && ! validScriptSrc( scriptSrc ) ) {
return setNotice(
<Notice status="error" isDismissible={ false }>
{ __( 'Invalid PayPal script URL.', 'jetpack-paypal-payments' ) }
</Notice>
);
}
if ( hostedButtonId && ! validHostedButtonId( hostedButtonId ) ) {
return setNotice(
<Notice status="error" isDismissible={ false }>
{ __( 'Invalid PayPal button ID.', 'jetpack-paypal-payments' ) }
</Notice>
);
}
if ( 'single' === buttonType && buttonText && ! validButtonText( buttonText ) ) {
return setNotice(
<Notice status="error" isDismissible={ false }>
{ __( 'Button text must be between 1 and 50 characters.', 'jetpack-paypal-payments' ) }
</Notice>
);
}
setNotice( null );
}, [ buttonType, scriptSrc, hostedButtonId, buttonText, rawHeadCode, rawBodyCode ] );
const blockProps = useBlockProps();
// Early return for preview rendering
if ( ! isSelected && ! notice && canShowPreview( attributes ) ) {
return (
<div { ...blockProps }>
<PayPalPreview attributes={ attributes } />
</div>
);
}
const stackedButtonCodeLabel = __( 'Part 2 code', 'jetpack-paypal-payments' );
const stackedButtonCodePlaceholder = __(
'Paste the part 2 code here…',
'jetpack-paypal-payments'
);
const singleButtonCodeLabel = __( 'Single button code', 'jetpack-paypal-payments' );
const singleButtonCodePlaceholder = __(
'Paste the single button code here…',
'jetpack-paypal-payments'
);
return (
<div { ...blockProps }>
<Placeholder
icon={ PayPalIcon }
label={ __( 'PayPal Payment Buttons', 'jetpack-paypal-payments' ) }
isColumnLayout
instructions={ buttonType === 'stacked' ? stackedInstructions : singleInstructions }
notices={ notice }
>
<ItemGroup>
<Item>
{ createInterpolateElement(
__(
'1. <SignupLink><strong>Sign up</strong></SignupLink> or <LoginLink><strong>log in</strong></LoginLink> to PayPal to get your Payment Button code.',
'jetpack-paypal-payments'
),
{
SignupLink: <Link openInNewTab href={ getPayPalSignupUrl() } />,
LoginLink: <Link openInNewTab href={ getPayPalLoginUrl() } />,
strong: <strong />,
}
) }
</Item>
<Item>
{ 'stacked' === buttonType &&
__(
'2. After login, choose Payment Buttons. Enter your product or service details, and build the buttons. Copy the button code for Stacked Buttons (copy html code).',
'jetpack-paypal-payments'
) }
{ 'single' === buttonType &&
__(
'2. After login, choose Payment Buttons. Enter your product or service details, and build the buttons. Copy the button code for Single Button.',
'jetpack-paypal-payments'
) }
</Item>
<Item>{ __( '3. Paste the code below.', 'jetpack-paypal-payments' ) }</Item>
</ItemGroup>
<ToggleGroupControl
label={ __( 'Button type', 'jetpack-paypal-payments' ) }
value={ buttonType }
hideLabelFromVision
onChange={ type => {
const newAttributes = { buttonType: type };
newAttributes.scriptSrc = '';
newAttributes.buttonText = '';
newAttributes.hostedButtonId = '';
setRawHeadCode( '' );
setRawBodyCode( '' );
setAttributes( newAttributes );
} }
isBlock
__nextHasNoMarginBottom={ true }
__next40pxDefaultSize={ true }
>
<ToggleGroupControlOption
value="stacked"
label={ __( 'Stacked Buttons (Recommended)', 'jetpack-paypal-payments' ) }
aria-label={ __(
'Stacked Buttons are the recommended option for better conversion rates.',
'jetpack-paypal-payments'
) }
showTooltip={ true }
/>
<ToggleGroupControlOption
value="single"
label={ __( 'Single Button', 'jetpack-paypal-payments' ) }
/>
</ToggleGroupControl>
{ 'stacked' === buttonType && (
<PlainText
value={ rawHeadCode }
onChange={ code => {
setRawHeadCode( code );
const extractedSrc = extractScriptSrc( code );
setAttributes( {
scriptSrc: extractedSrc,
} );
} }
placeholder={ __( 'Paste the part 1 code here…', 'jetpack-paypal-payments' ) }
aria-label={ __( 'Part 1 code', 'jetpack-paypal-payments' ) }
name="paypal-payment-buttons-code-head"
/>
) }
<PlainText
value={ rawBodyCode }
onChange={ code => {
setRawBodyCode( code );
const extractedButtonId = extractHostedButtonId( code );
const extractedButtonText = extractButtonText( code );
setAttributes( {
hostedButtonId: extractedButtonId,
buttonText: extractedButtonText,
} );
} }
placeholder={
'stacked' === buttonType ? stackedButtonCodePlaceholder : singleButtonCodePlaceholder
}
aria-label={ 'stacked' === buttonType ? stackedButtonCodeLabel : singleButtonCodeLabel }
name="paypal-payment-buttons-code-body"
/>
</Placeholder>
</div>
);
}
@@ -0,0 +1,11 @@
import { registerJetpackBlockFromMetadata } from '../block/register-jetpack-block';
import metadata from './block.json';
import edit from './edit';
import PayPalIcon from './icon';
import './editor.scss';
registerJetpackBlockFromMetadata( metadata, {
edit,
save: () => null,
icon: PayPalIcon,
} );
@@ -0,0 +1,21 @@
/**
* WordPress dependencies
*/
import { SVG, Path } from '@wordpress/components';
export default (
<SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 154.7 190.5">
<Path
fill="#001c64"
d="M60.5 38.1a5.5 5.5 0 0 0-5.4 4.6l-9 57.2-8.3 52.5 8.3-52.5a5.5 5.5 0 0 1 5.4-4.6H78a54 54 0 0 0 53.8-51.6 50 50 0 0 0-23.4-5.6z"
/>
<Path
fill="#0070e0"
d="M131.7 43.7a54 54 0 0 1-53.8 51.6H51.5c-2.7 0-5 2-5.4 4.6l-8.3 52.5-5.2 33a4.5 4.5 0 0 0 4.4 5.1h28.7a5.5 5.5 0 0 0 5.4-4.6l7.6-48a5.5 5.5 0 0 1 5.4-4.5H101a54 54 0 0 0 53.2-45.7c3-18.7-6.5-35.6-22.5-44z"
/>
<Path
fill="#003087"
d="M28 0a5.5 5.5 0 0 0-5.5 4.6L.1 147.2a4.5 4.5 0 0 0 4.4 5.2h33.3l8.3-52.5 9-57.2a5.5 5.5 0 0 1 5.4-4.6h47.8c8.7 0 16.6 2 23.4 5.6C132 19.7 112.4 0 85.3 0z"
/>
</SVG>
);
@@ -0,0 +1,635 @@
<?php
/**
* Pay with PayPal (aka Simple Payments)
*
* Display a Pay with PayPal button as a Widget.
*
* @package automattic/jetpack-paypal-payments
*/
namespace Automattic\Jetpack\Paypal_Payments\Widgets;
use Automattic\Jetpack\PayPal_Payments;
use Automattic\Jetpack\Paypal_Payments\Simple_Payments;
use Automattic\Jetpack\Tracking;
use Jetpack;
use WP_Error;
use WP_Widget;
// Disable direct access/execution to/of the widget code.
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
if ( ! class_exists( 'Simple_Payments_Widget' ) ) {
/**
* Pay with PayPal (aka Simple Payments)
*
* Display a Pay with PayPal button as a Widget.
*/
class Simple_Payments_Widget extends WP_Widget {
/**
* The package version.
*
* @var string
*/
private $package_version = PayPal_Payments::PACKAGE_VERSION;
/**
* Currencies should be supported by PayPal:
*
* @var array $supported_currency_list
* @link https://developer.paypal.com/docs/api/reference/currency-codes/
*
* List has to be in sync with list at the block's client side and API's backend side:
* @link https://github.com/Automattic/jetpack/blob/31efa189ad223c0eb7ad085ac0650a23facf9ef5/extensions/blocks/simple-payments/constants.js#L9-L39
* @link https://github.com/Automattic/jetpack/blob/31efa189ad223c0eb7ad085ac0650a23facf9ef5/modules/simple-payments/simple-payments.php#L386-L415
*
* Indian Rupee (INR) is listed here for backwards compatibility with previously added widgets.
* It's not supported by Pay with PayPal because at the time of the creation of this file
* because it's limited to in-country PayPal India accounts only.
* Discussion: https://github.com/Automattic/wp-calypso/pull/28236
*/
public static $supported_currency_list = array(
'USD' => '$',
'GBP' => '&#163;',
'JPY' => '&#165;',
'BRL' => 'R$',
'EUR' => '&#8364;',
'NZD' => 'NZ$',
'AUD' => 'A$',
'CAD' => 'C$',
'INR' => '₹',
'ILS' => '₪',
'RUB' => '₽',
'MXN' => 'MX$',
'SEK' => 'Skr',
'HUF' => 'Ft',
'CHF' => 'CHF',
'CZK' => 'Kč',
'DKK' => 'Dkr',
'HKD' => 'HK$',
'NOK' => 'Kr',
'PHP' => '₱',
'PLN' => 'PLN',
'SGD' => 'S$',
'TWD' => 'NT$',
'THB' => '฿',
);
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'jetpack_simple_payments_widget',
/** This filter is documented in modules/widgets/facebook-likebox.php */
apply_filters( 'jetpack_widget_name', __( 'Pay with PayPal', 'jetpack-paypal-payments' ) ),
array(
'classname' => 'simple-payments',
'description' => __( 'Add a Pay with PayPal button as a Widget.', 'jetpack-paypal-payments' ),
'customize_selective_refresh' => true,
)
);
global $pagenow;
if ( is_customize_preview() || 'widgets.php' === $pagenow ) {
add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_styles' ) );
}
if ( is_customize_preview() && Simple_Payments::is_enabled_jetpack_simple_payments() ) {
add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) );
add_filter( 'customize_refresh_nonces', array( $this, 'filter_nonces' ) );
add_action( 'wp_ajax_customize-jetpack-simple-payments-buttons-get', array( $this, 'ajax_get_payment_buttons' ) );
add_action( 'wp_ajax_customize-jetpack-simple-payments-button-save', array( $this, 'ajax_save_payment_button' ) );
add_action( 'wp_ajax_customize-jetpack-simple-payments-button-delete', array( $this, 'ajax_delete_payment_button' ) );
}
add_filter( 'widget_types_to_hide_from_legacy_widget_block', array( $this, 'hide_simple_payment_widget' ) );
}
/**
* Return an array of the widgets hidden from the Legacy Widget block.
*
* This is used to hide the Pay with PayPal from the Legacy Widget block.
*
* @param array $widget_types the widget types that are currently hidden.
* @return array Widget types hidden from the Legacy Widget block
*/
public function hide_simple_payment_widget( $widget_types ) {
$widget_types[] = 'simple_payments_widget';
return $widget_types;
}
/**
* Return an associative array of default values.
*
* These values are used in new widgets.
*
* @return array Default values for the widget options.
*/
private function defaults() {
$current_user = wp_get_current_user();
$default_product_id = $this->get_first_product_id();
return array(
'title' => '',
'product_post_id' => $default_product_id,
'form_action' => '',
'form_product_id' => 0,
'form_product_title' => '',
'form_product_description' => '',
'form_product_image_id' => 0,
'form_product_image_src' => '',
'form_product_currency' => '',
'form_product_price' => '',
'form_product_multiple' => '',
'form_product_email' => $current_user->user_email,
);
}
/**
* Adds a nonce for customizing menus.
*
* @param array $nonces Array of nonces.
* @return array $nonces Modified array of nonces.
*/
public function filter_nonces( $nonces ) {
$nonces['customize-jetpack-simple-payments'] = wp_create_nonce( 'customize-jetpack-simple-payments' );
return $nonces;
}
/**
* Enqueue styles.
*/
public function enqueue_style() {
wp_enqueue_style( 'simple-payments-widget-style', plugins_url( 'simple-payments/style.css', __FILE__ ), array(), '20180518' );
}
/**
* Enqueue admin styles.
*/
public function admin_enqueue_styles() {
wp_enqueue_style(
'simple-payments-widget-customizer',
plugins_url( 'simple-payments/customizer.css', __FILE__ ),
array(),
$this->package_version
);
}
/**
* Enqueue admin scripts.
*/
public function admin_enqueue_scripts() {
wp_enqueue_media();
wp_enqueue_script(
'simple-payments-widget-customizer',
plugins_url( '/simple-payments/customizer.js', __FILE__ ),
array( 'jquery' ),
$this->package_version,
true
);
wp_localize_script(
'simple-payments-widget-customizer',
'jpSimplePaymentsStrings',
array(
'deleteConfirmation' => __( 'Are you sure you want to delete this item? It will be disabled and removed from all locations where it currently appears.', 'jetpack-paypal-payments' ),
)
);
}
/**
* Get payment buttons.
*/
public function ajax_get_payment_buttons() {
if ( ! check_ajax_referer( 'customize-jetpack-simple-payments', 'customize-jetpack-simple-payments-nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 400, JSON_UNESCAPED_SLASHES );
}
if ( ! current_user_can( 'customize' ) ) {
wp_send_json_error( 'customize_not_allowed', 403, JSON_UNESCAPED_SLASHES );
}
$post_type_object = get_post_type_object( Simple_Payments::$post_type_product );
if ( ! current_user_can( $post_type_object->cap->create_posts ) || ! current_user_can( $post_type_object->cap->publish_posts ) ) {
wp_send_json_error( 'insufficient_post_permissions', 403, JSON_UNESCAPED_SLASHES );
}
$product_posts = get_posts(
array(
'numberposts' => 100,
'orderby' => 'date',
'post_type' => Simple_Payments::$post_type_product,
'post_status' => 'publish',
)
);
$formatted_products = array_map( array( $this, 'format_product_post_for_ajax_reponse' ), $product_posts );
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_success( $formatted_products, null, JSON_UNESCAPED_SLASHES );
}
/**
* Format product_post object.
*
* @param object $product_post - info about the post the product is on.
*/
public function format_product_post_for_ajax_reponse( $product_post ) {
return array(
'ID' => $product_post->ID,
'post_title' => $product_post->post_title,
);
}
/**
* Handle saving the simple payments widget.
*/
public function ajax_save_payment_button() {
if ( ! check_ajax_referer( 'customize-jetpack-simple-payments', 'customize-jetpack-simple-payments-nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 400, JSON_UNESCAPED_SLASHES );
}
if ( ! current_user_can( 'customize' ) ) {
wp_send_json_error( 'customize_not_allowed', 403, JSON_UNESCAPED_SLASHES );
}
$post_type_object = get_post_type_object( Simple_Payments::$post_type_product );
if ( ! current_user_can( $post_type_object->cap->create_posts ) || ! current_user_can( $post_type_object->cap->publish_posts ) ) {
wp_send_json_error( 'insufficient_post_permissions', 403, JSON_UNESCAPED_SLASHES );
}
if ( empty( $_POST['params'] ) || ! is_array( $_POST['params'] ) ) {
wp_send_json_error( 'missing_params', 400, JSON_UNESCAPED_SLASHES );
}
$params = wp_unslash( $_POST['params'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Manually validated by validate_ajax_params().
$errors = $this->validate_ajax_params( $params );
if ( ! empty( $errors->errors ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( $errors, null, JSON_UNESCAPED_SLASHES );
}
$product_post_id = isset( $params['product_post_id'] ) ? (int) $params['product_post_id'] : 0;
$product_post = array(
'ID' => $product_post_id,
'post_type' => Simple_Payments::$post_type_product,
'post_status' => 'publish',
'post_title' => $params['post_title'],
'post_content' => $params['post_content'],
'_thumbnail_id' => ! empty( $params['image_id'] ) ? $params['image_id'] : -1,
'meta_input' => array(
'spay_currency' => $params['currency'],
'spay_price' => $params['price'],
'spay_multiple' => isset( $params['multiple'] ) ? (int) $params['multiple'] : 0,
'spay_email' => is_email( $params['email'] ),
),
);
if ( empty( $product_post_id ) ) {
$product_post_id = wp_insert_post( $product_post );
} else {
$product_post_id = wp_update_post( $product_post );
}
if ( ! $product_post_id || is_wp_error( $product_post_id ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( $product_post_id, null, JSON_UNESCAPED_SLASHES );
}
$tracks_properties = array(
'id' => $product_post_id,
'currency' => $params['currency'],
'price' => $params['price'],
);
if ( 0 === $product_post['ID'] ) {
$this->record_event( 'created', 'create', $tracks_properties );
} else {
$this->record_event( 'updated', 'update', $tracks_properties );
}
wp_send_json_success(
array(
'product_post_id' => $product_post_id,
'product_post_title' => $params['post_title'],
),
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
JSON_UNESCAPED_SLASHES
);
}
/**
* Handle deleting the simple payment widget.
*/
public function ajax_delete_payment_button() {
if ( ! check_ajax_referer( 'customize-jetpack-simple-payments', 'customize-jetpack-simple-payments-nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 400, JSON_UNESCAPED_SLASHES );
}
if ( ! current_user_can( 'customize' ) ) {
wp_send_json_error( 'customize_not_allowed', 403, JSON_UNESCAPED_SLASHES );
}
if ( empty( $_POST['params'] ) || ! is_array( $_POST['params'] ) ) {
wp_send_json_error( 'missing_params', 400, JSON_UNESCAPED_SLASHES );
}
$params = wp_unslash( $_POST['params'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Manually validated just below.
$illegal_params = array_diff( array_keys( $params ), array( 'product_post_id' ) );
if ( ! empty( $illegal_params ) ) {
wp_send_json_error( 'illegal_params', 400, JSON_UNESCAPED_SLASHES );
}
$product_id = (int) $params['product_post_id'];
$product_post = get_post( $product_id );
$return = array( 'status' => $product_post->post_status );
wp_delete_post( $product_id, true );
$status = get_post_status( $product_id );
if ( false === $status ) {
$return['status'] = 'deleted';
}
$this->record_event( 'deleted', 'delete', array( 'id' => $product_id ) );
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_success( $return, null, JSON_UNESCAPED_SLASHES );
}
/**
* Returns the number of decimal places on string representing a price.
*
* @param string $number Price to check.
* @return int|null number of decimal places.
*/
private function get_decimal_places( $number ) {
$parts = explode( '.', $number );
if ( count( $parts ) > 2 ) {
return null;
}
return isset( $parts[1] ) ? strlen( $parts[1] ) : 0;
}
/**
* Validate ajax parameters.
*
* @param array $params - the parameters.
*/
public function validate_ajax_params( $params ) {
$errors = new WP_Error();
$illegal_params = array_diff( array_keys( $params ), array( 'product_post_id', 'post_title', 'post_content', 'image_id', 'currency', 'price', 'multiple', 'email' ) );
if ( ! empty( $illegal_params ) ) {
$errors->add( 'illegal_params', __( 'Invalid parameters.', 'jetpack-paypal-payments' ) );
}
if ( empty( $params['post_title'] ) ) {
$errors->add( 'post_title', __( "People need to know what they're paying for! Please add a brief title.", 'jetpack-paypal-payments' ) );
}
if ( empty( $params['price'] ) || ! is_numeric( $params['price'] ) || (float) $params['price'] <= 0 ) {
$errors->add( 'price', __( 'Everything comes with a price tag these days. Please add a your product price.', 'jetpack-paypal-payments' ) );
}
// Japan's Yen is the only supported currency with a zero decimal precision.
$precision = strtoupper( $params['currency'] ) === 'JPY' ? 0 : 2;
$price_decimal_places = $this->get_decimal_places( $params['price'] );
if ( $price_decimal_places === null || $price_decimal_places > $precision ) {
$errors->add( 'price', __( 'Invalid price', 'jetpack-paypal-payments' ) );
}
if ( empty( $params['email'] ) || ! is_email( $params['email'] ) ) {
$errors->add( 'email', __( 'We want to make sure payments reach you, so please add an email address.', 'jetpack-paypal-payments' ) );
}
return $errors;
}
/**
* Get the id of the first product.
*/
public function get_first_product_id() {
$product_posts = get_posts(
array(
'numberposts' => 1,
'orderby' => 'date',
'post_type' => Simple_Payments::$post_type_product,
'post_status' => 'publish',
)
);
return ! empty( $product_posts ) ? $product_posts[0]->ID : null;
}
/**
* Front-end display of widget.
*
* @see WP_Widget::widget()
*
* @html-template-var array $instance
*
* @param array $args Widget arguments.
* @param array $instance Saved values from database.
*/
public function widget( $args, $instance ) {
$instance = wp_parse_args( $instance, $this->defaults() );
// Enqueue front end assets.
$this->enqueue_style();
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
/** This filter is documented in core/src/wp-includes/default-widgets.php */
$title = apply_filters( 'widget_title', $instance['title'] );
if ( ! empty( $title ) ) {
echo $args['before_title'] . $title . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '<div class="jetpack-simple-payments-content">';
if ( ! empty( $instance['form_action'] ) && in_array( $instance['form_action'], array( 'add', 'edit' ), true ) && is_customize_preview() ) {
require __DIR__ . '/simple-payments/widget.php';
} else {
$jsp = Simple_Payments::get_instance();
$simple_payments_button = $jsp->parse_shortcode(
array(
'id' => $instance['product_post_id'],
)
);
if ( $simple_payments_button !== null || is_customize_preview() ) {
echo $simple_payments_button; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
echo '</div><!--simple-payments-->';
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
/** This action is already documented in modules/widgets/gravatar-profile.php */
do_action( 'jetpack_stats_extra', 'widget_view', 'simple_payments' );
}
/**
* Gets the latests field value from either the old instance or the new instance.
*
* @param array $new_instance mixed Array of values for the new form instance.
* @param array $old_instance mixed Array of values for the old form instance.
* @param mixed $field mixed Field value.
*/
private function get_latest_field_value( $new_instance, $old_instance, $field ) {
return ! empty( $new_instance[ $field ] )
? sanitize_text_field( $new_instance[ $field ] )
: $old_instance[ $field ];
}
/**
* Gets the product fields from the product post. If no post found
* it returns the default values.
*
* @param int $product_post_id Product Post ID.
* @return array $fields Product Fields from the Product Post.
*/
private function get_product_from_post( $product_post_id ) {
$product_post = get_post( $product_post_id );
$form_product_id = $product_post_id;
if ( ! empty( $product_post ) ) {
$form_product_image_id = get_post_thumbnail_id( $product_post_id );
return array(
'form_product_id' => $form_product_id,
'form_product_title' => get_the_title( $product_post ),
'form_product_description' => $product_post->post_content,
'form_product_image_id' => $form_product_image_id,
'form_product_image_src' => wp_get_attachment_image_url( $form_product_image_id, 'thumbnail' ),
'form_product_currency' => get_post_meta( $product_post_id, 'spay_currency', true ),
'form_product_price' => get_post_meta( $product_post_id, 'spay_price', true ),
'form_product_multiple' => get_post_meta( $product_post_id, 'spay_multiple', true ) || '0',
'form_product_email' => get_post_meta( $product_post_id, 'spay_email', true ),
);
}
return $this->defaults();
}
/**
* Record a Track event and bump a MC stat.
*
* @param string $stat_name - the name of the stat.
* @param string $event_action - the action we're recording.
* @param array $event_properties - proprties of the event.
*/
private function record_event( $stat_name, $event_action, $event_properties = array() ) {
$current_user = wp_get_current_user();
// `bumps_stats_extra` only exists on .com
if ( function_exists( 'bump_stats_extras' ) && function_exists( 'require_lib' ) ) {
require_lib( 'tracks/client' );
tracks_record_event( $current_user, 'simple_payments_button_' . $event_action, $event_properties );
/** This action is documented in modules/widgets/social-media-icons.php */
do_action( 'jetpack_bump_stats_extra', 'simple_payments', $stat_name );
return;
}
$tracking = new Tracking();
$tracking->tracks_record_event( $current_user, 'jetpack_wpa_simple_payments_button_' . $event_action, $event_properties );
if ( class_exists( 'Jetpack' ) ) {
$jetpack = Jetpack::init();
// $jetpack->stat automatically prepends the stat group with 'jetpack-'
$jetpack->stat( 'simple_payments', $stat_name );
$jetpack->do_stats( 'server_side' );
}
}
/**
* Sanitize widget form values as they are saved.
*
* @see WP_Widget::update()
*
* @param array $new_instance Values just sent to be saved.
* @param array $old_instance Previously saved values from database.
*
* @return array Updated safe values to be saved.
*/
public function update( $new_instance, $old_instance ) {
$defaults = $this->defaults();
// do not overrite `product_post_id` for `$new_instance` with the defaults.
$new_instance = wp_parse_args( $new_instance, array_diff_key( $defaults, array( 'product_post_id' => 0 ) ) );
$old_instance = wp_parse_args( $old_instance, $defaults );
$required_widget_props = array(
'title' => $this->get_latest_field_value( $new_instance, $old_instance, 'title' ),
'product_post_id' => $this->get_latest_field_value( $new_instance, $old_instance, 'product_post_id' ),
'form_action' => $this->get_latest_field_value( $new_instance, $old_instance, 'form_action' ),
);
if ( strcmp( $new_instance['form_action'], $old_instance['form_action'] ) !== 0 ) {
if ( 'edit' === $new_instance['form_action'] ) {
return array_merge( $this->get_product_from_post( (int) $old_instance['product_post_id'] ), $required_widget_props );
}
if ( 'clear' === $new_instance['form_action'] ) {
return array_merge( $this->defaults(), $required_widget_props );
}
}
$form_product_image_id = (int) $new_instance['form_product_image_id'];
$form_product_email = ! empty( $new_instance['form_product_email'] )
? sanitize_text_field( $new_instance['form_product_email'] )
: $defaults['form_product_email'];
return array_merge(
$required_widget_props,
array(
'form_product_id' => (int) $new_instance['form_product_id'],
'form_product_title' => sanitize_text_field( $new_instance['form_product_title'] ),
'form_product_description' => sanitize_text_field( $new_instance['form_product_description'] ),
'form_product_image_id' => $form_product_image_id,
'form_product_image_src' => wp_get_attachment_image_url( $form_product_image_id, 'thumbnail' ),
'form_product_currency' => sanitize_text_field( $new_instance['form_product_currency'] ),
'form_product_price' => sanitize_text_field( $new_instance['form_product_price'] ),
'form_product_multiple' => sanitize_text_field( $new_instance['form_product_multiple'] ),
'form_product_email' => $form_product_email,
)
);
}
/**
* Back-end widget form.
*
* @see WP_Widget::form()
*
* @html-template-var array $instance
* @html-template-var WP_Post[] $product_posts
*
* @param array $instance Previously saved values from database.
* @return string|void
*/
public function form( $instance ) {
if ( ! Simple_Payments::is_enabled_jetpack_simple_payments() ) {
require __DIR__ . '/simple-payments/admin-warning.php';
return;
}
$instance = wp_parse_args( $instance, $this->defaults() );
$product_posts = get_posts( // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
array(
'numberposts' => 100,
'orderby' => 'date',
'post_type' => Simple_Payments::$post_type_product,
'post_status' => 'publish',
)
);
require __DIR__ . '/simple-payments/form.php';
}
}
}
@@ -0,0 +1,42 @@
<?php
/**
* Display the Pay with PayPal Form.
*
* @html-template Automattic\Jetpack\Paypal_Payments\Widgets\Simple_Payments_Widget::form
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
?>
<div class='jetpack-simple-payments-disabled-error'>
<p>
<?php
/**
* Show error and help if Pay with PayPal is disabled.
*
* @package automattic/jetpack
*/
$support_url = ( defined( 'IS_WPCOM' ) && IS_WPCOM )
? 'https://wordpress.com/support/pay-with-paypal/'
: 'https://jetpack.com/support/pay-with-paypal/';
printf(
wp_kses(
// translators: variable is a link to the support page.
__( 'Your plan doesn\'t include Pay with PayPal. <a href="%s" rel="noopener noreferrer" target="_blank">Learn more and upgrade</a>.', 'jetpack-paypal-payments' ),
array(
'a' => array(
'href' => array(),
'rel' => array(),
'target' => array(),
),
)
),
esc_url( $support_url )
);
?>
</p>
</div>
@@ -0,0 +1,80 @@
.widget-content .jetpack-simple-payments,
.widget-content .jetpack-simple-payments-form {
clear: both;
}
.widget-content .jetpack-simple-payments-disabled-error {
background: #fff;
border-left: 4px solid #dc3232;
box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.1);
margin: 5px 0 15px;
padding: 1px 12px;
}
.widget-content .jetpack-simple-payments-form .invalid {
border: 1px solid #d63638;
}
.widget-content .jetpack-simple-payments-form .cost label {
display: block;
}
.widget-content .jetpack-simple-payments-image-fieldset {
position: relative;
width: 100%;
}
.widget-content .jetpack-simple-payments-image-fieldset .placeholder {
border: 1px dashed #c3c4c7;
box-sizing: border-box;
cursor: pointer;
line-height: 20px;
padding: 9px 0;
position: relative;
text-align: center;
width: 100%;
margin: 4px 0 1em;
}
.widget-content .jetpack-simple-payments-image {
max-width: 100%;
margin-top: 4px;
position: relative;
text-align: center;
}
.widget-content .jetpack-simple-payments-image img {
max-width: 100%;
box-sizing: border-box;
border: 1px dashed #c3c4c7;
padding: 4px;
height: auto;
cursor: pointer;
}
.widget-content .jetpack-simple-payments-image img:hover {
border-style: solid;
}
.widget-content .jetpack-simple-payments-form .field-currency {
display: inline-block;
vertical-align: top;
width: 40%;
}
.widget-content .jetpack-simple-payments-form .field-price {
display: inline-block;
line-height: 20px;
width: 58%;
}
.widget-content .jetpack-simple-payments-form .alignleft button,
.widget-content .jetpack-simple-payments-form .alignright span {
display: inline-block;
margin-top: 5px;
}
.widget-content .button-link:disabled,
.widget-content .button-link:hover[disabled] {
color: #a7aaad;
}
@@ -0,0 +1,536 @@
/* global jpSimplePaymentsStrings, wp, jQuery, _ */
/* eslint no-var: 0, quote-props: 0 */
( function ( api, wp, $ ) {
var $document = $( document );
$document.ready( function () {
$document.on( 'widget-added', function ( event, widgetContainer ) {
if ( widgetContainer.is( '[id*="jetpack_simple_payments_widget"]' ) ) {
initWidget( widgetContainer );
}
} );
$document.on( 'widget-synced widget-updated', function ( event, widgetContainer ) {
//this fires for all widgets, this prevent errors for non SP widgets
if ( ! widgetContainer.is( '[id*="jetpack_simple_payments_widget"]' ) ) {
return;
}
event.preventDefault();
syncProductLists();
var widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
enableFormActions( widgetForm );
updateProductImage( widgetForm );
} );
} );
/**
* Initialize the widget with event handlers
*
* @param {jQuery} widgetContainer - The jQuery object containing the widget container
*/
function initWidget( widgetContainer ) {
var widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
//Add New Button
widgetForm
.find( '.jetpack-simple-payments-add-product' )
.on( 'click', showAddNewForm( widgetForm ) );
//Edit Button
widgetForm
.find( '.jetpack-simple-payments-edit-product' )
.on( 'click', showEditForm( widgetForm ) );
//Select an Image
widgetForm
.find(
'.jetpack-simple-payments-image-fieldset .placeholder, .jetpack-simple-payments-image > img'
)
.on( 'click', selectImage( widgetForm ) );
//Remove Image Button
widgetForm
.find( '.jetpack-simple-payments-remove-image' )
.on( 'click', removeImage( widgetForm ) );
//Save Product button
widgetForm
.find( '.jetpack-simple-payments-save-product' )
.on( 'click', saveChanges( widgetForm ) );
//Cancel Button
widgetForm
.find( '.jetpack-simple-payments-cancel-form' )
.on( 'click', clearForm( widgetForm ) );
//Delete Selected Product
widgetForm
.find( '.jetpack-simple-payments-delete-product' )
.on( 'click', deleteProduct( widgetForm ) );
//Input, Select and Checkbox change
widgetForm.find( 'select, input, textarea, checkbox' ).on(
'change input propertychange',
_.debounce( function () {
disableFormActions( widgetForm );
}, 250 )
);
}
/**
* Sync the product lists
*/
function syncProductLists() {
var request = wp.ajax.post( 'customize-jetpack-simple-payments-buttons-get', {
'customize-jetpack-simple-payments-nonce':
api.settings.nonce[ 'customize-jetpack-simple-payments' ],
customize_changeset_uuid: api.settings.changeset.uuid,
} );
request.done( function ( data ) {
var selectedProduct = 0;
$( document )
.find( 'select.jetpack-simple-payments-products' )
.each( function ( index, select ) {
var $select = $( select );
selectedProduct = $select.val();
$select.find( 'option' ).remove();
$select.append(
$.map( data, function ( product ) {
return $( '<option>', { value: product.ID, text: product.post_title } );
} )
);
$select.val( selectedProduct );
} );
} );
}
/**
* Show the form and disable related controls
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
*/
function showForm( widgetForm ) {
//reset validations
widgetForm.find( '.invalid' ).removeClass( 'invalid' );
//disable widget title and product selector
widgetForm
.find( '.jetpack-simple-payments-widget-title' )
.add( '.jetpack-simple-payments-products' )
//disable add and edit buttons
.add( '.jetpack-simple-payments-add-product' )
.add( '.jetpack-simple-payments-edit-product' )
//disable save, delete and cancel until the widget update event is fired
.add( '.jetpack-simple-payments-save-product' )
.add( '.jetpack-simple-payments-cancel-form' )
.add( '.jetpack-simple-payments-delete-product' )
.attr( 'disabled', 'disabled' );
//show form
widgetForm.find( '.jetpack-simple-payments-form' ).show();
}
/**
* Hide the form and enable related controls
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
*/
function hideForm( widgetForm ) {
//enable widget title and product selector
widgetForm
.find( '.jetpack-simple-payments-widget-title' )
.add( '.jetpack-simple-payments-products' )
.removeAttr( 'disabled' );
//hide the form
widgetForm.find( '.jetpack-simple-payments-form' ).hide();
}
/**
* Change the form action
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @param {string} action - The action to set ('add', 'edit', or 'clear')
*/
function changeFormAction( widgetForm, action ) {
widgetForm.find( '.jetpack-simple-payments-form-action' ).val( action ).change();
}
/**
* Show the add new product form
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function showAddNewForm( widgetForm ) {
return function ( event ) {
event.preventDefault();
showForm( widgetForm );
changeFormAction( widgetForm, 'add' );
};
}
/**
* Show the edit product form
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function showEditForm( widgetForm ) {
return function ( event ) {
event.preventDefault();
showForm( widgetForm );
changeFormAction( widgetForm, 'edit' );
};
}
/**
* Clear the form and reset state
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function clearForm( widgetForm ) {
return function ( event ) {
event.preventDefault();
hideForm( widgetForm );
widgetForm
.find( '.jetpack-simple-payments-add-product, .jetpack-simple-payments-edit-product' )
.attr( 'disabled', 'disabled' );
changeFormAction( widgetForm, 'clear' );
};
}
/**
* Enable form action buttons based on current state
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
*/
function enableFormActions( widgetForm ) {
var isFormVisible = widgetForm.find( '.jetpack-simple-payments-form' ).is( ':visible' );
var isProductSelectVisible = widgetForm
.find( '.jetpack-simple-payments-products' )
.is( ':visible' ); //areProductsVisible ?
var isEdit = widgetForm.find( '.jetpack-simple-payments-form-action' ).val() === 'edit';
if ( isFormVisible ) {
widgetForm
.find( '.jetpack-simple-payments-save-product' )
.add( '.jetpack-simple-payments-cancel-form' )
.removeAttr( 'disabled' );
} else {
widgetForm.find( '.jetpack-simple-payments-add-product' ).removeAttr( 'disabled' );
}
if ( isFormVisible && isEdit ) {
widgetForm.find( '.jetpack-simple-payments-delete-product' ).removeAttr( 'disabled' );
}
if ( isProductSelectVisible && ! isFormVisible ) {
widgetForm.find( '.jetpack-simple-payments-edit-product' ).removeAttr( 'disabled' );
}
}
/**
* Disable all form action buttons
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
*/
function disableFormActions( widgetForm ) {
widgetForm
.find( '.jetpack-simple-payments-add-product' )
.add( '.jetpack-simple-payments-edit-product' )
.add( '.jetpack-simple-payments-save-product' )
.add( '.jetpack-simple-payments-cancel-form' )
.add( '.jetpack-simple-payments-delete-product' )
.attr( 'disabled', 'disabled' );
}
/**
* Handle image selection
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function selectImage( widgetForm ) {
return function ( event ) {
event.preventDefault();
var imageContainer = widgetForm.find( '.jetpack-simple-payments-image' );
var mediaFrame = new wp.media.view.MediaFrame.Select( {
title: 'Choose Product Image',
multiple: false,
library: { type: 'image' },
button: { text: 'Choose Image' },
} );
mediaFrame.on( 'select', function () {
var selection = mediaFrame.state().get( 'selection' ).first().toJSON();
//hide placeholder
widgetForm.find( '.jetpack-simple-payments-image-fieldset .placeholder' ).hide();
//load image from media library
imageContainer.find( 'img' ).attr( 'src', selection.url ).show();
//show image and remove button
widgetForm.find( '.jetpack-simple-payments-image' ).show();
//set hidden field for the selective refresh
widgetForm.find( '.jetpack-simple-payments-form-image-id' ).val( selection.id ).change();
} );
mediaFrame.open();
};
}
/**
* Handle image removal
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function removeImage( widgetForm ) {
return function ( event ) {
event.preventDefault();
//show placeholder
widgetForm.find( '.jetpack-simple-payments-image-fieldset .placeholder' ).show();
//hide image and remove button
widgetForm.find( '.jetpack-simple-payments-image' ).hide();
//set hidden field for the selective refresh
widgetForm.find( '.jetpack-simple-payments-form-image-id' ).val( '' ).change();
};
}
/**
* Update the product image display
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
*/
function updateProductImage( widgetForm ) {
var newImageId = parseInt(
widgetForm.find( '.jetpack-simple-payments-form-image-id' ).val(),
10
);
var newImageSrc = widgetForm.find( '.jetpack-simple-payments-form-image-src' ).val();
var placeholder = widgetForm.find( '.jetpack-simple-payments-image-fieldset .placeholder' );
var image = widgetForm.find( '.jetpack-simple-payments-image > img' );
var imageControls = widgetForm.find( '.jetpack-simple-payments-image' );
if ( newImageId && newImageSrc ) {
image.attr( 'src', newImageSrc );
placeholder.hide();
imageControls.show();
} else {
placeholder.show();
image.removeAttr( 'src' );
imageControls.hide();
}
}
/**
* Calculate the number of decimal places in a number string
*
* @param {string} number - The number string to check
* @return {number|null} The number of decimal places or null if invalid
*/
function decimalPlaces( number ) {
var parts = number.split( '.' );
if ( parts.length > 2 ) {
return null;
}
return parts[ 1 ] ? parts[ 1 ].length : 0;
}
/**
* Validate the form fields
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {boolean} Whether the form is valid
*/
function isFormValid( widgetForm ) {
widgetForm.find( '.invalid' ).removeClass( 'invalid' );
var errors = false;
var postTitle = widgetForm.find( '.jetpack-simple-payments-form-product-title' ).val();
if ( ! postTitle ) {
widgetForm.find( '.jetpack-simple-payments-form-product-title' ).addClass( 'invalid' );
errors = true;
}
var productPrice = widgetForm.find( '.jetpack-simple-payments-form-product-price' ).val();
if ( ! productPrice || isNaN( productPrice ) || parseFloat( productPrice ) <= 0 ) {
widgetForm.find( '.jetpack-simple-payments-form-product-price' ).addClass( 'invalid' );
errors = true;
}
// Japan's Yen is the only supported currency with a zero decimal precision.
var precision =
widgetForm.find( '.jetpack-simple-payments-form-product-currency' ).val() === 'JPY' ? 0 : 2;
var priceDecimalPlaces = decimalPlaces( productPrice );
if ( priceDecimalPlaces === null || priceDecimalPlaces > precision ) {
widgetForm.find( '.jetpack-simple-payments-form-product-price' ).addClass( 'invalid' );
errors = true;
}
var productEmail = widgetForm.find( '.jetpack-simple-payments-form-product-email' ).val();
var isProductEmailValid =
// eslint-disable-next-line no-control-regex
/^((([a-z]|\d|[!#$%&'*+\-/=?^_`{|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#$%&'*+\-/=?^_`{|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?$/i.test(
productEmail
);
if ( ! productEmail || ! isProductEmailValid ) {
widgetForm.find( '.jetpack-simple-payments-form-product-email' ).addClass( 'invalid' );
errors = true;
}
return ! errors;
}
/**
* Save product changes
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function saveChanges( widgetForm ) {
return function ( event ) {
event.preventDefault();
if ( ! isFormValid( widgetForm ) ) {
return;
}
var productPostId = widgetForm.find( '.jetpack-simple-payments-form-product-id' ).val();
disableFormActions( widgetForm );
widgetForm.find( '.spinner' ).show();
var request = wp.ajax.post( 'customize-jetpack-simple-payments-button-save', {
'customize-jetpack-simple-payments-nonce':
api.settings.nonce[ 'customize-jetpack-simple-payments' ],
customize_changeset_uuid: api.settings.changeset.uuid,
params: {
product_post_id: productPostId,
post_title: widgetForm.find( '.jetpack-simple-payments-form-product-title' ).val(),
post_content: widgetForm
.find( '.jetpack-simple-payments-form-product-description' )
.val(),
image_id: widgetForm.find( '.jetpack-simple-payments-form-image-id' ).val(),
currency: widgetForm.find( '.jetpack-simple-payments-form-product-currency' ).val(),
price: widgetForm.find( '.jetpack-simple-payments-form-product-price' ).val(),
multiple: widgetForm
.find( '.jetpack-simple-payments-form-product-multiple' )
.is( ':checked' )
? 1
: 0,
email: widgetForm.find( '.jetpack-simple-payments-form-product-email' ).val(),
},
} );
request.done( function ( data ) {
var select = widgetForm.find( 'select.jetpack-simple-payments-products' );
var productOption = select.find( 'option[value="' + productPostId + '"]' );
if ( productOption.length > 0 ) {
productOption.text( data.product_post_title );
} else {
select.append(
$( '<option>', {
value: data.product_post_id,
text: data.product_post_title,
} )
);
select.val( data.product_post_id ).change();
}
widgetForm.find( '.jetpack-simple-payments-products-fieldset' ).show();
widgetForm.find( '.jetpack-simple-payments-products-warning' ).hide();
changeFormAction( widgetForm, 'clear' );
hideForm( widgetForm );
} );
request.fail( function ( data ) {
var validCodes = {
post_title: 'product-title',
price: 'product-price',
email: 'product-email',
};
data.forEach( function ( item ) {
if ( Object.hasOwn( validCodes, item.code ) ) {
widgetForm
.find( '.jetpack-simple-payments-form-' + validCodes[ item.code ] )
.addClass( 'invalid' );
}
} );
enableFormActions( widgetForm );
} );
};
}
/**
* Delete a product
*
* @param {jQuery} widgetForm - The jQuery object containing the widget form
* @return {Function} Event handler function
*/
function deleteProduct( widgetForm ) {
return function ( event ) {
event.preventDefault();
// eslint-disable-next-line no-alert
if ( ! confirm( jpSimplePaymentsStrings.deleteConfirmation ) ) {
return;
}
var formProductId = parseInt(
widgetForm.find( '.jetpack-simple-payments-form-product-id' ).val(),
10
);
if ( ! formProductId ) {
return;
}
disableFormActions( widgetForm );
widgetForm.find( '.spinner' ).show();
var request = wp.ajax.post( 'customize-jetpack-simple-payments-button-delete', {
'customize-jetpack-simple-payments-nonce':
api.settings.nonce[ 'customize-jetpack-simple-payments' ],
customize_changeset_uuid: api.settings.changeset.uuid,
params: {
product_post_id: formProductId,
},
} );
request.done( function () {
var productList = widgetForm.find( 'select.jetpack-simple-payments-products' )[ 0 ];
productList.remove( productList.selectedIndex );
productList.dispatchEvent( new Event( 'change' ) );
if ( $( productList ).has( 'option' ).length === 0 ) {
//hide products select and label
widgetForm.find( '.jetpack-simple-payments-products-fieldset' ).hide();
//show empty products list warning
widgetForm.find( '.jetpack-simple-payments-products-warning' ).show();
}
changeFormAction( widgetForm, 'clear' );
hideForm( widgetForm );
} );
};
}
} )( wp.customize, wp, jQuery );
@@ -0,0 +1,238 @@
<?php
/**
* Display the Pay with PayPal Form.
*
* @html-template Automattic\Jetpack\Paypal_Payments\Widgets\Simple_Payments_Widget::form
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
// phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- HTML template, let Phan handle it.
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Widget Title', 'jetpack-paypal-payments' ); ?>
</label>
<input
type="text"
class="widefat jetpack-simple-payments-widget-title"
id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
value="<?php echo esc_attr( $instance['title'] ); ?>" />
</p>
<p class="jetpack-simple-payments-products-fieldset"
<?php
if ( empty( $product_posts ) ) {
echo 'style="display:none;"';
}
?>
>
<label for="<?php echo esc_attr( $this->get_field_id( 'product_post_id' ) ); ?>">
<?php esc_html_e( 'Select a Pay with PayPal button:', 'jetpack-paypal-payments' ); ?>
</label>
<select
class="widefat jetpack-simple-payments-products"
id="<?php echo esc_attr( $this->get_field_id( 'product_post_id' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'product_post_id' ) ); ?>">
<?php foreach ( $product_posts as $product_post ) { ?>
<option value="<?php echo (int) $product_post->ID; ?>" <?php selected( (int) $instance['product_post_id'], $product_post->ID ); ?>>
<?php echo esc_attr( get_the_title( $product_post ) ); ?>
</option>
<?php } ?>
</select>
</p>
<?php if ( is_customize_preview() ) { ?>
<p class="jetpack-simple-payments-products-warning"
<?php
if ( ! empty( $product_posts ) ) {
echo 'style="display:none;"';
}
?>
>
<?php esc_html_e( "Looks like you don't have any products. You can create one using the Add New button below.", 'jetpack-paypal-payments' ); ?>
</p>
<p>
<div class="alignleft">
<button class="button jetpack-simple-payments-edit-product" <?php disabled( empty( $product_posts ), true ); ?>>
<?php esc_html_e( 'Edit Selected', 'jetpack-paypal-payments' ); ?>
</button>
</div>
<div class="alignright">
<button class="button jetpack-simple-payments-add-product"><?php esc_html_e( 'Add New', 'jetpack-paypal-payments' ); ?></button>
</div>
<br class="clear">
</p>
<hr />
<div class="jetpack-simple-payments-form" style="display: none;">
<input
type="hidden"
id="<?php echo esc_attr( $this->get_field_id( 'form_action' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_action' ) ); ?>"
value="<?php echo esc_attr( $instance['form_action'] ); ?>"
class="jetpack-simple-payments-form-action" />
<input
type="hidden"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_id' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_id' ) ); ?>"
value="<?php echo esc_attr( $instance['form_product_id'] ); ?>"
class="jetpack-simple-payments-form-product-id" />
<input
type="hidden"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_image_id' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_image_id' ) ); ?>"
value="<?php echo esc_attr( $instance['form_product_image_id'] ); ?>"
class="jetpack-simple-payments-form-image-id" />
<input
type="hidden"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_image_src' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_image_src' ) ); ?>"
value="<?php echo esc_attr( $instance['form_product_image_src'] ); ?>"
class="jetpack-simple-payments-form-image-src" />
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'form_product_title' ) ); ?>">
<?php esc_html_e( 'What is this payment for?', 'jetpack-paypal-payments' ); ?>
</label>
<input
type="text"
class="widefat field-title jetpack-simple-payments-form-product-title"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_title' ) ); ?>"
value="<?php echo esc_attr( $instance['form_product_title'] ); ?>" />
<br />
<small>
<?php esc_html_e( 'For example: event tickets, charitable donations, training courses, coaching fees, etc.', 'jetpack-paypal-payments' ); ?>
</small>
</p>
<div class="jetpack-simple-payments-image-fieldset">
<label><?php esc_html_e( 'Product image', 'jetpack-paypal-payments' ); ?></label>
<div class="placeholder"
<?php
if ( ! empty( $instance['form_product_image_id'] ) ) {
echo 'style="display:none;"';
}
?>
>
<?php esc_html_e( 'Select an image', 'jetpack-paypal-payments' ); ?>
</div>
<div class="jetpack-simple-payments-image"
<?php
if ( empty( $instance['form_product_image_id'] ) ) {
echo 'style="display:none;"';
}
?>
>
<img src="<?php echo esc_url( $instance['form_product_image_src'] ); ?>" />
<button class="button jetpack-simple-payments-remove-image"><?php esc_html_e( 'Remove image', 'jetpack-paypal-payments' ); ?></button>
</div>
</div>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'form_product_description' ) ); ?>">
<?php esc_html_e( 'Description', 'jetpack-paypal-payments' ); ?>
</label>
<textarea
class="field-description widefat jetpack-simple-payments-form-product-description"
rows=5
id="<?php echo esc_attr( $this->get_field_id( 'form_product_description' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_description' ) ); ?>"><?php echo esc_textarea( $instance['form_product_description'] ); ?></textarea>
</p>
<p class="cost">
<label for="<?php echo esc_attr( $this->get_field_id( 'form_product_price' ) ); ?>">
<?php esc_html_e( 'Price', 'jetpack-paypal-payments' ); ?>
</label>
<select
class="field-currency widefat jetpack-simple-payments-form-product-currency"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_currency' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_currency' ) ); ?>">
<?php
foreach ( Automattic\Jetpack\Paypal_Payments\Widgets\Simple_Payments_Widget::$supported_currency_list as $code => $currency ) {
?>
<option value="<?php echo esc_attr( $code ); ?>"<?php selected( $instance['form_product_currency'], $code ); ?>>
<?php echo esc_html( "$code $currency" ); ?>
</option>
<?php } ?>
</select>
<input
type="text"
class="field-price widefat jetpack-simple-payments-form-product-price"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_price' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_price' ) ); ?>"
value="<?php echo esc_attr( $instance['form_product_price'] ); ?>"
placeholder="1.00" />
</p>
<p>
<input
class="field-multiple jetpack-simple-payments-form-product-multiple"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_multiple' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_multiple' ) ); ?>"
type="checkbox"
value="1"
<?php checked( $instance['form_product_multiple'], '1' ); ?> />
<label for="<?php echo esc_attr( $this->get_field_id( 'form_product_multiple' ) ); ?>">
<?php esc_html_e( 'Allow people to buy more than one item at a time.', 'jetpack-paypal-payments' ); ?>
</label>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'form_product_email' ) ); ?>">
<?php esc_html_e( 'Email', 'jetpack-paypal-payments' ); ?>
</label>
<input
class="field-email widefat jetpack-simple-payments-form-product-email"
id="<?php echo esc_attr( $this->get_field_id( 'form_product_email' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'form_product_email' ) ); ?>"
type="email"
value="<?php echo esc_attr( $instance['form_product_email'] ); ?>" />
<small>
<?php
printf(
wp_kses(
/* Translators: placeholders are a link to Paypal website and a target attribute. */
__( 'This is where PayPal will send your money. To claim a payment, you\'ll need a <a href="%1$s" %2$s>PayPal account</a> connected to a bank account.', 'jetpack-paypal-payments' ),
array(
'a' => array(
'href' => array(),
'target' => array(),
),
)
),
'https://paypal.com',
'target="_blank"'
);
?>
</small>
</p>
<p>
<div class="alignleft">
<button type="button" class="button-link button-link-delete jetpack-simple-payments-delete-product">
<?php esc_html_e( 'Delete Product', 'jetpack-paypal-payments' ); ?>
</button>
</div>
<div class="alignright">
<button name="<?php echo esc_attr( $this->get_field_name( 'save' ) ); ?>" class="button jetpack-simple-payments-save-product"><?php esc_html_e( 'Save', 'jetpack-paypal-payments' ); ?></button>
<span> | <button type="button" class="button-link jetpack-simple-payments-cancel-form"><?php esc_html_e( 'Cancel', 'jetpack-paypal-payments' ); ?></button></span>
</div>
<br class="clear">
</p>
<hr />
</div>
<?php } else { ?>
<p class="jetpack-simple-payments-products-warning">
<?php
printf(
wp_kses(
/* Translators: placeholder is a link to the customizer. */
__( 'This widget adds a payment button of your choice to your sidebar. To create or edit the payment buttons themselves, <a href="%s">use the Customizer</a>.', 'jetpack-paypal-payments' ),
array(
'a' => array(
'href' => array(),
),
)
),
esc_url( add_query_arg( array( 'autofocus[panel]' => 'widgets' ), admin_url( 'customize.php' ) ) )
);
?>
</p>
<?php } ?>
@@ -0,0 +1,10 @@
@media screen and (min-width: 400px) {
.widget.jetpack-simple-payments .jetpack-simple-payments-product {
flex-direction: column;
}
.widget.jetpack-simple-payments .jetpack-simple-payments-details {
padding-left: 0;
}
}
@@ -0,0 +1,46 @@
<?php
/**
* Display the Pay with PayPal Widget.
*
* @html-template Automattic\Jetpack\Paypal_Payments\Widgets\Simple_Payments_Widget::widget
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
// phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- HTML template, let Phan handle it.
?>
<div class='jetpack-simple-payments-wrapper'>
<div class='jetpack-simple-payments-product'>
<div class='jetpack-simple-payments-product-image'
<?php
if ( empty( $instance['form_product_image_id'] ) ) {
echo 'style="display:none;"';
}
?>
>
<div class='jetpack-simple-payments-image'>
<?php echo wp_get_attachment_image( (int) $instance['form_product_image_id'], 'full' ); ?>
</div>
</div>
<div class='jetpack-simple-payments-details'>
<div class='jetpack-simple-payments-title'><p><?php echo esc_html( $instance['form_product_title'] ); ?></p></div>
<div class='jetpack-simple-payments-description'><p><?php echo esc_html( $instance['form_product_description'] ); ?></p></div>
<div class='jetpack-simple-payments-price'><p><?php echo esc_html( $instance['form_product_price'] ); ?> <?php echo esc_html( $instance['form_product_currency'] ); ?></p></div>
<div class='jetpack-simple-payments-purchase-box'>
<?php if ( $instance['form_product_multiple'] ) { ?>
<div class='jetpack-simple-payments-items'>
<input
type='number'
class='jetpack-simple-payments-items-number'
value='1'
min='1' />
</div>
<?php } ?>
</div>
</div>
</div>
</div>