Merge trunk

This commit is contained in:
Emili Castells Guasch 2025-01-21 10:44:15 +01:00
commit 6ec624d37b
77 changed files with 3278 additions and 1090 deletions

View file

@ -1,15 +1,20 @@
import { useEffect, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { OnboardingHooks, CommonHooks } from '../data';
import SpinnerOverlay from './ReusableComponents/SpinnerOverlay';
import SendOnlyMessage from './Screens/SendOnlyMessage';
import OnboardingScreen from './Screens/Onboarding';
import SettingsScreen from './Screens/Settings';
import { initStore as initSettingsStore } from '../data/settings-tab';
import { useSettingsState } from '../data/settings-tab/hooks';
// Initialize the settings store
initSettingsStore();
const SettingsApp = () => {
const onboardingProgress = OnboardingHooks.useSteps();
const { isReady: settingsIsReady } = useSettingsState();
const {
isReady: merchantIsReady,
merchant: { isSendOnlyCountry },
@ -21,41 +26,41 @@ const SettingsApp = () => {
event.stopImmediatePropagation();
return undefined;
};
window.addEventListener( 'beforeunload', suppressBeforeUnload );
return () => {
window.removeEventListener( 'beforeunload', suppressBeforeUnload );
};
}, [] );
const wrapperClass = classNames( 'ppcp-r-app', {
loading: ! onboardingProgress.isReady,
loading: ! onboardingProgress.isReady || ! settingsIsReady,
} );
const Content = useMemo( () => {
if ( ! onboardingProgress.isReady || ! merchantIsReady ) {
if (
! onboardingProgress.isReady ||
! merchantIsReady ||
! settingsIsReady
) {
return (
<SpinnerOverlay
message={ __( 'Loading…', 'woocommerce-paypal-payments' ) }
/>
);
}
if ( isSendOnlyCountry ) {
return <SendOnlyMessage />;
}
if ( ! onboardingProgress.completed ) {
return <OnboardingScreen />;
}
return <SettingsScreen />;
}, [
isSendOnlyCountry,
merchantIsReady,
onboardingProgress.completed,
onboardingProgress.isReady,
settingsIsReady,
] );
return <div className={ wrapperClass }>{ Content }</div>;

View file

@ -1,107 +1,138 @@
import { CheckboxControl } from '@wordpress/components';
import classNames from 'classnames';
export const PayPalCheckbox = ( props ) => {
let isChecked = null;
export const PayPalCheckbox = ( {
currentValue,
label,
value,
checked = null,
disabled = null,
changeCallback,
} ) => {
let isChecked = checked;
if ( Array.isArray( props.currentValue ) ) {
isChecked = props.currentValue.includes( props.value );
} else {
isChecked = props.currentValue;
if ( null === isChecked ) {
if ( Array.isArray( currentValue ) ) {
isChecked = currentValue.includes( value );
} else {
isChecked = currentValue;
}
}
const className = classNames( { 'is-disabled': disabled } );
const onChange = ( newState ) => {
let newValue;
if ( ! Array.isArray( currentValue ) ) {
newValue = newState;
} else if ( newState ) {
newValue = [ ...currentValue, value ];
} else {
newValue = currentValue.filter(
( optionValue ) => optionValue !== value
);
}
changeCallback( newValue );
};
return (
<div className="ppcp-r__checkbox">
<CheckboxControl
label={ props?.label ? props.label : '' }
value={ props.value }
checked={ isChecked }
onChange={ ( checked ) =>
handleCheckboxState( checked, props )
}
/>
</div>
<CheckboxControl
label={ label }
value={ value }
checked={ isChecked }
disabled={ disabled }
onChange={ onChange }
className={ className }
/>
);
};
export const PayPalCheckboxGroup = ( props ) => {
const renderCheckboxGroup = () => {
return props.value.map( ( checkbox ) => {
return (
<PayPalCheckbox
label={ checkbox.label }
value={ checkbox.value }
key={ checkbox.value }
currentValue={ props.currentValue }
changeCallback={ props.changeCallback }
/>
);
} );
};
export const CheckboxGroup = ( { options, value, onChange } ) => (
<>
{ options.map( ( checkbox ) => (
<PayPalCheckbox
key={ checkbox.value }
label={ checkbox.label }
value={ checkbox.value }
checked={ checkbox.checked }
disabled={ checkbox.disabled }
description={ checkbox.description }
tooltip={ checkbox.tooltip }
currentValue={ value }
changeCallback={ onChange }
/>
) ) }
</>
);
return <>{ renderCheckboxGroup() }</>;
};
export const PayPalRdb = ( props ) => {
export const PayPalRdb = ( {
id,
name,
value,
currentValue,
handleRdbState,
} ) => {
return (
<div className="ppcp-r__radio">
{ /* todo: Can we remove the wrapper div? */ }
<input
id={ props?.id }
className="ppcp-r__radio-value"
type="radio"
checked={ props.value === props.currentValue }
name={ props.name }
value={ props.value }
onChange={ () => props.handleRdbState( props.value ) }
id={ id }
checked={ value === currentValue }
name={ name }
value={ value }
onChange={ () => handleRdbState( value ) }
/>
<span className="ppcp-r__radio-presentation"></span>
</div>
);
};
export const PayPalRdbWithContent = ( props ) => {
const className = [ 'ppcp-r__radio-wrapper' ];
if ( props?.className ) {
className.push( props.className );
}
export const PayPalRdbWithContent = ( {
className,
id,
name,
label,
description,
value,
currentValue,
handleRdbState,
toggleAdditionalContent,
children,
} ) => {
const wrapperClasses = classNames( 'ppcp-r__radio-wrapper', className );
return (
<div className="ppcp-r__radio-outer-wrapper">
<div className={ className }>
<PayPalRdb { ...props } />
<div className={ wrapperClasses }>
<PayPalRdb
id={ id }
name={ name }
value={ value }
currentValue={ currentValue }
handleRdbState={ handleRdbState }
/>
<div className="ppcp-r__radio-content">
<label htmlFor={ props?.id }>{ props.label }</label>
{ props.description && (
<label htmlFor={ id }>{ label }</label>
{ description && (
<p
className="ppcp-r__radio-description"
dangerouslySetInnerHTML={ {
__html: props.description,
__html: description,
} }
/>
) }
</div>
</div>
{ props?.toggleAdditionalContent &&
props.children &&
props.value === props.currentValue && (
<div className="ppcp-r__radio-content-additional">
{ props.children }
</div>
) }
{ toggleAdditionalContent && children && value === currentValue && (
<div className="ppcp-r__radio-content-additional">
{ children }
</div>
) }
</div>
);
};
export const handleCheckboxState = ( checked, props ) => {
let newValue = null;
if ( ! Array.isArray( props.currentValue ) ) {
newValue = checked;
} else if ( checked ) {
newValue = [ ...props.currentValue, props.value ];
} else {
newValue = props.currentValue.filter(
( value ) => value !== props.value
);
}
props.changeCallback( newValue );
};

View file

@ -0,0 +1,26 @@
/**
* Temporary component, until the experimental HStack block editor component is stable.
*
* @see https://wordpress.github.io/gutenberg/?path=/docs/components-experimental-hstack--docs
* @file
*/
import classNames from 'classnames';
const HStack = ( { className, spacing = 3, children } ) => {
const wrapperClass = classNames(
'components-flex components-h-stack',
className
);
const styles = {
gap: `calc(${ 4 * spacing }px)`,
};
return (
<div className={ wrapperClass } style={ styles }>
{ children }
</div>
);
};
export default HStack;

View file

@ -1,9 +1,24 @@
import classNames from 'classnames';
// Block Elements
export const Title = ( { children, className = '' } ) => (
<span className={ `ppcp-r-settings-block__title ${ className }`.trim() }>
{ children }
</span>
);
export const Title = ( {
children,
altStyle = false,
big = false,
className = '',
} ) => {
const elementClasses = classNames(
'ppcp-r-settings-block__title',
className,
{
'style-alt': altStyle,
'style-big': big,
}
);
return <span className={ elementClasses }>{ children }</span>;
};
export const TitleWrapper = ( { children } ) => (
<span className="ppcp-r-settings-block__title-wrapper">{ children }</span>
);
@ -14,13 +29,28 @@ export const SupplementaryLabel = ( { children } ) => (
</span>
);
export const Description = ( { children, className = '' } ) => (
<span
className={ `ppcp-r-settings-block__description ${ className }`.trim() }
>
{ children }
</span>
);
export const Description = ( { children, asHtml = false, className = '' } ) => {
// Don't output anything if description is empty.
if ( ! children ) {
return null;
}
const elementClasses = classNames(
'ppcp-r-settings-block__description',
className
);
if ( ! asHtml ) {
return <span className={ elementClasses }>{ children }</span>;
}
return (
<span
className={ elementClasses }
dangerouslySetInnerHTML={ { __html: children } }
/>
);
};
export const Action = ( { children } ) => (
<div className="ppcp-r-settings-block__action">{ children }</div>
@ -33,11 +63,18 @@ export const Header = ( { children, className = '' } ) => (
);
// Card Elements
export const Content = ( { children, id = '' } ) => (
<div id={ id } className="ppcp-r-settings-card__content">
{ children }
</div>
);
export const Content = ( { children, className = '', id = '' } ) => {
const elementClasses = classNames(
'ppcp-r-settings-card__content',
className
);
return (
<div id={ id } className={ elementClasses }>
{ children }
</div>
);
};
export const ContentWrapper = ( { children } ) => (
<div className="ppcp-r-settings-card__content-wrapper">{ children }</div>

View file

@ -1,31 +1,16 @@
import { useState } from '@wordpress/element';
import ConnectionStatus from './TabSettingsElements/ConnectionStatus';
import CommonSettings from './TabSettingsElements/CommonSettings';
import ExpertSettings from './TabSettingsElements/ExpertSettings';
import { useSettings } from '../../../data/settings-tab/hooks';
const TabSettings = () => {
const [ settings, setSettings ] = useState( {
invoicePrefix: '',
authorizeOnly: false,
captureVirtualOnlyOrders: false,
savePaypalAndVenmo: false,
saveCreditCardAndDebitCard: false,
payNowExperience: false,
sandboxAccountCredentials: false,
sandboxMode: null,
sandboxEnabled: false,
sandboxClientId: '',
sandboxSecretKey: '',
sandboxConnected: false,
logging: false,
subtotalMismatchFallback: null,
brandName: '',
softDescriptor: '',
paypalLandingPage: null,
buttonLanguage: '',
} );
const { settings, setSettings } = useSettings();
const updateFormValue = ( key, value ) => {
setSettings( { ...settings, [ key ]: value } );
setSettings( {
...settings,
[ key ]: value,
} );
};
return (

View file

@ -1,336 +0,0 @@
import { __, sprintf } from '@wordpress/i18n';
import { SelectControl, RadioControl } from '@wordpress/components';
import { PayPalCheckboxGroup } from '../../ReusableComponents/Fields';
import { useState, useMemo, useEffect } from '@wordpress/element';
import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js';
import {
defaultLocationSettings,
paymentMethodOptions,
colorOptions,
shapeOptions,
buttonLayoutOptions,
buttonLabelOptions,
} from '../../../data/settings/tab-styling-data';
const TabStyling = () => {
const [ location, setLocation ] = useState( 'cart' );
const [ canRender, setCanRender ] = useState( false );
const [ locationSettings, setLocationSettings ] = useState( {
...defaultLocationSettings,
} );
// Sometimes buttons won't render. This fixes the timing problem.
useEffect( () => {
const handleDOMContentLoaded = () => setCanRender( true );
if (
document.readyState === 'interactive' ||
document.readyState === 'complete'
) {
handleDOMContentLoaded();
} else {
document.addEventListener(
'DOMContentLoaded',
handleDOMContentLoaded
);
}
}, [] );
const currentLocationSettings = useMemo( () => {
return locationSettings[ location ];
}, [ location, locationSettings ] );
const locationOptions = useMemo( () => {
return Object.keys( locationSettings ).reduce(
( locationOptionsData, key ) => {
locationOptionsData.push( {
value: locationSettings[ key ].value,
label: locationSettings[ key ].label,
} );
return locationOptionsData;
},
[]
);
}, [] );
const updateButtonSettings = ( key, value ) => {
setLocationSettings( {
...locationSettings,
[ location ]: {
...currentLocationSettings,
settings: {
...currentLocationSettings.settings,
[ key ]: value,
},
},
} );
};
const updateButtonStyle = ( key, value ) => {
setLocationSettings( {
...locationSettings,
[ location ]: {
...currentLocationSettings,
settings: {
...currentLocationSettings.settings,
style: {
...currentLocationSettings.settings.style,
[ key ]: value,
},
},
},
} );
};
if ( ! canRender ) {
return <></>;
}
return (
<div className="ppcp-r-styling">
<div className="ppcp-r-styling__settings">
<SectionIntro location={ location } />
<SectionLocations
locationOptions={ locationOptions }
location={ location }
setLocation={ setLocation }
/>
<SectionPaymentMethods
locationSettings={ currentLocationSettings }
updateButtonSettings={ updateButtonSettings }
/>
<SectionButtonLayout
locationSettings={ currentLocationSettings }
updateButtonStyle={ updateButtonStyle }
/>
<SectionButtonShape
locationSettings={ currentLocationSettings }
updateButtonStyle={ updateButtonStyle }
/>
<SectionButtonLabel
locationSettings={ currentLocationSettings }
updateButtonStyle={ updateButtonStyle }
/>
<SectionButtonColor
locationSettings={ currentLocationSettings }
updateButtonStyle={ updateButtonStyle }
/>
<SectionButtonTagline
locationSettings={ currentLocationSettings }
updateButtonStyle={ updateButtonStyle }
/>
</div>
<div className="ppcp-preview ppcp-r-button-preview ppcp-r-styling__preview">
<div className="ppcp-r-styling__preview-inner">
<SectionButtonPreview
locationSettings={ currentLocationSettings }
/>
</div>
</div>
</div>
);
};
const TabStylingSection = ( props ) => {
let sectionTitleClassName = 'ppcp-r-styling__section';
if ( props?.className ) {
sectionTitleClassName += ` ${ props.className }`;
}
return (
<div className={ sectionTitleClassName }>
<span className="ppcp-r-styling__title">{ props.title }</span>
{ props?.description && (
<p
dangerouslySetInnerHTML={ {
__html: props.description,
} }
className="ppcp-r-styling__description"
/>
) }
{ props.children }
</div>
);
};
const SectionIntro = ( { location } ) => {
const { description, descriptionLink } =
defaultLocationSettings[ location ];
const buttonStyleDescription = sprintf( description, descriptionLink );
return (
<TabStylingSection
className="ppcp-r-styling__section--rc ppcp-r-styling__section--empty"
title={ __( 'Button Styling', 'wooocommerce-paypal-payments' ) }
description={ buttonStyleDescription }
/>
);
};
const SectionLocations = ( { locationOptions, location, setLocation } ) => {
return (
<TabStylingSection className="ppcp-r-styling__section--rc">
<SelectControl
className="ppcp-r-styling__select"
value={ location }
onChange={ ( newLocation ) => setLocation( newLocation ) }
label={ __( 'Locations', 'woocommerce-paypal-payments' ) }
options={ locationOptions }
/>
</TabStylingSection>
);
};
const SectionPaymentMethods = ( {
locationSettings,
updateButtonSettings,
} ) => {
return (
<TabStylingSection
title={ __( 'Payment Methods', 'woocommerce-paypal-payments' ) }
className="ppcp-r-styling__section--rc"
>
<div className="ppcp-r-styling__payment-method-checkboxes">
<PayPalCheckboxGroup
value={ paymentMethodOptions }
changeCallback={ ( newValue ) =>
updateButtonSettings( 'paymentMethods', newValue )
}
currentValue={ locationSettings.settings.paymentMethods }
/>
</div>
</TabStylingSection>
);
};
const SectionButtonLayout = ( { locationSettings, updateButtonStyle } ) => {
const buttonLayoutIsAllowed =
locationSettings.settings.style?.layout &&
locationSettings.settings.style?.tagline === false;
return (
buttonLayoutIsAllowed && (
<TabStylingSection
className="ppcp-r-styling__section--rc"
title={ __( 'Button Layout', 'woocommerce-paypal-payments' ) }
>
<RadioControl
className="ppcp-r__horizontal-control"
onChange={ ( newValue ) =>
updateButtonStyle( 'layout', newValue )
}
selected={ locationSettings.settings.style.layout }
options={ buttonLayoutOptions }
/>
</TabStylingSection>
)
);
};
const SectionButtonShape = ( { locationSettings, updateButtonStyle } ) => {
return (
<TabStylingSection
title={ __( 'Shape', 'woocommerce-paypal-payments' ) }
className="ppcp-r-styling__section--rc"
>
<RadioControl
className="ppcp-r__horizontal-control"
onChange={ ( newValue ) =>
updateButtonStyle( 'shape', newValue )
}
selected={ locationSettings.settings.style.shape }
options={ shapeOptions }
/>
</TabStylingSection>
);
};
const SectionButtonLabel = ( { locationSettings, updateButtonStyle } ) => {
return (
<TabStylingSection>
<SelectControl
className="ppcp-r-styling__select"
onChange={ ( newValue ) =>
updateButtonStyle( 'label', newValue )
}
value={ locationSettings.settings.style.label }
label={ __( 'Button Label', 'woocommerce-paypal-payments' ) }
options={ buttonLabelOptions }
/>
</TabStylingSection>
);
};
const SectionButtonColor = ( { locationSettings, updateButtonStyle } ) => {
return (
<TabStylingSection>
<SelectControl
className=" ppcp-r-styling__select"
label={ __( 'Button Color', 'woocommerce-paypal-payments' ) }
onChange={ ( newValue ) =>
updateButtonStyle( 'color', newValue )
}
value={ locationSettings.settings.style.color }
options={ colorOptions }
/>
</TabStylingSection>
);
};
const SectionButtonTagline = ( { locationSettings, updateButtonStyle } ) => {
const taglineIsAllowed =
locationSettings.settings.style.hasOwnProperty( 'tagline' ) &&
locationSettings.settings.style?.layout === 'horizontal';
return (
taglineIsAllowed && (
<TabStylingSection
title={ __( 'Tagline', 'woocommerce-paypal-payments' ) }
className="ppcp-r-styling__section--rc"
>
<PayPalCheckboxGroup
value={ [
{
value: 'tagline',
label: __(
'Enable Tagline',
'woocommerce-paypal-payments'
),
},
] }
changeCallback={ ( newValue ) => {
updateButtonStyle( 'tagline', newValue );
} }
currentValue={ locationSettings.settings.style.tagline }
/>
</TabStylingSection>
)
);
};
const SectionButtonPreview = ( { locationSettings } ) => {
return (
<PayPalScriptProvider
options={ {
clientId: 'test',
merchantId: 'QTQX5NP6N9WZU',
components: 'buttons,googlepay',
'disable-funding': 'card',
'buyer-country': 'US',
currency: 'USD',
} }
>
<PayPalButtons
style={ locationSettings.settings.style }
forceReRender={ [ locationSettings.settings.style ] }
>
Error
</PayPalButtons>
</PayPalScriptProvider>
);
};
export default TabStyling;

View file

@ -1,16 +1,34 @@
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { CommonHooks, StylingHooks } from '../../../../data';
import TopNavigation from '../../../ReusableComponents/TopNavigation';
import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
const SettingsNavigation = () => {
const { withActivity } = CommonHooks.useBusyState();
// Todo: Implement other stores here.
const { persist: persistStyling } = StylingHooks.useStore();
const handleSaveClick = () => {
// Todo: Add other stores here.
withActivity(
'persist-styling',
'Save styling details',
persistStyling
);
};
const title = __( 'PayPal Payments', 'woocommerce-paypal-payments' );
return (
<TopNavigation title={ title } exitOnTitleClick={ true }>
<Button variant="primary" disabled={ false }>
{ __( 'Save', 'woocommerce-paypal-payments' ) }
</Button>
<BusyStateWrapper>
<Button variant="primary" onClick={ handleSaveClick }>
{ __( 'Save', 'woocommerce-paypal-payments' ) }
</Button>
</BusyStateWrapper>
</TopNavigation>
);
};

View file

@ -0,0 +1,20 @@
import { __ } from '@wordpress/i18n';
import { StylingHooks } from '../../../../../../data';
import { SelectStylingSection } from '../Layout';
const SectionButtonColor = ( { location } ) => {
const { color, setColor, choices } = StylingHooks.useColorProps( location );
return (
<SelectStylingSection
title={ __( 'Button Color', 'woocommerce-paypal-payments' ) }
className="button-color"
options={ choices }
value={ color }
onChange={ setColor }
/>
);
};
export default SectionButtonColor;

View file

@ -0,0 +1,20 @@
import { __ } from '@wordpress/i18n';
import { StylingHooks } from '../../../../../../data';
import { SelectStylingSection } from '../Layout';
const SectionButtonLabel = ( { location } ) => {
const { label, setLabel, choices } = StylingHooks.useLabelProps( location );
return (
<SelectStylingSection
title={ __( 'Button Label', 'woocommerce-paypal-payments' ) }
className="button-label"
options={ choices }
value={ label }
onChange={ setLabel }
/>
);
};
export default SectionButtonLabel;

View file

@ -0,0 +1,29 @@
import { __ } from '@wordpress/i18n';
import { StylingHooks } from '../../../../../../data';
import { RadiobuttonStylingSection } from '../Layout';
import { Tagline } from './index';
const SectionButtonLayout = ( { location } ) => {
const { isAvailable, layout, setLayout, choices } =
StylingHooks.useLayoutProps( location );
if ( ! isAvailable ) {
return null;
}
return (
<>
<RadiobuttonStylingSection
className="button-layout"
title={ __( 'Button Layout', 'woocommerce-paypal-payments' ) }
options={ choices }
selected={ layout }
onChange={ setLayout }
/>
<Tagline location={ location } />
</>
);
};
export default SectionButtonLayout;

View file

@ -0,0 +1,20 @@
import { __ } from '@wordpress/i18n';
import { StylingHooks } from '../../../../../../data';
import { RadiobuttonStylingSection } from '../Layout';
const SectionButtonShape = ( { location } ) => {
const { shape, setShape, choices } = StylingHooks.useShapeProps( location );
return (
<RadiobuttonStylingSection
title={ __( 'Shape', 'woocommerce-paypal-payments' ) }
className="button-shape"
options={ choices }
selected={ shape }
onChange={ setShape }
/>
);
};
export default SectionButtonShape;

View file

@ -0,0 +1,62 @@
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { help } from '@wordpress/icons';
import { StylingHooks } from '../../../../../../data';
import {
SelectStylingSection,
StylingSection,
CheckboxStylingSection,
} from '../Layout';
const LocationSelector = ( { location, setLocation } ) => {
const { choices, details, isActive, setActive } =
StylingHooks.useLocationProps( location );
const activateCheckbox = {
value: 'active',
label: __(
'Enable payment methods in this location',
'woocommerce-paypal-payments'
),
};
return (
<>
<StylingSection
className="header-section"
bigTitle={ true }
title={ __( 'Button Styling', 'wooocommerce-paypal-payments' ) }
description={ __(
'Customize the appearance of the PayPal smart buttons on your website and choose which payment buttons to display.',
'woocommerce-paypal-payments'
) }
/>
<SelectStylingSection
className="location-selector"
title={ __( 'Location', 'woocommerce-paypal-payments' ) }
separatorAndGap={ false }
options={ choices }
value={ location }
onChange={ setLocation }
>
{ details.link && (
<Button
icon={ help }
href={ details.link }
target="_blank"
/>
) }
</SelectStylingSection>
<CheckboxStylingSection
className="location-activation"
separatorAndGap={ false }
options={ [ activateCheckbox ] }
value={ isActive }
onChange={ setActive }
/>
</>
);
};
export default LocationSelector;

View file

@ -0,0 +1,21 @@
import { __ } from '@wordpress/i18n';
import { StylingHooks } from '../../../../../../data';
import { CheckboxStylingSection } from '../Layout';
const SectionPaymentMethods = ( { location } ) => {
const { paymentMethods, setPaymentMethods, choices } =
StylingHooks.usePaymentMethodProps( location );
return (
<CheckboxStylingSection
title={ __( 'Payment Methods', 'woocommerce-paypal-payments' ) }
className="payment-methods"
options={ choices }
value={ paymentMethods }
onChange={ setPaymentMethods }
/>
);
};
export default SectionPaymentMethods;

View file

@ -0,0 +1,33 @@
import { __ } from '@wordpress/i18n';
import { StylingHooks } from '../../../../../../data';
import { CheckboxStylingSection } from '../Layout';
const SectionTagline = ( { location } ) => {
const { isAvailable, tagline, setTagline } =
StylingHooks.useTaglineProps( location );
if ( ! isAvailable ) {
return null;
}
const checkbox = {
value: 'active',
label: __(
'Show tagline below buttons',
'woocommerce-paypal-payments'
),
};
return (
<CheckboxStylingSection
className="tagline"
separatorAndGap={ false }
options={ [ checkbox ] }
value={ tagline }
onChange={ setTagline }
/>
);
};
export default SectionTagline;

View file

@ -0,0 +1,7 @@
export { default as LocationSelector } from './LocationSelector';
export { default as ButtonColor } from './ButtonColor';
export { default as ButtonLabel } from './ButtonLabel';
export { default as ButtonLayout } from './ButtonLayout';
export { default as ButtonShape } from './ButtonShape';
export { default as PaymentMethods } from './PaymentMethods';
export { default as Tagline } from './Tagline';

View file

@ -0,0 +1,34 @@
import SettingsBlock from '../../../../../ReusableComponents/SettingsBlocks/SettingsBlock';
import {
Description,
Header,
Title,
Content,
} from '../../../../../ReusableComponents/SettingsBlocks';
const StylingSection = ( {
title,
bigTitle = false,
className = '',
description = '',
separatorAndGap = true,
children,
} ) => {
return (
<SettingsBlock
className={ className }
separatorAndGap={ separatorAndGap }
>
<Header>
<Title altStyle={ true } big={ bigTitle }>
{ title }
</Title>
<Description>{ description }</Description>
</Header>
<Content className="section-content">{ children }</Content>
</SettingsBlock>
);
};
export default StylingSection;

View file

@ -0,0 +1,39 @@
import classNames from 'classnames';
import { CheckboxGroup } from '../../../../../ReusableComponents/Fields';
import HStack from '../../../../../ReusableComponents/HStack';
import StylingSection from './StylingSection';
const StylingSectionWithCheckboxes = ( {
title,
className = '',
description = '',
separatorAndGap = true,
options,
value,
onChange,
children,
} ) => {
className = classNames( 'has-checkboxes', className );
return (
<StylingSection
title={ title }
className={ className }
description={ description }
separatorAndGap={ separatorAndGap }
>
<HStack spacing={ 6 }>
<CheckboxGroup
options={ options }
value={ value }
onChange={ onChange }
/>
</HStack>
{ children }
</StylingSection>
);
};
export default StylingSectionWithCheckboxes;

View file

@ -0,0 +1,39 @@
import { RadioControl } from '@wordpress/components';
import classNames from 'classnames';
import HStack from '../../../../../ReusableComponents/HStack';
import StylingSection from './StylingSection';
const StylingSectionWithRadiobuttons = ( {
title,
className = '',
description = '',
separatorAndGap = true,
options,
selected,
onChange,
children,
} ) => {
className = classNames( 'has-radio-buttons', className );
return (
<StylingSection
title={ title }
className={ className }
description={ description }
separatorAndGap={ separatorAndGap }
>
<HStack>
<RadioControl
options={ options }
selected={ selected }
onChange={ onChange }
/>
</HStack>
{ children }
</StylingSection>
);
};
export default StylingSectionWithRadiobuttons;

View file

@ -0,0 +1,37 @@
import { SelectControl } from '@wordpress/components';
import classNames from 'classnames';
import StylingSection from './StylingSection';
const StylingSectionWithSelect = ( {
title,
className = '',
description = '',
separatorAndGap = true,
options,
value,
onChange,
children,
} ) => {
className = classNames( 'has-select', className );
return (
<StylingSection
title={ title }
className={ className }
description={ description }
separatorAndGap={ separatorAndGap }
>
<SelectControl
__nextHasNoMarginBottom
options={ options }
value={ value }
onChange={ onChange }
/>
{ children }
</StylingSection>
);
};
export default StylingSectionWithSelect;

View file

@ -0,0 +1,4 @@
export { default as StylingSection } from './StylingSection';
export { default as CheckboxStylingSection } from './StylingSectionWithCheckboxes';
export { default as RadiobuttonStylingSection } from './StylingSectionWithRadiobuttons';
export { default as SelectStylingSection } from './StylingSectionWithSelect';

View file

@ -0,0 +1,64 @@
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
import { STYLING_PAYMENT_METHODS, StylingHooks } from '../../../../../data';
import { useMemo } from '@wordpress/element';
const PREVIEW_CLIENT_ID = 'test';
const PREVIEW_MERCHANT_ID = 'QTQX5NP6N9WZU';
const PreviewPanel = ( { location } ) => {
const { paymentMethods } = StylingHooks.usePaymentMethodProps( location );
const { layout } = StylingHooks.useLayoutProps( location );
const { shape } = StylingHooks.useShapeProps( location );
const { label } = StylingHooks.useLabelProps( location );
const { color } = StylingHooks.useColorProps( location );
const { tagline } = StylingHooks.useTaglineProps( location );
const style = useMemo(
() => ( {
layout,
shape,
label,
color,
tagline,
} ),
[ color, label, layout, shape, tagline ]
);
const disableFunding = useMemo( () => {
const disabled = [ 'card' ];
Object.values( STYLING_PAYMENT_METHODS )
.filter( ( method ) => method.isFunding )
.filter( ( method ) => ! paymentMethods.includes( method.value ) )
.forEach( ( method ) => {
disabled.push( method.value );
} );
return disabled;
}, [ paymentMethods ] );
// TODO: Changes in the providerOptions are not reflected on the page.
const providerOptions = useMemo(
() => ( {
clientId: PREVIEW_CLIENT_ID,
merchantId: PREVIEW_MERCHANT_ID,
components: 'buttons',
'disable-funding': disableFunding.join( ',' ),
'buyer-country': 'US', // Todo: simulate shop country here?
currency: 'USD',
} ),
[ disableFunding ]
);
return (
<div className="preview-panel">
<div className="preview-panel-inner">
<PayPalScriptProvider options={ providerOptions }>
<PayPalButtons style={ style } forceReRender={ [ style ] }>
Error
</PayPalButtons>
</PayPalScriptProvider>
</div>
</div>
);
};
export default PreviewPanel;

View file

@ -0,0 +1,41 @@
import {
LocationSelector,
PaymentMethods,
ButtonLayout,
ButtonShape,
ButtonLabel,
ButtonColor,
} from './Content';
import { StylingHooks } from '../../../../../data';
const SettingsPanel = ( { location, setLocation } ) => {
const { isActive } = StylingHooks.useLocationProps( location );
const LocationDetails = () => {
if ( ! isActive ) {
return null;
}
return (
<>
<PaymentMethods location={ location } />
<ButtonLayout location={ location } />
<ButtonShape location={ location } />
<ButtonLabel location={ location } />
<ButtonColor location={ location } />
</>
);
};
return (
<div className="settings-panel">
<LocationSelector
location={ location }
setLocation={ setLocation }
/>
<LocationDetails />
</div>
);
};
export default SettingsPanel;

View file

@ -0,0 +1,16 @@
import { StylingHooks } from '../../../../data';
import PreviewPanel from '../Components/Styling/PreviewPanel';
import SettingsPanel from '../Components/Styling/SettingsPanel';
const TabStyling = () => {
const { location, setLocation } = StylingHooks.useStylingLocation();
return (
<div className="ppcp-r-styling">
<SettingsPanel location={ location } setLocation={ setLocation } />
<PreviewPanel location={ location } />
</div>
);
};
export default TabStyling;

View file

@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
import TabOverview from '../../Overview/TabOverview';
import TabPaymentMethods from '../../Overview/TabPaymentMethods';
import TabSettings from '../../Overview/TabSettings';
import TabStyling from '../../Overview/TabStyling';
import TabStyling from './TabStyling';
import TabPayLaterMessaging from '../../Overview/TabPayLaterMessaging';
/**

View file

@ -36,28 +36,37 @@ export const hydrate = ( payload ) => ( {
payload,
} );
/**
* Generic transient-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setTransient = ( prop, value ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { [ prop ]: value },
} );
/**
* Generic persistent-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
} );
/**
* Transient. Marks the store as "ready", i.e., fully initialized.
*
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isReady },
} );
/**
* Persistent. Sets a sample value.
* TODO: Replace with a real action/property.
*
* @param {string} value
* @return {Action} The action.
*/
export const setSampleValue = ( value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { sampleValue: value },
} );
export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
/**
* Side effect. Triggers the persistence of store data to the server.

View file

@ -7,39 +7,24 @@
* @file
*/
import { useSelect, useDispatch } from '@wordpress/data';
import { useDispatch } from '@wordpress/data';
import { createHooksForStore } from '../utils';
import { STORE_NAME } from './constants';
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
);
const usePersistent = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const useHooks = () => {
const {
persist,
// TODO: Replace with real property.
setSampleValue,
} = useDispatch( STORE_NAME );
const { useTransient, usePersistent } = createHooksForStore( STORE_NAME );
const { persist } = useDispatch( STORE_NAME );
// Read-only flags and derived state.
// Nothing here yet.
// Transient accessors.
const isReady = useTransient( 'isReady' );
const [ isReady ] = useTransient( 'isReady' );
// Persistent accessors.
// TODO: Replace with real property.
const sampleValue = usePersistent( 'sampleValue' );
const [ sampleValue, setSampleValue ] = usePersistent( 'sampleValue' );
return {
persist,

View file

@ -36,16 +36,37 @@ export const hydrate = ( payload ) => ( {
payload,
} );
/**
* Generic transient-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setTransient = ( prop, value ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { [ prop ]: value },
} );
/**
* Generic persistent-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
} );
/**
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
*
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isReady },
} );
export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
/**
* Transient. Sets the active settings tab.
@ -53,21 +74,8 @@ export const setIsReady = ( isReady ) => ( {
* @param {string} activeModal
* @return {Action} The action.
*/
export const setActiveModal = ( activeModal ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { activeModal },
} );
/**
* Transient. Changes the "saving" flag.
*
* @param {boolean} isSaving
* @return {Action} The action.
*/
export const setIsSaving = ( isSaving ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isSaving },
} );
export const setActiveModal = ( activeModal ) =>
setTransient( 'activeModal', activeModal );
/**
* Transient (Activity): Marks the start of an async activity
@ -107,10 +115,8 @@ export const stopActivity = ( id ) => ( {
* @param {boolean} useSandbox
* @return {Action} The action.
*/
export const setSandboxMode = ( useSandbox ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { useSandbox },
} );
export const setSandboxMode = ( useSandbox ) =>
setPersistent( 'useSandbox', useSandbox );
/**
* Persistent. Toggles the "Manual Connection" mode on or off.
@ -118,10 +124,8 @@ export const setSandboxMode = ( useSandbox ) => ( {
* @param {boolean} useManualConnection
* @return {Action} The action.
*/
export const setManualConnectionMode = ( useManualConnection ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { useManualConnection },
} );
export const setManualConnectionMode = ( useManualConnection ) =>
setPersistent( 'useManualConnection', useManualConnection );
/**
* Side effect. Saves the persistent details to the WP database.

View file

@ -9,41 +9,31 @@
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { createHooksForStore } from '../utils';
import { STORE_NAME } from './constants';
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
);
const usePersistent = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const useHooks = () => {
const { useTransient, usePersistent } = createHooksForStore( STORE_NAME );
const {
persist,
setSandboxMode,
setManualConnectionMode,
sandboxOnboardingUrl,
productionOnboardingUrl,
authenticateWithCredentials,
authenticateWithOAuth,
setActiveModal,
startWebhookSimulation,
checkWebhookSimulationState,
} = useDispatch( STORE_NAME );
// Transient accessors.
const isReady = useTransient( 'isReady' );
const activeModal = useTransient( 'activeModal' );
const [ isReady ] = useTransient( 'isReady' );
const [ activeModal, setActiveModal ] = useTransient( 'activeModal' );
// Persistent accessors.
const isSandboxMode = usePersistent( 'useSandbox' );
const isManualConnectionMode = usePersistent( 'useManualConnection' );
const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' );
const [ isManualConnectionMode, setManualConnectionMode ] = usePersistent(
'useManualConnection'
);
const merchant = useSelect(
( select ) => select( STORE_NAME ).merchant(),
[]

View file

@ -0,0 +1,10 @@
export { BUSINESS_TYPES, PRODUCT_TYPES } from './onboarding/configuration';
export {
STYLING_LOCATIONS,
STYLING_PAYMENT_METHODS,
STYLING_LABELS,
STYLING_COLORS,
STYLING_LAYOUTS,
STYLING_SHAPES,
} from './styling/configuration';

View file

@ -1,20 +1,24 @@
import { addDebugTools } from './debug';
import * as Onboarding from './onboarding';
import * as Common from './common';
import * as Styling from './styling';
import * as Payment from './payment';
Onboarding.initStore();
Common.initStore();
Payment.initStore();
Styling.initStore();
export const OnboardingHooks = Onboarding.hooks;
export const CommonHooks = Common.hooks;
export const PaymentHooks = Payment.hooks;
export const StylingHooks = Styling.hooks;
export const OnboardingStoreName = Onboarding.STORE_NAME;
export const CommonStoreName = Common.STORE_NAME;
export const PaymentStoreName = Payment.STORE_NAME;
export const StylingStoreName = Styling.STORE_NAME;
export * from './constants';
export * from './configuration';
addDebugTools( window.ppcpSettings, [ Onboarding, Common, Payment ] );
addDebugTools( window.ppcpSettings, [ Onboarding, Common, Payment, Styling ] );

View file

@ -36,16 +36,37 @@ export const hydrate = ( payload ) => ( {
payload,
} );
/**
* Generic transient-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setTransient = ( prop, value ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { [ prop ]: value },
} );
/**
* Generic persistent-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
} );
/**
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
*
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isReady },
} );
export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
/**
* Transient. Sets the "manualClientId" value.
@ -53,10 +74,8 @@ export const setIsReady = ( isReady ) => ( {
* @param {string} manualClientId
* @return {Action} The action.
*/
export const setManualClientId = ( manualClientId ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { manualClientId },
} );
export const setManualClientId = ( manualClientId ) =>
setTransient( 'manualClientId', manualClientId );
/**
* Transient. Sets the "manualClientSecret" value.
@ -64,10 +83,8 @@ export const setManualClientId = ( manualClientId ) => ( {
* @param {string} manualClientSecret
* @return {Action} The action.
*/
export const setManualClientSecret = ( manualClientSecret ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { manualClientSecret },
} );
export const setManualClientSecret = ( manualClientSecret ) =>
setTransient( 'manualClientSecret', manualClientSecret );
/**
* Persistent.Set the "onboarding completed" flag which shows or hides the wizard.
@ -75,10 +92,8 @@ export const setManualClientSecret = ( manualClientSecret ) => ( {
* @param {boolean} completed
* @return {Action} The action.
*/
export const setCompleted = ( completed ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { completed },
} );
export const setCompleted = ( completed ) =>
setPersistent( 'completed', completed );
/**
* Persistent. Sets the onboarding wizard to a new step.
@ -86,10 +101,7 @@ export const setCompleted = ( completed ) => ( {
* @param {number} step
* @return {Action} The action.
*/
export const setStep = ( step ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { step },
} );
export const setStep = ( step ) => setPersistent( 'step', step );
/**
* Persistent. Sets the "isCasualSeller" value.
@ -97,10 +109,8 @@ export const setStep = ( step ) => ( {
* @param {boolean} isCasualSeller
* @return {Action} The action.
*/
export const setIsCasualSeller = ( isCasualSeller ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { isCasualSeller },
} );
export const setIsCasualSeller = ( isCasualSeller ) =>
setPersistent( 'isCasualSeller', isCasualSeller );
/**
* Persistent. Sets the "areOptionalPaymentMethodsEnabled" value.
@ -110,10 +120,11 @@ export const setIsCasualSeller = ( isCasualSeller ) => ( {
*/
export const setAreOptionalPaymentMethodsEnabled = (
areOptionalPaymentMethodsEnabled
) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { areOptionalPaymentMethodsEnabled },
} );
) =>
setPersistent(
'areOptionalPaymentMethodsEnabled',
areOptionalPaymentMethodsEnabled
);
/**
* Persistent. Sets the "products" array.
@ -121,10 +132,8 @@ export const setAreOptionalPaymentMethodsEnabled = (
* @param {string[]} products
* @return {Action} The action.
*/
export const setProducts = ( products ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { products },
} );
export const setProducts = ( products ) =>
setPersistent( 'products', products );
/**
* Side effect. Triggers the persistence of onboarding data to the server.

View file

@ -1,8 +1,24 @@
/**
* Configuration for UI components.
*
* @file
*/
/**
* Onboarding options for StepBusiness
*
* @type {Object}
*/
export const BUSINESS_TYPES = {
CASUAL_SELLER: 'casual_seller',
BUSINESS: 'business',
};
/**
* Onboarding options for StepProducts
*
* @type {Object}
*/
export const PRODUCT_TYPES = {
VIRTUAL: 'virtual',
PHYSICAL: 'physical',

View file

@ -8,7 +8,7 @@
export const STORE_NAME = 'wc/paypal/onboarding';
/**
* REST path to hydrate data of this module by loading data from the WP DB..
* REST path to hydrate data of this module by loading data from the WP DB.
*
* Used by: Resolvers
* See: OnboardingRestEndpoint.php

View file

@ -9,32 +9,14 @@
import { useSelect, useDispatch } from '@wordpress/data';
import { PRODUCT_TYPES } from '../constants';
import { createHooksForStore } from '../utils';
import { PRODUCT_TYPES } from './configuration';
import { STORE_NAME } from './constants';
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
);
const usePersistent = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const useHooks = () => {
const {
persist,
setStep,
setCompleted,
setIsCasualSeller,
setManualClientId,
setManualClientSecret,
setAreOptionalPaymentMethodsEnabled,
setProducts,
} = useDispatch( STORE_NAME );
const { useTransient, usePersistent } = createHooksForStore( STORE_NAME );
const { persist } = useDispatch( STORE_NAME );
// Read-only flags and derived state.
const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] );
@ -44,18 +26,22 @@ const useHooks = () => {
);
// Transient accessors.
const isReady = useTransient( 'isReady' );
const manualClientId = useTransient( 'manualClientId' );
const manualClientSecret = useTransient( 'manualClientSecret' );
const [ isReady ] = useTransient( 'isReady' );
const [ manualClientId, setManualClientId ] =
useTransient( 'manualClientId' );
const [ manualClientSecret, setManualClientSecret ] =
useTransient( 'manualClientSecret' );
// Persistent accessors.
const step = usePersistent( 'step' );
const completed = usePersistent( 'completed' );
const isCasualSeller = usePersistent( 'isCasualSeller' );
const areOptionalPaymentMethodsEnabled = usePersistent(
'areOptionalPaymentMethodsEnabled'
);
const products = usePersistent( 'products' );
const [ step, setStep ] = usePersistent( 'step' );
const [ completed, setCompleted ] = usePersistent( 'completed' );
const [ isCasualSeller, setIsCasualSeller ] =
usePersistent( 'isCasualSeller' );
const [
areOptionalPaymentMethodsEnabled,
setAreOptionalPaymentMethodsEnabled,
] = usePersistent( 'areOptionalPaymentMethodsEnabled' );
const [ products, setProducts ] = usePersistent( 'products' );
const savePersistent = async ( setter, value ) => {
setter( value );

View file

@ -0,0 +1,40 @@
/**
* Settings action types
*
* Defines the constants used for dispatching actions in the settings store.
* Each constant represents a unique action type that can be handled by reducers.
*
* @file
*/
export default {
/**
* Represents setting transient (temporary) state data.
* These values are not persisted and will reset on page reload.
*/
SET_TRANSIENT: 'ppcp/settings/SET_TRANSIENT',
/**
* Represents setting persistent state data.
* These values are meant to be saved to the server and persist between page loads.
*/
SET_PERSISTENT: 'ppcp/settings/SET_PERSISTENT',
/**
* Resets the store state to its initial values.
* Used when needing to clear all settings data.
*/
RESET: 'ppcp/settings/RESET',
/**
* Initializes the store with data, typically used during store initialization
* to set up the initial state with data from the server.
*/
HYDRATE: 'ppcp/settings/HYDRATE',
/**
* Triggers the persistence of store data to the server.
* Used when changes need to be saved to the backend.
*/
DO_PERSIST_DATA: 'ppcp/settings/DO_PERSIST_DATA',
};

View file

@ -0,0 +1,71 @@
/**
* Action Creators: Define functions to create action objects.
*
* These functions update state or trigger side effects (e.g., async operations).
* Actions are categorized as Transient, Persistent, or Side effect.
*
* @file
*/
import { select } from '@wordpress/data';
import ACTION_TYPES from './action-types';
import { STORE_NAME } from './constants';
/**
* @typedef {Object} Action An action object that is handled by a reducer or control.
* @property {string} type - The action type.
* @property {Object?} payload - Optional payload for the action.
*/
/**
* Special. Resets all values in the store to initial defaults.
*
* @return {Action} The action.
*/
export const reset = () => ( {
type: ACTION_TYPES.RESET,
} );
/**
* Persistent. Sets the full store details during app initialization.
*
* @param {Object} payload Initial store data
* @return {Action} The action.
*/
export const hydrate = ( payload ) => ( {
type: ACTION_TYPES.HYDRATE,
payload,
} );
/**
* Transient. Marks the store as "ready", i.e., fully initialized.
*
* @param {boolean} isReady Whether the store is ready
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isReady },
} );
/**
* Persistent. Updates the settings data in the store.
*
* @param {Object} settings The settings object to store
* @return {Action} The action.
*/
export const setSettings = ( settings ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: settings,
} );
/**
* Side effect. Triggers the persistence of store data to the server.
* Yields an action with the current persistent data to be saved.
*
* @return {Action} The action.
*/
export const persist = function* () {
const data = yield select( STORE_NAME ).persistentData();
yield { type: ACTION_TYPES.DO_PERSIST_DATA, data };
};

