diff options
Diffstat (limited to 'plugins/jetpack/extensions/blocks/simple-payments')
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 Binary files differnew file mode 100644 index 00000000..ceea141d --- /dev/null +++ b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button-2x.png diff --git a/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png Binary files differnew file mode 100644 index 00000000..13bbad02 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/simple-payments/paypal-button.png 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; +}; |