summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/extensions/blocks/simple-payments')
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/constants.js39
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/edit.js579
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/editor.scss63
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/featured-media.js69
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/help-message.js25
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/help-message.scss23
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/index.js131
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.pngbin0 -> 8186 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/paypal-button.pngbin0 -> 7496 bytes
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js68
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss93
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/save.js9
-rw-r--r--plugins/jetpack/extensions/blocks/simple-payments/utils.js29
14 files changed, 1135 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/constants.js b/plugins/jetpack/extensions/blocks/simple-payments/constants.js
new file mode 100644
index 00000000..e593f947
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/constants.js
@@ -0,0 +1,39 @@
+export const SIMPLE_PAYMENTS_PRODUCT_POST_TYPE = 'jp_pay_product';
+
+export const DEFAULT_CURRENCY = 'USD';
+
+// https://developer.paypal.com/docs/integration/direct/rest/currency-codes/
+// If this list changes, Simple Payments in Jetpack must be updated as well.
+// See https://github.com/Automattic/jetpack/blob/master/modules/simple-payments/simple-payments.php
+
+/**
+ * Indian Rupee 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
+ */
+export const SUPPORTED_CURRENCY_LIST = [
+ DEFAULT_CURRENCY,
+ 'EUR',
+ 'AUD',
+ 'BRL',
+ 'CAD',
+ 'CZK',
+ 'DKK',
+ 'HKD',
+ 'HUF',
+ 'ILS',
+ 'JPY',
+ 'MYR',
+ 'MXN',
+ 'TWD',
+ 'NZD',
+ 'NOK',
+ 'PHP',
+ 'PLN',
+ 'GBP',
+ 'RUB',
+ 'SGD',
+ 'SEK',
+ 'CHF',
+ 'THB',
+];
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/edit.js b/plugins/jetpack/extensions/blocks/simple-payments/edit.js
new file mode 100644
index 00000000..f49ca94d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/edit.js
@@ -0,0 +1,579 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import emailValidator from 'email-validator';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { compose, withInstanceId } from '@wordpress/compose';
+import { dispatch, withSelect } from '@wordpress/data';
+import { get, isEmpty, isEqual, pick, trimEnd } from 'lodash';
+import { getCurrencyDefaults } from '@automattic/format-currency';
+import {
+ Disabled,
+ ExternalLink,
+ SelectControl,
+ TextareaControl,
+ TextControl,
+ ToggleControl,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import HelpMessage from './help-message';
+import ProductPlaceholder from './product-placeholder';
+import FeaturedMedia from './featured-media';
+import { decimalPlaces, formatPrice } from './utils';
+import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, SUPPORTED_CURRENCY_LIST } from './constants';
+
+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 } = 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();
+ }
+ }
+
+ componentDidUpdate( prevProps ) {
+ const { hasPublishAction, isSelected } = 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();
+ }
+ }
+
+ 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 } = this.props;
+ if ( ! this.shouldInjectPaymentAttributes || isEmpty( simplePayment ) ) {
+ return;
+ }
+
+ const { attributes, setAttributes } = this.props;
+ const { content, currency, email, featuredMediaId, multiple, price, title } = attributes;
+
+ setAttributes( {
+ content: get( simplePayment, [ 'content', 'raw' ], content ),
+ currency: get( simplePayment, [ 'meta', 'spay_currency' ], currency ),
+ email: get( simplePayment, [ 'meta', 'spay_email' ], email ),
+ featuredMediaId: get( simplePayment, [ 'featured_media' ], featuredMediaId ),
+ multiple: Boolean( get( simplePayment, [ 'meta', 'spay_multiple' ], Boolean( multiple ) ) ),
+ price: get( simplePayment, [ 'meta', 'spay_price' ], price || undefined ),
+ title: get( 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( __( '%s is not a valid email address.', 'jetpack' ), email )
+ : null,
+ fieldPriceError:
+ apiErrorKey === 'spay_price' ? __( 'Invalid price.', 'jetpack' ) : 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
+ *
+ * @returns {Boolean} True when valid, false when invalid
+ */
+ validatePrice = () => {
+ const { currency, price } = this.props.attributes;
+ const { precision } = getCurrencyDefaults( currency );
+
+ if ( ! price || parseFloat( price ) === 0 ) {
+ this.setState( {
+ fieldPriceError: __(
+ 'If you’re selling something, you need a price tag. Add yours here.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ if ( Number.isNaN( parseFloat( price ) ) ) {
+ this.setState( {
+ fieldPriceError: __( 'Invalid price', 'jetpack' ),
+ } );
+ 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'
+ ),
+ } );
+ return false;
+ }
+
+ if ( decimalPlaces( price ) > precision ) {
+ if ( precision === 0 ) {
+ this.setState( {
+ fieldPriceError: __(
+ 'We know every penny counts, but prices in this currency can’t contain decimal values.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ this.setState( {
+ fieldPriceError: sprintf(
+ _n(
+ 'The price cannot have more than %d decimal place.',
+ 'The price cannot have more than %d decimal places.',
+ precision,
+ 'jetpack'
+ ),
+ precision
+ ),
+ } );
+ return false;
+ }
+
+ if ( this.state.fieldPriceError ) {
+ this.setState( { fieldPriceError: null } );
+ }
+
+ return true;
+ };
+
+ /**
+ * Validate email
+ *
+ * Stores error message in state.fieldEmailError
+ *
+ * @returns {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'
+ ),
+ } );
+ return false;
+ }
+
+ if ( ! emailValidator.validate( email ) ) {
+ this.setState( {
+ fieldEmailError: sprintf( __( '%s is not a valid email address.', 'jetpack' ), email ),
+ } );
+ return false;
+ }
+
+ if ( this.state.fieldEmailError ) {
+ this.setState( { fieldEmailError: null } );
+ }
+
+ return true;
+ };
+
+ /**
+ * Validate title
+ *
+ * Stores error message in state.fieldTitleError
+ *
+ * @returns {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 they’re paying for.',
+ 'jetpack'
+ ),
+ } );
+ return false;
+ }
+
+ if ( this.state.fieldTitleError ) {
+ this.setState( { fieldTitleError: null } );
+ }
+
+ return true;
+ };
+
+ handleEmailChange = email => {
+ this.props.setAttributes( { email } );
+ this.setState( { fieldEmailError: null } );
+ };
+
+ handleFeaturedMediaSelect = media => {
+ this.props.setAttributes( { featuredMediaId: get( media, 'id', 0 ) } );
+ };
+
+ 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 };
+ } );
+
+ render() {
+ const { fieldEmailError, fieldPriceError, fieldTitleError } = this.state;
+ const {
+ attributes,
+ featuredMedia,
+ instanceId,
+ isSelected,
+ setAttributes,
+ simplePayment,
+ } = this.props;
+ const {
+ content,
+ currency,
+ email,
+ featuredMediaId,
+ featuredMediaUrl: featuredMediaUrlAttribute,
+ featuredMediaTitle: featuredMediaTitleAttribute,
+ multiple,
+ price,
+ productId,
+ title,
+ } = attributes;
+
+ const featuredMediaUrl =
+ featuredMediaUrlAttribute || ( featuredMedia && featuredMedia.source_url );
+ const featuredMediaTitle =
+ featuredMediaTitleAttribute || ( featuredMedia && featuredMedia.alt_text );
+
+ /**
+ * 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={ formatPrice( price, currency ) }
+ multiple={ multiple }
+ title={ title }
+ />
+ );
+ }
+
+ const Wrapper = isDisabled ? Disabled : 'div';
+
+ return (
+ <Wrapper className="wp-block-jetpack-simple-payments">
+ <FeaturedMedia
+ { ...{ featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } }
+ />
+ <div>
+ <TextControl
+ aria-describedby={ `${ instanceId }-title-error` }
+ className={ classNames( 'simple-payments__field', 'simple-payments__field-title', {
+ 'simple-payments__field-has-error': fieldTitleError,
+ } ) }
+ label={ __( 'Item name', 'jetpack' ) }
+ onChange={ this.handleTitleChange }
+ placeholder={ __( 'Item name', 'jetpack' ) }
+ 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' ) }
+ onChange={ this.handleContentChange }
+ placeholder={ __( 'Describe your item in a few words', 'jetpack' ) }
+ value={ content }
+ />
+
+ <div className="simple-payments__price-container">
+ <SelectControl
+ className="simple-payments__field simple-payments__field-currency"
+ label={ __( 'Currency', 'jetpack' ) }
+ onChange={ this.handleCurrencyChange }
+ options={ this.getCurrencyList }
+ value={ currency }
+ />
+ <TextControl
+ aria-describedby={ `${ instanceId }-price-error` }
+ className={ classNames( 'simple-payments__field', 'simple-payments__field-price', {
+ 'simple-payments__field-has-error': fieldPriceError,
+ } ) }
+ label={ __( 'Price', 'jetpack' ) }
+ onChange={ this.handlePriceChange }
+ placeholder={ formatPrice( 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' ) }
+ onChange={ this.handleMultipleChange }
+ />
+ </div>
+
+ <TextControl
+ aria-describedby={ `${ instanceId }-email-${ fieldEmailError ? 'error' : 'help' }` }
+ className={ classNames( 'simple-payments__field', 'simple-payments__field-email', {
+ 'simple-payments__field-has-error': fieldEmailError,
+ } ) }
+ label={ __( 'Email', 'jetpack' ) }
+ onChange={ this.handleEmailChange }
+ placeholder={ __( 'Email', 'jetpack' ) }
+ required
+ type="email"
+ value={ email }
+ />
+ <HelpMessage id={ `${ instanceId }-email-error` } isError>
+ { fieldEmailError }
+ </HelpMessage>
+ <HelpMessage id={ `${ instanceId }-email-help` }>
+ { __(
+ 'Enter the email address associated with your PayPal account. Don’t have an account?',
+ 'jetpack'
+ ) + ' ' }
+ <ExternalLink href="https://www.paypal.com/">
+ { __( 'Create one on PayPal', 'jetpack' ) }
+ </ExternalLink>
+ </HelpMessage>
+ </div>
+ </Wrapper>
+ );
+ }
+}
+
+const mapSelectToProps = withSelect( ( select, props ) => {
+ const { getEntityRecord, getMedia } = 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;
+
+ return {
+ hasPublishAction: !! get( getCurrentPost(), [ '_links', 'wp:action-publish' ] ),
+ isSaving: !! isSavingPost(),
+ simplePayment,
+ featuredMedia: featuredMediaId ? getMedia( featuredMediaId ) : null,
+ };
+} );
+
+export default compose(
+ mapSelectToProps,
+ withInstanceId
+)( SimplePaymentsEdit );
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/editor.js b/plugins/jetpack/extensions/blocks/simple-payments/editor.js
new file mode 100644
index 00000000..d05f4039
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import registerJetpackBlock from '../../shared/register-jetpack-block';
+import { name, settings } from '.';
+
+registerJetpackBlock( name, settings );
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/editor.scss b/plugins/jetpack/extensions/blocks/simple-payments/editor.scss
new file mode 100644
index 00000000..3345a324
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/editor.scss
@@ -0,0 +1,63 @@
+@import '../../shared/styles/gutenberg-colors.scss';
+@import '../../shared/styles/gutenberg-variables.scss';
+
+.wp-block-jetpack-simple-payments {
+ font-family: $default-font;
+ display: grid;
+ grid-template-columns: 200px auto;
+ grid-column-gap: 10px;
+
+ .simple-payments__field {
+ .components-base-control__label {
+ display: none;
+ }
+ .components-base-control__field {
+ margin-bottom: 1em;
+ }
+ // Reset empty space under textarea on Chrome
+ textarea {
+ display: block;
+ }
+ }
+
+ .simple-payments__field-has-error {
+ .components-text-control__input,
+ .components-textarea-control__input {
+ border-color: var( --color-error );
+ }
+ }
+
+ .simple-payments__price-container {
+ display: flex;
+ flex-wrap: wrap;
+ .simple-payments__field {
+ margin-right: 10px;
+ }
+ .simple-payments__help-message {
+ flex: 1 1 100%;
+ margin-top: 0;
+ }
+ }
+
+ .simple-payments__field-price {
+ .components-text-control__input {
+ max-width: 90px;
+ }
+ }
+
+ .simple-payments__field-email {
+ .components-text-control__input {
+ max-width: 400px;
+ }
+ }
+
+ .simple-payments__field-multiple {
+ .components-toggle-control__label {
+ line-height: 1.4em;
+ }
+ }
+
+ .simple-payments__field-content .components-textarea-control__input {
+ min-height: 32px;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/featured-media.js b/plugins/jetpack/extensions/blocks/simple-payments/featured-media.js
new file mode 100644
index 00000000..c0da48a9
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/featured-media.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BlockControls, MediaPlaceholder, MediaUpload } from '@wordpress/editor';
+import { Fragment } from '@wordpress/element';
+import { get } from 'lodash';
+import { IconButton, Toolbar, ToolbarButton } from '@wordpress/components';
+
+const onSelectMedia = setAttributes => media =>
+ setAttributes( {
+ featuredMediaId: get( media, 'id', 0 ),
+ featuredMediaUrl: get( media, 'url', null ),
+ featuredMediaTitle: get( media, 'title', null ),
+ } );
+
+export default ( { featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } ) => {
+ if ( ! featuredMediaId ) {
+ return (
+ <MediaPlaceholder
+ icon="format-image"
+ labels={ {
+ title: __( 'Product Image', 'jetpack' ),
+ } }
+ accept="image/*"
+ allowedTypes={ [ 'image' ] }
+ onSelect={ onSelectMedia( setAttributes ) }
+ />
+ );
+ }
+
+ return (
+ <div>
+ <Fragment>
+ <BlockControls>
+ <Toolbar>
+ <MediaUpload
+ onSelect={ onSelectMedia( setAttributes ) }
+ allowedTypes={ [ 'image' ] }
+ value={ featuredMediaId }
+ render={ ( { open } ) => (
+ <IconButton
+ className="components-toolbar__control"
+ label={ __( 'Edit Image', 'jetpack' ) }
+ icon="edit"
+ onClick={ open }
+ />
+ ) }
+ />
+ <ToolbarButton
+ icon={ 'trash' }
+ title={ __( 'Remove Image', 'jetpack' ) }
+ onClick={ () =>
+ setAttributes( {
+ featuredMediaId: null,
+ featuredMediaUrl: null,
+ featuredMediaTitle: null,
+ } )
+ }
+ />
+ </Toolbar>
+ </BlockControls>
+ <figure>
+ <img src={ featuredMediaUrl } alt={ featuredMediaTitle } />
+ </figure>
+ </Fragment>
+ </div>
+ );
+};
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/help-message.js b/plugins/jetpack/extensions/blocks/simple-payments/help-message.js
new file mode 100644
index 00000000..57a6e681
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/help-message.js
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import GridiconNoticeOutline from 'gridicons/dist/notice-outline';
+import './help-message.scss';
+
+export default ( { children = null, isError = false, ...props } ) => {
+ const classes = classNames( 'simple-payments__help-message', {
+ 'simple-payments__help-message-is-error': isError,
+ } );
+
+ return (
+ children && (
+ <div className={ classes } { ...props }>
+ { isError && <GridiconNoticeOutline size="24" /> }
+ <span>{ children }</span>
+ </div>
+ )
+ );
+};
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/help-message.scss b/plugins/jetpack/extensions/blocks/simple-payments/help-message.scss
new file mode 100644
index 00000000..86f50f9e
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/help-message.scss
@@ -0,0 +1,23 @@
+
+.wp-block-jetpack-simple-payments {
+ .simple-payments__help-message {
+ display: flex;
+ font-size: 13px;
+ line-height: 1.4em;
+ margin-bottom: 1em;
+ margin-top: -0.5em;
+ svg {
+ margin-right: 5px;
+ min-width: 24px;
+ }
+ > span {
+ margin-top: 2px;
+ }
+ &.simple-payments__help-message-is-error {
+ color: var( --color-error );
+ svg {
+ fill: var( --color-error );
+ }
+ }
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/index.js b/plugins/jetpack/extensions/blocks/simple-payments/index.js
new file mode 100644
index 00000000..a1f0e0ed
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/index.js
@@ -0,0 +1,131 @@
+/**
+ * External dependencies
+ */
+import { __, _x } from '@wordpress/i18n';
+import { ExternalLink, Path, SVG } from '@wordpress/components';
+import { Fragment } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import { DEFAULT_CURRENCY } from './constants';
+
+/**
+ * Styles
+ */
+import './editor.scss';
+
+export const name = 'simple-payments';
+
+export const settings = {
+ title: __( 'Simple Payments button', 'jetpack' ),
+
+ description: (
+ <Fragment>
+ <p>
+ { __(
+ 'Lets you create and embed credit and debit card payment buttons with minimal setup.',
+ 'jetpack'
+ ) }
+ </p>
+ <ExternalLink href="https://support.wordpress.com/simple-payments/">
+ { __( 'Support reference', 'jetpack' ) }
+ </ExternalLink>
+ </Fragment>
+ ),
+
+ 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>
+ ),
+
+ category: 'jetpack',
+
+ keywords: [
+ _x( 'shop', 'block search term', 'jetpack' ),
+ _x( 'sell', 'block search term', 'jetpack' ),
+ 'PayPal',
+ ],
+
+ attributes: {
+ currency: {
+ type: 'string',
+ default: DEFAULT_CURRENCY,
+ },
+ 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: '',
+ },
+ },
+
+ 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: {
+ 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,
+ },
+};
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png
new file mode 100644
index 00000000..ceea141d
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png
new file mode 100644
index 00000000..13bbad02
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png
Binary files differ
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js
new file mode 100644
index 00000000..3f80c79c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.js
@@ -0,0 +1,68 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import './product-placeholder.scss';
+import paypalImage from './paypal-button.png';
+import paypalImage2x from './paypal-button-2x.png';
+
+export default ( {
+ title = '',
+ content = '',
+ formattedPrice = '',
+ multiple = false,
+ featuredMediaUrl = null,
+ featuredMediaTitle = null,
+} ) => (
+ <div className="jetpack-simple-payments-wrapper">
+ <div className="jetpack-simple-payments-product">
+ { featuredMediaUrl && (
+ <div className="jetpack-simple-payments-product-image">
+ <figure className="jetpack-simple-payments-image">
+ <img src={ featuredMediaUrl } alt={ featuredMediaTitle } />
+ </figure>
+ </div>
+ ) }
+ <div className="jetpack-simple-payments-details">
+ { title && (
+ <div className="jetpack-simple-payments-title">
+ <p>{ title }</p>
+ </div>
+ ) }
+ { content && (
+ <div className="jetpack-simple-payments-description">
+ <p>{ content }</p>
+ </div>
+ ) }
+ { formattedPrice && (
+ <div className="jetpack-simple-payments-price">
+ <p>{ formattedPrice }</p>
+ </div>
+ ) }
+ <div className="jetpack-simple-payments-purchase-box">
+ { multiple && (
+ <div className="jetpack-simple-payments-items">
+ <input
+ className="jetpack-simple-payments-items-number"
+ readOnly
+ type="number"
+ value="1"
+ />
+ </div>
+ ) }
+ <div className="jetpack-simple-payments-button">
+ <img
+ alt={ __( 'Pay with PayPal', 'jetpack' ) }
+ src={ paypalImage }
+ srcSet={ `${ paypalImage2x } 2x` }
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+);
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss
new file mode 100644
index 00000000..e138c863
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/product-placeholder.scss
@@ -0,0 +1,93 @@
+@import '../../shared/styles/jetpack-variables.scss';
+
+.simple-payments__loading {
+ animation: simple-payments-loading 1600ms ease-in-out infinite;
+}
+
+@keyframes simple-payments-loading {
+ 0% {
+ opacity: 0.5;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+ 100% {
+ opacity: 0.5;
+ }
+}
+
+.jetpack-simple-payments-wrapper {
+ margin-bottom: $jetpack-block-margin-bottom;
+}
+
+/* Higher specificity in order to reset paragraph style */
+body .jetpack-simple-payments-wrapper .jetpack-simple-payments-details p {
+ margin: 0 0 $jetpack-block-margin-bottom;
+ padding: 0;
+}
+
+.jetpack-simple-payments-product {
+ display: flex;
+ flex-direction: column;
+}
+
+.jetpack-simple-payments-product-image {
+ flex: 0 0 30%;
+ margin-bottom: $jetpack-block-margin-bottom;
+}
+
+.jetpack-simple-payments-image {
+ box-sizing: border-box;
+ min-width: 70px;
+ padding-top: 100%;
+ position: relative;
+}
+
+.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: bold;
+}
+
+.jetpack-simple-payments-purchase-box {
+ align-items: flex-start;
+ display: flex;
+}
+
+.jetpack-simple-payments-items {
+ flex: 0 0 auto;
+ margin-right: 10px;
+}
+
+input[type='number'].jetpack-simple-payments-items-number {
+ background: var( --color-white );
+ font-size: 16px;
+ line-height: 1;
+ max-width: 60px;
+ padding: 4px 8px;
+}
+
+@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;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/save.js b/plugins/jetpack/extensions/blocks/simple-payments/save.js
new file mode 100644
index 00000000..ed81e7a8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/save.js
@@ -0,0 +1,9 @@
+/**
+ * External dependencies
+ */
+import { RawHTML } from '@wordpress/element';
+
+export default function Save( { attributes } ) {
+ const { productId } = attributes;
+ return productId ? <RawHTML>{ `[simple-payment id="${ productId }"]` }</RawHTML> : null;
+}
diff --git a/plugins/jetpack/extensions/blocks/simple-payments/utils.js b/plugins/jetpack/extensions/blocks/simple-payments/utils.js
new file mode 100644
index 00000000..c29e367b
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/simple-payments/utils.js
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { getCurrencyDefaults } from '@automattic/format-currency';
+import { trimEnd } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE } from './constants';
+
+export const isValidSimplePaymentsProduct = product =>
+ product.type === SIMPLE_PAYMENTS_PRODUCT_POST_TYPE && product.status === 'publish';
+
+// based on https://stackoverflow.com/a/10454560/59752
+export const decimalPlaces = number => {
+ const match = ( '' + number ).match( /(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ );
+ if ( ! match ) {
+ return 0;
+ }
+ return Math.max( 0, ( match[ 1 ] ? match[ 1 ].length : 0 ) - ( match[ 2 ] ? +match[ 2 ] : 0 ) );
+};
+
+export const formatPrice = ( price, currency, withSymbol = true ) => {
+ const { precision, symbol } = getCurrencyDefaults( currency );
+ const value = price.toFixed( precision );
+ // Trim the dot at the end of symbol, e.g., 'kr.' becomes 'kr'
+ return withSymbol ? `${ value } ${ trimEnd( symbol, '.' ) }` : value;
+};