View file

@ -0,0 +1,28 @@
/**
* Name of the Redux store module.
*
* Used by: Reducer, Selector, Index
*
* @type {string}
*/
export const STORE_NAME = 'wc/paypal/settings';
/**
* REST path to hydrate data of this module by loading data from the WP DB.
*
* Used by: Resolvers
* See: SettingsRestEndpoint.php
*
* @type {string}
*/
export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/settings';
/**
* REST path to persist data of this module to the WP DB.
*
* Used by: Controls
* See: SettingsRestEndpoint.php
*
* @type {string}
*/
export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/settings';

View file

@ -0,0 +1,34 @@
/**
* Controls: Implement side effects, typically asynchronous operations.
*
* Controls use ACTION_TYPES keys as identifiers.
* They are triggered by corresponding actions and handle external interactions.
*
* @file
*/
import apiFetch from '@wordpress/api-fetch';
import { REST_PERSIST_PATH } from './constants';
import ACTION_TYPES from './action-types';
/**
* Control handlers for settings store actions.
* Each handler maps to an ACTION_TYPE and performs the corresponding async operation.
*/
export const controls = {
/**
* Persists settings data to the server via REST API.
* Triggered by the DO_PERSIST_DATA action to save settings changes.
*
* @param {Object} action The action object
* @param {Object} action.data The settings data to persist
* @return {Promise<Object>} The API response
*/
async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) {
return await apiFetch( {
path: REST_PERSIST_PATH,
method: 'POST',
data,
} );
},
};

