diff --git a/client/cart/blocks/product-details.js b/client/cart/blocks/product-details.js index f52d4270aa6..bdd79f2073e 100644 --- a/client/cart/blocks/product-details.js +++ b/client/cart/blocks/product-details.js @@ -16,6 +16,7 @@ import { getUPEConfig } from 'utils/checkout'; import WCPayAPI from '../../checkout/api'; import request from '../../checkout/utils/request'; import { useEffect, useState } from 'react'; +import { initializeUpeAppearanceEditor } from 'wcpay/utils/upe-appearance-editor'; // Create an API object, which will be used throughout the checkout. const api = new WCPayAPI( @@ -59,10 +60,20 @@ const ProductDetail = ( { cart, context } ) => { 'bnpl_cart_block' ); setAppearance( upeAppearance ); + initializeUpeAppearanceEditor( + upeAppearance, + 'bnpl_cart_block', + api, + [ 'pmme' ] + ); } if ( Object.keys( appearance ).length === 0 ) { generateUPEAppearance(); + } else { + initializeUpeAppearanceEditor( appearance, 'bnpl_cart_block', api, [ + 'pmme', + ] ); } }, [ appearance ] ); diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index e70765c551b..2ad271e6be8 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -302,12 +302,18 @@ export default class WCPayAPI { * * @param {Object} appearance The UPE appearance object with style values * @param {string} elementsLocation The location of the elements. + * @param {string} storageType One of 'transient' or 'persistent'. * * @return {Promise} The final promise for the request to the server. */ - saveUPEAppearance( appearance, elementsLocation ) { + saveUPEAppearance( + appearance, + elementsLocation, + storageType = 'transient' + ) { return this.request( getConfig( 'ajaxUrl' ), { elements_location: elementsLocation, + storage_type: storageType, appearance: JSON.stringify( appearance ), action: 'save_upe_appearance', // eslint-disable-next-line camelcase @@ -326,6 +332,33 @@ export default class WCPayAPI { } ); } + /** + * Resets all custom and computed UPE appearance values. + * + * @param {string} elementsLocation The location of the elements. + * + * @return {Promise} The final promise for the request to the server. + */ + resetUPEAppearance( elementsLocation ) { + return this.request( getConfig( 'ajaxUrl' ), { + elements_location: elementsLocation, + action: 'reset_upe_appearance', + // eslint-disable-next-line camelcase + _ajax_nonce: getConfig( 'resetUPEAppearanceNonce' ), + } ) + .then( ( response ) => { + return response.data; + } ) + .catch( ( error ) => { + if ( error.message ) { + throw error; + } else { + // Covers the case of error on the Ajaxrequest. + throw new Error( error.statusText ); + } + } ); + } + /** * Updates cart with selected shipping option. * diff --git a/client/checkout/blocks/payment-elements.js b/client/checkout/blocks/payment-elements.js index f782ba13545..97d57658d45 100644 --- a/client/checkout/blocks/payment-elements.js +++ b/client/checkout/blocks/payment-elements.js @@ -17,6 +17,7 @@ import { useFingerprint } from './hooks'; import { LoadableBlock } from 'wcpay/components/loadable'; import PaymentProcessor from './payment-processor'; import { getPaymentMethodTypes } from 'wcpay/checkout/utils/upe'; +import { initializeUpeAppearanceEditor } from 'wcpay/utils/upe-appearance-editor'; const PaymentElements = ( { api, ...props } ) => { const stripeForUPE = useStripeForUPE( api, props.paymentMethodId ); @@ -43,11 +44,18 @@ const PaymentElements = ( { api, ...props } ) => { upeAppearance, 'blocks_checkout' ); + initializeUpeAppearanceEditor( + upeAppearance, + 'blocks_checkout', + api + ); setAppearance( upeAppearance ); } if ( ! appearance ) { generateUPEAppearance(); + } else { + initializeUpeAppearanceEditor( appearance, 'blocks_checkout', api ); } if ( fingerprintErrorMessage ) { diff --git a/client/checkout/blocks/payment-method-label.js b/client/checkout/blocks/payment-method-label.js index 752a9b830db..e6e3fb22e2b 100644 --- a/client/checkout/blocks/payment-method-label.js +++ b/client/checkout/blocks/payment-method-label.js @@ -12,6 +12,7 @@ import { __ } from '@wordpress/i18n'; import './style.scss'; import { useEffect, useState } from '@wordpress/element'; import { getAppearance } from 'wcpay/checkout/upe-styles'; +import { initializeUpeAppearanceEditor } from 'wcpay/utils/upe-appearance-editor'; const bnplMethods = [ 'affirm', 'afterpay_clearpay', 'klarna' ]; const PaymentMethodMessageWrapper = ( { @@ -80,12 +81,19 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { upeAppearance, 'blocks_checkout' ); + initializeUpeAppearanceEditor( + upeAppearance, + 'blocks_checkout', + api + ); setAppearance( upeAppearance ); setUpeAppearanceTheme( upeAppearance.theme ); } if ( ! appearance ) { generateUPEAppearance(); + } else { + initializeUpeAppearanceEditor( appearance, 'blocks_checkout', api ); } }, [ api, appearance ] ); diff --git a/client/checkout/blocks/payment-processor.js b/client/checkout/blocks/payment-processor.js index 952470aa46b..ba44080dcbb 100644 --- a/client/checkout/blocks/payment-processor.js +++ b/client/checkout/blocks/payment-processor.js @@ -32,6 +32,7 @@ import { PAYMENT_METHOD_ERROR, PAYMENT_METHOD_NAME_CARD, } from 'wcpay/checkout/constants'; +import { registerElementsComponent } from 'wcpay/utils/upe-appearance-editor'; const getBillingDetails = ( billingData ) => { return { @@ -84,6 +85,10 @@ const PaymentProcessor = ( { setBillingAddress, } = useCustomerData(); + useEffect( () => { + registerElementsComponent( elements, 'blocks_checkout' ); + }, [ elements ] ); + useEffect( () => { if ( activePaymentMethod === PAYMENT_METHOD_NAME_CARD && diff --git a/client/checkout/classic/payment-processing.js b/client/checkout/classic/payment-processing.js index cfde44fcb5d..b70b88db141 100644 --- a/client/checkout/classic/payment-processing.js +++ b/client/checkout/classic/payment-processing.js @@ -32,6 +32,10 @@ import { SHORTCODE_BILLING_ADDRESS_FIELDS, PAYMENT_METHOD_ERROR, } from 'wcpay/checkout/constants'; +import { + initializeUpeAppearanceEditor, + registerElementsComponent, +} from 'wcpay/utils/upe-appearance-editor'; // It looks like on file import there are some side effects. Should probably be fixed. const gatewayUPEComponents = {}; @@ -62,10 +66,15 @@ async function initializeAppearance( api, elementsLocation ) { const upeConfigProperty = upeConfigMap[ elementsLocation ] ?? 'upeAppearance'; const appearance = getUPEConfig( upeConfigProperty ); + if ( appearance ) { + initializeUpeAppearanceEditor( appearance, elementsLocation, api ); return Promise.resolve( appearance ); } + const computedAppearance = getAppearance( elementsLocation ); + initializeUpeAppearanceEditor( computedAppearance, elementsLocation, api ); + return await api.saveUPEAppearance( getAppearance( elementsLocation ), elementsLocation @@ -277,6 +286,9 @@ async function createStripePaymentElement( const stripe = await api.getStripeForUPE( paymentMethodType ); const elements = stripe.elements( options ); + + registerElementsComponent( elements, elementsLocation ); + const createdStripePaymentElement = elements.create( 'payment', { ...getUpeSettings( paymentMethodType ), wallets: { @@ -504,12 +516,16 @@ export async function mountStripePaymentMethodMessagingElement( try { const stripe = await api.getStripe(); - const paymentMethodMessagingElement = stripe - .elements( { - appearance: appearance, - fonts: getFontRulesFromPage(), - } ) - .create( 'paymentMethodMessaging', { + const elements = stripe.elements( { + appearance: appearance, + fonts: getFontRulesFromPage(), + } ); + + registerElementsComponent( elements, location ); + + const paymentMethodMessagingElement = elements.create( + 'paymentMethodMessaging', + { currency: cartData.currency, amount: normalizeCurrencyToMinorUnit( cartData.amount, @@ -518,7 +534,8 @@ export async function mountStripePaymentMethodMessagingElement( countryCode: cartData.country, // Customer's country or base country of the store. paymentMethodTypes: [ paymentMethodType ], displayType: 'promotional_text', - } ); + } + ); return paymentMethodMessagingElement.mount( domElement ); } finally { diff --git a/client/product-details/bnpl-site-messaging/index.js b/client/product-details/bnpl-site-messaging/index.js index a435b16a0f5..9eb926b5f30 100644 --- a/client/product-details/bnpl-site-messaging/index.js +++ b/client/product-details/bnpl-site-messaging/index.js @@ -7,6 +7,7 @@ import WCPayAPI from 'wcpay/checkout/api'; import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; import { getUPEConfig } from 'wcpay/utils/checkout'; import apiRequest from 'wcpay/checkout/utils/request'; +// import { initializeUpeAppearanceEditor } from 'wcpay/utils/upe-appearance-editor'; const elementsLocations = { bnplProductPage: { @@ -32,15 +33,22 @@ const elementsLocations = { async function initializeAppearance( api, location ) { const { configKey, appearanceKey } = elementsLocations[ location ]; + // TODO: this does not work as expected, config is always null in Product page and Cart so it always re-computes. const appearance = getUPEConfig( configKey ); if ( appearance ) { + // initializeUpeAppearanceEditor( appearance, appearanceKey, api, [ + // 'pmme', + // ] ); return Promise.resolve( appearance ); } - return await api.saveUPEAppearance( - getAppearance( appearanceKey ), - appearanceKey - ); + const computedAppearance = getAppearance( appearanceKey ); + + // initializeUpeAppearanceEditor( computedAppearance, appearanceKey, api, [ + // 'pmme', + // ] ); + + return await api.saveUPEAppearance( computedAppearance, appearanceKey ); } export const initializeBnplSiteMessaging = async () => { diff --git a/client/upe-appearance-editor/UpeAppearanceEditor.jsx b/client/upe-appearance-editor/UpeAppearanceEditor.jsx new file mode 100644 index 00000000000..7ad5755b7d4 --- /dev/null +++ b/client/upe-appearance-editor/UpeAppearanceEditor.jsx @@ -0,0 +1,315 @@ +/** + * External dependencies + */ +import React, { useState, useCallback, useEffect } from 'react'; +import { + CardHeader, + CardBody, + CardFooter, + Button, + SelectControl, + TextControl, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './upe-appearance-editor.scss'; +import fieldsDefinition, { rgbToHex } from './fields-definition'; + +let initialPosition = 'bottom-left'; +try { + initialPosition = + localStorage.getItem( 'upe-appearance-editor-position' ) || + 'bottom-left'; +} catch ( error ) {} + +export function UpeAppearanceEditor( { + initialAppearance, + elementsLocation, + api, + applyAppearance, + sections = [ 'labels', 'inputs', 'text', 'pmme' ], +} ) { + const [ position, setPosition ] = useState( initialPosition ); + const [ loading, setLoading ] = useState( false ); + const [ appearance, setAppearance ] = useState( initialAppearance ); + const [ displayState, setDisplayState ] = useState( 'collapsed' ); + + const toggleDisplay = useCallback( () => { + setDisplayState( + displayState === 'collapsed' ? 'expanded' : 'collapsed' + ); + }, [ displayState ] ); + + const togglePosition = useCallback( () => { + const newPosition = + position === 'bottom-left' ? 'bottom-right' : 'bottom-left'; + try { + localStorage.setItem( + 'upe-appearance-editor-position', + newPosition + ); + } catch ( error ) {} + setPosition( newPosition ); + }, [ position ] ); + + useEffect( () => { + applyAppearance( appearance ); + }, [ appearance, applyAppearance ] ); + + const saveAppearance = useCallback( () => { + setLoading( true ); + applyAppearance( appearance ); + api.saveUPEAppearance( + appearance, + elementsLocation, + 'persistent' + ).then( () => { + setLoading( false ); + } ); + }, [ appearance, api, elementsLocation, applyAppearance ] ); + + const resetAppearance = useCallback( () => { + setLoading( true ); + api.resetUPEAppearance( elementsLocation ).then( () => { + window.location.reload(); + } ); + }, [ api, elementsLocation ] ); + + const mapFieldsForRule = ( rule ) => { + return fieldsDefinition.map( ( field ) => { + if ( ! ( field.property in appearance.rules[ rule ] ) ) { + return null; + } + if ( field.excludeFrom && field.excludeFrom.includes( rule ) ) { + return null; + } + const onChange = ( value ) => { + const transformedValue = field.transformValue( value ); + const newRuleValue = { + ...appearance.rules[ rule ], + [ field.property ]: transformedValue, + }; + + if ( field.linkedProperties ) { + field.linkedProperties.forEach( ( property ) => { + newRuleValue[ property ] = transformedValue; + } ); + } + + setAppearance( { + ...appearance, + rules: { + ...appearance.rules, + [ rule ]: newRuleValue, + }, + } ); + }; + const value = field.transformInput( + appearance.rules[ rule ][ field.property ] + ); + if ( field.type === 'select' ) { + return ( + + ); + } + return ( + + ); + } ); + }; + + return ( +
+
+ + Customize WooPayments + + + + + Form: { elementsLocation } + + { sections.includes( 'labels' ) && ( +
+ Labels + + setAppearance( { + ...appearance, + labels: value, + } ) + } + /> + { mapFieldsForRule( '.Label' ) } +
+ ) } + + { sections.includes( 'inputs' ) && ( +
+ Inputs + { mapFieldsForRule( '.Input' ) } +
+ ) } + + { sections.includes( 'inputs' ) && ( +
+ + Inputs (Invalid)  + + + { mapFieldsForRule( '.Input--invalid' ) } +
+ ) } + + { sections.includes( 'text' ) && ( +
+ Text (Redirect Payment Methods) + { mapFieldsForRule( '.Text--redirect' ) } +
+ ) } + + { sections.includes( 'pmme' ) && ( +
+ + Payment Messaging Elements (Klarna, Afterpay, + etc.) + + + setAppearance( { + ...appearance, + theme: value, + } ) + } + /> + + setAppearance( { + ...appearance, + variables: { + ...appearance.variables, + fontSizeBase: `${ value }px`, + }, + } ) + } + /> + { /* + setAppearance( { + ...appearance, + variables: { + ...appearance.variables, + colorBackground: value, + }, + } ) + } + /> */ } + + + setAppearance( { + ...appearance, + variables: { + ...appearance.variables, + colorText: value, + }, + } ) + } + /> + + Requires Saving and Reloading the page to take + effect + +
+ ) } +
+ + + + + + + +
+
+ ); +} diff --git a/client/upe-appearance-editor/fields-definition.js b/client/upe-appearance-editor/fields-definition.js new file mode 100644 index 00000000000..43ef56740e8 --- /dev/null +++ b/client/upe-appearance-editor/fields-definition.js @@ -0,0 +1,158 @@ +function componentToHex( val ) { + const a = Number( val ).toString( 16 ); + return a.length === 1 ? '0' + a : a; +} + +export function rgbToHex( rgb ) { + if ( rgb.startsWith( '#' ) ) { + return rgb; + } + return '#' + rgb.match( /\d+/g ).map( componentToHex ).join( '' ); +} + +const fieldDefaults = { + type: 'text', + transformInput: ( value ) => value, + transformValue: ( value ) => value, +}; + +const borderStyleFieldDefaults = { + ...fieldDefaults, + type: 'select', + options: [ + { label: 'None', value: 'none' }, + { label: 'Solid', value: 'solid' }, + { label: 'Dashed', value: 'dashed' }, + { label: 'Dotted', value: 'dotted' }, + { label: 'Double', value: 'double' }, + { label: 'Groove', value: 'groove' }, + { label: 'Ridge', value: 'ridge' }, + { label: 'Inset', value: 'inset' }, + { label: 'Outset', value: 'outset' }, + ], +}; + +const colorFieldDefaults = { + ...fieldDefaults, + type: 'color', + transformInput: rgbToHex, +}; + +const pxFieldDefaults = { + ...fieldDefaults, + type: 'number', + step: 1, + transformInput: ( value ) => value.replace( 'px', '' ), + transformValue: ( value ) => `${ value }px`, +}; + +const fieldsDefinition = [ + { + ...colorFieldDefaults, + label: 'Background Color', + property: 'backgroundColor', + }, + { + ...colorFieldDefaults, + label: 'Text Color', + property: 'color', + }, + { + ...pxFieldDefaults, + label: 'Font Size', + property: 'fontSize', + }, + { + ...pxFieldDefaults, + label: 'Line Height', + property: 'lineHeight', + excludeFrom: [ '.Label', '.Text' ], + }, + { + ...borderStyleFieldDefaults, + label: 'Border style', + property: 'borderBottomStyle', + linkedProperties: [ + 'borderLeftStyle', + 'borderRightStyle', + 'borderTopStyle', + ], + }, + { + ...colorFieldDefaults, + label: 'Border color', + property: 'borderBottomColor', + linkedProperties: [ + 'borderLeftColor', + 'borderRightColor', + 'borderTopColor', + ], + }, + { + ...pxFieldDefaults, + label: 'Border radius', + property: 'borderTopLeftRadius', + linkedProperties: [ + 'borderTopRightRadius', + 'borderBottomLeftRadius', + 'borderBottomRightRadius', + ], + }, + { + ...pxFieldDefaults, + label: 'Border bottom width', + property: 'borderBottomWidth', + }, + // { + // ...borderStyleFieldDefaults, + // label: 'Border left style', + // property: 'borderLeftStyle', + // }, + // { + // ...colorFieldDefaults, + // label: 'Border left color', + // property: 'borderLeftColor', + // }, + { + ...pxFieldDefaults, + label: 'Border left width', + property: 'borderLeftWidth', + }, + // { + // ...borderStyleFieldDefaults, + // label: 'Border right style', + // property: 'borderRightStyle', + // }, + // { + // ...colorFieldDefaults, + // label: 'Border right color', + // property: 'borderRightColor', + // }, + { + ...pxFieldDefaults, + label: 'Border right width', + property: 'borderRightWidth', + }, + // { + // ...borderStyleFieldDefaults, + // label: 'Border top style', + // property: 'borderTopStyle', + // }, + // { + // ...colorFieldDefaults, + // label: 'Border top color', + // property: 'borderTopColor', + // }, + { + ...pxFieldDefaults, + label: 'Border top width', + property: 'borderTopWidth', + }, + { + ...fieldDefaults, + label: 'Box Shadow', + property: 'boxShadow', + }, +]; + +export default fieldsDefinition; diff --git a/client/upe-appearance-editor/index.jsx b/client/upe-appearance-editor/index.jsx new file mode 100644 index 00000000000..2d3a85c9e2e --- /dev/null +++ b/client/upe-appearance-editor/index.jsx @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import ReactDOM from 'react-dom'; + +/** + * Internal dependencies + */ +import { UpeAppearanceEditor } from './UpeAppearanceEditor'; +import { debounce } from 'lodash'; + +function initializeUpeAppearanceEditor( + initialAppearance, + elementsLocation, + api, + sections +) { + const mountElementID = `upe-appearance-editor-root-${ elementsLocation }`; + if ( document.getElementById( mountElementID ) ) { + return; + } + + const applyAppearance = debounce( + ( appearance ) => + updateElementsAppearance( elementsLocation, appearance ), + 300 + ); + + const mountElement = document.createElement( 'div' ); + mountElement.id = mountElementID; + const parentElement = + document.querySelector( '.woocommerce' ) || document.body; + parentElement.appendChild( mountElement ); + + const root = ReactDOM.createRoot( mountElement ); + root.render( + + ); +} + +const elementsComponents = {}; + +function registerElementsComponent( elements, elementsLocation ) { + if ( ! elementsComponents[ elementsLocation ] ) { + elementsComponents[ elementsLocation ] = []; + } + elementsComponents[ elementsLocation ].push( elements ); +} + +function updateElementsAppearance( elementsLocation, appearance ) { + if ( ! elementsComponents[ elementsLocation ] ) { + return; + } + + elementsComponents[ elementsLocation ].forEach( ( elements ) => { + elements.update( { appearance } ); + } ); +} + +window.initializeUpeAppearanceEditor = initializeUpeAppearanceEditor; +window.registerElementsComponent = registerElementsComponent; diff --git a/client/upe-appearance-editor/upe-appearance-editor.scss b/client/upe-appearance-editor/upe-appearance-editor.scss new file mode 100644 index 00000000000..ff77f5d715a --- /dev/null +++ b/client/upe-appearance-editor/upe-appearance-editor.scss @@ -0,0 +1,102 @@ +.upe-appearance-editor { + position: fixed !important; + z-index: 999; + width: 500px; + height: 500px; + > div { + display: flex; + flex-direction: column; + height: 100%; + background-color: var( --wp--preset--color--background, #fff ); + color: var( --wp--preset--color--foreground, #000 ); + border-color: var( --wp--preset--color--foreground, #000 ); + border-width: 1px; + border-style: solid; + } + + fieldset { + border: 1px solid; + border-color: var( --wp--preset--color--foreground ); + } + + small { + font-size: 0.8rem; + } + + &.collapsed { + height: auto; + width: auto; + .components-card__header { + select { + display: none; + } + } + .components-card__body, + .components-card__footer { + display: none; + } + } + + &.bottom-left { + bottom: 10px; + left: 10px; + } + + &.bottom-right { + bottom: 10px; + right: 10px; + } + + &.top-left { + top: 10px; + left: 10px; + } + + &.top-right { + top: 10px; + right: 10px; + } + + > div { + display: flex; + flex-direction: column; + } + + .components-card__header { + cursor: pointer; + } + + .components-card__body { + overflow: auto; + padding-top: 0; + padding-bottom: 0; + + .elements-location { + float: right; + } + fieldset { + clear: both; + } + } + + .components-base-control__field { + display: flex; + flex-direction: row; + column-gap: 0.5rem; + } + + .components-base-control__label { + margin-right: 0.5rem; + font-size: 1rem; + } + + .components-input-control__label { + font-size: 1rem; + } + + .components-select-control { + flex-direction: row; + column-gap: 0.5rem; + align-items: end; + } +} diff --git a/client/utils/upe-appearance-editor.js b/client/utils/upe-appearance-editor.js new file mode 100644 index 00000000000..27ad429362d --- /dev/null +++ b/client/utils/upe-appearance-editor.js @@ -0,0 +1,21 @@ +export function initializeUpeAppearanceEditor( + initialAppearance, + elementsLocation, + api, + sections +) { + if ( window.initializeUpeAppearanceEditor ) { + window.initializeUpeAppearanceEditor( + initialAppearance, + elementsLocation, + api, + sections + ); + } +} + +export function registerElementsComponent( elements, elementsLocation ) { + if ( window.registerElementsComponent ) { + window.registerElementsComponent( elements, elementsLocation ); + } +} diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 0f53c3dde42..7ab841c9fd6 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -4119,6 +4119,7 @@ public function save_upe_appearance_ajax() { $elements_location = isset( $_POST['elements_location'] ) ? wc_clean( wp_unslash( $_POST['elements_location'] ) ) : null; $appearance = isset( $_POST['appearance'] ) ? json_decode( wc_clean( wp_unslash( $_POST['appearance'] ) ) ) : null; + $storage_type = isset( $_POST['storage_type'] ) ? wc_clean( wp_unslash( $_POST['storage_type'] ) ) : null; $valid_locations = [ 'blocks_checkout', 'shortcode_checkout', 'bnpl_product_page', 'bnpl_classic_cart', 'bnpl_cart_block', 'add_payment_method' ]; if ( ! $elements_location || ! in_array( $elements_location, $valid_locations, true ) ) { @@ -4127,7 +4128,7 @@ public function save_upe_appearance_ajax() { ); } - if ( in_array( $elements_location, [ 'blocks_checkout', 'shortcode_checkout' ], true ) ) { + if ( 'persistent' !== $storage_type && in_array( $elements_location, [ 'blocks_checkout', 'shortcode_checkout' ], true ) ) { $is_blocks_checkout = 'blocks_checkout' === $elements_location; /** * This filter is only called on "save" of the appearance, to avoid calling it on every page load. @@ -4146,28 +4147,22 @@ public function save_upe_appearance_ajax() { * * @since 7.4.0 */ - $appearance = apply_filters( 'wcpay_elements_appearance', $appearance, $elements_location ); - - $appearance_transient = [ - 'shortcode_checkout' => self::UPE_APPEARANCE_TRANSIENT, - 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT, - 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT, - 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT, - 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT, - 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT, - ][ $elements_location ]; - $appearance_theme_transient = [ - 'shortcode_checkout' => self::UPE_APPEARANCE_THEME_TRANSIENT, - 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_THEME_TRANSIENT, - 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT, - 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_THEME_TRANSIENT, - 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_THEME_TRANSIENT, - 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_THEME_TRANSIENT, - ][ $elements_location ]; + if ( 'persistent' !== $storage_type ) { + $appearance = apply_filters( 'wcpay_elements_appearance', $appearance, $elements_location ); + } + + $transients = $this->get_appearance_transients( $elements_location ); + $appearance_transient = $transients[0]; + $appearance_theme_transient = $transients[1]; if ( null !== $appearance ) { - set_transient( $appearance_transient, $appearance, DAY_IN_SECONDS ); - set_transient( $appearance_theme_transient, $appearance->theme, DAY_IN_SECONDS ); + if ( 'persistent' === $storage_type ) { + update_option( $appearance_transient, $appearance ); + update_option( $appearance_theme_transient, $appearance->theme ); + } else { + set_transient( $appearance_transient, $appearance, DAY_IN_SECONDS ); + set_transient( $appearance_theme_transient, $appearance->theme, DAY_IN_SECONDS ); + } } wp_send_json_success( $appearance, 200 ); @@ -4184,6 +4179,79 @@ public function save_upe_appearance_ajax() { } } + /** + * Handle AJAX request for resetting UPE appearance values. + * + * @throws Exception - If nonce or setup intent is invalid. + */ + public function reset_upe_appearance_ajax() { + try { + $is_nonce_valid = check_ajax_referer( 'wcpay_reset_upe_appearance_nonce', false, false ); + if ( ! $is_nonce_valid ) { + throw new Exception( + __( 'Unable to reset UPE appearance values at this time.', 'woocommerce-payments' ) + ); + } + + $elements_location = isset( $_POST['elements_location'] ) ? wc_clean( wp_unslash( $_POST['elements_location'] ) ) : null; + + $valid_locations = [ 'blocks_checkout', 'shortcode_checkout', 'bnpl_product_page', 'bnpl_classic_cart', 'bnpl_cart_block', 'add_payment_method' ]; + if ( ! $elements_location || ! in_array( $elements_location, $valid_locations, true ) ) { + throw new Exception( + __( 'Unable to reset UPE appearance values at this time.', 'woocommerce-payments' ) + ); + } + + $transients = $this->get_appearance_transients( $elements_location ); + $appearance_transient = $transients[0]; + $appearance_theme_transient = $transients[1]; + + delete_option( $appearance_transient ); + delete_option( $appearance_theme_transient ); + delete_transient( $appearance_transient ); + delete_transient( $appearance_theme_transient ); + + wp_send_json_success( true, 200 ); + } catch ( Exception $e ) { + // Send back error so it can be displayed to the customer. + wp_send_json_error( + [ + 'error' => [ + 'message' => WC_Payments_Utils::get_filtered_error_message( $e ), + ], + ], + WC_Payments_Utils::get_filtered_error_status_code( $e ) + ); + } + } + + /** + * Get the correct appearance transients for the given location. + * + * @param string $elements_location The location of the payment element. + * @return (string|null)[] + */ + private function get_appearance_transients( $elements_location ) { + $appearance_transient = [ + 'shortcode_checkout' => self::UPE_APPEARANCE_TRANSIENT, + 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT, + 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT, + 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT, + 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT, + 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT, + ][ $elements_location ]; + $appearance_theme_transient = [ + 'shortcode_checkout' => self::UPE_APPEARANCE_THEME_TRANSIENT, + 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_THEME_TRANSIENT, + 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT, + 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_THEME_TRANSIENT, + 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_THEME_TRANSIENT, + 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_THEME_TRANSIENT, + ][ $elements_location ]; + + return [ $appearance_transient, $appearance_theme_transient ]; + } + /** * Clear the saved UPE appearance transient value. */ @@ -4453,7 +4521,7 @@ public function wc_payments_get_payment_method_by_id( $payment_method_id ) { * @return string */ public function get_theme_icon() { - $upe_appearance_theme = get_transient( self::UPE_APPEARANCE_THEME_TRANSIENT ); + $upe_appearance_theme = WC_Payments_Utils::get_appearance_value( self::UPE_APPEARANCE_THEME_TRANSIENT ); if ( $upe_appearance_theme ) { return 'night' === $upe_appearance_theme ? $this->payment_method->get_dark_icon() : $this->payment_method->get_icon(); } diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index ee7a161f3b1..8e84acdea68 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -98,6 +98,8 @@ public function init_hooks() { add_action( 'wp', [ $this->gateway, 'maybe_process_upe_redirect' ] ); add_action( 'wp_ajax_save_upe_appearance', [ $this->gateway, 'save_upe_appearance_ajax' ] ); add_action( 'wp_ajax_nopriv_save_upe_appearance', [ $this->gateway, 'save_upe_appearance_ajax' ] ); + add_action( 'wp_ajax_reset_upe_appearance', [ $this->gateway, 'reset_upe_appearance_ajax' ] ); + add_action( 'wp_ajax_nopriv_reset_upe_appearance', [ $this->gateway, 'reset_upe_appearance_ajax' ] ); add_action( 'switch_theme', [ $this->gateway, 'clear_upe_appearance_transient' ] ); add_action( 'woocommerce_woocommerce_payments_updated', [ $this->gateway, 'clear_upe_appearance_transient' ] ); @@ -183,6 +185,7 @@ public function get_payment_fields_js_config() { 'createSetupIntentNonce' => wp_create_nonce( 'wcpay_create_setup_intent_nonce' ), 'initWooPayNonce' => wp_create_nonce( 'wcpay_init_woopay_nonce' ), 'saveUPEAppearanceNonce' => wp_create_nonce( 'wcpay_save_upe_appearance_nonce' ), + 'resetUPEAppearanceNonce' => wp_create_nonce( 'wcpay_reset_upe_appearance_nonce' ), 'genericErrorMessage' => __( 'There was a problem processing the payment. Please check your email inbox and refresh the page to try again.', 'woocommerce-payments' ), 'fraudServices' => $this->fraud_service->get_fraud_services_config(), 'features' => $this->gateway->supports, @@ -222,13 +225,13 @@ public function get_payment_fields_js_config() { $payment_fields['isCheckout'] = is_checkout(); $payment_fields['paymentMethodsConfig'] = $this->get_enabled_payment_method_config(); $payment_fields['testMode'] = WC_Payments::mode()->is_test(); - $payment_fields['upeAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_APPEARANCE_TRANSIENT ); - $payment_fields['upeAddPaymentMethodAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT ); - $payment_fields['upeBnplProductPageAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT ); - $payment_fields['upeBnplClassicCartAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT ); - $payment_fields['upeBnplCartBlockAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT ); - $payment_fields['wcBlocksUPEAppearance'] = get_transient( WC_Payment_Gateway_WCPay::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT ); - $payment_fields['wcBlocksUPEAppearanceTheme'] = get_transient( WC_Payment_Gateway_WCPay::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT ); + $payment_fields['upeAppearance'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::UPE_APPEARANCE_TRANSIENT ); + $payment_fields['upeAddPaymentMethodAppearance'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT ); + $payment_fields['upeBnplProductPageAppearance'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT ); + $payment_fields['upeBnplClassicCartAppearance'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT ); + $payment_fields['upeBnplCartBlockAppearance'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT ); + $payment_fields['wcBlocksUPEAppearance'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT ); + $payment_fields['wcBlocksUPEAppearanceTheme'] = WC_Payments_Utils::get_appearance_value( WC_Payment_Gateway_WCPay::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT ); $payment_fields['cartContainsSubscription'] = $this->gateway->is_subscription_item_in_cart(); $payment_fields['currency'] = get_woocommerce_currency(); $cart_total = ( WC()->cart ? WC()->cart->get_total( '' ) : 0 ); diff --git a/includes/class-wc-payments-utils.php b/includes/class-wc-payments-utils.php index 24608d2c898..cdfdf61cd39 100644 --- a/includes/class-wc-payments-utils.php +++ b/includes/class-wc-payments-utils.php @@ -1028,6 +1028,21 @@ public static function force_disconnected_enabled(): bool { return '1' === get_option( self::FORCE_DISCONNECTED_FLAG_NAME, '0' ); } + /** + * Gets the appearance value from the transient or the persistent option. + * + * @param string $appearance_transient The transient where the appearance is stored. + * @return array|string|false The appearance value or false if it's not set. + */ + public static function get_appearance_value( string $appearance_transient ) { + $persistent_appearance = get_option( $appearance_transient ); + if ( $persistent_appearance ) { + return $persistent_appearance; + } + + return get_transient( $appearance_transient ); + } + /** * Return the currency format based on the symbol position. * Similar to get_woocommerce_price_format but with an input. @@ -1381,7 +1396,7 @@ public static function is_store_api_request(): bool { * * @param string $location The theme location. * @param string $context The theme location to fall back to if both transients are set. - * @return string + * @return string|null */ public static function get_active_upe_theme_transient_for_location( string $location = 'checkout', string $context = 'blocks' ) { $themes = \WC_Payment_Gateway_WCPay::APPEARANCE_THEME_TRANSIENTS; @@ -1389,17 +1404,17 @@ public static function get_active_upe_theme_transient_for_location( string $loca // If an invalid location is sent, we fallback to trying $themes[ 'checkout' ][ 'block' ]. if ( ! isset( $themes[ $location ] ) ) { - $active_theme = get_transient( $themes['checkout']['blocks'] ); + $active_theme = self::get_appearance_value( $themes['checkout']['blocks'] ); } elseif ( ! isset( $themes[ $location ][ $context ] ) ) { // If the location is valid but the context is invalid, we fallback to trying $themes[ $location ][ 'block' ]. - $active_theme = get_transient( $themes[ $location ]['blocks'] ); + $active_theme = self::get_appearance_value( $themes[ $location ]['blocks'] ); } else { - $active_theme = get_transient( $themes[ $location ][ $context ] ); + $active_theme = self::get_appearance_value( $themes[ $location ][ $context ] ); } // If $active_theme is still false here, that means that $themes[ $location ][ $context ] is not set, so we try $themes[ $location ][ 'classic' ]. if ( ! $active_theme ) { - $active_theme = get_transient( $themes[ $location ][ 'blocks' === $context ? 'classic' : 'blocks' ] ); + $active_theme = self::get_appearance_value( $themes[ $location ][ 'blocks' === $context ? 'classic' : 'blocks' ] ); } // If $active_theme is still false here, nothing at the location is set so we'll try all locations. @@ -1411,7 +1426,7 @@ public static function get_active_upe_theme_transient_for_location( string $loca } foreach ( $contexts as $context => $transient ) { - $active_theme = get_transient( $transient ); + $active_theme = self::get_appearance_value( $transient ); if ( $active_theme ) { break 2; // This will break both loops. } @@ -1420,7 +1435,7 @@ public static function get_active_upe_theme_transient_for_location( string $loca } // If $active_theme is still false, we don't have any theme set in the transients, so we fallback to 'stripe'. - if ( $active_theme ) { + if ( $active_theme && is_string( $active_theme ) ) { return $active_theme; } diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 17300478794..e087e51c38d 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -742,6 +742,7 @@ function () { add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_assets_script' ] ); add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets_script' ] ); add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_cart_scripts' ] ); + add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_upe_appearance_editor_scripts' ] ); self::$duplicate_payment_prevention_service->init( self::$card_gateway, self::$order_service ); @@ -1849,6 +1850,25 @@ public static function enqueue_cart_scripts() { } } + /** + * Enqueue the UPE Appearance Editor scripts only for users with enough permissions in frontend pages. + */ + public static function enqueue_upe_appearance_editor_scripts() { + if ( is_admin() || ! current_user_can( 'manage_woocommerce' ) ) { + return; + } + + self::register_script_with_dependencies( 'WCPAY_UPE_APPEARANCE_EDITOR', 'dist/upe-appearance-editor' ); + wp_enqueue_script( 'WCPAY_UPE_APPEARANCE_EDITOR' ); + WC_Payments_Utils::enqueue_style( + 'WCPAY_UPE_APPEARANCE_EDITOR', + plugins_url( 'dist/upe-appearance-editor.css', WCPAY_PLUGIN_FILE ), + [], + self::get_file_version( 'dist/upe-appearance-editor.css' ), + 'all' + ); + } + /** * Register woopay hooks and scripts if feature is available. * diff --git a/webpack/shared.js b/webpack/shared.js index 2dce99ca3ec..740c8383cbd 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -41,6 +41,7 @@ module.exports = { 'cart-block': './client/cart/blocks/index.js', 'plugins-page': './client/plugins-page/index.js', 'frontend-tracks': './client/frontend-tracks/index.js', + 'upe-appearance-editor': './client/upe-appearance-editor/index.jsx', }, // Override webpack public path dynamically on every entry. // Required for chunks loading to work on sites with JS concatenation.