summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/extensions/blocks/membership-button')
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/edit.jsx392
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/editor.js7
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/editor.scss38
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/index.js69
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/membership-button.php19
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/view.js79
-rw-r--r--plugins/jetpack/extensions/blocks/membership-button/view.scss49
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;
+}