View file

@ -0,0 +1,55 @@
/**
* Hooks: Provide the main API for components to interact with the store.
*
* These encapsulate store interactions, offering a consistent interface.
* Hooks simplify data access and manipulation for components.
*
* @file
*/
import { useSelect, useDispatch } from '@wordpress/data';
import { STORE_NAME } from './constants';
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
);
const usePersistent = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const useHooks = () => {
const { persist, setSettings } = useDispatch( STORE_NAME );
// Read-only flags and derived state.
const isReady = useTransient( 'isReady' );
// Persistent accessors.
const settings = useSelect(
( select ) => select( STORE_NAME ).persistentData(),
[]
);
return {
persist,
isReady,
settings,
setSettings,
};
};
export const useSettingsState = () => {
const { persist, isReady } = useHooks();
return { persist, isReady };
};
export const useSettings = () => {
const { settings, setSettings } = useHooks();
return {
settings,
setSettings,
};
};

View file

@ -0,0 +1,51 @@
/**
* Store Configuration: Defines and registers the settings data store.
*
* Creates a Redux-style store with WordPress data layer integration.
* Combines reducers, actions, selectors and controls into a unified store.
*
* @file
*/
import { createReduxStore, register } from '@wordpress/data';
import { controls as wpControls } from '@wordpress/data-controls';
import { STORE_NAME } from './constants';
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
import * as hooks from './hooks';
import { resolvers } from './resolvers';
import { controls } from './controls';
/**
* Initializes and registers the settings store with WordPress data layer.
* Combines custom controls with WordPress data controls.
*
* @return {boolean} True if initialization succeeded, false otherwise.
*/
export const initStore = () => {
try {
const store = createReduxStore( STORE_NAME, {
reducer,
controls: { ...wpControls, ...controls },
actions,
selectors,
resolvers,
} );
register( store );
// Verify store registration
const isStoreRegistered = Boolean( wp.data.select( STORE_NAME ) );
if ( ! isStoreRegistered ) {
console.error( 'Store registration verification failed' );
return false;
}
return true;
} catch ( error ) {
console.error( 'Failed to initialize settings store:', error );
return false;
}
};
export { hooks, selectors, STORE_NAME };

