diff options
Diffstat (limited to 'plugins/jetpack/extensions/blocks/membership-button')
7 files changed, 653 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/blocks/membership-button/edit.jsx b/plugins/jetpack/extensions/blocks/membership-button/edit.jsx new file mode 100644 index 00000000..4843f802 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/edit.jsx @@ -0,0 +1,392 @@ +/** + * External dependencies + */ + +import classnames from 'classnames'; +import SubmitButton from '../../shared/submit-button'; +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; +import { trimEnd } from 'lodash'; +import formatCurrency, { getCurrencyDefaults } from '@automattic/format-currency'; + +import { + Button, + ExternalLink, + PanelBody, + Placeholder, + Spinner, + TextControl, + withNotices, + SelectControl, +} from '@wordpress/components'; +import { InspectorControls, BlockIcon } from '@wordpress/editor'; +import { Fragment, Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { icon, SUPPORTED_CURRENCY_LIST } from '.'; + +const API_STATE_LOADING = 0; +const API_STATE_CONNECTED = 1; +const API_STATE_NOTCONNECTED = 2; + +const PRODUCT_NOT_ADDING = 0; +const PRODUCT_FORM = 1; +const PRODUCT_FORM_SUBMITTED = 2; + +class MembershipsButtonEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + connected: API_STATE_LOADING, + connectURL: null, + addingMembershipAmount: PRODUCT_NOT_ADDING, + products: [], + editedProductCurrency: 'USD', + editedProductPrice: 5, + editedProductPriceValid: true, + editedProductTitle: '', + editedProductTitleValid: true, + editedProductRenewInterval: '1 month', + }; + this.timeout = null; + } + + componentDidMount = () => { + this.apiCall(); + }; + + onError = message => { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + }; + + apiCall = () => { + const path = '/wpcom/v2/memberships/status'; + const method = 'GET'; + const fetch = { path, method }; + apiFetch( fetch ).then( + result => { + const connectURL = result.connect_url; + const products = result.products; + const connected = result.connected_account_id + ? API_STATE_CONNECTED + : API_STATE_NOTCONNECTED; + this.setState( { connected, connectURL, products } ); + }, + result => { + const connectURL = null; + const connected = API_STATE_NOTCONNECTED; + this.setState( { connected, connectURL } ); + this.onError( result.message ); + } + ); + }; + 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 }; + } ); + + handleCurrencyChange = editedProductCurrency => this.setState( { editedProductCurrency } ); + handleRenewIntervalChange = editedProductRenewInterval => + this.setState( { editedProductRenewInterval } ); + + handlePriceChange = price => { + price = parseFloat( price ); + this.setState( { + editedProductPrice: price, + editedProductPriceValid: ! isNaN( price ) && price >= 5, + } ); + }; + + handleTitleChange = editedProductTitle => + this.setState( { + editedProductTitle, + editedProductTitleValid: editedProductTitle.length > 0, + } ); + // eslint-disable-next-line + saveProduct = () => { + if ( ! this.state.editedProductTitle || this.state.editedProductTitle.length === 0 ) { + this.setState( { editedProductTitleValid: false } ); + return; + } + if ( + ! this.state.editedProductPrice || + isNaN( this.state.editedProductPrice ) || + this.state.editedProductPrice < 5 + ) { + this.setState( { editedProductPriceValid: false } ); + return; + } + this.setState( { addingMembershipAmount: PRODUCT_FORM_SUBMITTED } ); + const path = '/wpcom/v2/memberships/product'; + const method = 'POST'; + const data = { + currency: this.state.editedProductCurrency, + price: this.state.editedProductPrice, + title: this.state.editedProductTitle, + interval: this.state.editedProductRenewInterval, + }; + const fetch = { path, method, data }; + apiFetch( fetch ).then( + result => { + this.setState( { + addingMembershipAmount: PRODUCT_NOT_ADDING, + products: this.state.products.concat( [ + { + id: result.id, + title: result.title, + interval: result.interval, + price: result.price, + }, + ] ), + } ); + }, + result => { + this.setState( { addingMembershipAmount: PRODUCT_FORM } ); + this.onError( result.message ); + } + ); + }; + + renderAddMembershipAmount = () => { + if ( this.state.addingMembershipAmount === PRODUCT_NOT_ADDING ) { + return ( + <Button + isDefault + isLarge + onClick={ () => this.setState( { addingMembershipAmount: PRODUCT_FORM } ) } + > + { __( 'Add Memberships Amounts', 'jetpack' ) } + </Button> + ); + } + if ( this.state.addingMembershipAmount === PRODUCT_FORM_SUBMITTED ) { + return; + } + + return ( + <div> + <div className="membership-button__price-container"> + <SelectControl + className="membership-button__field membership-button__field-currency" + label={ __( 'Currency', 'jetpack' ) } + onChange={ this.handleCurrencyChange } + options={ this.getCurrencyList } + value={ this.state.editedProductCurrency } + /> + <TextControl + label={ __( 'Price', 'jetpack' ) } + className={ classnames( { + 'membership-membership-button__field': true, + 'membership-button__field-price': true, + 'membership-button__field-error': ! this.state.editedProductPriceValid, + } ) } + onChange={ this.handlePriceChange } + placeholder={ formatCurrency( 0, this.state.editedProductCurrency ) } + required + step="1" + type="number" + value={ this.state.editedProductPrice || '' } + /> + </div> + <TextControl + className={ classnames( { + 'membership-button__field': true, + 'membership-button__field-error': ! this.state.editedProductTitleValid, + } ) } + label={ __( 'Describe your subscription in a few words', 'jetpack' ) } + onChange={ this.handleTitleChange } + placeholder={ __( 'Subscription description', 'jetpack' ) } + value={ this.state.editedProductTitle } + /> + <SelectControl + label={ __( 'Renew interval', 'jetpack' ) } + onChange={ this.handleRenewIntervalChange } + options={ [ + { + label: __( 'Monthly', 'jetpack' ), + value: '1 month', + }, + { + label: __( 'Yearly', 'jetpack' ), + value: '1 year', + }, + ] } + value={ this.state.editedProductRenewInterval } + /> + <div> + <Button + isDefault + isLarge + className="membership-button__field-button" + onClick={ this.saveProduct } + > + { __( 'Add Amount', 'jetpack' ) } + </Button> + <Button + isLarge + className="membership-button__field-button" + onClick={ () => this.setState( { addingMembershipAmount: PRODUCT_NOT_ADDING } ) } + > + { __( 'Cancel', 'jetpack' ) } + </Button> + </div> + </div> + ); + }; + getFormattedPriceByProductId = id => { + const product = this.state.products + .filter( prod => parseInt( prod.id ) === parseInt( id ) ) + .pop(); + return formatCurrency( parseFloat( product.price ), product.currency ); + }; + + setMembershipAmount = id => + this.props.setAttributes( { + planId: id, + submitButtonText: this.getFormattedPriceByProductId( id ) + __( ' Contribution', 'jetpack' ), + } ); + + renderMembershipAmounts = () => ( + <div> + { this.state.products.map( product => ( + <Button + className="membership-button__field-button" + isLarge + key={ product.id } + onClick={ () => this.setMembershipAmount( product.id ) } + > + { formatCurrency( parseFloat( product.price ), product.currency ) } + </Button> + ) ) } + </div> + ); + + renderDisclaimer = () => { + return ( + <div className="membership-button__disclaimer"> + <ExternalLink href="https://en.support.wordpress.com/memberships/#related-fees"> + { __( 'Read more about memberships and related fees.', 'jetpack' ) } + </ExternalLink> + </div> + ); + }; + + render = () => { + const { className, notices } = this.props; + const { connected, connectURL, products } = this.state; + + const inspectorControls = ( + <InspectorControls> + <PanelBody title={ __( 'Product', 'jetpack' ) }> + <SelectControl + label="Membership plan" + value={ this.props.attributes.planId } + onChange={ this.setMembershipAmount } + options={ this.state.products.map( product => ( { + label: formatCurrency( parseFloat( product.price ), product.currency ), + value: product.id, + key: product.id, + } ) ) } + /> + </PanelBody> + </InspectorControls> + ); + const blockClasses = classnames( className, [ + 'components-button', + 'is-primary', + 'is-button', + ] ); + const blockContent = ( + <SubmitButton + className={ blockClasses } + submitButtonText={ this.props.attributes.submitButtonText } + attributes={ this.props.attributes } + setAttributes={ this.props.setAttributes } + /> + ); + return ( + <Fragment> + { this.props.noticeUI } + { ( connected === API_STATE_LOADING || + this.state.addingMembershipAmount === PRODUCT_FORM_SUBMITTED ) && + ! this.props.attributes.planId && ( + <Placeholder icon={ <BlockIcon icon={ icon } /> } notices={ notices }> + <Spinner /> + </Placeholder> + ) } + { ! this.props.attributes.planId && connected === API_STATE_NOTCONNECTED && ( + <Placeholder + icon={ <BlockIcon icon={ icon } /> } + label={ __( 'Memberships', 'jetpack' ) } + notices={ notices } + > + <div className="components-placeholder__instructions wp-block-jetpack-membership-button"> + { __( + 'In order to start selling Membership plans, you have to connect to Stripe:', + 'jetpack' + ) } + <br /> + <br /> + <Button isDefault isLarge href={ connectURL } target="_blank"> + { __( 'Connect to Stripe or set up an account', 'jetpack' ) } + </Button> + <br /> + <br /> + <Button isLink onClick={ this.apiCall }> + { __( 'Re-check Connection', 'jetpack' ) } + </Button> + { this.renderDisclaimer() } + </div> + </Placeholder> + ) } + { ! this.props.attributes.planId && + connected === API_STATE_CONNECTED && + products.length === 0 && ( + <Placeholder + icon={ <BlockIcon icon={ icon } /> } + label={ __( 'Memberships', 'jetpack' ) } + notices={ notices } + > + <div className="components-placeholder__instructions wp-block-jetpack-membership-button"> + { __( 'Add your first Membership amount:', 'jetpack' ) } + <br /> + <br /> + { this.renderAddMembershipAmount() } + { this.renderDisclaimer() } + </div> + </Placeholder> + ) } + { ! this.props.attributes.planId && + this.state.addingMembershipAmount !== PRODUCT_FORM_SUBMITTED && + connected === API_STATE_CONNECTED && + products.length > 0 && ( + <Placeholder + icon={ <BlockIcon icon={ icon } /> } + label={ __( 'Memberships', 'jetpack' ) } + notices={ notices } + > + <div className="components-placeholder__instructions wp-block-jetpack-membership-button"> + { __( 'Select payment amount:', 'jetpack' ) } + { this.renderMembershipAmounts() } + { __( 'Or add another membership amount:', 'jetpack' ) } + <br /> + { this.renderAddMembershipAmount() } + { this.renderDisclaimer() } + </div> + </Placeholder> + ) } + { this.state.products && inspectorControls } + { this.props.attributes.planId && blockContent } + </Fragment> + ); + }; +} + +export default withNotices( MembershipsButtonEdit ); diff --git a/plugins/jetpack/extensions/blocks/membership-button/editor.js b/plugins/jetpack/extensions/blocks/membership-button/editor.js new file mode 100644 index 00000000..d05f4039 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/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/membership-button/editor.scss b/plugins/jetpack/extensions/blocks/membership-button/editor.scss new file mode 100644 index 00000000..be104f57 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/editor.scss @@ -0,0 +1,38 @@ +@import './view.scss'; + +.wp-block-jetpack-membership-button { + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen-Sans, Ubuntu, Cantarell, + Helvetica Neue, sans-serif; + + .membership-button__price-container { + display: flex; + flex-wrap: wrap; + } + .membership-button__field-price { + margin-left: 10px; + } + .wp-block-jetpack-membership-button_notification { + display: block; + } + + .editor-rich-text__inline-toolbar { + pointer-events: none; + .components-toolbar { + pointer-events: all; + } + } + + .membership-button__field-button { + margin: 4px; + } + + .membership-button__field-error .components-text-control__input { + border: 1px solid; + border-color: var( --color-error ); + } + + .membership-button__disclaimer { + margin-top: 20px; + font-style: italic; + } +} diff --git a/plugins/jetpack/extensions/blocks/membership-button/index.js b/plugins/jetpack/extensions/blocks/membership-button/index.js new file mode 100644 index 00000000..69578a79 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/index.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import { Path, Rect, SVG, G } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import edit from './edit'; +import './editor.scss'; + +export const name = 'membership-button'; + +export const icon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"> + <Rect x="0" fill="none" width="24" height="24" /> + <G> + <Path d="M20 4H4c-1.105 0-2 .895-2 2v12c0 1.105.895 2 2 2h16c1.105 0 2-.895 2-2V6c0-1.105-.895-2-2-2zm0 2v2H4V6h16zM4 18v-6h16v6H4zm2-4h7v2H6v-2zm9 0h3v2h-3v-2z" /> + </G> + </SVG> +); + +export const settings = { + title: __( 'Membership Button', 'jetpack' ), + icon, + description: __( 'Button allowing you to sell subscription products.', 'jetpack' ), + category: 'jetpack', + keywords: [ + _x( 'sell', 'block search term', 'jetpack' ), + _x( 'subscription', 'block search term', 'jetpack' ), + 'stripe', + ], + attributes: { + planId: { + type: 'integer', + }, + submitButtonText: { + type: 'string', + }, + customBackgroundButtonColor: { + type: 'string', + }, + customTextButtonColor: { + type: 'string', + }, + }, + edit, + save: () => null, +}; + +// These are Stripe Settlement currencies https://stripe.com/docs/currencies since memberships supports only Stripe ATM. +export const SUPPORTED_CURRENCY_LIST = [ + 'USD', + 'AUD', + 'BRL', + 'CAD', + 'CHF', + 'DKK', + 'EUR', + 'GBP', + 'HKD', + 'JPY', + 'MXN', + 'NOK', + 'NZD', + 'SEK', + 'SGD', +]; diff --git a/plugins/jetpack/extensions/blocks/membership-button/membership-button.php b/plugins/jetpack/extensions/blocks/membership-button/membership-button.php new file mode 100644 index 00000000..d8488cf2 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/membership-button.php @@ -0,0 +1,19 @@ +<?php // phpcs:disable Squiz.Commenting.FileComment.Missing +/** + * Memberships block. + * + * @since 7.3.0 + * + * @package Jetpack + */ + +if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_active() ) { + require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php'; + + jetpack_register_block( + 'jetpack/membership-button', + array( + 'render_callback' => array( Jetpack_Memberships::get_instance(), 'render_button' ), + ) + ); +} diff --git a/plugins/jetpack/extensions/blocks/membership-button/view.js b/plugins/jetpack/extensions/blocks/membership-button/view.js new file mode 100644 index 00000000..6e10a1d3 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/view.js @@ -0,0 +1,79 @@ +/* global tb_show, tb_remove */ + +/** + * Internal dependencies + */ +import './view.scss'; +const name = 'membership-button'; +const blockClassName = 'wp-block-jetpack-' + name; + +/** + * Since "close" button is inside our checkout iframe, in order to close it, it has to pass a message to higher scope to close the modal. + * + * @param {event} eventFromIframe - message event that gets emmited in the checkout iframe. + * @listens message + */ +function handleIframeResult( eventFromIframe ) { + if ( eventFromIframe.origin === 'https://subscribe.wordpress.com' && eventFromIframe.data ) { + const data = JSON.parse( eventFromIframe.data ); + if ( data && data.action === 'close' ) { + window.removeEventListener( 'message', handleIframeResult ); + tb_remove(); + } + } +} + +function activateSubscription( block, blogId, planId, poweredText, lang ) { + block.addEventListener( 'click', () => { + tb_show( + null, + 'https://subscribe.wordpress.com/memberships/?blog=' + + blogId + + '&plan=' + + planId + + '&lang=' + + lang + + 'TB_iframe=true&height=600&width=400', + null + ); + window.addEventListener( 'message', handleIframeResult, false ); + const tbWindow = document.querySelector( '#TB_window' ); + tbWindow.classList.add( 'jetpack-memberships-modal' ); + const footer = document.createElement( 'DIV' ); + footer.classList.add( 'TB_footer' ); + footer.innerHTML = poweredText; + tbWindow.appendChild( footer ); + } ); +} + +const initializeMembershipButtonBlocks = () => { + const membershipButtonBlocks = Array.prototype.slice.call( + document.querySelectorAll( '.' + blockClassName ) + ); + membershipButtonBlocks.forEach( block => { + const blogId = block.getAttribute( 'data-blog-id' ); + const planId = block.getAttribute( 'data-plan-id' ); + const lang = block.getAttribute( 'data-lang' ); + const poweredText = block + .getAttribute( 'data-powered-text' ) + .replace( + 'WordPress.com', + '<a href="https://wordpress.com" target="_blank" rel="noreferrer noopener">WordPress.com</a>' + ); + try { + activateSubscription( block, blogId, planId, poweredText, lang ); + } catch ( err ) { + // eslint-disable-next-line no-console + console.error( 'Problem activating Membership Button ' + planId, err ); + } + } ); +}; + +if ( typeof window !== 'undefined' && typeof document !== 'undefined' ) { + // `DOMContentLoaded` may fire before the script has a chance to run + if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', initializeMembershipButtonBlocks ); + } else { + initializeMembershipButtonBlocks(); + } +} diff --git a/plugins/jetpack/extensions/blocks/membership-button/view.scss b/plugins/jetpack/extensions/blocks/membership-button/view.scss new file mode 100644 index 00000000..cc0eb71c --- /dev/null +++ b/plugins/jetpack/extensions/blocks/membership-button/view.scss @@ -0,0 +1,49 @@ +/* Additional styling to thickbox that displays modal */ +/* stylelint-disable selector-max-id */ + +.jetpack-memberships-modal #TB_title { + border-radius: 4px 4px 0 0; +} +#TB_window.jetpack-memberships-modal { + border-radius: 4px; + background-color: $muriel-gray-0; + background-image: url( 'https://s0.wp.com/i/loading/loading-64.gif' ); + background-repeat: no-repeat; + background-position: center; + bottom: 10%; + margin-top: 0 !important; + top: 10%; +} + +.jetpack-memberships-modal #TB_iframeContent { + height: calc( 100% - 50px ) !important; +} +@media only screen and ( max-width: 480px ) { + #TB_window.jetpack-memberships-modal { + bottom: 0; + left: 0; + margin-left: 0 !important; + right: 0; + top: 0; + width: 100% !important; + } + .jetpack-memberships-modal #TB_iframeContent { + width: 100% !important; + } +} + +.jetpack-memberships-modal #TB_iframeContent { + height: calc( 100% - 80px ) !important; +} +.jetpack-memberships-modal .TB_footer { + border-top: 1px solid $muriel-gray-50; + color: $muriel-blue-200; + font-size: 13px; + padding: 4px 0; + text-align: center; +} +.jetpack-memberships-modal .TB_footer a, +.jetpack-memberships-modal .TB_footer a:hover, +.jetpack-memberships-modal .TB_footer a:visited { + color: $muriel-hot-blue-500; +} |