Merge pull request #2728 from woocommerce/PCP-3809-settings-data-store-welcome-screen

Settings data store welcome screen (3809)
This commit is contained in:
Emili Castells 2024-10-25 11:06:42 +02:00 committed by GitHub
commit d0723fe513
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 823 additions and 119 deletions

View file

@ -1,9 +1,44 @@
export const debounce = ( callback, delayMs ) => {
let timeoutId = null;
return ( ...args ) => {
window.clearTimeout( timeoutId );
timeoutId = window.setTimeout( () => {
callback.apply( null, args );
}, delayMs );
const state = {
timeoutId: null,
args: null,
};
/**
* Cancels any pending debounced execution.
*/
const cancel = () => {
if ( state.timeoutId ) {
window.clearTimeout( state.timeoutId );
}
state.timeoutId = null;
state.args = null;
};
/**
* Immediately executes the debounced function if there's a pending execution.
* @return {void}
*/
const flush = () => {
// If there's nothing pending, return early.
if ( ! state.timeoutId ) {
return;
}
callback.apply( null, state.args || [] );
cancel();
};
const debouncedFunc = ( ...args ) => {
cancel();
state.args = args;
state.timeoutId = window.setTimeout( flush, delayMs );
};
// Attach utility methods
debouncedFunc.cancel = cancel;
debouncedFunc.flush = flush;
return debouncedFunc;
};

View file

@ -0,0 +1,8 @@
<svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.2097 15.1042C12.875 15.1042 11.7931 16.1861 11.7931 17.5208V48.3333C11.7931 49.668 12.875 50.75 14.2097 50.75H26.2931V35.6458C26.2931 34.3111 27.375 33.2292 28.7097 33.2292H35.9597V17.5208C35.9597 16.1861 34.8777 15.1042 33.5431 15.1042H14.2097Z" fill="#6FC400"/>
<path d="M33.5431 50.75H45.6264C46.9611 50.75 48.0431 49.668 48.0431 48.3333V35.6458C48.0431 34.3111 46.9611 33.2292 45.6264 33.2292H35.9597V48.3333C35.9597 49.668 34.8777 50.75 33.5431 50.75Z" fill="#5BBBFC"/>
<path d="M26.2931 50.75H33.5431C34.8777 50.75 35.9597 49.668 35.9597 48.3333V33.2292H28.7097C27.375 33.2292 26.2931 34.3111 26.2931 35.6458V50.75Z" fill="#FAF8F5"/>
<path d="M40.7458 18.3711C40.6878 17.9916 40.5811 17.621 40.4284 17.2683L36.1315 8.24801C36.1135 8.20728 36.0919 8.16822 36.0668 8.13132C35.8803 7.85532 35.6272 7.63009 35.3304 7.4762C35.0337 7.3223 34.7028 7.24463 34.3681 7.25029H13.207C12.8982 7.2504 12.597 7.34442 12.3437 7.51967C12.0904 7.69493 11.8973 7.94301 11.7904 8.2305C11.7904 8.3647 7.27013 17.2333 7.25838 17.3675C7.12438 17.709 7.03356 18.0657 6.98799 18.4294C6.46484 23.8673 13.3716 24.7075 13.7066 19.0946C13.9065 24.1532 20.2431 24.2407 20.4782 19.0946C20.6486 24.194 27.044 24.2874 27.2556 19.0946C27.4731 24.1999 33.8038 24.1999 34.0271 19.0946C34.3857 24.8417 41.3924 23.8264 40.7517 18.3652L40.7458 18.3711Z" fill="#005400"/>
<path d="M50.4226 35.138C50.377 34.8461 50.2932 34.561 50.1732 34.2897L46.7971 27.351C46.7829 27.3197 46.7659 27.2897 46.7463 27.2613C46.5997 27.049 46.4008 26.8757 46.1677 26.7573C45.9345 26.6389 45.6745 26.5792 45.4115 26.5836H28.785C28.5424 26.5836 28.3056 26.656 28.1066 26.7908C27.9076 26.9256 27.7559 27.1164 27.6719 27.3376C27.6719 27.4408 24.1203 34.2628 24.111 34.366C24.0058 34.6287 23.9344 34.9031 23.8986 35.1829C23.4876 39.3659 28.9143 40.0122 29.1775 35.6945C29.3346 39.5858 34.3133 39.6531 34.498 35.6945C34.632 39.6172 39.6569 39.689 39.8232 35.6945C39.994 39.6217 44.9682 39.6217 45.1437 35.6945C45.4254 40.1154 50.9306 39.3344 50.4272 35.1335L50.4226 35.138Z" fill="#005400"/>
<path d="M31.1264 42.2917C31.1264 42.959 30.5854 43.5 29.9181 43.5C29.2507 43.5 28.7097 42.959 28.7097 42.2917C28.7097 41.6243 29.2507 41.0833 29.9181 41.0833C30.5854 41.0833 31.1264 41.6243 31.1264 42.2917Z" fill="#001C64"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,6 @@
<svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M47.125 25.375C47.125 35.3852 39.0102 43.5 29 43.5C18.9898 43.5 10.875 35.3852 10.875 25.375C10.875 15.3648 18.9898 7.25 29 7.25C39.0102 7.25 47.125 15.3648 47.125 25.375Z" fill="#DD7F57"/>
<path d="M14.5 45.9167C14.5 37.9085 20.9919 31.4167 29 31.4167C37.0081 31.4167 43.5 37.9085 43.5 45.9167V50.0064C43.5 50.4171 43.1671 50.75 42.7564 50.75H15.2436C14.8329 50.75 14.5 50.4171 14.5 50.0064V45.9167Z" fill="#5BBBFC"/>
<path d="M29 29C33.0041 29 36.25 25.7541 36.25 21.75C36.25 17.7459 33.0041 14.5 29 14.5C24.9959 14.5 21.75 17.7459 21.75 21.75C21.75 25.7541 24.9959 29 29 29Z" fill="#FAF8F5"/>
<path d="M29 31.4167C23.6925 31.4167 19.0507 34.2683 16.5237 38.523C19.7734 41.6075 24.1659 43.5 29 43.5C33.8342 43.5 38.2259 41.6075 41.4756 38.523C38.9486 34.2683 34.3075 31.4167 29 31.4167Z" fill="#FAF8F5"/>
</svg>

After

Width:  |  Height:  |  Size: 919 B

View file

@ -1,6 +1,7 @@
* {
font-family: "Inter", sans-serif;
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
}
a:not(.button) {

View file

@ -0,0 +1,50 @@
.ppcp-r-select-box-wrapper {
max-width: 590px;
margin: 0 auto;
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.ppcp-r-select-box {
width: 100%;
border: 1px solid $color-gray-500;
border-radius: 8px;
padding: 28px 32px;
display: flex;
align-items: center;
box-sizing: border-box;
input[type='radio'] {
width: 20px;
height: 20px;
position: absolute;
left: 0;
top: 0;
margin: 0;
opacity: 0;
}
&__radio {
width: 20px;
height: 20px;
position: relative;
box-sizing: border-box;
&:checked {
+ .ppcp-r-select-box__presentation {
background:black;
}
}
}
&__radio-presentation {
width: 20px;
height: 20px;
border: 1px solid $color-gray-600;
border-radius: 20px;
display: block;
pointer-events: none;
}
}

View file

@ -10,5 +10,6 @@
@import './components/reusable-components/separator';
@import './components/reusable-components/payment-method-icons';
@import './components/reusable-components/settings-wrapper';
@import './components/reusable-components/select-box';
@import './components/screens/onboarding/step-welcome';
}

View file

@ -1,4 +1,4 @@
import Onboarding from './components/screens/onboarding/onboarding.js';
import Onboarding from './Components/Screens/Onboarding/Onboarding.js';
export function App() {
return (

View file

@ -0,0 +1,58 @@
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce';
/**
* Approach 1: Component Injection
*
* A generic wrapper that adds debounced store updates to any controlled component.
*
* @param {Object} props
* @param {React.ComponentType} props.control The controlled component to render
* @param {string|number} props.value The controlled value
* @param {Function} props.onChange Change handler
* @param {number} [props.delay=300] Debounce delay in milliseconds
*/
const DataStoreControl = ( {
control: ControlComponent,
value: externalValue,
onChange,
delay = 300,
...props
} ) => {
const [ internalValue, setInternalValue ] = useState( externalValue );
const onChangeRef = useRef( onChange );
onChangeRef.current = onChange;
const debouncedUpdate = useRef(
debounce( ( value ) => {
onChangeRef.current( value );
}, delay )
).current;
useEffect( () => {
setInternalValue( externalValue );
debouncedUpdate?.cancel();
}, [ externalValue ] );
useEffect( () => {
return () => debouncedUpdate?.cancel();
}, [ debouncedUpdate ] );
const handleChange = useCallback(
( newValue ) => {
setInternalValue( newValue );
debouncedUpdate( newValue );
},
[ debouncedUpdate ]
);
return (
<ControlComponent
{ ...props }
value={ internalValue }
onChange={ handleChange }
/>
);
};
export default DataStoreControl;

View file

@ -1,4 +1,4 @@
import PaymentMethodIcon from './payment-method-icon';
import PaymentMethodIcon from './PaymentMethodIcon';
const PaymentMethodIcons = ( props ) => {
return (

View file

@ -0,0 +1,34 @@
import data from '../../utils/data';
const SelectBox = ( props ) => {
return (
<div className="ppcp-r-select-box">
<div className="ppcp-r-select-box__radio">
<input
checked="checked"
className="ppcp-r-select-box__radio-value"
type="radio"
/>
<span className="ppcp-r-select-box__radio-presentation"></span>
</div>
<div className="ppcp-r-select-box__content">
{ data().getImage( props.icon ) }
<div className="ppcp-r-select-box__content-inner">
<span className="ppcp-r-select-box__title">
{ props.title }
</span>
<p className="ppcp-r-select-box__description">
{ props.description }
</p>
{ props.children && (
<div className="ppcp-r-select-box__additional-content">
{ props.children }
</div>
) }
</div>
</div>
</div>
);
};
export default SelectBox;

View file

@ -0,0 +1,5 @@
const SelectBoxWrapper = ( props ) => {
return <div className="ppcp-r-select-box-wrapper">{ props.children }</div>;
};
export default SelectBoxWrapper;

View file

@ -1,9 +1,6 @@
import { useState } from '@wordpress/element';
import { ToggleControl } from '@wordpress/components';
const SettingsToggleBlock = ( props ) => {
const [ isToggled, setToggled ] = useState( false );
const SettingsToggleBlock = ( { isToggled, setToggled, ...props } ) => {
return (
<div className="ppcp-r-toggle-block">
<div className="ppcp-r-toggle-block__wrapper">

View file

@ -1,5 +1,5 @@
import Container from '../../reusable-components/container';
import StepWelcome from './step-welcome.js';
import Container from '../../ReusableComponents/Container.js';
import StepWelcome from './StepWelcome.js';
const Onboarding = () => {
return (

View file

@ -0,0 +1,71 @@
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader.js';
import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper.js';
import SelectBox from '../../ReusableComponents/SelectBox.js';
import { __ } from '@wordpress/i18n';
import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons';
const StepBusiness = () => {
return (
<div className="ppcp-r-page-welcome">
<OnboardingHeader
title={ __(
'Tell Us About Your Business',
'woocommerce-paypal-payments'
) }
/>
<div className="ppcp-r-inner-container">
<SelectBoxWrapper>
<SelectBox
title={ __(
'Casual Seller',
'woocommerce-paypal-payments'
) }
description={ __(
'I sell occasionally and mainly use PayPal for personal transactions.',
'woocommerce-paypal-payments'
) }
icon="icon-business-casual-seller.svg"
>
<PaymentMethodIcons
icons={ [
'paypal',
'venmo',
'visa',
'mastercard',
'amex',
'discover',
] }
/>
</SelectBox>
<SelectBox
title={ __(
'Business',
'woocommerce-paypal-payments'
) }
description={ __(
'I run a registered business and sell full-time.',
'woocommerce-paypal-payments'
) }
icon="icon-business-business.svg"
>
<PaymentMethodIcons
icons={ [
'paypal',
'venmo',
'visa',
'mastercard',
'amex',
'discover',
'apple-pay',
'google-pay',
'ideal',
] }
/>
</SelectBox>
</SelectBoxWrapper>
</div>
</div>
);
};
export default StepBusiness;

View file

@ -1,9 +1,11 @@
import OnboardingHeader from '../../reusable-components/onboarding-header.js';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader.js';
import { __, sprintf } from '@wordpress/i18n';
import { Button, TextControl } from '@wordpress/components';
import PaymentMethodIcons from '../../reusable-components/payment-method-icons';
import SettingsToggleBlock from '../../reusable-components/settings-toggle-block';
import Separator from '../../reusable-components/separator';
import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons';
import SettingsToggleBlock from '../../ReusableComponents/SettingsToggleBlock';
import Separator from '../../ReusableComponents/Separator';
import { useOnboardingDetails } from '../../../data';
import DataStoreControl from '../../ReusableComponents/DataStoreControl';
const StepWelcome = () => {
return (
@ -72,6 +74,17 @@ const WelcomeFeatures = () => {
};
const WelcomeForm = () => {
const {
isSandboxMode,
setSandboxMode,
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
} = useOnboardingDetails();
const advancedUsersDescription = sprintf(
// translators: %s: Link to PayPal REST application guide
__(
@ -92,6 +105,8 @@ const WelcomeForm = () => {
'Activate Sandbox mode to safely test PayPal with sample data. Once your store is ready to go live, you can easily switch to your production account.',
'woocommerce-paypal-payments'
) }
isToggled={ !! isSandboxMode }
setToggled={ setSandboxMode }
>
<Button variant="secondary">
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
@ -104,21 +119,28 @@ const WelcomeForm = () => {
'woocommerce-paypal-payments'
) }
description={ advancedUsersDescription }
isToggled={ !! isManualConnectionMode }
setToggled={ setManualConnectionMode }
>
<TextControl
<DataStoreControl
control={ TextControl }
label={ __(
'Sandbox Client ID',
'woocommerce-paypal-payments'
) }
></TextControl>
<TextControl
value={ clientId }
onChange={ setClientId }
/>
<DataStoreControl
control={ TextControl }
label={ __(
'Sandbox Secret Key',
'woocommerce-paypal-payments'
) }
value={ clientSecret }
onChange={ setClientSecret }
type="password"
></TextControl>
/>
<Button variant="secondary">
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>

View file

@ -1,5 +1,12 @@
export default {
SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS',
// Transient data.
SET_IS_SAVING_ONBOARDING_DETAILS: 'SET_IS_SAVING_ONBOARDING_DETAILS',
// Persistent data.
SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS',
SET_ONBOARDING_STEP: 'SET_ONBOARDING_STEP',
SET_SANDBOX_MODE: 'SET_SANDBOX_MODE',
SET_MANUAL_CONNECTION_MODE: 'SET_MANUAL_CONNECTION_MODE',
SET_CLIENT_ID: 'SET_CLIENT_ID',
SET_CLIENT_SECRET: 'SET_CLIENT_SECRET',
};

View file

@ -1,16 +1,28 @@
import { dispatch, select } from '@wordpress/data';
import { select } from '@wordpress/data';
import { apiFetch } from '@wordpress/data-controls';
import { __ } from '@wordpress/i18n';
import ACTION_TYPES from './action-types';
import { NAMESPACE, STORE_NAME } from '../constants';
/**
* Non-persistent. Changes the "saving" flag.
*
* @param {boolean} isSaving
* @return {{type: string, isSaving}} The action.
*/
export const setIsSaving = ( isSaving ) => {
return {
type: ACTION_TYPES.SET_IS_SAVING_ONBOARDING_DETAILS,
isSaving,
};
};
/**
* Persistent. Set the full onboarding details, usually during app initialization.
*
* @param {Object} payload
* @return {{payload, type: string}} The action.
* @return {{type: string, payload}} The action.
*/
export const updateOnboardingDetails = ( payload ) => {
export const setOnboardingDetails = ( payload ) => {
return {
type: ACTION_TYPES.SET_ONBOARDING_DETAILS,
payload,
@ -31,48 +43,81 @@ export const setOnboardingStep = ( step ) => {
};
/**
* Non-persistent. Changes the "saving" flag.
* Persistent. Sets the sandbox mode on or off.
*
* @param {boolean} isSaving
* @return {{type: string, isSaving}} The action.
* @param {boolean} sandboxMode
* @return {{type: string, useSandbox}} An action.
*/
export const updateIsSaving = ( isSaving ) => {
export const setSandboxMode = ( sandboxMode ) => {
return {
type: ACTION_TYPES.SET_IS_SAVING_ONBOARDING_DETAILS,
isSaving,
type: ACTION_TYPES.SET_SANDBOX_MODE,
useSandbox: sandboxMode,
};
};
/**
* Persistent. Toggles the "Manual Connection" mode on or off.
*
* @param {boolean} manualConnectionMode
* @return {{type: string, useManualConnection}} An action.
*/
export const setManualConnectionMode = ( manualConnectionMode ) => {
return {
type: ACTION_TYPES.SET_MANUAL_CONNECTION_MODE,
useManualConnection: manualConnectionMode,
};
};
/**
* Persistent. Changes the "client ID" value.
*
* @param {string} clientId
* @return {{type: string, clientId}} The action.
*/
export const setClientId = ( clientId ) => {
return {
type: ACTION_TYPES.SET_CLIENT_ID,
clientId,
};
};
/**
* Persistent. Changes the "client secret" value.
*
* @param {string} clientSecret
* @return {{type: string, clientSecret}} The action.
*/
export const setClientSecret = ( clientSecret ) => {
return {
type: ACTION_TYPES.SET_CLIENT_SECRET,
clientSecret,
};
};
/**
* Saves the persistent details to the WP database.
*
* @return {Generator<any>} A generator function that handles the saving process.
* @return {any} A generator function that handles the saving process.
*/
export function* persist() {
let error = null;
try {
const path = `${ NAMESPACE }/onboarding`;
const data = select( STORE_NAME ).getOnboardingData();
const data = select( STORE_NAME ).getPersistentData();
yield updateIsSaving( true );
yield setIsSaving( true );
yield apiFetch( {
path,
method: 'post',
data,
} );
yield dispatch( 'core/notices' ).createSuccessNotice(
__( 'Progress saved.', 'woocommerce-paypal-payments' )
);
} catch ( e ) {
error = e;
yield dispatch( 'core/notices' ).createErrorNotice(
__( 'Error saving progress.', 'woocommerce-paypal-payments' )
);
console.error( 'Error saving progress.', e );
} finally {
yield updateIsSaving( false );
yield setIsSaving( false );
}
return error === null;

View file

@ -2,22 +2,61 @@ import { useSelect, useDispatch } from '@wordpress/data';
import { STORE_NAME } from '../constants';
export const useOnboardingDetails = () => {
const { setOnboardingStep, persist } = useDispatch( STORE_NAME );
const {
setOnboardingStep,
setSandboxMode,
setManualConnectionMode,
persist,
setClientId,
setClientSecret,
} = useDispatch( STORE_NAME );
// Transient accessors.
const isSaving = useSelect( ( select ) => {
return select( STORE_NAME ).getTransientData().isSaving;
}, [] );
// Persistent accessors.
const clientId = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().clientId;
}, [] );
const clientSecret = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().clientSecret;
}, [] );
const onboardingStep = useSelect( ( select ) => {
return select( STORE_NAME ).getOnboardingStep();
return select( STORE_NAME ).getPersistentData().step || 0;
}, [] );
const isSaving = useSelect( ( select ) => {
return select( STORE_NAME ).isSaving();
const isSandboxMode = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().useSandbox;
}, [] );
const isManualConnectionMode = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().useManualConnection;
}, [] );
const setDetailAndPersist = async ( setter, value ) => {
setter( value );
await persist();
};
return {
onboardingStep,
isSaving,
setOnboardingStep: async ( step ) => {
setOnboardingStep( step );
await persist();
},
isSandboxMode,
isManualConnectionMode,
clientId,
setClientId: ( value ) => setDetailAndPersist( setClientId, value ),
clientSecret,
setClientSecret: ( value ) =>
setDetailAndPersist( setClientSecret, value ),
setOnboardingStep: ( step ) =>
setDetailAndPersist( setOnboardingStep, step ),
setSandboxMode: ( state ) =>
setDetailAndPersist( setSandboxMode, state ),
setManualConnectionMode: ( state ) =>
setDetailAndPersist( setManualConnectionMode, state ),
};
};

View file

@ -4,6 +4,10 @@ const defaultState = {
isSaving: false,
data: {
step: 0,
useSandbox: false,
useManualConnection: false,
clientId: '',
clientSecret: '',
},
};
@ -11,32 +15,54 @@ export const onboardingReducer = (
state = defaultState,
{ type, ...action }
) => {
switch ( type ) {
case ACTION_TYPES.SET_ONBOARDING_DETAILS:
return {
...state,
data: action.payload,
};
const setTransient = ( changes ) => {
const { data, ...transientChanges } = changes;
return { ...state, ...transientChanges };
};
const setPersistent = ( changes ) => {
const validChanges = Object.keys( changes ).reduce( ( acc, key ) => {
if ( key in defaultState.data ) {
acc[ key ] = changes[ key ];
}
return acc;
}, {} );
return {
...state,
data: { ...state.data, ...validChanges },
};
};
switch ( type ) {
// Transient data.
case ACTION_TYPES.SET_IS_SAVING_ONBOARDING_DETAILS:
return {
...state,
isSaving: action.isSaving,
};
return setTransient( { isSaving: action.isSaving } );
// Persistent data.
case ACTION_TYPES.SET_CLIENT_ID:
return setPersistent( { clientId: action.clientId } );
case ACTION_TYPES.SET_CLIENT_SECRET:
return setPersistent( { clientSecret: action.clientSecret } );
case ACTION_TYPES.SET_ONBOARDING_DETAILS:
return setPersistent( action.payload );
case ACTION_TYPES.SET_ONBOARDING_STEP:
return {
...state,
data: {
...( state.data || {} ),
step: action.step,
},
};
return setPersistent( { step: action.step } );
case ACTION_TYPES.SET_SANDBOX_MODE:
return setPersistent( { useSandbox: action.useSandbox } );
case ACTION_TYPES.SET_MANUAL_CONNECTION_MODE:
return setPersistent( {
useManualConnection: action.useManualConnection,
} );
default:
return state;
}
return state;
};
export default onboardingReducer;

View file

@ -2,17 +2,17 @@ import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls';
import { NAMESPACE } from '../constants';
import { updateOnboardingDetails } from './actions';
import { setOnboardingDetails } from './actions';
/**
* Retrieve settings from the site's REST API.
*/
export function* getOnboardingData() {
export function* getPersistentData() {
const path = `${ NAMESPACE }/onboarding`;
try {
const result = yield apiFetch( { path } );
yield updateOnboardingDetails( result );
yield setOnboardingDetails( result );
} catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice(
__(

View file

@ -1,4 +1,4 @@
const EMPTY_OBJ = {};
const EMPTY_OBJ = Object.freeze( {} );
const getOnboardingState = ( state ) => {
if ( ! state ) {
@ -8,14 +8,11 @@ const getOnboardingState = ( state ) => {
return state.onboarding || EMPTY_OBJ;
};
export const getOnboardingData = ( state ) => {
export const getPersistentData = ( state ) => {
return getOnboardingState( state ).data || EMPTY_OBJ;
};
export const isSaving = ( state ) => {
return getOnboardingState( state ).isSaving || false;
};
export const getOnboardingStep = ( state ) => {
return getOnboardingData( state ).step || 0;
export const getTransientData = ( state ) => {
const { data, ...transientState } = getOnboardingState( state );
return transientState || EMPTY_OBJ;
};

View file

@ -0,0 +1,99 @@
<?php
/**
* Abstract Data Model Base Class
*
* @package WooCommerce\PayPalCommerce\Settings\Data
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data;
use RuntimeException;
/**
* Abstract class AbstractDataModel
*
* Provides a base implementation for data models that can be serialized to and from arrays,
* and provide persistence capabilities.
*/
abstract class AbstractDataModel {
/**
* Stores the model data.
*
* @var array
*/
protected array $data = array();
/**
* Option key for WordPress storage.
* Must be overridden by the child class!
*/
protected const OPTION_KEY = '';
/**
* Default values for the model.
* Child classes should override this method to define their default structure.
*
* @return array
*/
abstract protected function get_defaults() : array;
/**
* Constructor.
*
* @throws RuntimeException If the OPTION_KEY is not defined in the child class.
*/
public function __construct() {
if ( empty( static::OPTION_KEY ) ) {
throw new RuntimeException( 'OPTION_KEY must be defined in child class.' );
}
$this->data = $this->get_defaults();
$this->load();
}
/**
* Loads the model data from WordPress options.
*/
public function load() : void {
$saved_data = get_option( static::OPTION_KEY, array() );
$this->data = array_merge( $this->data, $saved_data );
}
/**
* Saves the model data to WordPress options.
*/
public function save() : void {
update_option( static::OPTION_KEY, $this->data );
}
/**
* Gets all model data as an array.
*
* @return array
*/
public function to_array() : array {
return array_merge( array(), $this->data );
}
/**
* Sets all model data from an array.
*
* @param array $data The model data.
*/
public function from_array( array $data ) : void {
foreach ( $data as $key => $value ) {
if ( ! array_key_exists( $key, $this->data ) ) {
continue;
}
$setter = "set_$key";
if ( method_exists( $this, $setter ) ) {
$this->$setter( $value );
} else {
$this->data[ $key ] = $value;
}
}
}
}

View file

@ -1,6 +1,6 @@
<?php
/**
* Settings container class
* Onboarding Profile class
*
* @package WooCommerce\PayPalCommerce\Settings\Data
*/
@ -10,33 +10,125 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data;
/**
* Class OnboardingProfile
*
* This class serves as a container for managing the onboarding profile details
* within the WooCommerce PayPal Commerce plugin. It provides methods to retrieve
* and save the onboarding profile data using WordPress options.
*/
class OnboardingProfile {
class OnboardingProfile extends AbstractDataModel {
/**
* Options key where profile details are stored.
* Option key where profile details are stored.
*
* @var string
*/
private const KEY = 'woocommerce-ppcp-data-onboarding';
protected const OPTION_KEY = 'woocommerce-ppcp-data-onboarding';
/**
* Returns the current onboarding profile details.
* Get default values for the model.
*
* @return array
*/
public function get_data() : array {
return get_option( self::KEY, array() );
protected function get_defaults() : array {
return array(
'step' => 0,
'use_sandbox' => false,
'use_manual_connection' => false,
'client_id' => '',
'client_secret' => '',
);
}
// -----
/**
* Gets the 'step' setting.
*
* @return int
*/
public function get_step() : int {
return (int) $this->data['step'];
}
/**
* Saves the onboarding profile details.
* Sets the 'step' setting.
*
* @param array $data The profile details to save.
* @param int $step Whether to use sandbox mode.
*/
public function save_data( array $data ) : void {
update_option( self::KEY, $data );
public function set_step( int $step ) : void {
$this->data['step'] = $step;
}
/**
* Gets the 'use sandbox' setting.
*
* @return bool
*/
public function get_use_sandbox() : bool {
return (bool) $this->data['use_sandbox'];
}
/**
* Sets the 'use sandbox' setting.
*
* @param bool $use_sandbox Whether to use sandbox mode.
*/
public function set_use_sandbox( bool $use_sandbox ) : void {
$this->data['use_sandbox'] = $use_sandbox;
}
/**
* Gets the 'use manual connection' setting.
*
* @return bool
*/
public function get_use_manual_connection() : bool {
return (bool) $this->data['use_manual_connection'];
}
/**
* Sets the 'use manual connection' setting.
*
* @param bool $use_manual_connection Whether to use manual connection.
*/
public function set_use_manual_connection( bool $use_manual_connection ) : void {
$this->data['use_manual_connection'] = $use_manual_connection;
}
/**
* Gets the client ID.
*
* @return string
*/
public function get_client_id() : string {
return $this->data['client_id'];
}
/**
* Sets the client ID.
*
* @param string $client_id The client ID.
*/
public function set_client_id( string $client_id ) : void {
$this->data['client_id'] = sanitize_text_field( $client_id );
}
/**
* Gets the client secret.
*
* @return string
*/
public function get_client_secret() : string {
return $this->data['client_secret'];
}
/**
* Sets the client secret.
*
* @param string $client_secret The client secret.
*/
public function set_client_secret( string $client_secret ) : void {
$this->data['client_secret'] = sanitize_text_field( $client_secret );
}
}

View file

@ -17,7 +17,8 @@ use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
/**
* REST controller for the onboarding module.
*
* Responsible for persisting and loading the state of the onboarding wizard.
* This API acts as the intermediary between the "external world" and our
* internal data model.
*/
class OnboardingRestEndpoint extends RestEndpoint {
/**
@ -32,7 +33,35 @@ class OnboardingRestEndpoint extends RestEndpoint {
*
* @var OnboardingProfile
*/
protected $profile;
protected OnboardingProfile $profile;
/**
* Field mapping for request to profile transformation.
*
* @var array
*/
private array $field_map = array(
'step' => array(
'js_name' => 'step',
'sanitize' => 'to_number',
),
'use_sandbox' => array(
'js_name' => 'useSandbox',
'sanitize' => 'to_boolean',
),
'use_manual_connection' => array(
'js_name' => 'useManualConnection',
'sanitize' => 'to_boolean',
),
'client_id' => array(
'js_name' => 'clientId',
'sanitize' => 'sanitize_text_field',
),
'client_secret' => array(
'js_name' => 'clientSecret',
'sanitize' => 'sanitize_text_field',
),
);
/**
* Constructor.
@ -73,42 +102,35 @@ class OnboardingRestEndpoint extends RestEndpoint {
}
/**
* Returns an object with all details of the current onboarding wizard
* progress.
* Returns all details of the current onboarding wizard progress.
*
* @return WP_REST_Response The current state of the onboarding wizard.
*/
public function get_details() : WP_REST_Response {
$details = $this->profile->get_data();
$js_data = $this->sanitize_for_javascript(
$this->profile->to_array(),
$this->field_map
);
return rest_ensure_response( $details );
return rest_ensure_response( $js_data );
}
/**
* Receives an object with onboarding details and persists it in the DB.
* Updates onboarding details based on the request.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response The current state of the onboarding wizard.
* @return WP_REST_Response The updated state of the onboarding wizard.
*/
public function update_details( WP_REST_Request $request ) : WP_REST_Response {
$details = $this->profile->get_data();
$wp_data = $this->sanitize_for_wordpress(
$request->get_params(),
$this->field_map
);
$get_param = fn( $key ) => wc_clean( wp_unslash( $request->get_param( $key ) ) );
$this->profile->from_array( $wp_data );
$this->profile->save();
$raw_step = $get_param( 'step' );
$raw_completed = $get_param( 'completed' );
if ( is_numeric( $raw_step ) ) {
$details['step'] = intval( $raw_step );
}
if ( null !== $raw_completed ) {
$details['completed'] = (bool) $raw_completed;
}
$this->profile->save_data( $details );
return rest_ensure_response( $details );
return $this->get_details();
}
}

View file

@ -33,4 +33,93 @@ class RestEndpoint extends WC_REST_Controller {
public function check_permission() : bool {
return current_user_can( 'manage_woocommerce' );
}
/**
* Sanitizes parameters based on a field mapping.
*
* This method iterates through a field map, applying sanitization methods
* to the corresponding values in the input parameters array.
*
* @param array $params The input parameters to sanitize.
* @param array $field_map An associative array mapping profile keys to sanitization rules.
* Each rule should have 'js_name' and 'sanitize' keys.
*
* @return array An array of sanitized parameters.
*/
protected function sanitize_for_wordpress( array $params, array $field_map ) : array {
$sanitized = array();
foreach ( $field_map as $key => $details ) {
$source_key = $details['js_name'] ?? '';
$sanitation_cb = $details['sanitize'] ?? null;
if ( ! $source_key || ! isset( $params[ $source_key ] ) ) {
continue;
}
$value = $params[ $source_key ];
if ( null === $sanitation_cb ) {
$sanitized[ $key ] = $value;
} elseif ( method_exists( $this, $sanitation_cb ) ) {
$sanitized[ $key ] = $this->{$sanitation_cb}( $value );
} elseif ( is_callable( $sanitation_cb ) ) {
$sanitized[ $key ] = $sanitation_cb( $value );
}
}
return $sanitized;
}
/**
* Sanitizes data for JavaScript based on a field mapping.
*
* This method transforms the input data array according to the provided field map,
* renaming keys to their JavaScript equivalents as specified in the mapping.
*
* @param array $data The input data array to be sanitized.
* @param array $field_map An associative array mapping PHP keys to JavaScript key names.
* Each element should have a 'js_name' key specifying the JavaScript
* name.
*
* @return array An array of sanitized data with keys renamed for JavaScript use.
*/
protected function sanitize_for_javascript( array $data, array $field_map ) : array {
$sanitized = array();
foreach ( $field_map as $key => $details ) {
$output_key = $details['js_name'] ?? '';
if ( ! $output_key || ! isset( $data[ $key ] ) ) {
continue;
}
$sanitized[ $output_key ] = $data[ $key ];
}
return $sanitized;
}
/**
* Convert a value to a boolean.
*
* @param mixed $value The value to convert.
*
* @return bool|null The boolean value, or null if not set.
*/
protected function to_boolean( $value ) : ?bool {
return $value !== null ? (bool) $value : null;
}
/**
* Convert a value to a number.
*
* @param mixed $value The value to convert.
*
* @return int|float|null The numeric value, or null if not set.
*/
protected function to_number( $value ) {
return $value !== null ? ( is_numeric( $value ) ? $value + 0 : null ) : null;
}
}

View file

@ -72,7 +72,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
wp_register_style(
'ppcp-admin-settings',
$module_url . '/assets/style-style.css',
$module_url . '/assets/style-style-rtl.css',
$style_asset_file['dependencies'],
$style_asset_file['version']
);