View file

@ -0,0 +1,107 @@
/**
* Reducer: Defines store structure and state updates for this module.
*
* Manages both transient (temporary) and persistent (saved) state.
* The initial state must define all properties, as dynamic additions are not supported.
*
* @file
*/
import { createReducer, createSetters } from '../utils';
import ACTION_TYPES from './action-types';
// Store structure.
/**
* Transient: Values that are _not_ saved to the DB (like app lifecycle-flags).
* These reset on page reload.
*/
const defaultTransient = Object.freeze( {
isReady: false,
} );
/**
* Persistent: Values that are loaded from and saved to the DB.
* These represent the core PayPal payment settings configuration.
*/
const defaultPersistent = Object.freeze( {
invoicePrefix: '', // Prefix for PayPal invoice IDs
authorizeOnly: false, // Whether to only authorize payments initially
captureVirtualOnlyOrders: false, // Auto-capture virtual-only orders
savePaypalAndVenmo: false, // Enable PayPal & Venmo vaulting
saveCreditCardAndDebitCard: false, // Enable card vaulting
payNowExperience: false, // Enable Pay Now experience
sandboxAccountCredentials: false, // Use sandbox credentials
sandboxMode: null, // Sandbox mode configuration
sandboxEnabled: false, // Whether sandbox mode is active
sandboxClientId: '', // Sandbox API client ID
sandboxSecretKey: '', // Sandbox API secret key
sandboxConnected: false, // Sandbox connection status
logging: false, // Enable debug logging
subtotalMismatchFallback: null, // Handling for subtotal mismatches
brandName: '', // Merchant brand name for PayPal
softDescriptor: '', // Payment descriptor on statements
paypalLandingPage: null, // PayPal checkout landing page
buttonLanguage: '', // Language for PayPal buttons
} );
// Reducer logic.
const [ setTransient, setPersistent ] = createSetters(
defaultTransient,
defaultPersistent
);
/**
* Reducer implementation mapping actions to state updates.
*/
const reducer = createReducer( defaultTransient, defaultPersistent, {
/**
* Updates temporary state values
*
* @param {Object} state Current state
* @param {Object} payload Update payload
* @return {Object} Updated state
*/
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) => {
return setTransient( state, payload );
},
/**
* Updates persistent configuration values
*
* @param {Object} state Current state
* @param {Object} payload Update payload
* @return {Object} Updated state
*/
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
setPersistent( state, payload ),
/**
* Resets state to defaults while maintaining initialization status
*
* @param {Object} state Current state
* @return {Object} Reset state
*/
[ ACTION_TYPES.RESET ]: ( state ) => {
const cleanState = setTransient(
setPersistent( state, defaultPersistent ),
defaultTransient
);
cleanState.isReady = true; // Keep initialization flag
return cleanState;
},
/**
* Initializes persistent state with data from the server
*
* @param {Object} state Current state
* @param {Object} payload Hydration payload containing server data
* @param {Object} payload.data The settings data to hydrate
* @return {Object} Hydrated state
*/
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) =>
setPersistent( state, payload.data ),
} );
export default reducer;

