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' ) && (
+
+ ) }
+
+ { sections.includes( 'inputs' ) && (
+
+ ) }
+
+ { sections.includes( 'inputs' ) && (
+
+ ) }
+
+ { sections.includes( 'text' ) && (
+
+ ) }
+
+ { sections.includes( 'pmme' ) && (
+
+ ) }
+
+
+
+
+
+
+
+
+
+
+ );
+}
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.