🔀 Merge branch 'trunk'

# Conflicts:
#	modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcFlow.js
#	modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcOptionalPaymentMethods.js
This commit is contained in:
Philipp Stracker 2025-02-04 15:59:07 +01:00
commit 0894d9e691
No known key found for this signature in database
34 changed files with 1130 additions and 429 deletions

View file

@ -15,12 +15,44 @@
}
// Todo List and Feature Items
.ppcp-r-todo-items {
display: flex;
flex-direction: column;
}
.ppcp-r-todo-item {
position: relative;
display: flex;
align-items: center;
gap: 18px;
width: 100%;
border-bottom: 1px solid $color-gray-300;
padding-bottom: 16px;
padding-top: 16px;
opacity: 1;
transform: translateY(0);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:first-child, &.is-dismissing:first-child + .ppcp-r-todo-item {
padding-top: 0;
}
&:last-child,
&:not(.is-dismissing):has(+ .is-dismissing:last-child) {
border-bottom: none;
padding-bottom: 0;
}
&.is-dismissing {
opacity: 0;
transform: translateY(-4px);
height: 0;
margin: 0;
padding: 0;
border: 0;
.ppcp-r-todo-item__inner {
height: 0;
padding: 0;
margin: 0;
}
}
&:hover {
cursor: pointer;
@ -32,15 +64,6 @@
}
}
&:not(:last-child) {
border-bottom: 1px solid $color-gray-400;
padding-bottom: 16px;
}
&:not(:first-child) {
padding-top: 16px;
}
&.is-completed {
.ppcp-r-todo-item__icon {
border-style: solid;
@ -68,17 +91,46 @@
&__inner {
position: relative;
height: auto;
overflow: hidden;
display: flex;
align-items: center;
gap: 18px;
width: 100%;
padding-right: 36px;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
&__close {
margin-left: auto;
&__dismiss {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
background-color: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: $color-gray-400;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
&:hover {
cursor: pointer;
color: $color-blueberry;
background-color: $color-gray-100;
color: $color-gray-700;
}
.dashicons {
font-size: 14px;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
}
}
@ -104,6 +156,7 @@
border-radius: 50%;
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
@ -180,3 +233,23 @@
gap: 48px;
}
.ppcp-highlight {
animation: ppcp-highlight-fade 2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid $color-blueberry;
border-radius: var(--container-border-radius);
position: relative;
z-index: 1;
}
@keyframes ppcp-highlight-fade {
0%, 20% {
background-color: rgba($color-blueberry, 0.08);
border-color: $color-blueberry;
border-width: 1px;
}
100% {
background-color: transparent;
border-color: $color-gray-300;
border-width: 1px;
}
}

View file

@ -2,6 +2,7 @@ import classNames from 'classnames';
import { Description, Header, Title, TitleExtra, Content } from './Elements';
const SettingsBlock = ( {
id,
className,
children,
title,
@ -15,14 +16,18 @@ const SettingsBlock = ( {
'ppcp--horizontal': horizontalLayout,
} );
const props = {
className: blockClassName,
...( id && { id } ),
};
return (
<div className={ blockClassName } id={ className }>
<div { ...props }>
<BlockTitle
blockTitle={ title }
blockSuffix={ titleSuffix }
blockDescription={ description }
/>
<Content asCard={ false }>{ children }</Content>
</div>
);

View file

@ -1,5 +1,7 @@
import { ToggleControl, Icon, Button } from '@wordpress/components';
import { cog } from '@wordpress/icons';
import { useEffect } from '@wordpress/element';
import { useActiveHighlight } from '../../../data/common/hooks';
import SettingsBlock from '../SettingsBlock';
import PaymentMethodIcon from '../PaymentMethodIcon';
@ -10,8 +12,28 @@ const PaymentMethodItemBlock = ( {
onSelect,
isSelected,
} ) => {
const { activeHighlight, setActiveHighlight } = useActiveHighlight();
const isHighlighted = activeHighlight === paymentMethod.id;
// Reset the active highlight after 2 seconds
useEffect( () => {
if ( isHighlighted ) {
const timer = setTimeout( () => {
setActiveHighlight( null );
}, 2000 );
return () => clearTimeout( timer );
}
}, [ isHighlighted, setActiveHighlight ] );
return (
<SettingsBlock className="ppcp--method-item" separatorAndGap={ false }>
<SettingsBlock
id={ paymentMethod.id }
className={ `ppcp--method-item ${
isHighlighted ? 'ppcp-highlight' : ''
}` }
separatorAndGap={ false }
>
<div className="ppcp--method-inner">
<div className="ppcp--method-title-wrapper">
{ paymentMethod?.icon && (

View file

@ -1,20 +1,64 @@
import { selectTab, TAB_IDS } from '../../../utils/tabSelector';
import { useEffect, useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { STORE_NAME as TODOS_STORE_NAME } from '../../../data/todos';
const TodoSettingsBlock = ( {
todosData,
className = '',
setActiveModal,
setActiveHighlight,
onDismissTodo,
} ) => {
const [ dismissingIds, setDismissingIds ] = useState( new Set() );
const { completedTodos, dismissedTodos } = useSelect(
( select ) => ( {
completedTodos:
select( TODOS_STORE_NAME ).getCompletedTodos() || [],
dismissedTodos:
select( TODOS_STORE_NAME ).getDismissedTodos() || [],
} ),
[]
);
useEffect( () => {
if ( dismissedTodos.length === 0 ) {
setDismissingIds( new Set() );
}
}, [ dismissedTodos ] );
const TodoSettingsBlock = ( { todosData, className = '' } ) => {
if ( todosData.length === 0 ) {
return null;
}
const handleDismiss = ( todoId, e ) => {
e.preventDefault();
e.stopPropagation();
setDismissingIds( ( prev ) => new Set( [ ...prev, todoId ] ) );
setTimeout( () => {
onDismissTodo( todoId );
}, 300 );
};
// Filter out dismissed todos for display
const visibleTodos = todosData.filter(
( todo ) => ! dismissedTodos.includes( todo.id )
);
return (
<div
className={ `ppcp-r-settings-block__todo ppcp-r-todo-items ${ className }` }
>
{ todosData.slice( 0, 5 ).map( ( todo ) => (
{ visibleTodos.map( ( todo ) => (
<TodoItem
key={ todo.id }
id={ todo.id }
title={ todo.title }
description={ todo.description }
isCompleted={ todo.isCompleted }
isCompleted={ completedTodos.includes( todo.id ) }
isDismissing={ dismissingIds.has( todo.id ) }
onDismiss={ ( e ) => handleDismiss( todo.id, e ) }
onClick={ async () => {
if ( todo.action.type === 'tab' ) {
const tabId =
@ -23,6 +67,13 @@ const TodoSettingsBlock = ( { todosData, className = '' } ) => {
} else if ( todo.action.type === 'external' ) {
window.open( todo.action.url, '_blank' );
}
if ( todo.action.modal ) {
setActiveModal( todo.action.modal );
}
if ( todo.action.highlight ) {
setActiveHighlight( todo.action.highlight );
}
} }
/>
) ) }
@ -30,12 +81,19 @@ const TodoSettingsBlock = ( { todosData, className = '' } ) => {
);
};
const TodoItem = ( { title, description, isCompleted, onClick } ) => {
const TodoItem = ( {
title,
description,
isCompleted,
isDismissing,
onClick,
onDismiss,
} ) => {
return (
<div
className={ `ppcp-r-todo-item ${
isCompleted ? 'is-completed' : ''
}` }
} ${ isDismissing ? 'is-dismissing' : '' }` }
onClick={ onClick }
>
<div className="ppcp-r-todo-item__inner">
@ -54,6 +112,13 @@ const TodoItem = ( { title, description, isCompleted, onClick } ) => {
</div>
) }
</div>
<button
className="ppcp-r-todo-item__dismiss"
onClick={ onDismiss }
aria-label="Dismiss todo item"
>
<span className="dashicons dashicons-no-alt"></span>
</button>
</div>
</div>
);

View file

@ -14,7 +14,9 @@ import SettingsCard from '../../../ReusableComponents/SettingsCard';
import { TITLE_BADGE_POSITIVE } from '../../../ReusableComponents/TitleBadge';
import { useTodos } from '../../../../data/todos/hooks';
import { useMerchantInfo } from '../../../../data/common/hooks';
import { STORE_NAME } from '../../../../data/common';
import { STORE_NAME as COMMON_STORE_NAME } from '../../../../data/common';
import { STORE_NAME as TODOS_STORE_NAME } from '../../../../data/todos';
import { getFeatures } from '../Components/Overview/features-config';
import {
@ -23,29 +25,9 @@ import {
} from '../../../ReusableComponents/Icons';
const TabOverview = () => {
const { todos, isReady: areTodosReady } = useTodos();
// Don't render todos section until data is ready
const showTodos = areTodosReady && todos.length > 0;
return (
<div className="ppcp-r-tab-overview">
{ showTodos && (
<SettingsCard
className="ppcp-r-tab-overview-todo"
title={ __(
'Things to do next',
'woocommerce-paypal-payments'
) }
description={ __(
'Complete these tasks to keep your store updated with the latest products and services.',
'woocommerce-paypal-payments'
) }
>
<TodoSettingsBlock todosData={ todos } />
</SettingsCard>
) }
<OverviewTodos />
<OverviewFeatures />
<OverviewHelp />
</div>
@ -54,11 +36,82 @@ const TabOverview = () => {
export default TabOverview;
const OverviewTodos = () => {
const [ isResetting, setIsResetting ] = useState( false );
const { todos, isReady: areTodosReady, dismissTodo } = useTodos();
const { setActiveModal, setActiveHighlight } =
useDispatch( COMMON_STORE_NAME );
const { resetDismissedTodos, setDismissedTodos } =
useDispatch( TODOS_STORE_NAME );
const { createSuccessNotice } = useDispatch( noticesStore );
const showTodos = areTodosReady && todos.length > 0;
const resetHandler = async () => {
setIsResetting( true );
try {
await setDismissedTodos( [] );
await resetDismissedTodos();
createSuccessNotice(
__(
'Dismissed items restored successfully.',
'woocommerce-paypal-payments'
),
{ icon: NOTIFICATION_SUCCESS }
);
} finally {
setIsResetting( false );
}
};
if ( ! showTodos ) {
return null;
}
return (
<SettingsCard
className="ppcp-r-tab-overview-todo"
title={ __( 'Things to do next', 'woocommerce-paypal-payments' ) }
description={
<>
<p>
{ __(
'Complete these tasks to keep your store updated with the latest products and services.',
'woocommerce-paypal-payments'
) }
</p>
<Button
variant="tertiary"
onClick={ resetHandler }
disabled={ isResetting }
>
<Icon icon={ reusableBlock } size={ 18 } />
{ isResetting
? __( 'Restoring…', 'woocommerce-paypal-payments' )
: __(
'Restore dismissed Things To Do',
'woocommerce-paypal-payments'
) }
</Button>
</>
}
>
<TodoSettingsBlock
todosData={ todos }
setActiveModal={ setActiveModal }
setActiveHighlight={ setActiveHighlight }
onDismissTodo={ dismissTodo }
/>
</SettingsCard>
);
};
const OverviewFeatures = () => {
const [ isRefreshing, setIsRefreshing ] = useState( false );
const { merchant, features: merchantFeatures } = useMerchantInfo();
const { refreshFeatureStatuses, setActiveModal } =
useDispatch( STORE_NAME );
useDispatch( COMMON_STORE_NAME );
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
@ -68,7 +121,7 @@ const OverviewFeatures = () => {
[ setActiveModal ]
);
// Map merchant features status to our config
// Map merchant features status to the config
const features = useMemo( () => {
return featuresData.map( ( feature ) => {
const merchantFeature = merchantFeatures?.[ feature.id ];

View file

@ -1,160 +0,0 @@
import { __ } from '@wordpress/i18n';
import { selectTab, TAB_IDS } from '../../../utils/tabSelector';
export const todosData = [
{
id: 'enable_fastlane',
title: __( 'Enable Fastlane', 'woocommerce-paypal-payments' ),
description: __(
'Accelerate your guest checkout with Fastlane by PayPal.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () =>
selectTab( TAB_IDS.PAYMENT_METHODS, 'ppcp-card-payments-card' ),
},
{
id: 'enable_credit_debit_cards',
title: __(
'Enable Credit and Debit Cards on your checkout',
'woocommerce-paypal-payments'
),
description: __(
'Credit and Debit Cards is now available for Blocks checkout pages.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () =>
selectTab( TAB_IDS.PAYMENT_METHODS, 'ppcp-card-payments-card' ),
},
{
id: 'enable_pay_later_messaging',
title: __(
'Enable Pay Later messaging',
'woocommerce-paypal-payments'
),
description: __(
'Show Pay Later messaging to boost conversion rate and increase cart size.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => selectTab( TAB_IDS.OVERVIEW, 'pay_later_messaging' ),
},
{
id: 'configure_paypal_subscription',
title: __(
'Configure a PayPal Subscription',
'woocommerce-paypal-payments'
),
description: __(
'Connect a subscriptions-type product from WooCommerce with PayPal.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => {
console.log(
'Take merchant to product list, filtered with subscription-type products'
);
},
},
{
id: 'register_domain_apple_pay',
title: __(
'Register Domain for Apple Pay',
'woocommerce-paypal-payments'
),
description: __(
'To enable Apple Pay, you must register your domain with PayPal.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => selectTab( TAB_IDS.OVERVIEW, 'apple_pay' ),
},
{
id: 'add_digital_wallets_to_account',
title: __(
'Add digital wallets to your account',
'woocommerce-paypal-payments'
),
description: __(
'Add the ability to accept Apple Pay & Google Pay to your PayPal account.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => {
console.log(
'Take merchant to PayPal to enable Apple Pay & Google Pay'
);
},
},
{
id: 'add_apple_pay_to_account',
title: __(
'Add Apple Pay to your account',
'woocommerce-paypal-payments'
),
description: __(
'Add the ability to accept Apple Pay to your PayPal account.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => {
console.log( 'Take merchant to PayPal to enable Apple Pay' );
},
},
{
id: 'add_google_pay_to_account',
title: __(
'Add Google Pay to your account',
'woocommerce-paypal-payments'
),
description: __(
'Add the ability to accept Google Pay to your PayPal account.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => {
console.log( 'Take merchant to PayPal to enable Google Pay' );
},
},
{
id: 'enable_apple_pay',
title: __( 'Enable Apple Pay', 'woocommerce-paypal-payments' ),
description: __(
'Allow your buyers to check out via Apple Pay.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => selectTab( TAB_IDS.OVERVIEW, 'apple_pay' ),
},
{
id: 'enable_google_pay',
title: __( 'Enable Google Pay', 'woocommerce-paypal-payments' ),
description: __(
'Allow your buyers to check out via Google Pay.',
'woocommerce-paypal-payments'
),
isCompleted: () => {
return false;
},
onClick: () => selectTab( TAB_IDS.OVERVIEW, 'google_pay' ),
},
];

View file

@ -77,6 +77,15 @@ export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
export const setActiveModal = ( activeModal ) =>
setTransient( 'activeModal', activeModal );
/**
* Transient. Sets the active settings highlight.
*
* @param {string} activeHighlight
* @return {Action} The action.
*/
export const setActiveHighlight = ( activeHighlight ) =>
setTransient( 'activeHighlight', activeHighlight );
/**
* Transient (Activity): Marks the start of an async activity
* Think of it as "setIsBusy(true)"

View file

@ -28,6 +28,8 @@ const useHooks = () => {
// Transient accessors.
const [ isReady ] = useTransient( 'isReady' );
const [ activeModal, setActiveModal ] = useTransient( 'activeModal' );
const [ activeHighlight, setActiveHighlight ] =
useTransient( 'activeHighlight' );
// Persistent accessors.
const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' );
@ -62,6 +64,8 @@ const useHooks = () => {
isReady,
activeModal,
setActiveModal,
activeHighlight,
setActiveHighlight,
isSandboxMode,
setSandboxMode: ( state ) => {
return savePersistent( setSandboxMode, state );
@ -162,6 +166,11 @@ export const useActiveModal = () => {
return { activeModal, setActiveModal };
};
export const useActiveHighlight = () => {
const { activeHighlight, setActiveHighlight } = useHooks();
return { activeHighlight, setActiveHighlight };
};
// -- Not using the `useHooks()` data provider --
export const useBusyState = () => {

View file

@ -16,6 +16,7 @@ const defaultTransient = Object.freeze( {
isReady: false,
activities: new Map(),
activeModal: '',
activeHighlight: '',
// Read only values, provided by the server via hydrate.
merchant: Object.freeze( {

View file

@ -23,14 +23,19 @@ export const initTodoSync = () => {
try {
const paymentState = select( 'wc/paypal/payment' ).persistentData();
const todosState = select( 'wc/paypal/todos' ).getTodos();
const completedTodos =
select( 'wc/paypal/todos' ).getCompletedTodos();
// Skip if states haven't been initialized yet
if ( ! paymentState || ! todosState || ! previousPaymentState ) {
if ( ! paymentState || ! previousPaymentState ) {
previousPaymentState = paymentState;
isProcessing = false;
return;
}
// Track which todos should be marked as completed
let newCompletedTodos = [ ...( completedTodos || [] ) ];
Object.entries( TODO_TRIGGERS ).forEach(
( [ paymentMethod, todoId ] ) => {
const wasEnabled =
@ -38,26 +43,34 @@ export const initTodoSync = () => {
const isEnabled = paymentState[ paymentMethod ]?.enabled;
if ( wasEnabled !== isEnabled ) {
const todoToUpdate = todosState.find(
( todo ) => todo.id === todoId
);
if ( todoToUpdate ) {
const updatedTodos = todosState.map( ( todo ) =>
todo.id === todoId
? { ...todo, isCompleted: isEnabled }
: todo
);
dispatch( 'wc/paypal/todos' ).setTodos(
updatedTodos
if ( isEnabled ) {
// Add to completed todos if not already there
if ( ! newCompletedTodos.includes( todoId ) ) {
newCompletedTodos.push( todoId );
}
} else {
// Remove from completed todos
newCompletedTodos = newCompletedTodos.filter(
( id ) => id !== todoId
);
}
}
}
);
previousPaymentState = paymentState;
// Only dispatch if there are changes
if (
newCompletedTodos.length !== completedTodos.length ||
newCompletedTodos.some(
( id ) => ! completedTodos.includes( id )
)
) {
dispatch( 'wc/paypal/todos' ).setCompletedTodos(
newCompletedTodos
);
}
previousPaymentState = { ...paymentState };
} catch ( error ) {
console.error( 'Error in todo sync:', error );
} finally {

View file

@ -7,10 +7,14 @@
export default {
// Transient data
SET_TRANSIENT: 'TODOS:SET_TRANSIENT',
SET_COMPLETED_TODOS: 'TODOS:SET_COMPLETED_TODOS',
// Persistent data
SET_TODOS: 'TODOS:SET_TODOS',
SET_DISMISSED_TODOS: 'TODOS:SET_DISMISSED_TODOS',
// Controls
DO_FETCH_TODOS: 'TODOS:DO_FETCH_TODOS',
DO_PERSIST_DATA: 'TODOS:DO_PERSIST_DATA',
DO_RESET_DISMISSED_TODOS: 'TODOS:DO_RESET_DISMISSED_TODOS',
};

View file

@ -7,7 +7,9 @@
* @file
*/
import { select } from '@wordpress/data';
import ACTION_TYPES from './action-types';
import { STORE_NAME } from './constants';
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
@ -19,6 +21,32 @@ export const setTodos = ( todos ) => ( {
payload: todos,
} );
export const setDismissedTodos = ( dismissedTodos ) => ( {
type: ACTION_TYPES.SET_DISMISSED_TODOS,
payload: dismissedTodos,
} );
export const fetchTodos = function* () {
yield { type: ACTION_TYPES.DO_FETCH_TODOS };
};
export const persist = function* () {
const data = yield select( STORE_NAME ).persistentData();
yield { type: ACTION_TYPES.DO_PERSIST_DATA, data };
};
export const resetDismissedTodos = function* () {
const result = yield { type: ACTION_TYPES.DO_RESET_DISMISSED_TODOS };
if ( result && result.success ) {
// After successful reset, fetch fresh todos
yield fetchTodos();
}
return result;
};
export const setCompletedTodos = ( completedTodos ) => ( {
type: ACTION_TYPES.SET_COMPLETED_TODOS,
payload: completedTodos,
} );

View file

@ -6,3 +6,6 @@
export const STORE_NAME = 'wc/paypal/todos';
export const REST_PATH = '/wc/v3/wc_paypal/todos';
export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/todos';
export const REST_RESET_DISMISSED_TODOS_PATH =
'/wc/v3/wc_paypal/reset-dismissed-todos';

View file

@ -8,7 +8,11 @@
*/
import apiFetch from '@wordpress/api-fetch';
import { REST_PATH } from './constants';
import {
REST_PATH,
REST_PERSIST_PATH,
REST_RESET_DISMISSED_TODOS_PATH,
} from './constants';
import ACTION_TYPES from './action-types';
export const controls = {
@ -19,4 +23,25 @@ export const controls = {
} );
return response?.data || [];
},
async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) {
return await apiFetch( {
path: REST_PERSIST_PATH,
method: 'POST',
data,
} );
},
async [ ACTION_TYPES.DO_RESET_DISMISSED_TODOS ]() {
try {
return await apiFetch( {
path: REST_RESET_DISMISSED_TODOS_PATH,
method: 'POST',
} );
} catch ( e ) {
return {
success: false,
error: e,
message: e.message,
};
}
},
};

View file

@ -9,25 +9,87 @@
import { useSelect, useDispatch } from '@wordpress/data';
import { STORE_NAME } from './constants';
import { createHooksForStore } from '../utils';
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
const ensureArray = ( value ) => {
if ( ! value ) {
return [];
}
return Array.isArray( value ) ? value : Object.values( value );
};
const useHooks = () => {
const { useTransient } = createHooksForStore( STORE_NAME );
const { fetchTodos, setDismissedTodos, setCompletedTodos, persist } =
useDispatch( STORE_NAME );
// Read-only flags and derived state.
const [ isReady ] = useTransient( 'isReady' );
// Get todos data from store
const { todos, dismissedTodos, completedTodos } = useSelect( ( select ) => {
const store = select( STORE_NAME );
return {
todos: ensureArray( store.getTodos() ),
dismissedTodos: ensureArray( store.getDismissedTodos() ),
completedTodos: ensureArray( store.getCompletedTodos() ),
};
}, [] );
const dismissedSet = new Set( dismissedTodos );
const dismissTodo = async ( todoId ) => {
if ( ! dismissedSet.has( todoId ) ) {
const newDismissedTodos = [ ...dismissedTodos, todoId ];
await setDismissedTodos( newDismissedTodos );
}
};
const setTodoCompleted = async ( todoId, isCompleted ) => {
let newCompletedTodos;
if ( isCompleted ) {
newCompletedTodos = [ ...completedTodos, todoId ];
} else {
newCompletedTodos = completedTodos.filter(
( id ) => id !== todoId
);
}
await setCompletedTodos( newCompletedTodos );
};
const filteredTodos = todos.filter(
( todo ) => ! dismissedSet.has( todo.id )
);
export const useTodos = () => {
const todos = useSelect(
( select ) => select( STORE_NAME ).getTodos(),
[]
);
const isReady = useTransient( 'isReady' );
const { fetchTodos } = useDispatch( STORE_NAME );
return {
todos,
persist,
isReady,
todos: filteredTodos,
dismissedTodos,
completedTodos,
fetchTodos,
dismissTodo,
setTodoCompleted,
};
};
export const useStore = () => {
const { persist, isReady } = useHooks();
return { persist, isReady };
};
export const useTodos = () => {
const { todos, fetchTodos, dismissTodo, setTodoCompleted, isReady } =
useHooks();
return { todos, fetchTodos, dismissTodo, setTodoCompleted, isReady };
};
export const useDismissedTodos = () => {
const { dismissedTodos } = useHooks();
return { dismissedTodos };
};
export const useCompletedTodos = () => {
const { completedTodos } = useHooks();
return { completedTodos };
};

View file

@ -18,6 +18,7 @@ import ACTION_TYPES from './action-types';
*/
const defaultTransient = Object.freeze( {
isReady: false,
completedTodos: [],
} );
/**
@ -26,6 +27,7 @@ const defaultTransient = Object.freeze( {
*/
const defaultPersistent = Object.freeze( {
todos: [],
dismissedTodos: [],
} );
// Reducer logic.
@ -60,6 +62,41 @@ const reducer = createReducer( defaultTransient, defaultPersistent, {
return changePersistent( state, { todos: payload } );
},
/**
* Updates dismissed todos list while preserving existing entries
*
* @param {Object} state Current state
* @param {Array} payload Array of todo IDs to mark as dismissed
* @return {Object} Updated state
*/
[ ACTION_TYPES.SET_DISMISSED_TODOS ]: ( state, payload ) => {
return changePersistent( state, {
dismissedTodos: Array.isArray( payload ) ? payload : [],
} );
},
/**
* Updates completed todos list while preserving existing entries
*
* @param {Object} state Current state
* @param {Array} payload Array of todo IDs to mark as completed
* @return {Object} Updated state
*/
[ ACTION_TYPES.SET_COMPLETED_TODOS ]: ( state, payload ) => {
return changeTransient( state, {
completedTodos: Array.isArray( payload ) ? payload : [],
} );
},
/**
* Resets dismissed todos list to an empty array
* @param {Object} state Current state
* @return {Object} Updated state
*/
[ ACTION_TYPES.DO_RESET_DISMISSED_TODOS ]: ( state ) => {
return changePersistent( state, { dismissedTodos: [] } );
},
/**
* Resets state to defaults while maintaining initialization status
*

View file

@ -18,12 +18,11 @@ export const resolvers = {
try {
const response = yield apiFetch( { path: REST_PATH } );
// Make sure we're accessing the correct part of the response
const todos = response?.data || [];
const { todos = [], dismissedTodos = [] } = response?.data || {};
yield dispatch( STORE_NAME ).setTodos( todos );
yield dispatch( STORE_NAME ).setDismissedTodos( dismissedTodos );
yield dispatch( STORE_NAME ).setIsReady( true );
} catch ( e ) {
console.error( 'Resolver error:', e );
yield dispatch( STORE_NAME ).setIsReady( false );

View file

@ -22,7 +22,16 @@ export const transientData = ( state ) => {
};
export const getTodos = ( state ) => {
// Access todos directly from state first
const todos = state?.todos || persistentData( state ).todos || EMPTY_ARR;
return todos;
const todos = state?.todos || persistentData( state ).todos;
return todos || EMPTY_ARR;
};
export const getDismissedTodos = ( state ) => {
const dismissed =
state?.dismissedTodos || persistentData( state ).dismissedTodos;
return dismissed || EMPTY_ARR;
};
export const getCompletedTodos = ( state ) => {
return state?.completedTodos || EMPTY_ARR; // Only look at root state, not persistent data
};

View file

@ -6,6 +6,7 @@ import {
PaymentHooks,
SettingsHooks,
StylingHooks,
TodosHooks,
} from '../data';
export const useSaveSettings = () => {
@ -14,6 +15,7 @@ export const useSaveSettings = () => {
const { persist: persistPayment } = PaymentHooks.useStore();
const { persist: persistSettings } = SettingsHooks.useStore();
const { persist: persistStyling } = StylingHooks.useStore();
const { persist: persistTodos } = TodosHooks.useStore();
const { persist: persistPayLaterMessaging } =
PayLaterMessagingHooks.useStore();
@ -36,6 +38,7 @@ export const useSaveSettings = () => {
'Save styling details',
persistStyling
);
withActivity( 'persist-todos', 'Save todos state', persistTodos );
withActivity(
'persist-pay-later-messaging',
'Save pay later messaging details',
@ -45,6 +48,7 @@ export const useSaveSettings = () => {
persistPayment,
persistSettings,
persistStyling,
persistTodos,
persistPayLaterMessaging,
withActivity,
] );

View file

@ -50,10 +50,18 @@ export const selectTab = ( tabId, scrollToId ) => {
// Resolve after scroll animation
setTimeout( resolve, 300 );
} else {
console.error(
`Failed to scroll: Element with ID "${
scrollToId || 'ppcp-settings-container'
}" not found`
);
resolve();
}
}, 100 );
} else {
console.error(
`Failed to select tab: Tab with ID "${ tabId }" not found`
);
resolve();
}
} );