View file

@ -0,0 +1,57 @@
/**
* Resolvers: Handle asynchronous data fetching for the store.
*
* These functions update store state with data from external sources.
* Each resolver corresponds to a specific selector (selector with same name must exist).
* Resolvers are called automatically when selectors request unavailable data.
*
* @file
*/
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls';
import { STORE_NAME, REST_HYDRATE_PATH } from './constants';
export const resolvers = {
/**
* Retrieve PayPal settings from the site's REST API.
* Hydrates the store with the retrieved data and marks it as ready.
*
* @generator
* @yield {Object} API fetch and dispatch actions
*/
*persistentData() {
try {
// Fetch settings data from REST API
const result = yield apiFetch( {
path: REST_HYDRATE_PATH,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
} );
// Update store with retrieved data
yield dispatch( STORE_NAME ).hydrate( result );
// Mark store as ready for use
yield dispatch( STORE_NAME ).setIsReady( true );
} catch ( e ) {
// Log detailed error information for debugging
console.error( 'Full error details:', {
error: e,
path: REST_HYDRATE_PATH,
store: STORE_NAME,
} );
// Display user-friendly error notice
yield dispatch( 'core/notices' ).createErrorNotice(
__(
'Error retrieving PayPal settings details.',
'woocommerce-paypal-payments'
)
);
}
},
};

