diff options
Diffstat (limited to 'plugins/jetpack/extensions/blocks/simple-payments/edit.js')
-rw-r--r-- | plugins/jetpack/extensions/blocks/simple-payments/edit.js | 579 |
1 files changed, 579 insertions, 0 deletions
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 ); |