View file

@ -0,0 +1,46 @@
/**
* Selectors: Extract specific pieces of state from the store.
*
* These functions provide a consistent interface for accessing store data.
* They allow components to retrieve data without knowing the store structure.
*
* @file
*/
/**
* Empty frozen object used as fallback when state is undefined.
*
* @constant
* @type {Object}
*/
const EMPTY_OBJ = Object.freeze( {} );
/**
* Base selector that ensures a valid state object.
*
* @param {Object|undefined} state The current state
* @return {Object} The state or empty object if undefined
*/
export const getState = ( state ) => state || EMPTY_OBJ;
/**
* Retrieves persistent (saved) data from the store.
*
* @param {Object} state The current state
* @return {Object} The persistent data or empty object if undefined
*/
export const persistentData = ( state ) => {
return getState( state ).data || EMPTY_OBJ;
};
/**
* Retrieves transient (temporary) data from the store.
* Excludes persistent data stored in the 'data' property.
*
* @param {Object} state The current state
* @return {Object} The transient state or empty object if undefined
*/
export const transientData = ( state ) => {
const { data, ...transientState } = getState( state );
return transientState || EMPTY_OBJ;
};

View file

@ -1,162 +0,0 @@
import { __ } from '@wordpress/i18n';
const cartAndExpressCheckoutSettings = {
paymentMethods: [],
style: {
shape: 'pill',
label: 'paypal',
color: 'gold',
},
};
const settings = {
paymentMethods: [],
style: {
layout: 'vertical',
shape: cartAndExpressCheckoutSettings.style.shape,
label: cartAndExpressCheckoutSettings.style.label,
color: cartAndExpressCheckoutSettings.style.color,
tagline: false,
},
};
export const defaultLocationSettings = {
cart: {
value: 'cart',
label: __( 'Cart', 'woocommerce-paypal-payments' ),
settings: { ...cartAndExpressCheckoutSettings },
// translators: %s: Link to Cart page
description: __(
'Customize the appearance of the PayPal smart buttons on the <a href="%s">[MISSING LINK]Cart page</a> and select which additional payment buttons to display in this location.',
'wooocommerce-paypal-payments'
),
descriptionLink: '#',
},
'classic-checkout': {
value: 'classic-checkout',
label: __( 'Classic Checkout', 'woocommerce-paypal-payments' ),
settings: { ...settings },
// translators: %s: Link to Classic Checkout page
description: __(
'Customize the appearance of the PayPal smart buttons on the <a href="%s">[MISSING LINK]Classic Checkout page</a> and choose which additional payment buttons to display in this location.',
'wooocommerce-paypal-payments'
),
descriptionLink: '#',
},
'express-checkout': {
value: 'express-checkout',
label: __( 'Express Checkout', 'woocommerce-paypal-payments' ),
settings: { ...cartAndExpressCheckoutSettings },
// translators: %s: Link to Express Checkout location
description: __(
'Customize the appearance of the PayPal smart buttons on the <a href="%s">[MISSING LINK]Express Checkout location</a> and choose which additional payment buttons to display in this location.',
'wooocommerce-paypal-payments'
),
descriptionLink: '#',
},
'mini-cart': {
value: 'mini-cart',
label: __( 'Mini Cart', 'woocommerce-paypel-payements' ),
settings: { ...settings },
// translators: %s: Link to Mini Cart
description: __(
'Customize the appearance of the PayPal smart buttons on the <a href="%s">[MISSING LINK]Mini Cart</a> and choose which additional payment buttons to display in this location.',
'wooocommerce-paypal-payments'
),
descriptionLink: '#',
},
'product-page': {
value: 'product-page',
label: __( 'Product Page', 'woocommerce-paypal-payments' ),
settings: { ...settings },
// translators: %s: Link to Product Page
description: __(
'Customize the appearance of the PayPal smart buttons on the <a href="%s">[MISSING LINK]Product Page</a> and choose which additional payment buttons to display in this location.',
'wooocommerce-paypal-payments'
),
descriptionLink: '#',
},
};
export const paymentMethodOptions = [
{
value: 'venmo',
label: __( 'Venmo', 'woocommerce-paypal-payments' ),
},
{
value: 'paylater',
label: __( 'Pay Later', 'woocommerce-paypal-payments' ),
},
{
value: 'googlepay',
label: __( 'Google Pay', 'woocommerce-paypal-payments' ),
},
{
value: 'applepay',
label: __( 'Apple Pay', 'woocommerce-paypal-payments' ),
},
];
export const buttonLabelOptions = [
{
value: 'paypal',
label: __( 'PayPal', 'woocommerce-paypal-payments' ),
},
{
value: 'checkout',
label: __( 'Checkout', 'woocommerce-paypal-payments' ),
},
{
value: 'buynow',
label: __( 'PayPal Buy Now', 'woocommerce-paypal-payments' ),
},
{
value: 'pay',
label: __( 'Pay with PayPal', 'woocommerce-paypal-payments' ),
},
];
export const colorOptions = [
{
value: 'gold',
label: __( 'Gold (Recommended)', 'woocommerce-paypal-payments' ),
},
{
value: 'blue',
label: __( 'Blue', 'woocommerce-paypal-payments' ),
},
{
value: 'silver',
label: __( 'Silver', 'woocommerce-paypal-payments' ),
},
{
value: 'black',
label: __( 'Black', 'woocommerce-paypal-payments' ),
},
{
value: 'white',
label: __( 'White', 'woocommerce-paypal-payments' ),
},
];
export const buttonLayoutOptions = [
{
label: __( 'Vertical', 'woocommerce-paypal-payments' ),
value: 'vertical',
},
{
label: __( 'Horizontal', 'woocommerce-paypal-payments' ),
value: 'horizontal',
},
];
export const shapeOptions = [
{
value: 'pill',
label: __( 'Pill', 'woocommerce-paypal-payments' ),
},
{
value: 'rect',
label: __( 'Rectangle', 'woocommerce-paypal-payments' ),
},
];

View file

@ -0,0 +1,18 @@
/**
* Action Types: Define unique identifiers for actions across all store modules.
*
* @file
*/
export default {
// Transient data.
SET_TRANSIENT: 'STYLE:SET_TRANSIENT',
// Persistent data.
SET_PERSISTENT: 'STYLE:SET_PERSISTENT',
RESET: 'STYLE:RESET',
HYDRATE: 'STYLE:HYDRATE',
// Controls - always start with "DO_".
DO_PERSIST_DATA: 'STYLE:DO_PERSIST_DATA',
};

View file

@ -0,0 +1,80 @@
/**
* Action Creators: Define functions to create action objects.
*
* These functions update state or trigger side effects (e.g., async operations).
* Actions are categorized as Transient, Persistent, or Side effect.
*
* @file
*/
import { select } from '@wordpress/data';
import ACTION_TYPES from './action-types';
import { STORE_NAME } from './constants';
/**
* @typedef {Object} Action An action object that is handled by a reducer or control.
* @property {string} type - The action type.
* @property {Object?} payload - Optional payload for the action.
*/
/**
* Special. Resets all values in the store to initial defaults.
*
* @return {Action} The action.
*/
export const reset = () => ( { type: ACTION_TYPES.RESET } );
/**
* Persistent. Set the full store details during app initialization.
*
* @param {{data: {}, flags?: {}}} payload
* @return {Action} The action.
*/
export const hydrate = ( payload ) => ( {
type: ACTION_TYPES.HYDRATE,
payload,
} );
/**
* Generic transient-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setTransient = ( prop, value ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { [ prop ]: value },
} );
/**
* Generic persistent-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
} );
/**
* Transient. Changes the "ready-state" of the module.
*
* @param {boolean} state Whether the store is ready to be used.
* @return {Action} The action.
*/
export const setIsReady = ( state ) => setTransient( 'isReady', state );
/**
* Side effect. Triggers the persistence of store data to the server.
*
* @return {Action} The action.
*/
export const persist = function* () {
const data = yield select( STORE_NAME ).persistentData();
yield { type: ACTION_TYPES.DO_PERSIST_DATA, data };
};

View file

@ -0,0 +1,131 @@
/**
* Configuration for UI components.
*
* @file
*/
import { __ } from '@wordpress/i18n';
export const STYLING_LOCATIONS = {
cart: {
value: 'cart',
label: __( 'Cart', 'woocommerce-paypal-payments' ),
link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-cart',
props: { layout: false, tagline: false },
},
classicCheckout: {
value: 'classicCheckout',
label: __( 'Classic Checkout', 'woocommerce-paypal-payments' ),
link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-checkout',
props: { layout: true, tagline: true },
},
expressCheckout: {
value: 'expressCheckout',
label: __( 'Express Checkout', 'woocommerce-paypal-payments' ),
link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-block-express-checkout',
props: { layout: false, tagline: false },
},
miniCart: {
value: 'miniCart',
label: __( 'Mini Cart', 'woocommerce-paypel-payements' ),
link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-mini-cart',
props: { layout: true, tagline: true },
},
product: {
value: 'product',
label: __( 'Product Page', 'woocommerce-paypal-payments' ),
link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-single-product',
props: { layout: true, tagline: true },
},
};
export const STYLING_LABELS = {
paypal: {
value: 'paypal',
label: __( 'PayPal', 'woocommerce-paypal-payments' ),
},
checkout: {
value: 'checkout',
label: __( 'Checkout', 'woocommerce-paypal-payments' ),
},
buynow: {
value: 'buynow',
label: __( 'PayPal Buy Now', 'woocommerce-paypal-payments' ),
},
pay: {
value: 'pay',
label: __( 'Pay with PayPal', 'woocommerce-paypal-payments' ),
},
};
export const STYLING_COLORS = {
gold: {
value: 'gold',
label: __( 'Gold (Recommended)', 'woocommerce-paypal-payments' ),
},
blue: {
value: 'blue',
label: __( 'Blue', 'woocommerce-paypal-payments' ),
},
silver: {
value: 'silver',
label: __( 'Silver', 'woocommerce-paypal-payments' ),
},
black: {
value: 'black',
label: __( 'Black', 'woocommerce-paypal-payments' ),
},
white: {
value: 'white',
label: __( 'White', 'woocommerce-paypal-payments' ),
},
};
export const STYLING_LAYOUTS = {
vertical: {
value: 'vertical',
label: __( 'Vertical', 'woocommerce-paypal-payments' ),
},
horizontal: {
value: 'horizontal',
label: __( 'Horizontal', 'woocommerce-paypal-payments' ),
},
};
export const STYLING_SHAPES = {
rect: {
value: 'rect',
label: __( 'Rectangle', 'woocommerce-paypal-payments' ),
},
pill: {
value: 'pill',
label: __( 'Pill', 'woocommerce-paypal-payments' ),
},
};
export const STYLING_PAYMENT_METHODS = {
paypal: {
value: '',
label: __( 'PayPal', 'woocommerce-paypal-payments' ),
checked: true,
disabled: true,
},
venmo: {
value: 'venmo',
label: __( 'Venmo', 'woocommerce-paypal-payments' ),
isFunding: true,
},
paylater: {
value: 'paylater',
label: __( 'Pay Later', 'woocommerce-paypal-payments' ),
isFunding: true,
},
googlepay: {
value: 'googlepay',
label: __( 'Google Pay', 'woocommerce-paypal-payments' ),
},
applepay: {
value: 'applepay',
label: __( 'Apple Pay', 'woocommerce-paypal-payments' ),
},
};

View file

@ -0,0 +1,28 @@
/**
* Name of the Redux store module.
*
* Used by: Reducer, Selector, Index
*
* @type {string}
*/
export const STORE_NAME = 'wc/paypal/style';
/**
* REST path to hydrate data of this module by loading data from the WP DB.
*
* Used by: Resolvers
* See: StylingRestEndpoint.php
*
* @type {string}
*/
export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/styling';
/**
* REST path to persist data of this module to the WP DB.
*
* Used by: Controls
* See: StylingRestEndpoint.php
*
* @type {string}
*/
export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/styling';

View file

@ -0,0 +1,23 @@
/**
* Controls: Implement side effects, typically asynchronous operations.
*
* Controls use ACTION_TYPES keys as identifiers.
* They are triggered by corresponding actions and handle external interactions.
*
* @file
*/
import apiFetch from '@wordpress/api-fetch';
import { REST_PERSIST_PATH } from './constants';
import ACTION_TYPES from './action-types';
export const controls = {
async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) {
return await apiFetch( {
path: REST_PERSIST_PATH,
method: 'POST',
data,
} );
},
};

View file

@ -0,0 +1,211 @@
/**
* Hooks: Provide the main API for components to interact with the store.
*
* These encapsulate store interactions, offering a consistent interface.
* Hooks simplify data access and manipulation for components.
*
* @file
*/
import { useCallback } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { createHooksForStore } from '../utils';
import { STORE_NAME } from './constants';
import {
STYLING_COLORS,
STYLING_LABELS,
STYLING_LAYOUTS,
STYLING_LOCATIONS,
STYLING_PAYMENT_METHODS,
STYLING_SHAPES,
} from './configuration';
const useHooks = () => {
const { useTransient } = createHooksForStore( STORE_NAME );
const { persist, setPersistent } = useDispatch( STORE_NAME );
// Transient accessors.
const [ isReady ] = useTransient( 'isReady' );
const [ location, setLocation ] = useTransient( 'location' );
// Persistent accessors.
const persistentData = useSelect(
( select ) => select( STORE_NAME ).persistentData(),
[]
);
const getLocationProp = useCallback(
( locationId, prop ) => {
if ( undefined === persistentData[ locationId ]?.[ prop ] ) {
console.error(
`Trying to access non-existent style property: ${ locationId }.${ prop }. Possibly wrong style name - review the reducer.`
);
return null;
}
return persistentData[ locationId ][ prop ];
},
[ persistentData ]
);
const setLocationProp = useCallback(
( locationId, prop, value ) => {
const updatedStyles = {
...persistentData[ locationId ],
[ prop ]: value,
};
setPersistent( locationId, updatedStyles );
},
[ persistentData, setPersistent ]
);
return {
persist,
isReady,
location,
setLocation,
getLocationProp,
setLocationProp,
};
};
export const useStore = () => {
const { persist, isReady } = useHooks();
return { persist, isReady };
};
export const useStylingLocation = () => {
const { location, setLocation } = useHooks();
return { location, setLocation };
};
export const useLocationProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const details = STYLING_LOCATIONS[ location ] ?? {};
const sanitize = ( value ) => ( undefined === value ? true : !! value );
return {
choices: Object.values( STYLING_LOCATIONS ),
details,
isActive: sanitize( getLocationProp( location, 'enabled' ) ),
setActive: ( state ) =>
setLocationProp( location, 'enabled', sanitize( state ) ),
};
};
export const usePaymentMethodProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const sanitize = ( value ) => {
if ( Array.isArray( value ) ) {
return value;
}
return value ? [ value ] : [];
};
return {
choices: Object.values( STYLING_PAYMENT_METHODS ),
paymentMethods: sanitize( getLocationProp( location, 'methods' ) ),
setPaymentMethods: ( methods ) =>
setLocationProp( location, 'methods', sanitize( methods ) ),
};
};
export const useColorProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const sanitize = ( value ) => {
const isValidColor = Object.values( STYLING_COLORS ).some(
( color ) => color.value === value
);
return isValidColor ? value : STYLING_COLORS.gold.value;
};
return {
choices: Object.values( STYLING_COLORS ),
color: sanitize( getLocationProp( location, 'color' ) ),
setColor: ( color ) =>
setLocationProp( location, 'color', sanitize( color ) ),
};
};
export const useShapeProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const sanitize = ( value ) => {
const isValidColor = Object.values( STYLING_SHAPES ).some(
( color ) => color.value === value
);
return isValidColor ? value : STYLING_SHAPES.rect.value;
};
return {
choices: Object.values( STYLING_SHAPES ),
shape: sanitize( getLocationProp( location, 'shape' ) ),
setShape: ( shape ) =>
setLocationProp( location, 'shape', sanitize( shape ) ),
};
};
export const useLabelProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const sanitize = ( value ) => {
const isValidColor = Object.values( STYLING_LABELS ).some(
( color ) => color.value === value
);
return isValidColor ? value : STYLING_LABELS.paypal.value;
};
return {
choices: Object.values( STYLING_LABELS ),
label: sanitize( getLocationProp( location, 'label' ) ),
setLabel: ( label ) =>
setLocationProp( location, 'label', sanitize( label ) ),
};
};
export const useLayoutProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const { details } = useLocationProps( location );
const isAvailable = false !== details.props.layout;
const sanitize = ( value ) => {
const isValidColor = Object.values( STYLING_LAYOUTS ).some(
( color ) => color.value === value
);
return isValidColor ? value : STYLING_LAYOUTS.vertical.value;
};
return {
choices: Object.values( STYLING_LAYOUTS ),
isAvailable,
layout: sanitize( getLocationProp( location, 'layout' ) ),
setLayout: ( layout ) =>
setLocationProp( location, 'layout', sanitize( layout ) ),
};
};
export const useTaglineProps = ( location ) => {
const { getLocationProp, setLocationProp } = useHooks();
const { details } = useLocationProps( location );
// Tagline is only available for horizontal layouts.
const isAvailable =
false !== details.props.tagline &&
STYLING_LAYOUTS.horizontal.value ===
getLocationProp( location, 'layout' );
const sanitize = ( value ) => !! value;
return {
isAvailable,
tagline: isAvailable
? sanitize( getLocationProp( location, 'tagline' ) )
: false,
setTagline: ( tagline ) =>
setLocationProp( location, 'tagline', sanitize( tagline ) ),
};
};

View file

@ -0,0 +1,24 @@
import { createReduxStore, register } from '@wordpress/data';
import { controls as wpControls } from '@wordpress/data-controls';
import { STORE_NAME } from './constants';
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
import * as hooks from './hooks';
import { resolvers } from './resolvers';
import { controls } from './controls';
export const initStore = () => {
const store = createReduxStore( STORE_NAME, {
reducer,
controls: { ...wpControls, ...controls },
actions,
selectors,
resolvers,
} );
register( store );
};
export { hooks, selectors, STORE_NAME };

View file

@ -0,0 +1,128 @@
/**
* Reducer: Defines store structure and state updates for this module.
*
* Manages both transient (temporary) and persistent (saved) state.
* The initial state must define all properties, as dynamic additions are not supported.
*
* @file
*/
import { createReducer, createSetters } from '../utils';
import ACTION_TYPES from './action-types';
import {
STYLING_COLORS,
STYLING_LABELS,
STYLING_LAYOUTS,
STYLING_LOCATIONS,
STYLING_SHAPES,
} from './configuration';
// Store structure.
// Transient: Values that are _not_ saved to the DB (like app lifecycle-flags).
const defaultTransient = Object.freeze( {
isReady: false,
location: STYLING_LOCATIONS.cart.value, // Which location is selected in the Styling tab.
} );
// Persistent: Values that are loaded from the DB.
const defaultPersistent = Object.freeze( {
[ STYLING_LOCATIONS.cart.value ]: Object.freeze( {
enabled: true,
methods: [],
label: STYLING_LABELS.pay.value,
shape: STYLING_SHAPES.rect.value,
color: STYLING_COLORS.gold.value,
} ),
[ STYLING_LOCATIONS.classicCheckout.value ]: Object.freeze( {
enabled: true,
methods: [],
label: STYLING_LABELS.checkout.value,
shape: STYLING_SHAPES.rect.value,
color: STYLING_COLORS.gold.value,
layout: STYLING_LAYOUTS.vertical.value,
tagline: false,
} ),
[ STYLING_LOCATIONS.expressCheckout.value ]: Object.freeze( {
enabled: true,
methods: [],
label: STYLING_LABELS.checkout.value,
shape: STYLING_SHAPES.rect.value,
color: STYLING_COLORS.gold.value,
} ),
[ STYLING_LOCATIONS.miniCart.value ]: Object.freeze( {
enabled: true,
methods: [],
label: STYLING_LABELS.pay.value,
shape: STYLING_SHAPES.rect.value,
color: STYLING_COLORS.gold.value,
layout: STYLING_LAYOUTS.vertical.value,
tagline: false,
} ),
[ STYLING_LOCATIONS.product.value ]: Object.freeze( {
enabled: true,
methods: [],
label: STYLING_LABELS.buynow.value,
shape: STYLING_SHAPES.rect.value,
color: STYLING_COLORS.gold.value,
layout: STYLING_LAYOUTS.vertical.value,
tagline: false,
} ),
} );
const sanitizeLocation = ( oldDetails, newDetails ) => {
// Skip if provided details are not a plain object.
if (
! newDetails ||
'object' !== typeof newDetails ||
Array.isArray( newDetails )
) {
return oldDetails;
}
return { ...oldDetails, ...newDetails };
};
// Reducer logic.
const [ setTransient, setPersistent ] = createSetters(
defaultTransient,
defaultPersistent
);
const reducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) =>
setTransient( state, payload ),
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
setPersistent( state, payload ),
[ ACTION_TYPES.RESET ]: ( state ) => {
const cleanState = setTransient(
setPersistent( state, defaultPersistent ),
defaultTransient
);
// Keep "read-only" details and initialization flags.
cleanState.isReady = true;
return cleanState;
},
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const validData = Object.keys( defaultPersistent ).reduce(
( data, location ) => {
data[ location ] = sanitizeLocation(
state.data[ location ],
payload.data[ location ]
);
return data;
},
{}
);
return setPersistent( state, validData );
},
} );
export default reducer;

View file

@ -0,0 +1,36 @@
/**
* Resolvers: Handle asynchronous data fetching for the store.
*
* These functions update store state with data from external sources.
* Each resolver corresponds to a specific selector (selector with same name must exist).
* Resolvers are called automatically when selectors request unavailable data.
*
* @file
*/
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls';
import { STORE_NAME, REST_HYDRATE_PATH } from './constants';
export const resolvers = {
/**
* Retrieve settings from the site's REST API.
*/
*persistentData() {
try {
const result = yield apiFetch( { path: REST_HYDRATE_PATH } );
yield dispatch( STORE_NAME ).hydrate( result );
yield dispatch( STORE_NAME ).setIsReady( true );
} catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice(
__(
'Error retrieving style-details.',
'woocommerce-paypal-payments'
)
);
}
},
};

View file

@ -0,0 +1,21 @@
/**
* Selectors: Extract specific pieces of state from the store.
*
* These functions provide a consistent interface for accessing store data.
* They allow components to retrieve data without knowing the store structure.
*
* @file
*/
const EMPTY_OBJ = Object.freeze( {} );
const getState = ( state ) => state || EMPTY_OBJ;
export const persistentData = ( state ) => {
return getState( state ).data || EMPTY_OBJ;
};
export const transientData = ( state ) => {
const { data, ...transientState } = getState( state );
return transientState || EMPTY_OBJ;
};

View file

@ -1,3 +1,6 @@
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
/**
* Updates an object with new values, filtering based on allowed keys.
*
@ -13,6 +16,10 @@ const updateObject = ( oldObject, newValues, allowedKeys = {} ) => ( {
...Object.keys( newValues ).reduce( ( acc, key ) => {
if ( key in allowedKeys ) {
acc[ key ] = newValues[ key ];
} else {
console.warn(
`Ignoring unknown key "${ key }" - to use it, add it to the initial store properties in the reducer.`
);
}
return acc;
}, {} ),
@ -73,3 +80,63 @@ export const createReducer = (
return state;
};
};
/**
* Returns an object with two hooks:
* - useTransient( prop )
* - usePersistent( prop )
*
* Both hooks have a similar syntax to the native "useState( prop )" hook, but provide access to
* a transient or persistent property in the relevant Redux store.
*
* Sample:
*
* const { useTransient } = createHooksForStore( STORE_NAME );
* const [ isReady, setIsReady ] = useTransient( 'isReady' );
*
* @param {string} storeName Store name.
* @return {{useTransient, usePersistent}} Store hooks.
*/
export const createHooksForStore = ( storeName ) => {
const createHook = ( selector, dispatcher ) => ( key ) => {
const value = useSelect(
( select ) => {
const store = select( storeName );
if ( ! store?.[ selector ] ) {
throw new Error(
`Please create the selector "${ selector }" for store "${ storeName }"`
);
}
const selectorResult = store[ selector ]();
if ( undefined === selectorResult?.[ key ] ) {
console.error(
`Warning: ${ selector }()[${ key }] is undefined in store "${ storeName }". This may indicate a bug.`
);
}
return selectorResult?.[ key ];
},
[ key ]
);
const actions = useDispatch( storeName );
const setValue = useCallback(
( newValue ) => {
if ( ! actions?.[ dispatcher ] ) {
throw new Error(
`Please create the action "${ dispatcher }" for store "${ storeName }"`
);
}
actions[ dispatcher ]( key, newValue );
},
[ actions, key ]
);
return [ value, setValue ];
};
return {
useTransient: createHook( 'transientData', 'setTransient' ),
usePersistent: createHook( 'persistentData', 'setPersistent' ),
};
};