Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nielslange/98183bf36117121905c7bc8b1a770879 to your computer and use it in GitHub Desktop.
Save nielslange/98183bf36117121905c7bc8b1a770879 to your computer and use it in GitHub Desktop.
Gift Wrapping block within WooCommerce Blocks
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useMemo,
useEffect,
useCallback,
} from '@wordpress/element';
import { usePrevious } from '@woocommerce/base-hooks';
import deprecated from '@wordpress/deprecated';
import { useDispatch, useSelect } from '@wordpress/data';
import {
CHECKOUT_STORE_KEY,
PAYMENT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useEventEmitters, reducer as emitReducer } from './event-emit';
import { emitterCallback, noticeContexts } from '../../../event-emit';
import { useStoreEvents } from '../../../hooks/use-store-events';
import {
getExpressPaymentMethods,
getPaymentMethods,
} from '../../../../../blocks-registry/payment-methods/registry';
import { useEditorContext } from '../../editor-context';
type CheckoutEventsContextType = {
// Submits the checkout and begins processing.
onSubmit: () => void;
// Deprecated in favour of onCheckoutSuccess.
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutFail.
onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutValidationBeforeProcessing.
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutValidation.
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire if the api call to /checkout is successful
onCheckoutSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire if the api call to /checkout fails
onCheckoutFail: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout performs validation on the form
onCheckoutValidation: ReturnType< typeof emitterCallback >;
};
const CheckoutEventsContext = createContext< CheckoutEventsContextType >( {
onSubmit: () => void null,
onCheckoutAfterProcessingWithSuccess: () => () => void null, // deprecated for onCheckoutSuccess
onCheckoutAfterProcessingWithError: () => () => void null, // deprecated for onCheckoutFail
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
onCheckoutValidationBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidation
onCheckoutSuccess: () => () => void null,
onCheckoutFail: () => () => void null,
onCheckoutValidation: () => () => void null,
} );
export const useCheckoutEventsContext = () => {
return useContext( CheckoutEventsContext );
};
/**
* Checkout Events provider
* Emit Checkout events and provide access to Checkout event handlers
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {string} props.redirectUrl Initialize what the checkout will redirect to after successful submit.
*/
export const CheckoutEventsProvider = ( {
children,
redirectUrl,
}: {
children: React.ReactChildren;
redirectUrl: string;
} ): JSX.Element => {
const paymentMethods = getPaymentMethods();
const expressPaymentMethods = getExpressPaymentMethods();
const { isEditor } = useEditorContext();
const { __internalUpdateAvailablePaymentMethods } =
useDispatch( PAYMENT_STORE_KEY );
// Update the payment method store when paymentMethods or expressPaymentMethods changes.
// Ensure this happens in the editor even if paymentMethods is empty. This won't happen instantly when the objects
// are updated, but on the next re-render.
useEffect( () => {
if (
! isEditor &&
Object.keys( paymentMethods ).length === 0 &&
Object.keys( expressPaymentMethods ).length === 0
) {
return;
}
__internalUpdateAvailablePaymentMethods();
}, [
isEditor,
paymentMethods,
expressPaymentMethods,
__internalUpdateAvailablePaymentMethods,
] );
const {
__internalSetRedirectUrl,
__internalEmitValidateEvent,
__internalEmitAfterProcessingEvents,
__internalSetBeforeProcessing,
} = useDispatch( CHECKOUT_STORE_KEY );
const {
checkoutRedirectUrl,
checkoutStatus,
isCheckoutBeforeProcessing,
isCheckoutAfterProcessing,
checkoutHasError,
checkoutOrderId,
checkoutOrderNotes,
checkoutCustomerId,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
checkoutRedirectUrl: store.getRedirectUrl(),
checkoutStatus: store.getCheckoutStatus(),
isCheckoutBeforeProcessing: store.isBeforeProcessing(),
isCheckoutAfterProcessing: store.isAfterProcessing(),
checkoutHasError: store.hasError(),
checkoutOrderId: store.getOrderId(),
checkoutOrderNotes: store.getOrderNotes(),
checkoutGiftWrapping: store.getGiftWrapping(),
checkoutCustomerId: store.getCustomerId(),
};
} );
if ( redirectUrl && redirectUrl !== checkoutRedirectUrl ) {
__internalSetRedirectUrl( redirectUrl );
}
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { dispatchCheckoutEvent } = useStoreEvents();
const { checkoutNotices, paymentNotices, expressPaymentNotices } =
useSelect( ( select ) => {
const { getNotices } = select( 'core/notices' );
const checkoutContexts = Object.values( noticeContexts ).filter(
( context ) =>
context !== noticeContexts.PAYMENTS &&
context !== noticeContexts.EXPRESS_PAYMENTS
);
const allCheckoutNotices = checkoutContexts.reduce(
( acc, context ) => {
return [ ...acc, ...getNotices( context ) ];
},
[]
);
return {
checkoutNotices: allCheckoutNotices,
paymentNotices: getNotices( noticeContexts.PAYMENTS ),
expressPaymentNotices: getNotices(
noticeContexts.EXPRESS_PAYMENTS
),
};
}, [] );
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const { onCheckoutValidation, onCheckoutSuccess, onCheckoutFail } =
useEventEmitters( observerDispatch );
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
/**
* @deprecated use onCheckoutValidation instead
*
* To prevent the deprecation message being shown at render time
* we need an extra function between useMemo and event emitters
* so that the deprecated message gets shown only at invocation time.
* (useMemo calls the passed function at render time)
* See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4039/commits/a502d1be8828848270993264c64220731b0ae181
*/
const onCheckoutBeforeProcessing = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutValidation > ) {
deprecated( 'onCheckoutBeforeProcessing', {
alternative: 'onCheckoutValidation',
plugin: 'WooCommerce Blocks',
} );
return onCheckoutValidation( ...args );
};
}, [ onCheckoutValidation ] );
/**
* @deprecated use onCheckoutValidation instead
*/
const onCheckoutValidationBeforeProcessing = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutValidation > ) {
deprecated( 'onCheckoutValidationBeforeProcessing', {
since: '9.7.0',
alternative: 'onCheckoutValidation',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
} );
return onCheckoutValidation( ...args );
};
}, [ onCheckoutValidation ] );
/**
* @deprecated use onCheckoutSuccess instead
*/
const onCheckoutAfterProcessingWithSuccess = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutSuccess > ) {
deprecated( 'onCheckoutAfterProcessingWithSuccess', {
since: '9.7.0',
alternative: 'onCheckoutSuccess',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
} );
return onCheckoutSuccess( ...args );
};
}, [ onCheckoutSuccess ] );
/**
* @deprecated use onCheckoutFail instead
*/
const onCheckoutAfterProcessingWithError = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutFail > ) {
deprecated( 'onCheckoutAfterProcessingWithError', {
since: '9.7.0',
alternative: 'onCheckoutFail',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
} );
return onCheckoutFail( ...args );
};
}, [ onCheckoutFail ] );
// Emit CHECKOUT_VALIDATE event and set the error state based on the response of
// the registered callbacks
useEffect( () => {
if ( isCheckoutBeforeProcessing ) {
__internalEmitValidateEvent( {
observers: currentObservers.current,
setValidationErrors,
} );
}
}, [
isCheckoutBeforeProcessing,
setValidationErrors,
__internalEmitValidateEvent,
] );
const previousStatus = usePrevious( checkoutStatus );
const previousHasError = usePrevious( checkoutHasError );
// Emit CHECKOUT_SUCCESS and CHECKOUT_FAIL events
// and set checkout errors according to the callback responses
useEffect( () => {
if (
checkoutStatus === previousStatus &&
checkoutHasError === previousHasError
) {
return;
}
if ( isCheckoutAfterProcessing ) {
__internalEmitAfterProcessingEvents( {
observers: currentObservers.current,
notices: {
checkoutNotices,
paymentNotices,
expressPaymentNotices,
},
} );
}
}, [
checkoutStatus,
checkoutHasError,
checkoutRedirectUrl,
checkoutOrderId,
checkoutCustomerId,
checkoutOrderNotes,
isCheckoutAfterProcessing,
isCheckoutBeforeProcessing,
previousStatus,
previousHasError,
checkoutNotices,
expressPaymentNotices,
paymentNotices,
__internalEmitValidateEvent,
__internalEmitAfterProcessingEvents,
] );
const onSubmit = useCallback( () => {
dispatchCheckoutEvent( 'submit' );
__internalSetBeforeProcessing();
}, [ dispatchCheckoutEvent, __internalSetBeforeProcessing ] );
const checkoutEventHandlers = {
onSubmit,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onCheckoutSuccess,
onCheckoutFail,
onCheckoutValidation,
};
return (
<CheckoutEventsContext.Provider value={ checkoutEventHandlers }>
{ children }
</CheckoutEventsContext.Provider>
);
};
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import {
useEffect,
useRef,
useCallback,
useState,
useMemo,
} from '@wordpress/element';
import {
emptyHiddenAddressFields,
removeAllNotices,
} from '@woocommerce/base-utils';
import { useDispatch, useSelect, select as selectStore } from '@wordpress/data';
import {
CHECKOUT_STORE_KEY,
PAYMENT_STORE_KEY,
VALIDATION_STORE_KEY,
CART_STORE_KEY,
processErrorResponse,
} from '@woocommerce/block-data';
import {
getPaymentMethods,
getExpressPaymentMethods,
} from '@woocommerce/blocks-registry';
import {
ApiResponse,
CheckoutResponseSuccess,
CheckoutResponseError,
assertResponseIsValid,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
import { useCheckoutEventsContext } from './checkout-events';
import { useShippingDataContext } from './shipping';
import { useStoreCart } from '../../hooks/cart/use-store-cart';
/**
* CheckoutProcessor component.
*
* Subscribes to checkout context and triggers processing via the API.
*/
const CheckoutProcessor = () => {
const { onCheckoutValidation } = useCheckoutEventsContext();
const {
hasError: checkoutHasError,
redirectUrl,
isProcessing: checkoutIsProcessing,
isBeforeProcessing: checkoutIsBeforeProcessing,
isComplete: checkoutIsComplete,
orderNotes,
giftWrapping,
giftWrappingNote,
shouldCreateAccount,
extensionData,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
hasError: store.hasError(),
redirectUrl: store.getRedirectUrl(),
isProcessing: store.isProcessing(),
isBeforeProcessing: store.isBeforeProcessing(),
isComplete: store.isComplete(),
orderNotes: store.getOrderNotes(),
giftWrapping: store.getGiftWrapping(),
giftWrappingNote: store.getGiftWrappingNote(),
shouldCreateAccount: store.getShouldCreateAccount(),
extensionData: store.getExtensionData(),
};
} );
const { __internalSetHasError, __internalProcessCheckoutResponse } =
useDispatch( CHECKOUT_STORE_KEY );
const hasValidationErrors = useSelect(
( select ) => select( VALIDATION_STORE_KEY ).hasValidationErrors
);
const { shippingErrorStatus } = useShippingDataContext();
const { billingAddress, shippingAddress } = useSelect( ( select ) =>
select( CART_STORE_KEY ).getCustomerData()
);
const { cartNeedsPayment, cartNeedsShipping, receiveCartContents } =
useStoreCart();
const {
activePaymentMethod,
paymentMethodData,
isExpressPaymentMethodActive,
hasPaymentError,
isPaymentReady,
shouldSavePayment,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
paymentMethodData: store.getPaymentMethodData(),
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
hasPaymentError: store.hasPaymentError(),
isPaymentReady: store.isPaymentReady(),
shouldSavePayment: store.getShouldSavePaymentMethod(),
};
}, [] );
const paymentMethods = getPaymentMethods();
const expressPaymentMethods = getExpressPaymentMethods();
const currentBillingAddress = useRef( billingAddress );
const currentShippingAddress = useRef( shippingAddress );
const currentRedirectUrl = useRef( redirectUrl );
const [ isProcessingOrder, setIsProcessingOrder ] = useState( false );
const paymentMethodId = useMemo( () => {
const merged = {
...expressPaymentMethods,
...paymentMethods,
};
return merged?.[ activePaymentMethod ]?.paymentMethodId;
}, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] );
const checkoutWillHaveError =
( hasValidationErrors() && ! isExpressPaymentMethodActive ) ||
hasPaymentError ||
shippingErrorStatus.hasError;
const paidAndWithoutErrors =
! checkoutHasError &&
! checkoutWillHaveError &&
( isPaymentReady || ! cartNeedsPayment ) &&
checkoutIsProcessing;
// Determine if checkout has an error.
useEffect( () => {
if (
checkoutWillHaveError !== checkoutHasError &&
( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
! isExpressPaymentMethodActive
) {
__internalSetHasError( checkoutWillHaveError );
}
}, [
checkoutWillHaveError,
checkoutHasError,
checkoutIsProcessing,
checkoutIsBeforeProcessing,
isExpressPaymentMethodActive,
__internalSetHasError,
] );
// Keep the billing, shipping and redirectUrl current
useEffect( () => {
currentBillingAddress.current = billingAddress;
currentShippingAddress.current = shippingAddress;
currentRedirectUrl.current = redirectUrl;
}, [ billingAddress, shippingAddress, redirectUrl ] );
const checkValidation = useCallback( () => {
if ( hasValidationErrors() ) {
// If there is a shipping rates validation error, return the error message to be displayed.
if (
selectStore( VALIDATION_STORE_KEY ).getValidationError(
'shipping-rates-error'
) !== undefined
) {
return {
errorMessage: __(
'Sorry, this order requires a shipping option.',
'woo-gutenberg-products-block'
),
};
}
return false;
}
if ( hasPaymentError ) {
return {
errorMessage: __(
'There was a problem with your payment option.',
'woo-gutenberg-products-block'
),
context: 'wc/checkout/payments',
};
}
if ( shippingErrorStatus.hasError ) {
return {
errorMessage: __(
'There was a problem with your shipping option.',
'woo-gutenberg-products-block'
),
context: 'wc/checkout/shipping-methods',
};
}
return true;
}, [ hasValidationErrors, hasPaymentError, shippingErrorStatus.hasError ] );
// Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event
useEffect( () => {
let unsubscribeProcessing: () => void;
if ( ! isExpressPaymentMethodActive ) {
unsubscribeProcessing = onCheckoutValidation( checkValidation, 0 );
}
return () => {
if (
! isExpressPaymentMethodActive &&
typeof unsubscribeProcessing === 'function'
) {
unsubscribeProcessing();
}
};
}, [
onCheckoutValidation,
checkValidation,
isExpressPaymentMethodActive,
] );
// Redirect when checkout is complete and there is a redirect url.
useEffect( () => {
if ( currentRedirectUrl.current ) {
window.location.href = currentRedirectUrl.current;
}
}, [ checkoutIsComplete ] );
// POST to the Store API and process and display any errors, or set order complete
const processOrder = useCallback( async () => {
if ( isProcessingOrder ) {
return;
}
setIsProcessingOrder( true );
removeAllNotices();
const paymentData = cartNeedsPayment
? {
payment_method: paymentMethodId,
payment_data: preparePaymentData(
paymentMethodData,
shouldSavePayment,
activePaymentMethod
),
}
: {};
const data = {
shipping_address: cartNeedsShipping
? emptyHiddenAddressFields( currentShippingAddress.current )
: undefined,
billing_address: emptyHiddenAddressFields(
currentBillingAddress.current
),
customer_note: orderNotes,
gift_wrapping: giftWrapping,
gift_wrapping_note: giftWrappingNote,
create_account: shouldCreateAccount,
...paymentData,
extensions: { ...extensionData },
};
triggerFetch( {
path: '/wc/store/v1/checkout',
method: 'POST',
data,
cache: 'no-store',
parse: false,
} )
.then( ( response: unknown ) => {
assertResponseIsValid< CheckoutResponseSuccess >( response );
processCheckoutResponseHeaders( response.headers );
if ( ! response.ok ) {
throw response;
}
return response.json();
} )
.then( ( responseJson: CheckoutResponseSuccess ) => {
__internalProcessCheckoutResponse( responseJson );
setIsProcessingOrder( false );
} )
.catch( ( errorResponse: ApiResponse< CheckoutResponseError > ) => {
processCheckoutResponseHeaders( errorResponse?.headers );
try {
// This attempts to parse a JSON error response where the status code was 4xx/5xx.
errorResponse
.json()
.then(
( response ) => response as CheckoutResponseError
)
.then( ( response: CheckoutResponseError ) => {
if ( response.data?.cart ) {
// We don't want to receive the address here because it will overwrite fields.
receiveCartContents( response.data.cart );
}
processErrorResponse( response );
__internalProcessCheckoutResponse( response );
} );
} catch {
processErrorResponse( {
code: 'unknown_error',
message: __(
'Something went wrong. Please try placing your order again.',
'woo-gutenberg-products-block'
),
data: null,
} );
}
__internalSetHasError( true );
setIsProcessingOrder( false );
} );
}, [
isProcessingOrder,
cartNeedsPayment,
paymentMethodId,
paymentMethodData,
shouldSavePayment,
activePaymentMethod,
orderNotes,
giftWrapping,
giftWrappingNote,
shouldCreateAccount,
extensionData,
cartNeedsShipping,
receiveCartContents,
__internalSetHasError,
__internalProcessCheckoutResponse,
] );
// Process order if conditions are good.
useEffect( () => {
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
processOrder();
}
}, [ processOrder, paidAndWithoutErrors, isProcessingOrder ] );
return null;
};
export default CheckoutProcessor;
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, createInterpolateElement } from '@wordpress/element';
import { CheckboxControl } from '@woocommerce/blocks-checkout';
import { Textarea } from '@woocommerce/base-components/textarea';
interface GiftWrappingProps {
disabled: boolean;
onChange: ( {
giftWrapping,
giftWrappingNote,
}: {
giftWrapping: boolean;
giftWrappingNote: string;
} ) => void;
placeholder: string;
giftWrappingFee?: string;
value: string;
initialChecked: boolean;
}
const CheckoutGiftWrapping = ( {
disabled,
onChange,
placeholder,
giftWrappingFee,
value,
initialChecked,
}: GiftWrappingProps ): JSX.Element => {
const [ withGiftWrapping, setWithGiftWrapping ] =
useState( initialChecked );
const [ hiddenGiftWrappingText, setHiddenGiftWrappingText ] =
useState( '' );
const label = giftWrappingFee
? createInterpolateElement(
__(
'Add gift wrapping to your order <price/>',
'woo-gutenberg-products-block'
),
{
price: <span>({ giftWrappingFee })</span>,
}
)
: __(
'Add gift wrapping to your order',
'woo-gutenberg-products-block'
);
return (
<div className="wc-block-checkout__gift-wrapping">
<CheckboxControl
disabled={ disabled }
label={ label }
checked={ withGiftWrapping }
onChange={ ( isChecked ) => {
setWithGiftWrapping( isChecked );
if ( isChecked ) {
// When re-enabling the checkbox, store in context the gift wrapping message
// value previously stored in the component state.
onChange( {
giftWrapping: true,
giftWrappingNote: hiddenGiftWrappingText,
} );
return;
}
// When un-checking the checkbox, clear the gift wrapping message value in
// the context but store it in the component state.
onChange( {
giftWrapping: false,
giftWrappingNote: '',
} );
setHiddenGiftWrappingText( value );
} }
/>
{ withGiftWrapping && (
<Textarea
disabled={ disabled }
onTextChange={ ( text ) => {
onChange( {
giftWrapping: withGiftWrapping,
giftWrappingNote: text,
} );
} }
placeholder={ placeholder }
value={ value }
/>
) }
</div>
);
};
export default CheckoutGiftWrapping;
/**
* External dependencies
*/
import {
render,
findByLabelText,
fireEvent,
findByPlaceholderText,
queryByPlaceholderText,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import GiftWrapping from '../index';
describe( 'Checkout gift wrapping', () => {
it( 'Shows a textarea when the checkbox to add gift wrapping is toggled', async () => {
const { container } = render(
<GiftWrapping
disabled={ false }
onChange={ () => null }
value={ '' }
placeholder={ 'Enter a gift wrapping message' }
/>
);
const checkbox = await findByLabelText(
container,
'Add gift wrapping to your order'
);
await userEvent.click( checkbox );
const textarea = await findByPlaceholderText(
container,
'Enter a gift wrapping message'
);
expect( textarea ).toBeTruthy();
} );
it( 'Does not allow the textarea to be shown if disabled', async () => {
const { container } = render(
<GiftWrapping
disabled={ true }
onChange={ () => null }
value={ '' }
placeholder={ 'Enter a note' }
/>
);
const checkbox = await findByLabelText(
container,
'Add gift wrapping to your order'
);
await userEvent.click( checkbox );
const textarea = queryByPlaceholderText( container, 'Enter a note' );
expect( textarea ).toBeNull();
} );
it( 'Retains the gift wrapping message when toggling the textarea on and off', async () => {
const onChange = jest.fn();
const { container, rerender } = render(
<GiftWrapping
disabled={ false }
onChange={ onChange }
value={ '' }
placeholder={ 'Enter a note' }
/>
);
const checkbox = await findByLabelText(
container,
'Add gift wrapping to your order'
);
await userEvent.click( checkbox );
// The onChange handler should not have been called because the value is the same as what was stored
expect( onChange ).not.toHaveBeenCalled();
const textarea = await findByPlaceholderText(
container,
'Enter a note'
);
fireEvent.change( textarea, { target: { value: 'Test message' } } );
expect( onChange ).toHaveBeenLastCalledWith( 'Test message' );
// Rerender here with the new value to simulate the onChange updating the value
rerender(
<GiftWrapping
disabled={ false }
onChange={ onChange }
value={ 'Test message' }
placeholder={ 'Enter a note' }
/>
);
// Toggle off.
await userEvent.click( checkbox );
expect( onChange ).toHaveBeenLastCalledWith( '' );
// Rerender here with an empty value to simulate the onChange updating the value
rerender(
<GiftWrapping
disabled={ false }
onChange={ onChange }
value={ '' }
placeholder={ 'Enter a note' }
/>
);
// Toggle back on.
await userEvent.click( checkbox );
expect( onChange ).toHaveBeenLastCalledWith( 'Test message' );
} );
} );
/**
* External dependencies
*/
import classnames from 'classnames';
import { fields } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
import { registerBlockType, createBlock } from '@wordpress/blocks';
import type { BlockInstance } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import { blockAttributes, deprecatedAttributes } from './attributes';
import './inner-blocks';
import metadata from './block.json';
const settings = {
icon: {
src: (
<Icon
icon={ fields }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
...blockAttributes,
...deprecatedAttributes,
},
edit: Edit,
save: Save,
// Migrates v1 to v2 checkout.
deprecated: [
{
attributes: {
...metadata.attributes,
...blockAttributes,
...deprecatedAttributes,
},
save( { attributes }: { attributes: { className: string } } ) {
return (
<div
className={ classnames(
'is-loading',
attributes.className
) }
/>
);
},
migrate: ( attributes: {
showOrderNotes: boolean;
showPolicyLinks: boolean;
showReturnToCart: boolean;
cartPageId: number;
} ) => {
const {
showOrderNotes,
showPolicyLinks,
showReturnToCart,
cartPageId,
} = attributes;
return [
attributes,
[
createBlock(
'woocommerce/checkout-fields-block',
{},
[
createBlock(
'woocommerce/checkout-express-payment-block',
{},
[]
),
createBlock(
'woocommerce/checkout-contact-information-block',
{},
[]
),
createBlock(
'woocommerce/checkout-shipping-address-block',
{},
[]
),
createBlock(
'woocommerce/checkout-billing-address-block',
{},
[]
),
createBlock(
'woocommerce/checkout-shipping-methods-block',
{},
[]
),
createBlock(
'woocommerce/checkout-payment-block',
{},
[]
),
showOrderNotes
? createBlock(
'woocommerce/checkout-order-note-block',
{},
[]
)
: false,
createBlock(
'woocommerce/checkout-gift-wrapping-block',
{},
[]
),
showPolicyLinks
? createBlock(
'woocommerce/checkout-terms-block',
{},
[]
)
: false,
createBlock(
'woocommerce/checkout-actions-block',
{
showReturnToCart,
cartPageId,
},
[]
),
].filter( Boolean ) as BlockInstance[]
),
createBlock( 'woocommerce/checkout-totals-block', {} ),
],
];
},
isEligible: (
attributes: Record< string, unknown >,
innerBlocks: BlockInstance[]
) => {
return ! innerBlocks.some(
( block: { name: string } ) =>
block.name === 'woocommerce/checkout-fields-block'
);
},
},
],
};
registerBlockType( metadata, settings );
/**
* External dependencies
*/
import classnames from 'classnames';
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import { Main } from '@woocommerce/base-components/sidebar-layout';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
import type { TemplateArray } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { useCheckoutBlockControlsContext } from '../../context';
import {
useForcedLayout,
getAllowedBlocks,
} from '../../../cart-checkout-shared';
import './style.scss';
export const Edit = ( {
clientId,
attributes,
}: {
clientId: string;
attributes: {
className?: string;
isPreview?: boolean;
};
} ): JSX.Element => {
const blockProps = useBlockProps( {
className: classnames(
'wc-block-checkout__main',
attributes?.className
),
} );
const allowedBlocks = getAllowedBlocks( innerBlockAreas.CHECKOUT_FIELDS );
const { addressFieldControls: Controls } =
useCheckoutBlockControlsContext();
const defaultTemplate = [
[ 'woocommerce/checkout-express-payment-block', {}, [] ],
[ 'woocommerce/checkout-contact-information-block', {}, [] ],
[ 'woocommerce/checkout-shipping-method-block', {}, [] ],
[ 'woocommerce/checkout-pickup-options-block', {}, [] ],
[ 'woocommerce/checkout-shipping-address-block', {}, [] ],
[ 'woocommerce/checkout-billing-address-block', {}, [] ],
[ 'woocommerce/checkout-shipping-methods-block', {}, [] ],
[ 'woocommerce/checkout-payment-block', {}, [] ],
[ 'woocommerce/checkout-order-note-block', {}, [] ],
[ 'woocommerce/checkout-gift-wrapping-block', {}, [] ],
[ 'woocommerce/checkout-terms-block', {}, [] ],
[ 'woocommerce/checkout-actions-block', {}, [] ],
].filter( Boolean ) as unknown as TemplateArray;
useForcedLayout( {
clientId,
registeredBlocks: allowedBlocks,
defaultTemplate,
} );
return (
<Main { ...blockProps }>
<Controls />
<form className="wc-block-components-form wc-block-checkout__form">
<InnerBlocks
allowedBlocks={ allowedBlocks }
templateLock={ false }
template={ defaultTemplate }
renderAppender={ InnerBlocks.ButtonBlockAppender }
/>
</form>
</Main>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};
{
"name": "woocommerce/checkout-gift-wrapping-block",
"version": "1.0.0",
"title": "Gift Wrapping",
"description": "Allow customers to add gift wrapping to their order.",
"category": "woocommerce",
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false
},
"attributes": {
"className": {
"type": "string",
"default": ""
},
"giftWrappingFee": {
"type": "string",
"default": "0"
},
"lock": {
"type": "object",
"default": {
"remove": false,
"move": false
}
}
},
"parent": [ "woocommerce/checkout-fields-block" ],
"textdomain": "woo-gutenberg-products-block",
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2
}
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { FormStep } from '@woocommerce/base-components/cart-checkout';
import { useShippingData } from '@woocommerce/base-context/hooks';
import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { formatPrice, getMinorUnit } from '@woocommerce/price-format';
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import CheckoutGiftWrapping from '../../gift-wrapping';
interface BlockProps {
className?: string;
giftWrappingFee: string;
}
const Block = ( {
className,
giftWrappingFee,
}: BlockProps ): JSX.Element | null => {
const { needsShipping } = useShippingData();
const {
isProcessing: checkoutIsProcessing,
currentGiftWrappingNote,
currentGiftWrappingSelected,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
isProcessing: store.isProcessing(),
currentGiftWrappingNote: store.getGiftWrappingNote(),
currentGiftWrappingSelected: store.getGiftWrapping(),
};
} );
const { setGiftWrapping, setGiftWrappingNote } =
useDispatch( CHECKOUT_STORE_KEY );
const onChangeHandler = useCallback(
( {
giftWrapping: giftWrappingSelected,
giftWrappingNote: newGiftWrappingNote,
}: {
giftWrapping: boolean;
giftWrappingNote: string;
} ) => {
if ( giftWrappingSelected !== currentGiftWrappingSelected ) {
setGiftWrapping( giftWrappingSelected );
}
if ( newGiftWrappingNote !== currentGiftWrappingNote ) {
setGiftWrappingNote( newGiftWrappingNote );
}
},
[
setGiftWrapping,
setGiftWrappingNote,
currentGiftWrappingSelected,
currentGiftWrappingNote,
]
);
const fee = parseFloat( giftWrappingFee );
const price = fee !== 0 ? fee * 10 ** getMinorUnit() : '';
if ( ! needsShipping ) {
return null;
}
return (
<FormStep
id="gift-wrapping"
showStepNumber={ false }
className={ classnames(
'wc-block-checkout__gift-wrapping',
className
) }
disabled={ checkoutIsProcessing }
>
<CheckoutGiftWrapping
disabled={ checkoutIsProcessing }
onChange={ onChangeHandler }
placeholder={ __(
'Add an optional gift wrapping message.',
'woo-gutenberg-products-block'
) }
giftWrappingFee={ formatPrice( price ).trim() }
value={ currentGiftWrappingNote }
initialChecked={ currentGiftWrappingSelected }
/>
</FormStep>
);
};
export default Block;
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import Noninteractive from '@woocommerce/base-components/noninteractive';
/**
* Internal dependencies
*/
import Block from './block';
import './editor.scss';
interface Props {
attributes: {
giftWrappingFee: string;
};
setAttributes: ( attributes: Record< string, unknown > ) => void;
}
export const Edit = ( { attributes, setAttributes }: Props ): JSX.Element => {
const { giftWrappingFee } = attributes;
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<InspectorControls>
<PanelBody
title={ __(
'Gift Wrapping options',
'woo-gutenberg-products-block'
) }
>
<TextControl
label={ __(
'Gift Wrapping Fee',
'woo-gutenberg-products-block'
) }
value={ giftWrappingFee }
onChange={ ( value ) =>
setAttributes( { giftWrappingFee: value } )
}
help={ __(
'Format: 0.00',
'woo-gutenberg-products-block'
) }
/>
</PanelBody>
</InspectorControls>
<Noninteractive>
<Block giftWrappingFee={ giftWrappingFee } />
</Noninteractive>
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};
// Adjust padding and margins in the editor to improve selected block outlines.
.wp-block-woocommerce-checkout-gift-wrapping-block {
margin-top: 20px;
margin-bottom: 20px;
padding-top: 4px;
padding-bottom: 4px;
.wc-block-checkout__gift-wrapping {
margin-top: 0;
margin-bottom: 0;
}
}
/**
* External dependencies
*/
import { Icon, box } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
import './style.scss';
registerBlockType( 'woocommerce/checkout-gift-wrapping-block', {
title: 'Gift Wrapping',
icon: {
src: (
<Icon
icon={ box }
className="wc-block-editor-components-block-icon"
/>
),
},
edit: Edit,
save: Save,
} );
.wc-block-checkout__gift-wrapping {
margin: em($gap-large) 0;
}
.is-mobile,
.is-small,
.is-medium {
.wc-block-checkout__gift-wrapping {
@include with-translucent-border(1px 0);
margin-bottom: em($gap);
margin-top: em($gap);
padding: em($gap) 0;
}
}
.wc-block-checkout__gift-wrapping .wc-block-components-textarea {
margin-top: $gap;
}
.wc-block-components-form .wc-block-checkout__gift-wrapping.wc-block-components-checkout-step {
padding-left: 0;
}
/**
* Internal dependencies
*/
import CHECKOUT_ACTIONS from './checkout-actions-block/block.json';
import CHECKOUT_BILLING_ADDRESS from './checkout-billing-address-block/block.json';
import CHECKOUT_CONTACT_INFORMATION from './checkout-contact-information-block/block.json';
import CHECKOUT_EXPRESS_PAYMENT from './checkout-express-payment-block/block.json';
import CHECKOUT_FIELDS from './checkout-fields-block/block.json';
import CHECKOUT_ORDER_NOTE from './checkout-order-note-block/block.json';
import CHECKOUT_GIFT_WRAPPING from './checkout-gift-wrapping-block/block.json';
import CHECKOUT_PAYMENT from './checkout-payment-block/block.json';
import CHECKOUT_SHIPPING_ADDRESS from './checkout-shipping-address-block/block.json';
import CHECKOUT_SHIPPING_METHOD from './checkout-shipping-method-block/block.json';
import CHECKOUT_SHIPPING_METHODS from './checkout-shipping-methods-block/block.json';
import CHECKOUT_PICKUP_LOCATION from './checkout-pickup-options-block/block.json';
import CHECKOUT_TERMS from './checkout-terms-block/block.json';
import CHECKOUT_TOTALS from './checkout-totals-block/block.json';
import CHECKOUT_ORDER_SUMMARY from './checkout-order-summary-block/block.json';
import CHECKOUT_ORDER_SUMMARY_SUBTOTAL from './checkout-order-summary-subtotal/block.json';
import CHECKOUT_ORDER_SUMMARY_FEE from './checkout-order-summary-fee/block.json';
import CHECKOUT_ORDER_SUMMARY_DISCOUNT from './checkout-order-summary-discount/block.json';
import CHECKOUT_ORDER_SUMMARY_SHIPPING from './checkout-order-summary-shipping/block.json';
import CHECKOUT_ORDER_SUMMARY_COUPON_FORM from './checkout-order-summary-coupon-form/block.json';
import CHECKOUT_ORDER_SUMMARY_TAXES from './checkout-order-summary-taxes/block.json';
import CHECKOUT_ORDER_SUMMARY_CART_ITEMS from './checkout-order-summary-cart-items/block.json';
export default {
CHECKOUT_ACTIONS,
CHECKOUT_BILLING_ADDRESS,
CHECKOUT_CONTACT_INFORMATION,
CHECKOUT_EXPRESS_PAYMENT,
CHECKOUT_FIELDS,
CHECKOUT_ORDER_NOTE,
CHECKOUT_GIFT_WRAPPING,
CHECKOUT_PAYMENT,
CHECKOUT_SHIPPING_METHOD,
CHECKOUT_SHIPPING_ADDRESS,
CHECKOUT_SHIPPING_METHODS,
CHECKOUT_PICKUP_LOCATION,
CHECKOUT_TERMS,
CHECKOUT_TOTALS,
CHECKOUT_ORDER_SUMMARY,
CHECKOUT_ORDER_SUMMARY_SUBTOTAL,
CHECKOUT_ORDER_SUMMARY_FEE,
CHECKOUT_ORDER_SUMMARY_DISCOUNT,
CHECKOUT_ORDER_SUMMARY_SHIPPING,
CHECKOUT_ORDER_SUMMARY_COUPON_FORM,
CHECKOUT_ORDER_SUMMARY_TAXES,
CHECKOUT_ORDER_SUMMARY_CART_ITEMS,
};
/**
* Internal dependencies
*/
import './checkout-fields-block';
import './checkout-totals-block';
import './checkout-shipping-address-block';
import './checkout-terms-block';
import './checkout-contact-information-block';
import './checkout-billing-address-block';
import './checkout-actions-block';
import './checkout-order-note-block';
import './checkout-gift-wrapping-block';
import './checkout-order-summary-block';
import './checkout-payment-block';
import './checkout-express-payment-block';
import './checkout-shipping-method-block';
import './checkout-shipping-methods-block';
import './checkout-pickup-options-block';
import './checkout-order-summary-subtotal';
import './checkout-order-summary-fee';
import './checkout-order-summary-discount';
import './checkout-order-summary-shipping';
import './checkout-order-summary-coupon-form';
import './checkout-order-summary-taxes';
import './checkout-order-summary-cart-items';
/**
* External dependencies
*/
import { lazy } from '@wordpress/element';
import {
WC_BLOCKS_BUILD_URL,
LOCAL_PICKUP_ENABLED,
} from '@woocommerce/block-settings';
import { registerCheckoutBlock } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import metadata from './component-metadata';
// Modify webpack publicPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
__webpack_public_path__ = WC_BLOCKS_BUILD_URL;
// @todo When forcing all blocks at once, they will append based on the order they are registered. Introduce formal sorting param.
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_FIELDS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/fields" */ './checkout-fields-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_EXPRESS_PAYMENT,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/express-payment" */ './checkout-express-payment-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_CONTACT_INFORMATION,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/contact-information" */ './checkout-contact-information-block/frontend'
)
),
} );
if ( LOCAL_PICKUP_ENABLED ) {
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_SHIPPING_METHOD,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/shipping-method" */ './checkout-shipping-method-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_PICKUP_LOCATION,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/pickup-options" */ './checkout-pickup-options-block/frontend'
)
),
} );
}
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_SHIPPING_ADDRESS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/shipping-address" */ './checkout-shipping-address-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_BILLING_ADDRESS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/billing-address" */ './checkout-billing-address-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_SHIPPING_METHODS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/shipping-methods" */ './checkout-shipping-methods-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_PAYMENT,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/payment" */ './checkout-payment-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_NOTE,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-note" */ './checkout-order-note-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_GIFT_WRAPPING,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/gift-wrapping" */ './checkout-gift-wrapping-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_TERMS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/terms" */ './checkout-terms-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ACTIONS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/actions" */ './checkout-actions-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_TOTALS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/totals" */ './checkout-totals-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary" */ './checkout-order-summary-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_CART_ITEMS,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-cart-items" */
'./checkout-order-summary-cart-items/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_SUBTOTAL,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-subtotal" */
'./checkout-order-summary-subtotal/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_FEE,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-fee" */
'./checkout-order-summary-fee/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_DISCOUNT,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-discount" */
'./checkout-order-summary-discount/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_COUPON_FORM,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-coupon-form" */
'./checkout-order-summary-coupon-form/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_SHIPPING,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-shipping" */
'./checkout-order-summary-shipping/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: metadata.CHECKOUT_ORDER_SUMMARY_TAXES,
component: lazy(
() =>
import(
/* webpackChunkName: "checkout-blocks/order-summary-taxes" */
'./checkout-order-summary-taxes/frontend'
)
),
} );
export const ACTION_TYPES = {
SET_IDLE: 'SET_IDLE',
SET_REDIRECT_URL: 'SET_REDIRECT_URL',
SET_COMPLETE: 'SET_CHECKOUT_COMPLETE',
SET_BEFORE_PROCESSING: 'SET_BEFORE_PROCESSING',
SET_AFTER_PROCESSING: 'SET_AFTER_PROCESSING',
SET_PROCESSING: 'SET_CHECKOUT_IS_PROCESSING',
SET_HAS_ERROR: 'SET_CHECKOUT_HAS_ERROR',
SET_CUSTOMER_ID: 'SET_CHECKOUT_CUSTOMER_ID',
SET_ORDER_NOTES: 'SET_CHECKOUT_ORDER_NOTES',
SET_GIFT_WRAPPING: 'SET_CHECKOUT_GIFT_WRAPPING',
SET_GIFT_WRAPPING_NOTE: 'SET_CHECKOUT_GIFT_WRAPPING_NOTE',
INCREMENT_CALCULATING: 'INCREMENT_CALCULATING',
DECREMENT_CALCULATING: 'DECREMENT_CALCULATING',
SET_USE_SHIPPING_AS_BILLING: 'SET_USE_SHIPPING_AS_BILLING',
SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT',
SET_PREFERS_COLLECTION: 'SET_PREFERS_COLLECTION',
SET_EXTENSION_DATA: 'SET_EXTENSION_DATA',
SET_IS_CART: 'SET_IS_CART',
} as const;
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
// `Thunks are functions that can be dispatched, similar to actions creators
export * from './thunks';
/**
* Set the checkout status to `idle`
*/
export const __internalSetIdle = () => ( {
type: types.SET_IDLE,
} );
/**
* Set the checkout status to `before_processing`
*/
export const __internalSetBeforeProcessing = () => ( {
type: types.SET_BEFORE_PROCESSING,
} );
/**
* Set the checkout status to `processing`
*/
export const __internalSetProcessing = () => ( {
type: types.SET_PROCESSING,
} );
/**
* Set the checkout status to `after_processing`
*/
export const __internalSetAfterProcessing = () => ( {
type: types.SET_AFTER_PROCESSING,
} );
/**
* Set the checkout status to `complete`
*/
export const __internalSetComplete = (
data: Record< string, unknown > = {}
) => ( {
type: types.SET_COMPLETE,
data,
} );
/**
* Set the url to redirect to after checkout completes`
*
* @param redirectUrl the url to redirect to
*/
export const __internalSetRedirectUrl = ( redirectUrl: string ) => ( {
type: types.SET_REDIRECT_URL,
redirectUrl,
} );
/**
* Set whether the checkout has an error or not
*
* @param hasError Wether the checkout has an error or not
*/
export const __internalSetHasError = ( hasError = true ) => ( {
type: types.SET_HAS_ERROR,
hasError,
} );
/**
* Used when any of the totals, taxes, shipping, etc need to be calculated, the `calculatingCount` will be increased
* A `calculatingCount` of 0 means nothing is being updated.
*/
export const __internalIncrementCalculating = () => ( {
type: types.INCREMENT_CALCULATING,
} );
/**
* When any of the totals, taxes, shipping, etc are done beign calculated, the `calculatingCount` will be decreased
* A `calculatingCount` of 0 means nothing is being updated.
*/
export const __internalDecrementCalculating = () => ( {
type: types.DECREMENT_CALCULATING,
} );
/**
* Set the customer id
*
* @param customerId ID of the customer who is checking out.
*/
export const __internalSetCustomerId = ( customerId: number ) => ( {
type: types.SET_CUSTOMER_ID,
customerId,
} );
/**
* Whether to use the shipping address as the billing address
*
* @param useShippingAsBilling True if shipping address should be the same as billing, false otherwise
*/
export const __internalSetUseShippingAsBilling = (
useShippingAsBilling: boolean
) => ( {
type: types.SET_USE_SHIPPING_AS_BILLING,
useShippingAsBilling,
} );
/**
* Whether an account should be created for the user while checking out
*
* @param shouldCreateAccount True if an account should be created, false otherwise
*/
export const __internalSetShouldCreateAccount = (
shouldCreateAccount: boolean
) => ( {
type: types.SET_SHOULD_CREATE_ACCOUNT,
shouldCreateAccount,
} );
/**
* Set the notes for the order
*
* @param orderNotes String that represents a note for the order
*/
export const __internalSetOrderNotes = ( orderNotes: string ) => ( {
type: types.SET_ORDER_NOTES,
orderNotes,
} );
/**
* Set the gift wrapping for the order.
*
* @param giftWrapping boolean that represents whether the shopper wants gift wrapping for the order.
*/
export const __internalSetGiftWrapping = ( giftWrapping: boolean ) => ( {
type: types.SET_GIFT_WRAPPING,
giftWrapping,
} );
/**
* Set the gift wrapping note.
*
* @param giftWrappingNote The gift wrapping note for the order.
*/
export const setGiftWrappingNote = ( giftWrappingNote: string ) => ( {
type: types.SET_GIFT_WRAPPING_NOTE,
giftWrappingNote,
} );
export const setPrefersCollection = ( prefersCollection: boolean ) => ( {
type: types.SET_PREFERS_COLLECTION,
prefersCollection,
} );
/**
* Registers additional data under an extension namespace.
*/
export const __internalSetExtensionData = (
// The namespace for the extension. Defaults to 'default'. Must be unique to prevent conflicts.
namespace: string,
// Data to register under the namespace.
extensionData: Record< string, unknown >,
// If true, all data under the current extension namespace is replaced. If false, data is appended.
replace = false
) => ( {
type: types.SET_EXTENSION_DATA,
extensionData,
namespace,
replace,
} );
export type CheckoutAction =
| ReturnOrGeneratorYieldUnion<
| typeof __internalSetIdle
| typeof __internalSetComplete
| typeof __internalSetProcessing
| typeof __internalSetBeforeProcessing
| typeof __internalSetAfterProcessing
| typeof __internalSetRedirectUrl
| typeof __internalSetHasError
| typeof __internalIncrementCalculating
| typeof __internalDecrementCalculating
| typeof __internalSetCustomerId
| typeof __internalSetUseShippingAsBilling
| typeof __internalSetShouldCreateAccount
| typeof __internalSetOrderNotes
| typeof __internalSetGiftWrapping
| typeof setGiftWrappingNote
| typeof setPrefersCollection
| typeof __internalSetExtensionData
>
| Record< string, never >;
/**
* External dependencies
*/
import { isSameAddress } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { STATUS, checkoutData } from './constants';
export type CheckoutState = {
// Status of the checkout
status: STATUS;
// If any of the totals, taxes, shipping, etc need to be calculated, the count will be increased here
calculatingCount: number;
// True when the checkout is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
hasError: boolean;
// This is the url that checkout will redirect to when it's ready.
redirectUrl: string;
// This is the ID for the draft order if one exists.
orderId: number;
// Order notes introduced by the user in the checkout form.
orderNotes: string;
// Gift wrapping introduced by the user in the checkout form.
giftWrapping: boolean;
// Gift wrapping note, set by the shopper in the checkout form.
giftWrappingNote: string;
// This is the ID of the customer the draft order belongs to.
customerId: number;
// Should the billing form be hidden and inherit the shipping address?
useShippingAsBilling: boolean;
// Should a user account be created?
shouldCreateAccount: boolean;
// If customer wants to checkout with a local pickup option.
prefersCollection?: boolean | undefined;
// Custom checkout data passed to the store API on processing.
extensionData: Record< string, Record< string, unknown > >;
};
export const defaultState: CheckoutState = {
redirectUrl: '',
status: STATUS.PRISTINE,
hasError: false,
orderId: checkoutData.order_id,
customerId: checkoutData.customer_id,
calculatingCount: 0,
orderNotes: '',
giftWrapping: !! checkoutData.gift_wrapping,
giftWrappingNote: '',
useShippingAsBilling: isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
),
shouldCreateAccount: false,
prefersCollection: undefined,
extensionData: {},
};
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { STATUS } from './constants';
import { defaultState } from './default-state';
import { CheckoutAction } from './actions';
const reducer = ( state = defaultState, action: CheckoutAction ) => {
let newState = state;
switch ( action.type ) {
case types.SET_IDLE:
newState =
state.status !== STATUS.IDLE
? {
...state,
status: STATUS.IDLE,
}
: state;
break;
case types.SET_REDIRECT_URL:
newState =
action.redirectUrl !== undefined &&
action.redirectUrl !== state.redirectUrl
? {
...state,
redirectUrl: action.redirectUrl,
}
: state;
break;
case types.SET_COMPLETE:
newState = {
...state,
status: STATUS.COMPLETE,
redirectUrl:
typeof action.data?.redirectUrl === 'string'
? action.data.redirectUrl
: state.redirectUrl,
};
break;
case types.SET_PROCESSING:
newState = {
...state,
status: STATUS.PROCESSING,
hasError: false,
};
break;
case types.SET_BEFORE_PROCESSING:
newState = {
...state,
status: STATUS.BEFORE_PROCESSING,
hasError: false,
};
break;
case types.SET_AFTER_PROCESSING:
newState = {
...state,
status: STATUS.AFTER_PROCESSING,
};
break;
case types.SET_HAS_ERROR:
newState = {
...state,
hasError: action.hasError,
status:
state.status === STATUS.PROCESSING ||
state.status === STATUS.BEFORE_PROCESSING
? STATUS.IDLE
: state.status,
};
break;
case types.INCREMENT_CALCULATING:
newState = {
...state,
calculatingCount: state.calculatingCount + 1,
};
break;
case types.DECREMENT_CALCULATING:
newState = {
...state,
calculatingCount: Math.max( 0, state.calculatingCount - 1 ),
};
break;
case types.SET_CUSTOMER_ID:
if ( action.customerId !== undefined ) {
newState = {
...state,
customerId: action.customerId,
};
}
break;
case types.SET_USE_SHIPPING_AS_BILLING:
if (
action.useShippingAsBilling !== undefined &&
action.useShippingAsBilling !== state.useShippingAsBilling
) {
newState = {
...state,
useShippingAsBilling: action.useShippingAsBilling,
};
}
break;
case types.SET_SHOULD_CREATE_ACCOUNT:
if (
action.shouldCreateAccount !== undefined &&
action.shouldCreateAccount !== state.shouldCreateAccount
) {
newState = {
...state,
shouldCreateAccount: action.shouldCreateAccount,
};
}
break;
case types.SET_PREFERS_COLLECTION:
if (
action.prefersCollection !== undefined &&
action.prefersCollection !== state.prefersCollection
) {
newState = {
...state,
prefersCollection: action.prefersCollection,
};
}
break;
case types.SET_ORDER_NOTES:
if (
action.orderNotes !== undefined &&
state.orderNotes !== action.orderNotes
) {
newState = {
...state,
orderNotes: action.orderNotes,
};
}
break;
case types.SET_GIFT_WRAPPING:
newState = {
...state,
giftWrapping: !! action.giftWrapping,
};
break;
case types.SET_GIFT_WRAPPING_NOTE:
if (
action.giftWrappingNote !== undefined &&
state.giftWrapping !== action.giftWrappingNote
) {
newState = {
...state,
giftWrappingNote: action.giftWrappingNote,
};
}
break;
case types.SET_EXTENSION_DATA:
if (
action.extensionData !== undefined &&
action.namespace !== undefined
) {
newState = {
...state,
extensionData: {
...state.extensionData,
[ action.namespace ]: action.replace
? action.extensionData
: {
...state.extensionData[ action.namespace ],
...action.extensionData,
},
},
};
}
break;
}
return newState;
};
export default reducer;
/**
* External dependencies
*/
import { select } from '@wordpress/data';
import { hasCollectableRate } from '@woocommerce/base-utils';
import { isString, objectHasProp } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { STATUS } from './constants';
import { CheckoutState } from './default-state';
import { STORE_KEY as cartStoreKey } from '../cart/constants';
export const getCustomerId = ( state: CheckoutState ) => {
return state.customerId;
};
export const getOrderId = ( state: CheckoutState ) => {
return state.orderId;
};
export const getOrderNotes = ( state: CheckoutState ) => {
return state.orderNotes;
};
export const getGiftWrapping = ( state: CheckoutState ) => {
return state.giftWrapping;
};
export const getGiftWrappingNote = ( state: CheckoutState ) => {
return state.giftWrappingNote;
};
export const getRedirectUrl = ( state: CheckoutState ) => {
return state.redirectUrl;
};
export const getUseShippingAsBilling = ( state: CheckoutState ) => {
return state.useShippingAsBilling;
};
export const getExtensionData = ( state: CheckoutState ) => {
return state.extensionData;
};
export const getShouldCreateAccount = ( state: CheckoutState ) => {
return state.shouldCreateAccount;
};
export const getCheckoutStatus = ( state: CheckoutState ) => {
return state.status;
};
export const hasError = ( state: CheckoutState ) => {
return state.hasError;
};
export const hasOrder = ( state: CheckoutState ) => {
return !! state.orderId;
};
export const isComplete = ( state: CheckoutState ) => {
return state.status === STATUS.COMPLETE;
};
export const isIdle = ( state: CheckoutState ) => {
return state.status === STATUS.IDLE;
};
export const isBeforeProcessing = ( state: CheckoutState ) => {
return state.status === STATUS.BEFORE_PROCESSING;
};
export const isAfterProcessing = ( state: CheckoutState ) => {
return state.status === STATUS.AFTER_PROCESSING;
};
export const isProcessing = ( state: CheckoutState ) => {
return state.status === STATUS.PROCESSING;
};
export const isCalculating = ( state: CheckoutState ) => {
return state.calculatingCount > 0;
};
export const prefersCollection = ( state: CheckoutState ) => {
if ( typeof state.prefersCollection === 'undefined' ) {
const shippingRates = select( cartStoreKey ).getShippingRates();
if ( ! shippingRates || ! shippingRates.length ) {
return false;
}
const selectedRate = shippingRates[ 0 ].shipping_rates.find(
( rate ) => rate.selected
);
if (
objectHasProp( selectedRate, 'method_id' ) &&
isString( selectedRate.method_id )
) {
return hasCollectableRate( selectedRate?.method_id );
}
}
return state.prefersCollection;
};
/**
* Internal dependencies
*/
import reducer from '../reducers';
import { defaultState } from '../default-state';
import { STATUS } from '../constants';
import * as actions from '../actions';
describe.only( 'Checkout Store Reducer', () => {
it( 'should return the initial state', () => {
expect( reducer( undefined, {} ) ).toEqual( defaultState );
} );
it( 'should handle SET_IDLE', () => {
const expectedState = {
...defaultState,
status: STATUS.IDLE,
};
expect( reducer( defaultState, actions.__internalSetIdle() ) ).toEqual(
expectedState
);
} );
it( 'should handle SET_REDIRECT_URL', () => {
const expectedState = {
...defaultState,
redirectUrl: 'https://example.com',
};
expect(
reducer(
defaultState,
actions.__internalSetRedirectUrl( 'https://example.com' )
)
).toEqual( expectedState );
} );
it( 'should handle SET_COMPLETE', () => {
const expectedState = {
...defaultState,
status: STATUS.COMPLETE,
redirectUrl: 'https://example.com',
};
expect(
reducer(
defaultState,
actions.__internalSetComplete( {
redirectUrl: 'https://example.com',
} )
)
).toEqual( expectedState );
} );
it( 'should handle SET_PROCESSING', () => {
const expectedState = {
...defaultState,
status: STATUS.PROCESSING,
};
expect(
reducer( defaultState, actions.__internalSetProcessing() )
).toEqual( expectedState );
} );
it( 'should handle SET_HAS_ERROR when status is PROCESSING', () => {
const initialState = { ...defaultState, status: STATUS.PROCESSING };
const expectedState = {
...defaultState,
hasError: true,
status: STATUS.IDLE,
};
expect(
reducer( initialState, actions.__internalSetHasError( true ) )
).toEqual( expectedState );
} );
it( 'should handle SET_HAS_ERROR when status is BEFORE_PROCESSING', () => {
const initialState = {
...defaultState,
status: STATUS.BEFORE_PROCESSING,
};
const expectedState = {
...defaultState,
hasError: true,
status: STATUS.IDLE,
};
expect(
reducer( initialState, actions.__internalSetHasError( true ) )
).toEqual( expectedState );
} );
it( 'should handle SET_HAS_ERROR when status is anything else', () => {
const initialState = {
...defaultState,
status: STATUS.AFTER_PROCESSING,
};
const expectedState = {
...defaultState,
hasError: false,
status: STATUS.AFTER_PROCESSING,
};
expect(
reducer( initialState, actions.__internalSetHasError( false ) )
).toEqual( expectedState );
} );
it( 'should handle SET_BEFORE_PROCESSING', () => {
const expectedState = {
...defaultState,
status: STATUS.BEFORE_PROCESSING,
};
expect(
reducer( defaultState, actions.__internalSetBeforeProcessing() )
).toEqual( expectedState );
} );
it( 'should handle SET_AFTER_PROCESSING', () => {
const expectedState = {
...defaultState,
status: STATUS.AFTER_PROCESSING,
};
expect(
reducer( defaultState, actions.__internalSetAfterProcessing() )
).toEqual( expectedState );
} );
it( 'should handle INCREMENT_CALCULATING', () => {
const expectedState = {
...defaultState,
calculatingCount: 1,
};
expect(
reducer( defaultState, actions.__internalIncrementCalculating() )
).toEqual( expectedState );
} );
it( 'should handle DECREMENT_CALCULATING', () => {
const initialState = {
...defaultState,
calculatingCount: 1,
};
const expectedState = {
...defaultState,
calculatingCount: 0,
};
expect(
reducer( initialState, actions.__internalDecrementCalculating() )
).toEqual( expectedState );
} );
it( 'should handle SET_CUSTOMER_ID', () => {
const expectedState = {
...defaultState,
customerId: 1,
};
expect(
reducer( defaultState, actions.__internalSetCustomerId( 1 ) )
).toEqual( expectedState );
} );
it( 'should handle SET_USE_SHIPPING_AS_BILLING', () => {
const expectedState = {
...defaultState,
useShippingAsBilling: false,
};
expect(
reducer(
defaultState,
actions.__internalSetUseShippingAsBilling( false )
)
).toEqual( expectedState );
} );
it( 'should handle SET_SHOULD_CREATE_ACCOUNT', () => {
const expectedState = {
...defaultState,
shouldCreateAccount: true,
};
expect(
reducer(
defaultState,
actions.__internalSetShouldCreateAccount( true )
)
).toEqual( expectedState );
} );
it( 'should handle SET_ORDER_NOTES', () => {
const expectedState = {
...defaultState,
orderNotes: 'test order note',
};
expect(
reducer(
defaultState,
actions.__internalSetOrderNotes( 'test order note' )
)
).toEqual( expectedState );
} );
it( 'should handle SET_GIFT_WRAPPING', () => {
const expectedState = {
...defaultState,
giftWrapping: true,
};
expect(
reducer( defaultState, actions.__internalSetGiftWrapping( true ) )
).toEqual( expectedState );
} );
it( 'should handle SET_GIFT_WRAPPING_NOTE', () => {
const expectedState = {
...defaultState,
giftWrappingNote: 'test gift wrapping note',
};
expect(
reducer(
defaultState,
actions.setGiftWrappingNote( 'test gift wrapping note' )
)
).toEqual( expectedState );
} );
describe( 'should handle SET_EXTENSION_DATA', () => {
it( 'should set data under a namespace', () => {
const mockExtensionData = {
extensionNamespace: {
testKey: 'test-value',
testKey2: 'test-value-2',
},
};
const expectedState = {
...defaultState,
extensionData: mockExtensionData,
};
expect(
reducer(
defaultState,
actions.__internalSetExtensionData(
'extensionNamespace',
mockExtensionData.extensionNamespace
)
)
).toEqual( expectedState );
} );
it( 'should append data under a namespace', () => {
const mockExtensionData = {
extensionNamespace: {
testKey: 'test-value',
testKey2: 'test-value-2',
},
};
const expectedState = {
...defaultState,
extensionData: mockExtensionData,
};
const firstState = reducer(
defaultState,
actions.__internalSetExtensionData( 'extensionNamespace', {
testKey: 'test-value',
} )
);
const secondState = reducer(
firstState,
actions.__internalSetExtensionData( 'extensionNamespace', {
testKey2: 'test-value-2',
} )
);
expect( secondState ).toEqual( expectedState );
} );
it( 'support replacing data under a namespace', () => {
const mockExtensionData = {
extensionNamespace: {
testKey: 'test-value',
},
};
const expectedState = {
...defaultState,
extensionData: mockExtensionData,
};
const firstState = reducer(
defaultState,
actions.__internalSetExtensionData( 'extensionNamespace', {
testKeyOld: 'test-value',
} )
);
const secondState = reducer(
firstState,
actions.__internalSetExtensionData(
'extensionNamespace',
{ testKey: 'test-value' },
true
)
);
expect( secondState ).toEqual( expectedState );
} );
} );
} );
/**
* External dependencies
*/
import type { CheckoutResponse } from '@woocommerce/types';
import { store as noticesStore } from '@wordpress/notices';
import { dispatch as wpDispatch, select as wpSelect } from '@wordpress/data';
import { CartResponse } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { STORE_KEY as PAYMENT_STORE_KEY } from '../payment/constants';
import { removeNoticesByStatus } from '../../utils/notices';
import {
getPaymentResultFromCheckoutResponse,
runCheckoutFailObservers,
runCheckoutSuccessObservers,
} from './utils';
import {
EVENTS,
emitEvent,
emitEventWithAbort,
} from '../../base/context/providers/cart-checkout/checkout-events/event-emit';
import type {
emitValidateEventType,
emitAfterProcessingEventsType,
} from './types';
import type { DispatchFromMap } from '../mapped-types';
import * as actions from './actions';
import { STORE_KEY as CART_STORE_KEY } from '../cart/constants';
import { apiFetchWithHeaders } from '../shared-controls';
/**
* Based on the result of the payment, update the redirect url,
* set the payment processing response in the checkout data store
* and change the status to AFTER_PROCESSING
*/
export const __internalProcessCheckoutResponse = (
response: CheckoutResponse
) => {
return ( {
dispatch,
}: {
dispatch: DispatchFromMap< typeof actions >;
} ) => {
const paymentResult = getPaymentResultFromCheckoutResponse( response );
dispatch.__internalSetRedirectUrl( paymentResult?.redirectUrl || '' );
// The local `dispatch` here is bound to the actions of the data store. We need to use the global dispatch here
// to dispatch an action on a different store.
wpDispatch( PAYMENT_STORE_KEY ).__internalSetPaymentResult(
paymentResult
);
dispatch.__internalSetAfterProcessing();
};
};
/**
* Emit the CHECKOUT_VALIDATION event and process all
* registered observers
*/
export const __internalEmitValidateEvent: emitValidateEventType = ( {
observers,
setValidationErrors, // TODO: Fix this type after we move to validation store
} ) => {
return ( { dispatch, registry } ) => {
const { createErrorNotice } = registry.dispatch( noticesStore );
removeNoticesByStatus( 'error' );
emitEvent( observers, EVENTS.CHECKOUT_VALIDATION, {} ).then(
( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( {
errorMessage,
validationErrors,
context = 'wc/checkout',
} ) => {
createErrorNotice( errorMessage, { context } );
setValidationErrors( validationErrors );
}
);
}
dispatch.__internalSetIdle();
dispatch.__internalSetHasError();
} else {
dispatch.__internalSetProcessing();
}
}
);
};
};
/**
* Emit the CHECKOUT_FAIL if the checkout contains an error,
* or the CHECKOUT_SUCCESS if not. Set checkout errors according
* to the observer responses
*/
export const __internalEmitAfterProcessingEvents: emitAfterProcessingEventsType =
( { observers, notices } ) => {
return ( { select, dispatch, registry } ) => {
const { createErrorNotice } = registry.dispatch( noticesStore );
const data = {
redirectUrl: select.getRedirectUrl(),
orderId: select.getOrderId(),
customerId: select.getCustomerId(),
orderNotes: select.getOrderNotes(),
giftWrapping: select.getGiftWrapping(),
processingResponse:
wpSelect( PAYMENT_STORE_KEY ).getPaymentResult(),
};
if ( select.hasError() ) {
// allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
observers,
EVENTS.CHECKOUT_FAIL,
data
).then( ( observerResponses ) => {
runCheckoutFailObservers( {
observerResponses,
notices,
dispatch,
createErrorNotice,
data,
} );
} );
} else {
emitEventWithAbort(
observers,
EVENTS.CHECKOUT_SUCCESS,
data
).then( ( observerResponses: unknown[] ) => {
runCheckoutSuccessObservers( {
observerResponses,
dispatch,
createErrorNotice,
} );
} );
}
};
};
/**
* Handle changing the gift wrapping option.
*/
export const setGiftWrapping = ( giftWrapping: boolean ) => {
return async ( { dispatch, registry } ) => {
dispatch.__internalIncrementCalculating();
dispatch.__internalSetGiftWrapping( giftWrapping );
const cartDispatch = registry.dispatch( CART_STORE_KEY );
try {
//dispatch.shippingRatesBeingSelected( true );
const { response } = await apiFetchWithHeaders( {
path: `/wc/store/v1/cart/select-gift-wrapping`,
method: 'POST',
data: {
gift_wrapping: giftWrapping,
},
cache: 'no-store',
} );
// Remove shipping and billing address from the response, so we don't overwrite what the shopper is
// entering in the form if rates suddenly appear mid-edit.
const {
shipping_address: shippingAddress,
billing_address: billingAddress,
...rest
} = response;
cartDispatch.receiveCart( rest );
return response as CartResponse;
} catch ( error ) {
cartDispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.__internalDecrementCalculating();
}
};
};
/**
* External dependencies
*/
import type { ShippingAddress, BillingAddress } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import type { ApiErrorResponse } from './api-error-response';
export interface CheckoutResponseSuccess {
billing_address: BillingAddress;
customer_id: number;
customer_note: string;
extensions: Record< string, unknown >;
order_id: number;
order_key: string;
payment_method: string;
payment_result: {
payment_details: Record< string, string > | Record< string, never >;
payment_status: 'success' | 'failure' | 'pending' | 'error';
redirect_url: string;
};
shipping_address: ShippingAddress;
gift_wrapping: boolean;
gift_wrapping_note: string;
status: string;
}
export type CheckoutResponseError = ApiErrorResponse;
export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError;

Checkout API interface

Note on migration: We are in the process of moving much of the data from contexts into data stores, so this portion of the docs may change often as we do this. We will try to keep it up to date while the work is carried out.

Table of Contents

This document gives an overview of some of the major architectural components/APIs for the checkout block. If you haven't already, you may also want to read about the Checkout Flow and Events.

Data Stores

We are transitioning much of what is now available in Contexts, to @wordpress/data stores.

Checkout Data Store

This is responsible for holding all the data required for the checkout process.

For more details on the checkout data store, see the Checkout Data Store docs.

Selectors

For a full list of selectors see the [Checkout Data Store](

Data can be accessed through the following selectors:

  • isComplete(): True when checkout has finished processing and the subscribed checkout processing callbacks have all been invoked along with a successful processing of the checkout by the server.
  • isIdle(): When the checkout status is IDLE this flag is true. Checkout will be this status after any change to checkout state after the block is loaded. It will also be this status when retrying a purchase is possible after processing happens with an error.
  • isBeforeProcessing(): When the checkout status is BEFORE_PROCESSING this flag is true. Checkout will be this status when the user submits checkout for processing.
  • isProcessing(): When the checkout status is PROCESSING this flag is true. Checkout will be this status when all the observers on the event emitted with the BEFORE_PROCESSING status are completed without error. It is during this status that the block will be sending a request to the server on the checkout endpoint for processing the order.
  • isAfterProcessing(): When the checkout status is AFTER_PROCESSING this flag is true. Checkout will have this status after the the block receives the response from the server side processing request.
  • isComplete(): When the checkout status is COMPLETE this flag is true. Checkout will have this status after all observers on the events emitted during the AFTER_PROCESSING status are completed successfully. When checkout is at this status, the shopper's browser will be redirected to the value of redirectUrl at that point (usually the order-received route).
  • isCalculating(): This is true when the total is being re-calculated for the order. There are numerous things that might trigger a recalculation of the total: coupons being added or removed, shipping rates updated, shipping rate selected etc. This flag consolidates all activity that might be occurring (including requests to the server that potentially affect calculation of totals). So instead of having to check each of those individual states you can reliably just check if this boolean is true (calculating) or false (not calculating).
  • hasOrder(): This is true when orderId is truthy.
  • hasError(): This is true when the checkout has an error.
  • getOrderNotes(): Returns the order notes.
  • getGiftWrapping(): Returns the gift wrapping message.
  • getCustomerId(): Returns the customer ID.
  • getOrderId(): Returns the order ID.
  • getRedirectUrl(): Returns the redirect URL.
  • getExtensionData(): Returns the data registered by extensions.
  • getCheckoutStatus(): Returns the checkout status.
  • getShouldCreateAccount(): Returns true if the shopper has opted to create an account with their order.
  • getUseShippingAsBilling(): Returns the value of the useShippingAsBilling flag.

Actions

The following actions can be dispatched from the Checkout data store:

  • __internalSetIdle(): Set state.status to idle
  • __internalSetComplete(): Set state.status to complete
  • __internalSetProcessing(): Set state.status to processing
  • __internalSetBeforeProcessing(): Set state.status to before_processing
  • __internalSetAfterProcessing(): Set state.status to after_processing
  • __internalSrocessCheckoutResponse( response: CheckoutResponse ): This is a thunk that will extract the paymentResult from the CheckoutResponse, and dispatch 3 actions: __internalSetRedirectUrl, __internalSetPaymentResult and __internalSetAfterProcessing.
  • __internalSetRedirectUrl( url: string ): Set state.redirectUrl to url
  • __internalSetHasError( trueOrFalse: bool ): Set state.hasError to trueOrFalse
  • __internalIncrementCalculating(): Increment state.calculatingCount
  • __internalDecrementCalculating(): Decrement state.calculatingCount
  • __internalSetCustomerId( id: number ): Set state.customerId to id
  • __internalSetUseShippingAsBilling( useShippingAsBilling: boolean ): Set state.useShippingAsBilling to useShippingAsBilling
  • __internalSetShouldCreateAccount( shouldCreateAccount: boolean ): Set state.shouldCreateAccount to shouldCreateAccount
  • __internalSetOrderNotes( orderNotes: string ): Set state.orderNotes to orderNotes
  • __internalSetExtensionData( namespace: string, extensionData: Record< string, unknown > ): Set state.extensionData to extensionData

Contexts

Much of the data and api interface for components in the Checkout Block are constructed and exposed via usage of React.Context. In some cases the context maintains the "tree" state within the context itself (via useReducer) and in others it interacts with a global wp.data store (for data that communicates with the server).

You can find type definitions (typedef) for contexts in this file.

Shipping Method Data context

The shipping method data context exposes the api interfaces for the following things (typedef ShippingMethodDataContext) via the useShippingMethodData hook:

  • shippingErrorStatus: The current error status for the context.
  • dispatchErrorStatus: A function for dispatching a shipping error status. Used in combination with...
  • shippingErrorTypes: An object with the various error statuses that can be dispatched (NONE, INVALID_ADDRESS, UNKNOWN)
  • onShippingRateSuccess: This is a function for registering a callback to be invoked when shipping rates are retrieved successfully. Callbacks will receive the new rates as an argument.
  • onShippingRateFail: This is a function for registering a callback to be invoked when shipping rates fail to be retrieved. Callbacks will receive the error status as an argument.
  • onShippingRateSelectSuccess: This is a function for registering a callback to be invoked when shipping rate selection is successful.
  • onShippingRateSelectFail: This is a function for registering a callback to be invoked when shipping rates selection is unsuccessful.

Payment Method Events Context

The payment method events context exposes any event handlers related to payments (typedef PaymentEventsContext) via the usePaymentEventsContext hook.

  • onPaymentProcessing: This is an event subscriber that can be used to subscribe observers to be called when the status for the context is PROCESSING.

Checkout Events Context

The checkout events contexy exposes any event handlers related to the processing of the checkout:

  • onSubmit: This is a callback to be invoked either by submitting the checkout button, or by express payment methods to start checkout processing after they have finished their initialization process when their button has been clicked.
  • onCheckoutValidation: Used to register observers that will be invoked at validation time, after the checkout has been submitted but before the processing request is sent to the server.
  • onCheckoutSuccess: Used to register observers that will be invoked after checkout has been processed by the server successfully.
  • onCheckoutError: Used to register observers that will be invoked after checkout has been processed by the server and there was an error.

Hooks

These docs currently don't go into detail for all the hooks as that is fairly straightforward from existing implementations. However, one important extension interface hook will be highlighted here, usePaymentMethodInterface.

usePaymentMethodInterface

This hook is used to expose all the interfaces for the registered payment method components to utilize. Essentially the result from this hook is fed in as props on the registered payment components when they are setup by checkout. You can use the typedef (PaymentMethodInterface) to see what is fed to payment methods as props from this hook.

Why don't payment methods just implement this hook?

The contract is established through props fed to the payment method components via props. This allows us to avoid having to expose the hook publicly and experiment with how the props are retrieved and exposed in the future.

Examples

Passing a value from the client through to server side payment processing

In this example, lets pass some data from the BACS payment method to the server. Registration of BACS looks like this:

const bankTransferPaymentMethod = {
	name: PAYMENT_METHOD_NAME,
	label: <Label />,
	content: <Content />,
	edit: <Content />,
	canMakePayment: () => true,
	ariaLabel: label,
	supports: {
		features: settings?.supports ?? [],
	},
};

If we look a the Content component, we can see it defined as follows:

const Content = () => {
	return decodeEntities( settings.description || '' );
};

Payment method components are passed, by default, everything from the usePaymentMethodInterface hook. So we can consume this in our component like so:

const Content = ( props ) => {
	const { eventRegistration, emitResponse } = props;
	const { onPaymentProcessing } = eventRegistration;
	useEffect( () => {
		const unsubscribe = onPaymentProcessing( async () => {
			// Here we can do any processing we need, and then emit a response.
			// For example, we might validate a custom field, or perform an AJAX request, and then emit a response indicating it is valid or not.
			const myGatewayCustomData = '12345';
			const customDataIsValid = !! myGatewayCustomData.length;

			if ( customDataIsValid ) {
				return {
					type: emitResponse.responseTypes.SUCCESS,
					meta: {
						paymentMethodData: {
							myGatewayCustomData,
						},
					},
				};
			}

			return {
				type: emitResponse.responseTypes.ERROR,
				message: 'There was an error',
			};
		} );
		// Unsubscribes when this component is unmounted.
		return () => {
			unsubscribe();
		};
	}, [
		emitResponse.responseTypes.ERROR,
		emitResponse.responseTypes.SUCCESS,
		onPaymentProcessing,
	] );
	return decodeEntities( settings.description || '' );
};

Now when an order is placed, if we look at the API request payload, we can see the following JSON:

{
	"shipping_address": {},
	"billing_address": {},
	"customer_note": "",
	"create_account": false,
	"payment_method": "bacs",
	"payment_data": [
		{
			"key": "myGatewayCustomData",
			"value": "12345"
		}
	],
	"extensions": {}
}

wc/store/checkout

Table of Contents

Selectors

getCustomerId

Returns the WordPress user ID of the customer whose order is currently processed by the Checkout block.

Returns

  • number: WordPress user ID of the customer.

getOrderId

Returns the WooCommerce order ID of the order that is currently being processed by the Checkout block.

Returns

  • number: WooCommerce order ID

getOrderNotes

Returns the order notes.

Returns

  • string: Order notes.

getGiftWrapping

Returns the gift wrapping message.

Returns

  • string: Gift wrapping message.

getRedirectUrl

Returns the URL to redirect to after checkout is complete.

Returns

  • string: URL to redirect to.

getExtensionData

Returns the extra data registered by extensions.

Returns

  • Object: Extra data registered by extensions.
{
    [ extensionNamespace ]: {
        [ key ]: value,
    },
}

getCheckoutStatus

Returns the current status of the checkout process.

Returns

  • string: Current status of the checkout process. Possible values are: pristine, before-processing, processing, after-processing, complete, idle.

getShouldCreateAccount

Returns true if the shopper has opted to create an account with their order.

Returns

  • boolean: True if the shopper has opted to create an account with their order.

getUseShippingAsBilling

Returns true if the shopper has opted to use their shipping address as their billing address.

Returns

  • boolean: True if the shipping address should be used as the billing address.

hasError

Returns true if an error occurred, and false otherwise.

Returns

  • boolean: Whether an error occurred.

hasOrder

Returns true if a draft order had been created, and false otherwise.

Returns

  • boolean: Whether a draft order had been created.

isIdle

When the checkout status is IDLE this flag is true. Checkout will be this status after any change to checkout state after the block is loaded. It will also be this status when retrying a purchase is possible after processing happens with an error.

Returns

  • boolean: Whether the checkout has had some activity, but is currently waiting for user input.

isBeforeProcessing

When the checkout status is BEFORE_PROCESSING this flag is true. Checkout will be this status when the user submits checkout for processing.

Returns

  • boolean: Whether an order is about to be processed.

isProcessing

When the checkout status is PROCESSING this flag is true. Checkout will be this status when all the observers on the event emitted with the BEFORE_PROCESSING status are completed without error. It is during this status that the block will be sending a request to the server on the checkout endpoint for processing the order.

Returns

  • boolean: Whether the checkout is processing.

isAfterProcessing

When the checkout status is AFTER_PROCESSING this flag is true. Checkout will have this status after the the block receives the response from the server side processing request.

Returns

  • boolean: Whether an order had just been processed.

isComplete

When the checkout status is COMPLETE this flag is true. Checkout will have this status after all observers on the events emitted during the AFTER_PROCESSING status are completed successfully. When checkout is at this status, the shopper's browser will be redirected to the value of redirectUrl at that point (usually the order-received route).

Returns

  • boolean: Whether the order is complete.

isCalculating

This is true when the total is being re-calculated for the order. There are numerous things that might trigger a recalculation of the total: coupons being added or removed, shipping rates updated, shipping rate selected etc. This flag consolidates all activity that might be occurring (including requests to the server that potentially affect calculation of totals). So instead of having to check each of those individual states you can reliably just check if this boolean is true (calculating) or false (not calculating).

Returns

  • boolean: Whether there is an in-flight request to update any values.

We're hiring! Come work with us!

🐞 Found a mistake, or have a suggestion? Leave feedback about this document here.

/**
* External dependencies
*/
import { CURRENCY } from '@woocommerce/settings';
import type {
Currency,
CurrencyResponse,
CartShippingPackageShippingRate,
SymbolPosition,
} from '@woocommerce/types';
/**
* Get currency prefix.
*/
const getPrefix = (
// Currency symbol.
symbol: string,
// Position of currency symbol from settings.
symbolPosition: SymbolPosition
): string => {
const prefixes = {
left: symbol,
left_space: ' ' + symbol,
right: '',
right_space: '',
};
return prefixes[ symbolPosition ] || '';
};
/**
* Get currency suffix.
*/
const getSuffix = (
// Currency symbol.
symbol: string,
// Position of currency symbol from settings.
symbolPosition: SymbolPosition
): string => {
const suffixes = {
left: '',
left_space: '',
right: symbol,
right_space: ' ' + symbol,
};
return suffixes[ symbolPosition ] || '';
};
/**
* Currency information in normalized format from server settings.
*/
const siteCurrencySettings: Currency = {
code: CURRENCY.code,
symbol: CURRENCY.symbol,
thousandSeparator: CURRENCY.thousandSeparator,
decimalSeparator: CURRENCY.decimalSeparator,
minorUnit: CURRENCY.precision,
prefix: getPrefix(
CURRENCY.symbol,
CURRENCY.symbolPosition as SymbolPosition
),
suffix: getSuffix(
CURRENCY.symbol,
CURRENCY.symbolPosition as SymbolPosition
),
};
/**
* Get currency presicion.
*/
export const getMinorUnit = (): number => {
return CURRENCY.precision;
};
/**
* Get currency information in normalized format from an API response or the server.
*
* If no currency was provided, or currency_code is empty, the default store currency will be used.
*/
export const getCurrencyFromPriceResponse = (
// Currency data object, for example an API response containing currency formatting data.
currencyData?:
| CurrencyResponse
| Record< string, never >
| CartShippingPackageShippingRate
): Currency => {
if ( ! currencyData?.currency_code ) {
return siteCurrencySettings;
}
const {
currency_code: code,
currency_symbol: symbol,
currency_thousand_separator: thousandSeparator,
currency_decimal_separator: decimalSeparator,
currency_minor_unit: minorUnit,
currency_prefix: prefix,
currency_suffix: suffix,
} = currencyData;
return {
code: code || 'USD',
symbol: symbol || '$',
thousandSeparator:
typeof thousandSeparator === 'string' ? thousandSeparator : ',',
decimalSeparator:
typeof decimalSeparator === 'string' ? decimalSeparator : '.',
minorUnit: Number.isFinite( minorUnit ) ? minorUnit : 2,
prefix: typeof prefix === 'string' ? prefix : '$',
suffix: typeof suffix === 'string' ? suffix : '',
};
};
/**
* Get currency information in normalized format, allowing overrides.
*/
export const getCurrency = (
currencyData: Partial< Currency > = {}
): Currency => {
return {
...siteCurrencySettings,
...currencyData,
};
};
const applyThousandSeparator = (
numberString: string,
thousandSeparator: string
): string => {
return numberString.replace( /\B(?=(\d{3})+(?!\d))/g, thousandSeparator );
};
const splitDecimal = (
numberString: string
): {
beforeDecimal: string;
afterDecimal: string;
} => {
const parts = numberString.split( '.' );
const beforeDecimal = parts[ 0 ];
const afterDecimal = parts[ 1 ] || '';
return {
beforeDecimal,
afterDecimal,
};
};
const applyDecimal = (
afterDecimal: string,
decimalSeparator: string,
minorUnit: number
): string => {
if ( afterDecimal ) {
return `${ decimalSeparator }${ afterDecimal.padEnd(
minorUnit,
'0'
) }`;
}
if ( minorUnit > 0 ) {
return `${ decimalSeparator }${ '0'.repeat( minorUnit ) }`;
}
return '';
};
/**
* Format a price, provided using the smallest unit of the currency, as a
* decimal complete with currency symbols using current store settings.
*/
export const formatPrice = (
// Price in minor unit, e.g. cents.
price: number | string,
currencyData?: Currency
): string => {
if ( price === '' || price === undefined ) {
return '';
}
const priceInt: number =
typeof price === 'number' ? price : parseInt( price, 10 );
if ( ! Number.isFinite( priceInt ) ) {
return '';
}
const currency: Currency = getCurrency( currencyData );
const { minorUnit, prefix, suffix, decimalSeparator, thousandSeparator } =
currency;
const formattedPrice: number = priceInt / 10 ** minorUnit;
const { beforeDecimal, afterDecimal } = splitDecimal(
formattedPrice.toString()
);
const formattedValue = `${ prefix }${ applyThousandSeparator(
beforeDecimal,
thousandSeparator
) }${ applyDecimal(
afterDecimal,
decimalSeparator,
minorUnit
) }${ suffix }`;
// This uses a textarea to magically decode HTML currency symbols.
const txt = document.createElement( 'textarea' );
txt.innerHTML = formattedValue;
return txt.value;
};
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
* CatalogSorting class.
*/
class AddToCartForm extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'add-to-cart-form';
/**
* Initializes the AddToCartForm block and hooks into the `wc_add_to_cart_message_html` filter
* to prevent displaying the Cart Notice when the block is inside the Single Product block
* and the Add to Cart button is clicked.
*
* It also hooks into the `woocommerce_add_to_cart_redirect` filter to prevent redirecting
* to another page when the block is inside the Single Product block and the Add to Cart button
* is clicked.
*
* @return void
*/
protected function initialize() {
parent::initialize();
add_filter( 'wc_add_to_cart_message_html', array( $this, 'add_to_cart_message_html_filter' ), 10, 2 );
add_filter( 'woocommerce_add_to_cart_redirect', array( $this, 'add_to_cart_redirect_filter' ), 10, 1 );
}
/**
* Get the block's attributes.
*
* @param array $attributes Block attributes. Default empty array.
* @return array Block attributes merged with defaults.
*/
private function parse_attributes( $attributes ) {
// These should match what's set in JS `registerBlockType`.
$defaults = array(
'isDescendentOfSingleProductBlock' => false,
);
return wp_parse_args( $attributes, $defaults );
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
*
* @return string | void Rendered block output.
*/
protected function render( $attributes, $content, $block ) {
global $product;
$post_id = $block->context['postId'];
if ( ! isset( $post_id ) ) {
return '';
}
$previous_product = $product;
$product = wc_get_product( $post_id );
if ( ! $product instanceof \WC_Product ) {
$product = $previous_product;
return '';
}
ob_start();
/**
* Trigger the single product add to cart action for each product type.
*
* @since 9.7.0
*/
do_action( 'woocommerce_' . $product->get_type() . '_add_to_cart' );
$product = ob_get_clean();
if ( ! $product ) {
$product = $previous_product;
return '';
}
$parsed_attributes = $this->parse_attributes( $attributes );
$is_descendent_of_single_product_block = $parsed_attributes['isDescendentOfSingleProductBlock'];
$product = $this->add_is_descendent_of_single_product_block_hidden_input_to_product_form( $product, $is_descendent_of_single_product_block );
$classname = $attributes['className'] ?? '';
$classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$product_classname = $is_descendent_of_single_product_block ? 'product' : '';
$form = sprintf(
'<div class="wp-block-add-to-cart-form %1$s %2$s %3$s" style="%4$s">%5$s</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classname ),
esc_attr( $product_classname ),
esc_attr( $classes_and_styles['styles'] ),
$product
);
$product = $previous_product;
return $form;
}
/**
* Add a hidden input to the Add to Cart form to indicate that it is a descendent of a Single Product block.
*
* @param string $product The Add to Cart Form HTML.
* @param string $is_descendent_of_single_product_block Indicates if block is descendent of Single Product block.
*
* @return string The Add to Cart Form HTML with the hidden input.
*/
protected function add_is_descendent_of_single_product_block_hidden_input_to_product_form( $product, $is_descendent_of_single_product_block ) {
$hidden_is_descendent_of_single_product_block_input = sprintf(
'<input type="hidden" name="is-descendent-of-single-product-block" value="%1$s">',
$is_descendent_of_single_product_block ? 'true' : 'false'
);
$regex_pattern = '/<button\s+type="submit"[^>]*>.*?<\/button>/i';
preg_match( $regex_pattern, $product, $input_matches );
if ( ! empty( $input_matches ) ) {
$product = preg_replace( $regex_pattern, $hidden_is_descendent_of_single_product_block_input . $input_matches[0], $product );
}
return $product;
}
/**
* Filter the add to cart message to prevent the Notice from being displayed when the Add to Cart form is a descendent of a Single Product block
* and the Add to Cart button is clicked.
*
* @param string $message Message to be displayed when product is added to the cart.
*/
public function add_to_cart_message_html_filter( $message ) {
// phpcs:ignore
if ( isset( $_POST['is-descendent-of-single-product-block'] ) && 'true' === $_POST['is-descendent-of-single-product-block'] ) {
return false;
}
return $message;
}
/**
* Hooks into the `woocommerce_add_to_cart_redirect` filter to prevent redirecting
* to another page when the block is inside the Single Product block and the Add to Cart button
* is clicked.
*
* @param string $url The URL to redirect to after the product is added to the cart.
* @return string The filtered redirect URL.
*/
public function add_to_cart_redirect_filter( $url ) {
// phpcs:ignore
if ( isset( $_POST['is-descendent-of-single-product-block'] ) && 'true' == $_POST['is-descendent-of-single-product-block'] ) {
return wp_validate_redirect( wp_get_referer(), $url );
}
return $url;
}
/**
* Get the frontend script handle for this block type.
*
* @param string $key Data to get, or default to everything.
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* It isn't necessary register block assets because it is a server side block.
*/
protected function register_block_type_assets() {
return null;
}
}
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\Utils;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
/**
* Checkout class.
*
* @internal
*/
class Checkout extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout';
/**
* Chunks build folder.
*
* @var string
*/
protected $chunks_folder = 'checkout-blocks';
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
*/
protected function initialize() {
parent::initialize();
add_action( 'wp_loaded', array( $this, 'register_patterns' ) );
$this->register_post_save_hooks();
}
/**
* Register block pattern for Empty Cart Message to make it translatable.
*/
public function register_patterns() {
register_block_pattern(
'woocommerce/checkout-heading',
array(
'title' => '',
'inserter' => false,
'content' => '<!-- wp:heading {"align":"wide", "level":1} --><h1 class="wp-block-heading alignwide">' . esc_html__( 'Checkout', 'woo-gutenberg-products-block' ) . '</h1><!-- /wp:heading -->',
)
);
}
/**
* Get the editor script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return array|string;
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend style handle for this block type.
*
* @return string[]
*/
protected function get_block_type_style() {
return array_merge( parent::get_block_type_style(), [ 'wc-blocks-packages-style' ] );
}
/**
* Enqueue frontend assets for this block, just in time for rendering.
*
* @param array $attributes Any attributes that currently are available from the block.
*/
protected function enqueue_assets( array $attributes ) {
/**
* Fires before checkout block scripts are enqueued.
*
* @since 4.6.0
*/
do_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_before' );
parent::enqueue_assets( $attributes );
/**
* Fires after checkout block scripts are enqueued.
*
* @since 4.6.0
*/
do_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after' );
}
/**
* Append frontend scripts when rendering the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
if ( $this->is_checkout_endpoint() ) {
// Note: Currently the block only takes care of the main checkout form -- if an endpoint is set, refer to the
// legacy shortcode instead and do not render block.
return wc_current_theme_is_fse_theme() ? do_shortcode( '[woocommerce_checkout]' ) : '[woocommerce_checkout]';
}
// Deregister core checkout scripts and styles.
wp_dequeue_script( 'wc-checkout' );
wp_dequeue_script( 'wc-password-strength-meter' );
wp_dequeue_script( 'selectWoo' );
wp_dequeue_style( 'select2' );
/**
* We need to check if $content has any templates from prior iterations of the block, in order to update to the latest iteration.
* We test the iteration version by searching for new blocks brought in by it.
* The blocks used for testing should be always available in the block (not removable by the user).
* Checkout i1's content was returning an empty div, with no data-block-name attribute
*/
$regex_for_empty_block = '/<div class="[a-zA-Z0-9_\- ]*wp-block-woocommerce-checkout[a-zA-Z0-9_\- ]*"><\/div>/mi';
$has_i1_template = preg_match( $regex_for_empty_block, $content );
if ( $has_i1_template ) {
// This fallback needs to match the default templates defined in our Blocks.
$inner_blocks_html = '
<div data-block-name="woocommerce/checkout-fields-block" class="wp-block-woocommerce-checkout-fields-block">
<div data-block-name="woocommerce/checkout-express-payment-block" class="wp-block-woocommerce-checkout-express-payment-block"></div>
<div data-block-name="woocommerce/checkout-contact-information-block" class="wp-block-woocommerce-checkout-contact-information-block"></div>
<div data-block-name="woocommerce/checkout-shipping-address-block" class="wp-block-woocommerce-checkout-shipping-address-block"></div>
<div data-block-name="woocommerce/checkout-billing-address-block" class="wp-block-woocommerce-checkout-billing-address-block"></div>
<div data-block-name="woocommerce/checkout-shipping-methods-block" class="wp-block-woocommerce-checkout-shipping-methods-block"></div>
<div data-block-name="woocommerce/checkout-payment-block" class="wp-block-woocommerce-checkout-payment-block"></div>' .
( isset( $attributes['showOrderNotes'] ) && false === $attributes['showOrderNotes'] ? '' : '<div data-block-name="woocommerce/checkout-order-note-block" class="wp-block-woocommerce-checkout-order-note-block"></div>' ) .
'<div data-block-name="woocommerce/checkout-gift-wrapping-block" class="wp-block-woocommerce-checkout-gift-wrapping-block"></div>' .
( isset( $attributes['showPolicyLinks'] ) && false === $attributes['showPolicyLinks'] ? '' : '<div data-block-name="woocommerce/checkout-terms-block" class="wp-block-woocommerce-checkout-terms-block"></div>' ) .
'<div data-block-name="woocommerce/checkout-actions-block" class="wp-block-woocommerce-checkout-actions-block"></div>
</div>
<div data-block-name="woocommerce/checkout-totals-block" class="wp-block-woocommerce-checkout-totals-block">
<div data-block-name="woocommerce/checkout-order-summary-block" class="wp-block-woocommerce-checkout-order-summary-block"></div>
</div>
';
$content = str_replace( '</div>', $inner_blocks_html . '</div>', $content );
}
/**
* Checkout i3 added inner blocks for Order summary.
* We need to add them to Checkout i2 templates.
* The order needs to match the order in which these blocks were registered.
*/
$order_summary_with_inner_blocks = '$0
<div data-block-name="woocommerce/checkout-order-summary-cart-items-block" class="wp-block-woocommerce-checkout-order-summary-cart-items-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-subtotal-block" class="wp-block-woocommerce-checkout-order-summary-subtotal-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-fee-block" class="wp-block-woocommerce-checkout-order-summary-fee-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-discount-block" class="wp-block-woocommerce-checkout-order-summary-discount-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-coupon-form-block" class="wp-block-woocommerce-checkout-order-summary-coupon-form-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-shipping-block" class="wp-block-woocommerce-checkout-order-summary-shipping-block"></div>
<div data-block-name="woocommerce/checkout-order-summary-taxes-block" class="wp-block-woocommerce-checkout-order-summary-taxes-block"></div>
';
// Order summary subtotal block was added in i3, so we search for it to see if we have a Checkout i2 template.
$regex_for_order_summary_subtotal = '/<div[^<]*?data-block-name="woocommerce\/checkout-order-summary-subtotal-block"[^>]*?>/mi';
$regex_for_order_summary = '/<div[^<]*?data-block-name="woocommerce\/checkout-order-summary-block"[^>]*?>/mi';
$has_i2_template = ! preg_match( $regex_for_order_summary_subtotal, $content );
if ( $has_i2_template ) {
$content = preg_replace( $regex_for_order_summary, $order_summary_with_inner_blocks, $content );
}
/**
* Add the Local Pickup toggle to checkouts missing this forced template.
*/
$local_pickup_inner_blocks = '<div data-block-name="woocommerce/checkout-shipping-method-block" class="wp-block-woocommerce-checkout-shipping-method-block"></div>' . PHP_EOL . PHP_EOL . '<div data-block-name="woocommerce/checkout-pickup-options-block" class="wp-block-woocommerce-checkout-pickup-options-block"></div>' . PHP_EOL . PHP_EOL . '$0';
$has_local_pickup_regex = '/<div[^<]*?data-block-name="woocommerce\/checkout-shipping-method-block"[^>]*?>/mi';
$has_local_pickup = preg_match( $has_local_pickup_regex, $content );
if ( ! $has_local_pickup ) {
$shipping_address_block_regex = '/<div[^<]*?data-block-name="woocommerce\/checkout-shipping-address-block" class="wp-block-woocommerce-checkout-shipping-address-block"[^>]*?><\/div>/mi';
$content = preg_replace( $shipping_address_block_regex, $local_pickup_inner_blocks, $content );
}
return $content;
}
/**
* Check if we're viewing a checkout page endpoint, rather than the main checkout page itself.
*
* @return boolean
*/
protected function is_checkout_endpoint() {
return is_wc_endpoint_url( 'order-pay' ) || is_wc_endpoint_url( 'order-received' );
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
$this->asset_data_registry->add( 'countryData', CartCheckoutUtils::get_country_data(), true );
$this->asset_data_registry->add( 'baseLocation', wc_get_base_location(), true );
$this->asset_data_registry->add(
'checkoutAllowsGuest',
false === filter_var(
wc()->checkout()->is_registration_required(),
FILTER_VALIDATE_BOOLEAN
),
true
);
$this->asset_data_registry->add(
'checkoutAllowsSignup',
filter_var(
wc()->checkout()->is_registration_enabled(),
FILTER_VALIDATE_BOOLEAN
),
true
);
$this->asset_data_registry->add( 'checkoutShowLoginReminder', filter_var( get_option( 'woocommerce_enable_checkout_login_reminder' ), FILTER_VALIDATE_BOOLEAN ), true );
$this->asset_data_registry->add( 'displayCartPricesIncludingTax', 'incl' === get_option( 'woocommerce_tax_display_cart' ), true );
$this->asset_data_registry->add( 'displayItemizedTaxes', 'itemized' === get_option( 'woocommerce_tax_total_display' ), true );
$this->asset_data_registry->add( 'forcedBillingAddress', 'billing_only' === get_option( 'woocommerce_ship_to_destination' ), true );
$this->asset_data_registry->add( 'taxesEnabled', wc_tax_enabled(), true );
$this->asset_data_registry->add( 'couponsEnabled', wc_coupons_enabled(), true );
$this->asset_data_registry->add( 'shippingEnabled', wc_shipping_enabled(), true );
$this->asset_data_registry->add( 'hasDarkEditorStyleSupport', current_theme_supports( 'dark-editor-style' ), true );
$this->asset_data_registry->register_page_id( isset( $attributes['cartPageId'] ) ? $attributes['cartPageId'] : 0 );
$this->asset_data_registry->add( 'isBlockTheme', wc_current_theme_is_fse_theme(), true );
$pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [] );
$this->asset_data_registry->add( 'localPickupEnabled', wc_string_to_bool( $pickup_location_settings['enabled'] ?? 'no' ), true );
$is_block_editor = $this->is_block_editor();
// Hydrate the following data depending on admin or frontend context.
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'shippingMethodsExist' ) ) {
$methods_exist = wc_get_shipping_method_count( false, true ) > 0;
$this->asset_data_registry->add( 'shippingMethodsExist', $methods_exist );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'globalShippingMethods' ) ) {
$shipping_methods = WC()->shipping()->get_shipping_methods();
$formatted_shipping_methods = array_reduce(
$shipping_methods,
function( $acc, $method ) {
if ( in_array( $method->id, LocalPickupUtils::get_local_pickup_method_ids(), true ) ) {
return $acc;
}
if ( $method->supports( 'settings' ) ) {
$acc[] = [
'id' => $method->id,
'title' => $method->method_title,
'description' => $method->method_description,
];
}
return $acc;
},
[]
);
$this->asset_data_registry->add( 'globalShippingMethods', $formatted_shipping_methods );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'activeShippingZones' ) && class_exists( '\WC_Shipping_Zones' ) ) {
$shipping_zones = \WC_Shipping_Zones::get_zones();
$formatted_shipping_zones = array_reduce(
$shipping_zones,
function( $acc, $zone ) {
$acc[] = [
'id' => $zone['id'],
'title' => $zone['zone_name'],
'description' => $zone['formatted_zone_location'],
];
return $acc;
},
[]
);
$formatted_shipping_zones[] = [
'id' => 0,
'title' => __( 'International', 'woo-gutenberg-products-block' ),
'description' => __( 'Locations outside all other zones', 'woo-gutenberg-products-block' ),
];
$this->asset_data_registry->add( 'activeShippingZones', $formatted_shipping_zones );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'globalPaymentMethods' ) ) {
// These are used to show options in the sidebar. We want to get the full list of enabled payment methods,
// not just the ones that are available for the current cart (which may not exist yet).
$payment_methods = $this->get_enabled_payment_gateways();
$formatted_payment_methods = array_reduce(
$payment_methods,
function( $acc, $method ) {
$acc[] = [
'id' => $method->id,
'title' => $method->method_title,
'description' => $method->method_description,
];
return $acc;
},
[]
);
$this->asset_data_registry->add( 'globalPaymentMethods', $formatted_payment_methods );
}
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$this->hydrate_from_api();
$this->hydrate_customer_payment_methods();
}
/**
* Fires after checkout block data is registered.
*
* @since 2.6.0
*/
do_action( 'woocommerce_blocks_checkout_enqueue_data' );
}
/**
* Get payment methods that are enabled in settings.
*
* @return array
*/
protected function get_enabled_payment_gateways() {
$payment_gateways = WC()->payment_gateways->payment_gateways();
return array_filter(
$payment_gateways,
function( $payment_gateway ) {
return 'yes' === $payment_gateway->enabled;
}
);
}
/**
* Are we currently on the admin block editor screen?
*/
protected function is_block_editor() {
if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
return false;
}
$screen = get_current_screen();
return $screen && $screen->is_block_editor();
}
/**
* Get saved customer payment methods for use in checkout.
*/
protected function hydrate_customer_payment_methods() {
if ( ! is_user_logged_in() || $this->asset_data_registry->exists( 'customerPaymentMethods' ) ) {
return;
}
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
$payment_gateways = $this->get_enabled_payment_gateways();
$payment_methods = wc_get_customer_saved_methods_list( get_current_user_id() );
// Filter out payment methods that are not enabled.
foreach ( $payment_methods as $payment_method_group => $saved_payment_methods ) {
$payment_methods[ $payment_method_group ] = array_filter(
$saved_payment_methods,
function( $saved_payment_method ) use ( $payment_gateways ) {
return in_array( $saved_payment_method['method']['gateway'], array_keys( $payment_gateways ), true );
}
);
}
$this->asset_data_registry->add(
'customerPaymentMethods',
$payment_methods
);
remove_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
}
/**
* Hydrate the checkout block with data from the API.
*/
protected function hydrate_from_api() {
// Cache existing notices now, otherwise they are caught by the Cart Controller and converted to exceptions.
$old_notices = WC()->session->get( 'wc_notices', array() );
wc_clear_notices();
$this->asset_data_registry->hydrate_api_request( '/wc/store/v1/cart' );
add_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' );
$rest_preload_api_requests = rest_preload_api_request( [], '/wc/store/v1/checkout' );
$this->asset_data_registry->add( 'checkoutData', $rest_preload_api_requests['/wc/store/v1/checkout']['body'] ?? [] );
remove_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' );
// Restore notices.
WC()->session->set( 'wc_notices', $old_notices );
}
/**
* Callback for woocommerce_payment_methods_list_item filter to add token id
* to the generated list.
*
* @param array $list_item The current list item for the saved payment method.
* @param \WC_Token $token The token for the current list item.
*
* @return array The list item with the token id added.
*/
public static function include_token_id_with_payment_methods( $list_item, $token ) {
$list_item['tokenId'] = $token->get_id();
$brand = ! empty( $list_item['method']['brand'] ) ?
strtolower( $list_item['method']['brand'] ) :
'';
// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- need to match on translated value from core.
if ( ! empty( $brand ) && esc_html__( 'Credit card', 'woocommerce' ) !== $brand ) {
$list_item['method']['brand'] = wc_get_credit_card_type_label( $brand );
}
return $list_item;
}
/**
* Register script and style assets for the block type before it is registered.
*
* This registers the scripts; it does not enqueue them.
*/
protected function register_block_type_assets() {
parent::register_block_type_assets();
$chunks = $this->get_chunks_paths( $this->chunks_folder );
$vendor_chunks = $this->get_chunks_paths( 'vendors--checkout-blocks' );
$shared_chunks = [ 'cart-blocks/cart-express-payment--checkout-blocks/express-payment-frontend' ];
$this->register_chunk_translations( array_merge( $chunks, $vendor_chunks, $shared_chunks ) );
}
/**
* Register relevant hooks for when the block is saved.
*
* @return void
*/
protected function register_post_save_hooks() {
add_action( 'save_post', [ $this, 'save_gift_wrapping_fee' ], 10, 3 );
}
/**
* Save gift wrapping fee to wp_options when saving the post. This function parses the WP_Post and gets the gift
* wrapping fee from the checkout block.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update Whether this is an existing post being updated or not.
*
* @return void
*/
public function save_gift_wrapping_fee( $post_id, $post, $update ) {
$checkout_exists = Utils::is_block_present( $post, [ 'woocommerce/checkout' ] );
if ( ! $checkout_exists ) {
return;
}
$checkout_block = Utils::get_block( parse_blocks( $post->post_content ), 'woocommerce/checkout-gift-wrapping-block' );
if ( ! $checkout_block ) {
return;
}
$gift_wrapping_fee = $checkout_block['attrs']['giftWrappingFee'] ?? null;
update_option( 'wc_blocks_gift_wrapping_fee', $gift_wrapping_fee );
}
/**
* Get list of Checkout block & its inner-block types.
*
* @return array;
*/
public static function get_checkout_block_types() {
return [
'Checkout',
'CheckoutActionsBlock',
'CheckoutBillingAddressBlock',
'CheckoutContactInformationBlock',
'CheckoutExpressPaymentBlock',
'CheckoutFieldsBlock',
'CheckoutOrderNoteBlock',
'CheckoutGiftWrappingBlock',
'CheckoutOrderSummaryBlock',
'CheckoutOrderSummaryCartItemsBlock',
'CheckoutOrderSummaryCouponFormBlock',
'CheckoutOrderSummaryDiscountBlock',
'CheckoutOrderSummaryFeeBlock',
'CheckoutOrderSummaryShippingBlock',
'CheckoutOrderSummarySubtotalBlock',
'CheckoutOrderSummaryTaxesBlock',
'CheckoutPaymentBlock',
'CheckoutShippingAddressBlock',
'CheckoutShippingMethodsBlock',
'CheckoutShippingMethodBlock',
'CheckoutPickupOptionsBlock',
'CheckoutTermsBlock',
'CheckoutTotalsBlock',
];
}
}
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
/**
* CheckoutGiftWrappingBlock class.
*/
class CheckoutGiftWrappingBlock extends AbstractInnerBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'checkout-gift-wrapping-block';
}
<?php
namespace Automattic\WooCommerce\Blocks\Domain;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\AssetsController;
use Automattic\WooCommerce\Blocks\BlockPatterns;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\BlockTypesController;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\Domain\Services\GiftWrapping;
use Automattic\WooCommerce\Blocks\Domain\Services\Notices;
use Automattic\WooCommerce\Blocks\Domain\Services\DraftOrders;
use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating;
use Automattic\WooCommerce\Blocks\Domain\Services\GoogleAnalytics;
use Automattic\WooCommerce\Blocks\InboxNotifications;
use Automattic\WooCommerce\Blocks\Installer;
use Automattic\WooCommerce\Blocks\Migration;
use Automattic\WooCommerce\Blocks\Payments\Api as PaymentsApi;
use Automattic\WooCommerce\Blocks\Payments\Integrations\BankTransfer;
use Automattic\WooCommerce\Blocks\Payments\Integrations\CashOnDelivery;
use Automattic\WooCommerce\Blocks\Payments\Integrations\Cheque;
use Automattic\WooCommerce\Blocks\Payments\Integrations\PayPal;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use Automattic\WooCommerce\Blocks\Registry\Container;
use Automattic\WooCommerce\Blocks\Templates\CartTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutHeaderTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutTemplate;
use Automattic\WooCommerce\Blocks\Templates\ClassicTemplatesCompatibility;
use Automattic\WooCommerce\Blocks\Templates\OrderConfirmationTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductAttributeTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
use Automattic\WooCommerce\StoreApi\RoutesController;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\Blocks\Shipping\ShippingController;
use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
/**
* Takes care of bootstrapping the plugin.
*
* @since 2.5.0
*/
class Bootstrap {
/**
* Holds the Dependency Injection Container
*
* @var Container
*/
private $container;
/**
* Holds the Package instance
*
* @var Package
*/
private $package;
/**
* Holds the Migration instance
*
* @var Migration
*/
private $migration;
/**
* Constructor
*
* @param Container $container The Dependency Injection Container.
*/
public function __construct( Container $container ) {
$this->container = $container;
$this->package = $container->get( Package::class );
$this->migration = $container->get( Migration::class );
if ( $this->has_core_dependencies() ) {
$this->init();
/**
* Fires when the woocommerce blocks are loaded and ready to use.
*
* This hook is intended to be used as a safe event hook for when the plugin
* has been loaded, and all dependency requirements have been met.
*
* To ensure blocks are initialized, you must use the `woocommerce_blocks_loaded`
* hook instead of the `plugins_loaded` hook. This is because the functions
* hooked into plugins_loaded on the same priority load in an inconsistent and unpredictable manner.
*
* @since 2.5.0
*/
do_action( 'woocommerce_blocks_loaded' );
}
}
/**
* Init the package - load the blocks library and define constants.
*/
protected function init() {
$this->register_dependencies();
$this->register_payment_methods();
// This is just a temporary solution to make sure the migrations are run. We have to refactor this. More details: https://github.com/woocommerce/woocommerce-blocks/issues/10196.
if ( $this->package->get_version() !== $this->package->get_version_stored_on_db() ) {
$this->migration->run_migrations();
$this->package->set_version_stored_on_db();
}
add_action(
'admin_init',
function() {
// Delete this notification because the blocks are included in WC Core now. This will handle any sites
// with lingering notices.
InboxNotifications::delete_surface_cart_checkout_blocks_notification();
},
10,
0
);
$is_rest = wc()->is_rest_api_request();
// Load assets in admin and on the frontend.
if ( ! $is_rest ) {
$this->add_build_notice();
$this->container->get( AssetDataRegistry::class );
$this->container->get( Installer::class );
$this->container->get( AssetsController::class );
}
$this->container->get( DraftOrders::class )->init();
$this->container->get( CreateAccount::class )->init();
$this->container->get( Notices::class )->init();
$this->container->get( StoreApi::class )->init();
$this->container->get( GoogleAnalytics::class );
$this->container->get( GiftWrapping::class );
$this->container->get( BlockTypesController::class );
$this->container->get( BlockTemplatesController::class );
$this->container->get( ProductSearchResultsTemplate::class );
$this->container->get( ProductAttributeTemplate::class );
$this->container->get( CartTemplate::class );
$this->container->get( CheckoutTemplate::class );
$this->container->get( CheckoutHeaderTemplate::class );
$this->container->get( OrderConfirmationTemplate::class );
$this->container->get( ClassicTemplatesCompatibility::class );
$this->container->get( ArchiveProductTemplatesCompatibility::class )->init();
$this->container->get( SingleProductTemplateCompatibility::class )->init();
$this->container->get( BlockPatterns::class );
$this->container->get( PaymentsApi::class );
$this->container->get( ShippingController::class )->init();
}
/**
* Check core dependencies exist.
*
* @return boolean
*/
protected function has_core_dependencies() {
$has_needed_dependencies = class_exists( 'WooCommerce', false );
if ( $has_needed_dependencies ) {
$plugin_data = \get_file_data(
$this->package->get_path( 'woocommerce-gutenberg-products-block.php' ),
[
'RequiredWCVersion' => 'WC requires at least',
]
);
if ( isset( $plugin_data['RequiredWCVersion'] ) && version_compare( \WC()->version, $plugin_data['RequiredWCVersion'], '<' ) ) {
$has_needed_dependencies = false;
add_action(
'admin_notices',
function() {
if ( should_display_compatibility_notices() ) {
?>
<div class="notice notice-error">
<p><?php esc_html_e( 'The WooCommerce Blocks plugin requires a more recent version of WooCommerce and has been deactivated. Please update to the latest version of WooCommerce.', 'woo-gutenberg-products-block' ); ?></p>
</div>
<?php
}
}
);
}
}
return $has_needed_dependencies;
}
/**
* See if files have been built or not.
*
* @return bool
*/
protected function is_built() {
return file_exists(
$this->package->get_path( 'build/featured-product.js' )
);
}
/**
* Add a notice stating that the build has not been done yet.
*/
protected function add_build_notice() {
if ( $this->is_built() ) {
return;
}
add_action(
'admin_notices',
function() {
echo '<div class="error"><p>';
printf(
/* translators: %1$s is the install command, %2$s is the build command, %3$s is the watch command. */
esc_html__( 'WooCommerce Blocks development mode requires files to be built. From the plugin directory, run %1$s to install dependencies, %2$s to build the files or %3$s to build the files and watch for changes.', 'woo-gutenberg-products-block' ),
'<code>npm install</code>',
'<code>npm run build</code>',
'<code>npm start</code>'
);
echo '</p></div>';
}
);
}
/**
* Register core dependencies with the container.
*/
protected function register_dependencies() {
$this->container->register(
FeatureGating::class,
function () {
return new FeatureGating();
}
);
$this->container->register(
AssetApi::class,
function ( Container $container ) {
return new AssetApi( $container->get( Package::class ) );
}
);
$this->container->register(
AssetDataRegistry::class,
function( Container $container ) {
return new AssetDataRegistry( $container->get( AssetApi::class ) );
}
);
$this->container->register(
AssetsController::class,
function( Container $container ) {
return new AssetsController( $container->get( AssetApi::class ) );
}
);
$this->container->register(
PaymentMethodRegistry::class,
function() {
return new PaymentMethodRegistry();
}
);
$this->container->register(
Installer::class,
function () {
return new Installer();
}
);
$this->container->register(
BlockTypesController::class,
function ( Container $container ) {
$asset_api = $container->get( AssetApi::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new BlockTypesController( $asset_api, $asset_data_registry );
}
);
$this->container->register(
BlockTemplatesController::class,
function ( Container $container ) {
return new BlockTemplatesController( $container->get( Package::class ) );
}
);
$this->container->register(
ProductSearchResultsTemplate::class,
function () {
return new ProductSearchResultsTemplate();
}
);
$this->container->register(
ProductAttributeTemplate::class,
function () {
return new ProductAttributeTemplate();
}
);
$this->container->register(
CartTemplate::class,
function () {
return new CartTemplate();
}
);
$this->container->register(
CheckoutTemplate::class,
function () {
return new CheckoutTemplate();
}
);
$this->container->register(
CheckoutHeaderTemplate::class,
function () {
return new CheckoutHeaderTemplate();
}
);
$this->container->register(
OrderConfirmationTemplate::class,
function () {
return new OrderConfirmationTemplate();
}
);
$this->container->register(
ClassicTemplatesCompatibility::class,
function ( Container $container ) {
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new ClassicTemplatesCompatibility( $asset_data_registry );
}
);
$this->container->register(
ArchiveProductTemplatesCompatibility::class,
function () {
return new ArchiveProductTemplatesCompatibility();
}
);
$this->container->register(
SingleProductTemplateCompatibility::class,
function () {
return new SingleProductTemplateCompatibility();
}
);
$this->container->register(
DraftOrders::class,
function( Container $container ) {
return new DraftOrders( $container->get( Package::class ) );
}
);
$this->container->register(
CreateAccount::class,
function( Container $container ) {
return new CreateAccount( $container->get( Package::class ) );
}
);
$this->container->register(
GoogleAnalytics::class,
function( Container $container ) {
// Require Google Analytics Integration to be activated.
if ( ! class_exists( 'WC_Google_Analytics_Integration', false ) ) {
return;
}
$asset_api = $container->get( AssetApi::class );
return new GoogleAnalytics( $asset_api );
}
);
$this->container->register(
GiftWrapping::class,
function( Container $container ) {
return new GiftWrapping();
}
);
$this->container->register(
Notices::class,
function( Container $container ) {
return new Notices( $container->get( Package::class ) );
}
);
$this->container->register(
PaymentsApi::class,
function ( Container $container ) {
$payment_method_registry = $container->get( PaymentMethodRegistry::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new PaymentsApi( $payment_method_registry, $asset_data_registry );
}
);
$this->container->register(
StoreApi::class,
function () {
return new StoreApi();
}
);
// Maintains backwards compatibility with previous Store API namespace.
$this->container->register(
'Automattic\WooCommerce\Blocks\StoreApi\Formatters',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\StoreApi\Formatters', '7.2.0', 'Automattic\WooCommerce\StoreApi\Formatters', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( \Automattic\WooCommerce\StoreApi\Formatters::class );
}
);
$this->container->register(
'Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi', '7.2.0', 'Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( \Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema::class );
}
);
$this->container->register(
'Automattic\WooCommerce\Blocks\StoreApi\SchemaController',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\StoreApi\SchemaController', '7.2.0', 'Automattic\WooCommerce\StoreApi\SchemaController', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( SchemaController::class );
}
);
$this->container->register(
'Automattic\WooCommerce\Blocks\StoreApi\RoutesController',
function( Container $container ) {
$this->deprecated_dependency( 'Automattic\WooCommerce\Blocks\StoreApi\RoutesController', '7.2.0', 'Automattic\WooCommerce\StoreApi\RoutesController', '7.4.0' );
return $container->get( StoreApi::class )->container()->get( RoutesController::class );
}
);
$this->container->register(
BlockPatterns::class,
function () {
return new BlockPatterns( $this->package );
}
);
$this->container->register(
ShippingController::class,
function ( $container ) {
$asset_api = $container->get( AssetApi::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
return new ShippingController( $asset_api, $asset_data_registry );
}
);
}
/**
* Throws a deprecation notice for a dependency without breaking requests.
*
* @param string $function Class or function being deprecated.
* @param string $version Version in which it was deprecated.
* @param string $replacement Replacement class or function, if applicable.
* @param string $trigger_error_version Optional version to start surfacing this as a PHP error rather than a log. Defaults to $version.
*/
protected function deprecated_dependency( $function, $version, $replacement = '', $trigger_error_version = '' ) {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
$trigger_error_version = $trigger_error_version ? $trigger_error_version : $version;
$error_message = $replacement ? sprintf(
'%1$s is <strong>deprecated</strong> since version %2$s! Use %3$s instead.',
$function,
$version,
$replacement
) : sprintf(
'%1$s is <strong>deprecated</strong> since version %2$s with no alternative available.',
$function,
$version
);
/**
* Fires when a deprecated function is called.
*
* @since 7.3.0
*/
do_action( 'deprecated_function_run', $function, $replacement, $version );
$log_error = false;
// If headers have not been sent yet, log to avoid breaking the request.
if ( ! headers_sent() ) {
$log_error = true;
}
// If the $trigger_error_version was not yet reached, only log the error.
if ( version_compare( $this->package->get_version(), $trigger_error_version, '<' ) ) {
$log_error = true;
}
/**
* Filters whether to trigger an error for deprecated functions. (Same as WP core)
*
* @since 7.3.0
*
* @param bool $trigger Whether to trigger the error for deprecated functions. Default true.
*/
if ( ! apply_filters( 'deprecated_function_trigger_error', true ) ) {
$log_error = true;
}
if ( $log_error ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $error_message );
} else {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error( $error_message, E_USER_DEPRECATED );
}
}
/**
* Register payment method integrations with the container.
*/
protected function register_payment_methods() {
$this->container->register(
Cheque::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new Cheque( $asset_api );
}
);
$this->container->register(
PayPal::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new PayPal( $asset_api );
}
);
$this->container->register(
BankTransfer::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new BankTransfer( $asset_api );
}
);
$this->container->register(
CashOnDelivery::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
return new CashOnDelivery( $asset_api );
}
);
}
}
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
/**
* A class to handle getting, setting, and displaying information related to gift-wrapping on orders.
*/
class GiftWrapping {
/**
* Constructor.
*/
public function __construct() {
$this->init();
}
/**
* Sets up the Gift Wrapping feature, including adding hooks needed to show gift wrapping values in the order dashboard.
*
* @return void
*/
public function init() {
add_action( 'woocommerce_admin_order_data_after_shipping_address', [ $this, 'show_gift_wrapping_on_admin_order_page' ] );
add_action( 'woocommerce_order_details_after_order_table', [ $this, 'show_gift_wrapping_on_order_confirmation' ] );
add_action( 'woocommerce_email_after_order_table', [ $this, 'show_gift_wrapping_in_order_email' ] );
}
/**
* Output whether gift wrapping is required on the admin dashboard.
*
* @param \WC_Order $order The order the value is being displayed for.
* @return void
*/
public function show_gift_wrapping_on_admin_order_page( \WC_Order $order ) {
$gift_wrapping_required = (bool) $order->get_meta( 'wc_blocks_gift_wrapping_selected' );
echo '<p class="wc-blocks-gift-wrapping">';
echo '<strong>';
echo esc_html__( 'Gift wrapping required', 'woo-gutenberg-products-block' );
echo '</strong>: ';
echo esc_html( $gift_wrapping_required ? esc_html__( 'Yes', 'woo-gutenberg-products-block' ) : esc_html__( 'No', 'woo-gutenberg-products-block' ) );
if ( $gift_wrapping_required ) {
$gift_wrapping_note = $order->get_meta( 'wc_blocks_gift_wrapping_note' );
if ( $gift_wrapping_note ) {
echo '<br />';
echo '<strong>';
echo esc_html__( 'Gift wrapping note', 'woo-gutenberg-products-block' );
echo '</strong>: ';
echo esc_html( $gift_wrapping_note );
}
}
echo '</p>';
}
/**
* Output whether gift wrapping is required on the order confirmation page.
*
* @param \WC_Order $order The order the value is being displayed for.
* @return void
*/
public function show_gift_wrapping_on_order_confirmation( \WC_Order $order ) {
$gift_wrapping_required = (bool) $order->get_meta( 'wc_blocks_gift_wrapping_selected' );
$gift_wrapping_note = $order->get_meta( 'wc_blocks_gift_wrapping_note' );
echo '<section class="wc-blocks-gift-wrapping">';
echo '<h2>' . esc_html__( 'Gift wrapping', 'woo-gutenberg-products-block' ) . '</h2>';
echo '<table class="wc-blocks-gift-wrapping">';
echo '<tr><th>';
echo esc_html__( 'Required:', 'woo-gutenberg-products-block' );
echo '</th><td>';
echo $gift_wrapping_required ? esc_html__( 'Yes', 'woo-gutenberg-products-block' ) : esc_html__( 'No', 'woo-gutenberg-products-block' );
echo '</td></tr>';
echo '<tr><th>';
echo esc_html__( 'Note:', 'woo-gutenberg-products-block' );
echo '</th><td>';
echo $gift_wrapping_note ? esc_html( $gift_wrapping_note ) : esc_html__( 'n/a', 'woo-gutenberg-products-block' );
echo '</td></tr>';
echo '</table>';
echo '</section>';
}
/**
* Output whether gift wrapping is required on the order confirmation email.
*
* @param \WC_Order $order The order the value is being displayed for.
* @return void
*/
public function show_gift_wrapping_in_order_email( \WC_Order $order ) {
$gift_wrapping_required = (bool) $order->get_meta( 'wc_blocks_gift_wrapping_selected' );
$gift_wrapping_note = $order->get_meta( 'wc_blocks_gift_wrapping_note' );
echo '<h2>' . esc_html__( 'Gift wrapping', 'woo-gutenberg-products-block' ) . '</h2>';
echo '<table class="wc-blocks-gift-wrapping">';
echo '<tr><th>';
echo esc_html__( 'Required:', 'woo-gutenberg-products-block' );
echo '</th><td>';
echo $gift_wrapping_required ? esc_html__( 'Yes', 'woo-gutenberg-products-block' ) : esc_html__( 'No', 'woo-gutenberg-products-block' );
echo '</td></tr>';
echo '<tr><th>';
echo esc_html__( 'Note:', 'woo-gutenberg-products-block' );
echo '</th><td>';
echo $gift_wrapping_note ? esc_html( $gift_wrapping_note ) : esc_html__( 'n/a', 'woo-gutenberg-products-block' );
echo '</td></tr>';
echo '</table>';
echo '<br><br>';
}
}
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
/**
* CartRemoveCoupon class.
*/
class CartSelectGiftWrapping extends AbstractCartRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'cart-select-gift-wrapping';
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/select-gift-wrapping';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => [
'gift_wrapping' => [
'description' => __( 'Whether the order should be gift-wrapped.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
],
],
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
'allow_batch' => [ 'v1' => true ],
];
}
/**
* Handle the request and return a valid response for this endpoint.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$cart_controller = new CartController();
$draft_order = $cart_controller->select_gift_wrapping( $request );
return rest_ensure_response( $this->schema->get_item_response( $cart_controller->get_cart_instance() ) );
}
}
<?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Exception;
use Routes\AbstractRoute;
/**
* RoutesController class.
*/
class RoutesController {
/**
* Stores schema_controller.
*
* @var SchemaController
*/
protected $schema_controller;
/**
* Stores routes.
*
* @var array
*/
protected $routes = [];
/**
* Constructor.
*
* @param SchemaController $schema_controller Schema controller class passed to each route.
*/
public function __construct( SchemaController $schema_controller ) {
$this->schema_controller = $schema_controller;
$this->routes = [
'v1' => [
Routes\V1\Batch::IDENTIFIER => Routes\V1\Batch::class,
Routes\V1\Cart::IDENTIFIER => Routes\V1\Cart::class,
Routes\V1\CartAddItem::IDENTIFIER => Routes\V1\CartAddItem::class,
Routes\V1\CartApplyCoupon::IDENTIFIER => Routes\V1\CartApplyCoupon::class,
Routes\V1\CartCoupons::IDENTIFIER => Routes\V1\CartCoupons::class,
Routes\V1\CartCouponsByCode::IDENTIFIER => Routes\V1\CartCouponsByCode::class,
Routes\V1\CartExtensions::IDENTIFIER => Routes\V1\CartExtensions::class,
Routes\V1\CartItems::IDENTIFIER => Routes\V1\CartItems::class,
Routes\V1\CartItemsByKey::IDENTIFIER => Routes\V1\CartItemsByKey::class,
Routes\V1\CartRemoveCoupon::IDENTIFIER => Routes\V1\CartRemoveCoupon::class,
Routes\V1\CartRemoveItem::IDENTIFIER => Routes\V1\CartRemoveItem::class,
Routes\V1\CartSelectGiftWrapping::IDENTIFIER => Routes\V1\CartSelectGiftWrapping::class,
Routes\V1\CartSelectShippingRate::IDENTIFIER => Routes\V1\CartSelectShippingRate::class,
Routes\V1\CartUpdateItem::IDENTIFIER => Routes\V1\CartUpdateItem::class,
Routes\V1\CartUpdateCustomer::IDENTIFIER => Routes\V1\CartUpdateCustomer::class,
Routes\V1\Checkout::IDENTIFIER => Routes\V1\Checkout::class,
Routes\V1\ProductAttributes::IDENTIFIER => Routes\V1\ProductAttributes::class,
Routes\V1\ProductAttributesById::IDENTIFIER => Routes\V1\ProductAttributesById::class,
Routes\V1\ProductAttributeTerms::IDENTIFIER => Routes\V1\ProductAttributeTerms::class,
Routes\V1\ProductCategories::IDENTIFIER => Routes\V1\ProductCategories::class,
Routes\V1\ProductCategoriesById::IDENTIFIER => Routes\V1\ProductCategoriesById::class,
Routes\V1\ProductCollectionData::IDENTIFIER => Routes\V1\ProductCollectionData::class,
Routes\V1\ProductReviews::IDENTIFIER => Routes\V1\ProductReviews::class,
Routes\V1\ProductTags::IDENTIFIER => Routes\V1\ProductTags::class,
Routes\V1\Products::IDENTIFIER => Routes\V1\Products::class,
Routes\V1\ProductsById::IDENTIFIER => Routes\V1\ProductsById::class,
Routes\V1\ProductsBySlug::IDENTIFIER => Routes\V1\ProductsBySlug::class,
],
];
if ( Package::is_experimental_build() ) {
$this->routes['v1'][ Routes\V1\Order::IDENTIFIER ] = Routes\V1\Order::class;
$this->routes['v1'][ Routes\V1\CheckoutOrder::IDENTIFIER ] = Routes\V1\CheckoutOrder::class;
}
}
/**
* Register all Store API routes. This includes routes under specific version namespaces.
*/
public function register_all_routes() {
$this->register_routes( 'v1', 'wc/store' );
$this->register_routes( 'v1', 'wc/store/v1' );
}
/**
* Get a route class instance.
*
* Each route class is instantized with the SchemaController instance, and its main Schema Type.
*
* @throws \Exception If the schema does not exist.
* @param string $name Name of schema.
* @param string $version API Version being requested.
* @return AbstractRoute
*/
public function get( $name, $version = 'v1' ) {
$route = $this->routes[ $version ][ $name ] ?? false;
if ( ! $route ) {
throw new \Exception( "{$name} {$version} route does not exist" );
}
return new $route(
$this->schema_controller,
$this->schema_controller->get( $route::SCHEMA_TYPE, $route::SCHEMA_VERSION )
);
}
/**
* Register defined list of routes with WordPress.
*
* @param string $version API Version being registered..
* @param string $namespace Overrides the default route namespace.
*/
protected function register_routes( $version = 'v1', $namespace = 'wc/store/v1' ) {
if ( ! isset( $this->routes[ $version ] ) ) {
return;
}
$route_identifiers = array_keys( $this->routes[ $version ] );
foreach ( $route_identifiers as $route ) {
$route_instance = $this->get( $route, $version );
$route_instance->set_namespace( $namespace );
register_rest_route(
$route_instance->get_namespace(),
$route_instance->get_path(),
$route_instance->get_args()
);
}
}
}
<?php
namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
/**
* CheckoutSchema class.
*/
class CheckoutSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'checkout';
/**
* The schema item identifier.
*
* @var string
*/
const IDENTIFIER = 'checkout';
/**
* Billing address schema instance.
*
* @var BillingAddressSchema
*/
protected $billing_address_schema;
/**
* Shipping address schema instance.
*
* @var ShippingAddressSchema
*/
protected $shipping_address_schema;
/**
* Image Attachment schema instance.
*
* @var ImageAttachmentSchema
*/
protected $image_attachment_schema;
/**
* Constructor.
*
* @param ExtendSchema $extend Rest Extending instance.
* @param SchemaController $controller Schema Controller instance.
*/
public function __construct( ExtendSchema $extend, SchemaController $controller ) {
parent::__construct( $extend, $controller );
$this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER );
$this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER );
$this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
}
/**
* Checkout schema properties.
*
* @return array
*/
public function get_properties() {
return [
'order_id' => [
'description' => __( 'The order ID to process during checkout.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'status' => [
'description' => __( 'Order status. Payment providers will update this value after payment.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'order_key' => [
'description' => __( 'Order key used to check validity or protect access to certain order data.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'order_number' => [
'description' => __( 'Order number used for display.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'customer_note' => [
'description' => __( 'Note added to the order by the customer during checkout.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'gift_wrapping' => [
'description' => __( 'Whether the order should be gift-wrapped.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'optional' => true,
],
'gift_wrapping_note' => [
'description' => __( 'The note to be included with the gift-wrap.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'optional' => true,
],
'customer_id' => [
'description' => __( 'Customer ID if registered. Will return 0 for guests.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'billing_address' => [
'description' => __( 'Billing address.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->billing_address_schema->get_properties(),
'arg_options' => [
'sanitize_callback' => [ $this->billing_address_schema, 'sanitize_callback' ],
'validate_callback' => [ $this->billing_address_schema, 'validate_callback' ],
],
'required' => true,
],
'shipping_address' => [
'description' => __( 'Shipping address.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->shipping_address_schema->get_properties(),
'arg_options' => [
'sanitize_callback' => [ $this->shipping_address_schema, 'sanitize_callback' ],
'validate_callback' => [ $this->shipping_address_schema, 'validate_callback' ],
],
],
'payment_method' => [
'description' => __( 'The ID of the payment method being used to process the payment.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
// Validation may be based on cart contents which is not available here; this returns all enabled
// gateways. Further validation occurs during the request.
'enum' => array_values( WC()->payment_gateways->get_payment_gateway_ids() ),
],
'create_account' => [
'description' => __( 'Whether to create a new user account as part of order processing.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
],
'payment_result' => [
'description' => __( 'Result of payment processing, or false if not yet processed.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => [
'payment_status' => [
'description' => __( 'Status of the payment returned by the gateway. One of success, pending, failure, error.', 'woo-gutenberg-products-block' ),
'readonly' => true,
'type' => 'string',
],
'payment_details' => [
'description' => __( 'An array of data being returned from the payment gateway.', 'woo-gutenberg-products-block' ),
'readonly' => true,
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => 'string',
],
],
],
],
'redirect_url' => [
'description' => __( 'A URL to redirect the customer after checkout. This could be, for example, a link to the payment processors website.', 'woo-gutenberg-products-block' ),
'readonly' => true,
'type' => 'string',
],
],
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
/**
* Return the response for checkout.
*
* @param object $item Results from checkout action.
* @return array
*/
public function get_item_response( $item ) {
return $this->get_checkout_response( $item->order, $item->payment_result );
}
/**
* Get the checkout response based on the current order and any payments.
*
* @param \WC_Order $order Order object.
* @param PaymentResult $payment_result Payment result object.
* @return array
*/
protected function get_checkout_response( \WC_Order $order, PaymentResult $payment_result = null ) {
return [
'order_id' => $order->get_id(),
'status' => $order->get_status(),
'order_key' => $order->get_order_key(),
'order_number' => $order->get_order_number(),
'customer_note' => $order->get_customer_note(),
'customer_id' => $order->get_customer_id(),
'gift_wrapping' => (bool) $order->get_meta( 'wc_blocks_gift_wrapping_selected' ),
'gift_wrapping_note' => (bool) $order->get_meta( 'wc_blocks_gift_wrapping_note' ),
'billing_address' => (object) $this->billing_address_schema->get_item_response( $order ),
'shipping_address' => (object) $this->shipping_address_schema->get_item_response( $order ),
'payment_method' => $order->get_payment_method(),
'payment_result' => [
'payment_status' => $payment_result->status,
'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ),
'redirect_url' => $payment_result->redirect_url,
],
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ),
];
}
/**
* This prepares the payment details for the response so it's following the
* schema where it's an array of objects.
*
* @param array $payment_details An array of payment details from the processed payment.
*
* @return array An array of objects where each object has the key and value
* as distinct properties.
*/
protected function prepare_payment_details_for_response( array $payment_details ) {
return array_map(
function( $key, $value ) {
return (object) [
'key' => $key,
'value' => $value,
];
},
array_keys( $payment_details ),
$payment_details
);
}
}
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\NotPurchasableException;
use Automattic\WooCommerce\StoreApi\Exceptions\OutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\PartialOutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Exceptions\TooManyInCartException;
use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
use Automattic\WooCommerce\Blocks\Package;
use WP_Error;
/**
* Woo Cart Controller class.
*
* Helper class to bridge the gap between the cart API and Woo core.
*/
class CartController {
use DraftOrderTrait;
/**
* Makes the cart and sessions available to a route by loading them from core.
*/
public function load_cart() {
if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
wc_load_cart();
}
}
/**
* Recalculates the cart totals.
*/
public function calculate_totals() {
add_filter( 'woocommerce_cart_calculate_fees', [ $this, 'add_gift_wrapping_fee' ] );
$cart = $this->get_cart_instance();
$cart->get_cart();
$cart->calculate_fees();
$cart->calculate_shipping();
$cart->calculate_totals();
}
/**
* Adds the gift-wrapping fee to the cart.
*
* @param WC_Cart $cart Cart instance.
* @return void
*/
public function add_gift_wrapping_fee( $cart ) {
// Remove any existing gift wrapping fees before resetting it, to avoid duplicates or to remove it.
$fees = $cart->get_fees();
foreach ( $fees as $key => $fee ) {
if ( __( 'Gift wrapping', 'woo-gutenberg-products-block' ) === $fees[ $key ]->name ) {
unset( $fees[ $key ] );
}
}
$cart->fees_api()->set_fees( $fees );
if ( ! $this->get_draft_order() ) {
return;
}
$gift_wrapping_selected_meta = $this->get_draft_order()->get_meta( 'wc_blocks_gift_wrapping_selected' );
if ( ! $gift_wrapping_selected_meta ) {
return;
}
$fee = get_option( 'wc_blocks_gift_wrapping_fee', 0 );
$cart->add_fee( __( 'Gift wrapping', 'woo-gutenberg-products-block' ), $fee );
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*
* @todo Overriding the core add_to_cart method was necessary because core outputs notices when an item is added to
* the cart. For us this would cause notices to build up and output on the store, out of context. Core would need
* refactoring to split notices out from other cart actions.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return string
*/
public function add_to_cart( $request ) {
$cart = $this->get_cart_instance();
$request = wp_parse_args(
$request,
[
'id' => 0,
'quantity' => 1,
'variation' => [],
'cart_item_data' => [],
]
);
$request = $this->filter_request_data( $this->parse_variation_data( $request ) );
$product = $this->get_product_for_cart( $request );
$cart_id = $cart->generate_cart_id(
$this->get_product_id( $product ),
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
$this->validate_add_to_cart( $product, $request );
$quantity_limits = new QuantityLimits();
$existing_cart_id = $cart->find_product_in_cart( $cart_id );
if ( $existing_cart_id ) {
$cart_item = $cart->cart_contents[ $existing_cart_id ];
$quantity_validation = $quantity_limits->validate_cart_item_quantity( $request['quantity'] + $cart_item['quantity'], $cart_item );
if ( is_wp_error( $quantity_validation ) ) {
throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
}
$cart->set_quantity( $existing_cart_id, $request['quantity'] + $cart->cart_contents[ $existing_cart_id ]['quantity'], true );
return $existing_cart_id;
}
// Normalize quantity.
$add_to_cart_limits = $quantity_limits->get_add_to_cart_limits( $product );
$request_quantity = (int) $request['quantity'];
if ( $add_to_cart_limits['maximum'] ) {
$request_quantity = min( $request_quantity, $add_to_cart_limits['maximum'] );
}
$request_quantity = max( $request_quantity, $add_to_cart_limits['minimum'] );
$request_quantity = $quantity_limits->limit_to_multiple( $request_quantity, $add_to_cart_limits['multiple_of'] );
/**
* Filters the item being added to the cart.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_item_data Array of cart item data being added to the cart.
* @param string $cart_id Id of the item in the cart.
* @return array Updated cart item data.
*/
$cart->cart_contents[ $cart_id ] = apply_filters(
'woocommerce_add_cart_item',
array_merge(
$request['cart_item_data'],
array(
'key' => $cart_id,
'product_id' => $this->get_product_id( $product ),
'variation_id' => $this->get_variation_id( $product ),
'variation' => $request['variation'],
'quantity' => $request_quantity,
'data' => $product,
'data_hash' => wc_get_cart_item_data_hash( $product ),
)
),
$cart_id
);
/**
* Filters the entire cart contents when the cart changes.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_contents Array of all cart items.
* @return array Updated array of all cart items.
*/
$cart->cart_contents = apply_filters( 'woocommerce_cart_contents_changed', $cart->cart_contents );
/**
* Fires when an item is added to the cart.
*
* This hook fires when an item is added to the cart. This is triggered from the Store API in this context, but
* WooCommerce core add to cart events trigger the same hook.
*
* @since 2.5.0
*
* @internal Matches action name in WooCommerce core.
*
* @param string $cart_id ID of the item in the cart.
* @param integer $product_id ID of the product added to the cart.
* @param integer $request_quantity Quantity of the item added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param array $variation Array of variation data.
* @param array $cart_item_data Array of other cart item data.
*/
do_action(
'woocommerce_add_to_cart',
$cart_id,
$this->get_product_id( $product ),
$request_quantity,
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
return $cart_id;
}
/**
* Based on core `set_quantity` method, but validates if an item is sold individually first and enforces any limits in
* place.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param string $item_id Cart item id.
* @param integer $quantity Cart quantity.
*/
public function set_cart_item_quantity( $item_id, $quantity = 1 ) {
$cart_item = $this->get_cart_item( $item_id );
if ( empty( $cart_item ) ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 );
}
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_product', __( 'Cart item is invalid.', 'woo-gutenberg-products-block' ), 404 );
}
$quantity_validation = ( new QuantityLimits() )->validate_cart_item_quantity( $quantity, $cart_item );
if ( is_wp_error( $quantity_validation ) ) {
throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
}
$cart = $this->get_cart_instance();
$cart->set_quantity( $item_id, $quantity );
}
/**
* Validate all items in the cart and check for errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Product $product Product object associated with the cart item.
* @param array $request Add to cart request params.
*/
public function validate_add_to_cart( \WC_Product $product, $request ) {
if ( ! $product->is_purchasable() ) {
$this->throw_default_product_exception( $product );
}
if ( ! $product->is_in_stock() ) {
throw new RouteException(
'woocommerce_rest_product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( 'You cannot add &quot;%s&quot; to the cart because the product is out of stock.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
400
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart + $request['quantity'] ) {
throw new RouteException(
'woocommerce_rest_product_partially_out_of_stock',
sprintf(
/* translators: 1: product name 2: quantity in stock */
__( 'You cannot add that amount of &quot;%1$s&quot; to the cart because there is not enough stock (%2$s remaining).', 'woo-gutenberg-products-block' ),
$product->get_name(),
wc_format_stock_quantity_for_display( $qty_remaining, $product )
),
400
);
}
}
/**
* Filters if an item being added to the cart passed validation checks.
*
* Allow 3rd parties to validate if an item can be added to the cart. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to exceptions instead.
*
* @since 7.2.0
*
* @deprecated
* @param boolean $passed_validation True if the item passed validation.
* @param integer $product_id Product ID being validated.
* @param integer $quantity Quantity added to the cart.
* @param integer $variation_id Variation ID being added to the cart.
* @param array $variation Variation data.
* @return boolean
*/
$passed_validation = apply_filters(
'woocommerce_add_to_cart_validation',
true,
$this->get_product_id( $product ),
$request['quantity'],
$this->get_variation_id( $product ),
$request['variation']
);
if ( ! $passed_validation ) {
// Validation did not pass - see if an error notice was thrown.
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_add_to_cart_error' );
// If no notice was thrown, throw the default notice instead.
$this->throw_default_product_exception( $product );
}
/**
* Fires during validation when adding an item to the cart via the Store API.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
* @deprecated 7.1.0 Use woocommerce_store_api_validate_add_to_cart instead.
*/
wc_do_deprecated_action(
'wooocommerce_store_api_validate_add_to_cart',
array(
$product,
$request,
),
'7.1.0',
'woocommerce_store_api_validate_add_to_cart',
'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_add_to_cart instead.'
);
/**
* Fires during validation when adding an item to the cart via the Store API.
*
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from happening.
*
* @since 7.1.0
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
*/
do_action( 'woocommerce_store_api_validate_add_to_cart', $product, $request );
}
/**
* Generates the error message for out of stock products and adds product names to it.
*
* @param string $singular The message to use when only one product is in the list.
* @param string $plural The message to use when more than one product is in the list.
* @param array $items The list of cart items whose names should be inserted into the message.
* @returns string The translated and correctly pluralised message.
*/
private function add_product_names_to_message( $singular, $plural, $items ) {
$product_names = wc_list_pluck( $items, 'getProductName' );
$message = ( count( $items ) > 1 ) ? $plural : $singular;
return sprintf(
$message,
ArrayUtils::natural_language_join( $product_names, true )
);
}
/**
* Takes a string describing the type of stock extension, whether there is a single product or multiple products
* causing this exception and returns an appropriate error message.
*
* @param string $exception_type The type of exception encountered.
* @param string $singular_or_plural Whether to get the error message for a single product or multiple.
*
* @return string
*/
private function get_error_message_for_stock_exception_type( $exception_type, $singular_or_plural ) {
$stock_error_messages = [
'out_of_stock' => [
/* translators: %s: product name. */
'singular' => __(
'%s is out of stock and cannot be purchased. Please remove it from your cart.',
'woo-gutenberg-products-block'
),
/* translators: %s: product names. */
'plural' => __(
'%s are out of stock and cannot be purchased. Please remove them from your cart.',
'woo-gutenberg-products-block'
),
],
'not_purchasable' => [
/* translators: %s: product name. */
'singular' => __(
'%s cannot be purchased. Please remove it from your cart.',
'woo-gutenberg-products-block'
),
/* translators: %s: product names. */
'plural' => __(
'%s cannot be purchased. Please remove them from your cart.',
'woo-gutenberg-products-block'
),
],
'too_many_in_cart' => [
/* translators: %s: product names. */
'singular' => __(
'There are too many %s in the cart. Only 1 can be purchased. Please reduce the quantity in your cart.',
'woo-gutenberg-products-block'
),
/* translators: %s: product names. */
'plural' => __(
'There are too many %s in the cart. Only 1 of each can be purchased. Please reduce the quantities in your cart.',
'woo-gutenberg-products-block'
),
],
'partial_out_of_stock' => [
/* translators: %s: product names. */
'singular' => __(
'There is not enough %s in stock. Please reduce the quantity in your cart.',
'woo-gutenberg-products-block'
),
/* translators: %s: product names. */
'plural' => __(
'There are not enough %s in stock. Please reduce the quantities in your cart.',
'woo-gutenberg-products-block'
),
],
];
if (
isset( $stock_error_messages[ $exception_type ] ) &&
isset( $stock_error_messages[ $exception_type ][ $singular_or_plural ] )
) {
return $stock_error_messages[ $exception_type ][ $singular_or_plural ];
}
return __( 'There was an error with an item in your cart.', 'woo-gutenberg-products-block' );
}
/**
* Validate cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected in the cart.
*/
public function validate_cart() {
$this->validate_cart_items();
$this->validate_cart_coupons();
$cart = $this->get_cart_instance();
$cart_errors = new WP_Error();
/**
* Fires an action to validate the cart.
*
* Functions hooking into this should add custom errors using the provided WP_Error instance.
*
* @since 7.2.0
*
* @example See docs/examples/validate-cart.md
*
* @param \WP_Error $errors WP_Error object.
* @param \WC_Cart $cart Cart object.
*/
do_action( 'woocommerce_store_api_cart_errors', $cart_errors, $cart );
if ( $cart_errors->has_errors() ) {
throw new InvalidCartException(
'woocommerce_cart_error',
$cart_errors,
409
);
}
// Before running the woocommerce_check_cart_items hook, unhook validation from the core cart.
remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_items' ), 1 );
remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_coupons' ), 1 );
/**
* Fires when cart items are being validated.
*
* Allow 3rd parties to validate cart items. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to wp errors instead.
*
* @since 7.2.0
*
* @deprecated
* @internal Matches action name in WooCommerce core.
*/
do_action( 'woocommerce_check_cart_items' );
$cart_errors = NoticeHandler::convert_notices_to_wp_errors( 'woocommerce_rest_cart_item_error' );
if ( $cart_errors->has_errors() ) {
throw new InvalidCartException(
'woocommerce_cart_error',
$cart_errors,
409
);
}
}
/**
* Validate all items in the cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected due to insufficient stock levels.
*/
public function validate_cart_items() {
$cart = $this->get_cart_instance();
$cart_items = $this->get_cart_items();
$errors = [];
$out_of_stock_products = [];
$too_many_in_cart_products = [];
$partial_out_of_stock_products = [];
$not_purchasable_products = [];
foreach ( $cart_items as $cart_item_key => $cart_item ) {
try {
$this->validate_cart_item( $cart_item );
} catch ( RouteException $error ) {
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
} catch ( TooManyInCartException $error ) {
$too_many_in_cart_products[] = $error;
} catch ( NotPurchasableException $error ) {
$not_purchasable_products[] = $error;
} catch ( PartialOutOfStockException $error ) {
$partial_out_of_stock_products[] = $error;
} catch ( OutOfStockException $error ) {
$out_of_stock_products[] = $error;
}
}
if ( count( $errors ) > 0 ) {
$error = new WP_Error();
foreach ( $errors as $wp_error ) {
$error->merge_from( $wp_error );
}
throw new InvalidCartException(
'woocommerce_cart_error',
$error,
409
);
}
$error = $this->stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products );
if ( $error->has_errors() ) {
throw new InvalidCartException(
'woocommerce_stock_availability_error',
$error,
409
);
}
}
/**
* This method will take arrays of exceptions relating to stock, and will convert them to a WP_Error object.
*
* @param TooManyInCartException[] $too_many_in_cart_products Array of TooManyInCartExceptions.
* @param NotPurchasableException[] $not_purchasable_products Array of NotPurchasableExceptions.
* @param PartialOutOfStockException[] $partial_out_of_stock_products Array of PartialOutOfStockExceptions.
* @param OutOfStockException[] $out_of_stock_products Array of OutOfStockExceptions.
*
* @return WP_Error The WP_Error object returned. Will have errors if any exceptions were in the args. It will be empty if they do not.
*/
private function stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products ) {
$error = new WP_Error();
if ( count( $out_of_stock_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'plural' );
$error->add(
'woocommerce_rest_product_out_of_stock',
$this->add_product_names_to_message( $singular_error, $plural_error, $out_of_stock_products )
);
}
if ( count( $not_purchasable_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'plural' );
$error->add(
'woocommerce_rest_product_not_purchasable',
$this->add_product_names_to_message( $singular_error, $plural_error, $not_purchasable_products )
);
}
if ( count( $too_many_in_cart_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'plural' );
$error->add(
'woocommerce_rest_product_too_many_in_cart',
$this->add_product_names_to_message( $singular_error, $plural_error, $too_many_in_cart_products )
);
}
if ( count( $partial_out_of_stock_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'plural' );
$error->add(
'woocommerce_rest_product_partially_out_of_stock',
$this->add_product_names_to_message( $singular_error, $plural_error, $partial_out_of_stock_products )
);
}
return $error;
}
/**
* Validates an existing cart item and returns any errors.
*
* @throws TooManyInCartException Exception if more than one product that can only be purchased individually is in
* the cart.
* @throws PartialOutOfStockException Exception if an item has a quantity greater than what is available in stock.
* @throws OutOfStockException Exception thrown when an item is entirely out of stock.
* @throws NotPurchasableException Exception thrown when an item is not purchasable.
* @param array $cart_item Cart item array.
*/
public function validate_cart_item( $cart_item ) {
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
return;
}
if ( ! $product->is_purchasable() ) {
throw new NotPurchasableException(
'woocommerce_rest_product_not_purchasable',
$product->get_name()
);
}
if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
throw new TooManyInCartException(
'woocommerce_rest_product_too_many_in_cart',
$product->get_name()
);
}
if ( ! $product->is_in_stock() ) {
throw new OutOfStockException(
'woocommerce_rest_product_out_of_stock',
$product->get_name()
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart ) {
throw new PartialOutOfStockException(
'woocommerce_rest_product_partially_out_of_stock',
$product->get_name()
);
}
}
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occurring.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
* @deprecated 7.1.0 Use woocommerce_store_api_validate_cart_item instead.
*/
wc_do_deprecated_action(
'wooocommerce_store_api_validate_cart_item',
array(
$product,
$cart_item,
),
'7.1.0',
'woocommerce_store_api_validate_cart_item',
'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_cart_item instead.'
);
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occurring.
*
* @since 7.1.0
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
*/
do_action( 'woocommerce_store_api_validate_cart_item', $product, $cart_item );
}
/**
* Validate all coupons in the cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected.
*/
public function validate_cart_coupons() {
$cart_coupons = $this->get_cart_coupons();
$errors = [];
foreach ( $cart_coupons as $code ) {
$coupon = new \WC_Coupon( $code );
try {
$this->validate_cart_coupon( $coupon );
} catch ( RouteException $error ) {
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
}
}
if ( ! empty( $errors ) ) {
$error = new WP_Error();
foreach ( $errors as $wp_error ) {
$error->merge_from( $wp_error );
}
throw new InvalidCartException(
'woocommerce_coupons_error',
$error,
409
);
}
}
/**
* Validate the cart and get a list of errors.
*
* @return WP_Error A WP_Error instance containing the cart's errors.
*/
public function get_cart_errors() {
$errors = new WP_Error();
try {
$this->validate_cart();
} catch ( RouteException $error ) {
$errors->add( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
} catch ( InvalidCartException $error ) {
$errors->merge_from( $error->getError() );
} catch ( \Exception $error ) {
$errors->add( $error->getCode(), $error->getMessage() );
}
return $errors;
}
/**
* Get main instance of cart class.
*
* @throws RouteException When cart cannot be loaded.
* @return \WC_Cart
*/
public function get_cart_instance() {
$cart = wc()->cart;
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
throw new RouteException( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), 500 );
}
return $cart;
}
/**
* Return a cart item from the woo core cart class.
*
* @param string $item_id Cart item id.
* @return array
*/
public function get_cart_item( $item_id ) {
$cart = $this->get_cart_instance();
return isset( $cart->cart_contents[ $item_id ] ) ? $cart->cart_contents[ $item_id ] : [];
}
/**
* Returns all cart items.
*
* @param callable $callback Optional callback to apply to the array filter.
* @return array
*/
public function get_cart_items( $callback = null ) {
$cart = $this->get_cart_instance();
return $callback ? array_filter( $cart->get_cart(), $callback ) : array_filter( $cart->get_cart() );
}
/**
* Get hashes for items in the current cart. Useful for tracking changes.
*
* @return array
*/
public function get_cart_hashes() {
$cart = $this->get_cart_instance();
return [
'line_items' => $cart->get_cart_hash(),
'shipping' => md5( wp_json_encode( $cart->shipping_methods ) ),
'fees' => md5( wp_json_encode( $cart->get_fees() ) ),
'coupons' => md5( wp_json_encode( $cart->get_applied_coupons() ) ),
'taxes' => md5( wp_json_encode( $cart->get_taxes() ) ),
];
}
/**
* Empty cart contents.
*/
public function empty_cart() {
$cart = $this->get_cart_instance();
$cart->empty_cart();
}
/**
* See if cart has applied coupon by code.
*
* @param string $coupon_code Cart coupon code.
* @return bool
*/
public function has_coupon( $coupon_code ) {
$cart = $this->get_cart_instance();
return $cart->has_discount( $coupon_code );
}
/**
* Returns all applied coupons.
*
* @param callable $callback Optional callback to apply to the array filter.
* @return array
*/
public function get_cart_coupons( $callback = null ) {
$cart = $this->get_cart_instance();
return $callback ? array_filter( $cart->get_applied_coupons(), $callback ) : array_filter( $cart->get_applied_coupons() );
}
/**
* Get shipping packages from the cart with calculated shipping rates.
*
* @todo this can be refactored once https://github.com/woocommerce/woocommerce/pull/26101 lands.
*
* @param bool $calculate_rates Should rates for the packages also be returned.
* @return array
*/
public function get_shipping_packages( $calculate_rates = true ) {
$cart = $this->get_cart_instance();
// See if we need to calculate anything.
if ( ! $cart->needs_shipping() ) {
return [];
}
$packages = $cart->get_shipping_packages();
// Add extra package data to array.
if ( count( $packages ) ) {
$packages = array_map(
function( $key, $package, $index ) {
$package['package_id'] = isset( $package['package_id'] ) ? $package['package_id'] : $key;
$package['package_name'] = isset( $package['package_name'] ) ? $package['package_name'] : $this->get_package_name( $package, $index );
return $package;
},
array_keys( $packages ),
$packages,
range( 1, count( $packages ) )
);
}
$packages = $calculate_rates ? wc()->shipping()->calculate_shipping( $packages ) : $packages;
return $packages;
}
/**
* Creates a name for a package.
*
* @param array $package Shipping package from WooCommerce.
* @param int $index Package number.
* @return string
*/
protected function get_package_name( $package, $index ) {
/**
* Filters the shipping package name.
*
* @since 4.3.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $shipping_package_name Shipping package name.
* @param string $package_id Shipping package ID.
* @param array $package Shipping package from WooCommerce.
* @return string Shipping package name.
*/
return apply_filters(
'woocommerce_shipping_package_name',
$index > 1 ?
sprintf(
/* translators: %d: shipping package number */
_x( 'Shipment %d', 'shipping packages', 'woo-gutenberg-products-block' ),
$index
) :
_x( 'Shipment 1', 'shipping packages', 'woo-gutenberg-products-block' ),
$package['package_id'],
$package
);
}
/**
* Selects a shipping rate.
*
* @param int|string $package_id ID of the package to choose a rate for.
* @param string $rate_id ID of the rate being chosen.
*/
public function select_shipping_rate( $package_id, $rate_id ) {
$cart = $this->get_cart_instance();
$session_data = wc()->session->get( 'chosen_shipping_methods' ) ? wc()->session->get( 'chosen_shipping_methods' ) : [];
$session_data[ $package_id ] = $rate_id;
wc()->session->set( 'chosen_shipping_methods', $session_data );
}
/**
* Selects or deselects gift wrapping.
*
* @param array $request Request data.
*/
public function select_gift_wrapping( $request ) {
$draft_order = $this->get_draft_order();
$gift_wrapping_meta_value = (bool) $draft_order->get_meta( 'wc_blocks_gift_wrapping_selected' );
if ( isset( $request['gift_wrapping'] ) ) {
$draft_order->update_meta_data( 'wc_blocks_gift_wrapping_selected', (bool) $request['gift_wrapping'] );
} elseif ( true === $gift_wrapping_meta_value ) {
$draft_order->update_meta_data( 'wc_blocks_gift_wrapping_selected', false );
}
$draft_order->save();
$this->calculate_totals();
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*
* @todo Overriding the core apply_coupon method was necessary because core outputs notices when a coupon gets
* applied. For us this would cause notices to build up and output on the store, out of context. Core would need
* refactoring to split notices out from other cart actions.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param string $coupon_code Coupon code.
*/
public function apply_coupon( $coupon_code ) {
$cart = $this->get_cart_instance();
$applied_coupons = $this->get_cart_coupons();
$coupon = new \WC_Coupon( $coupon_code );
if ( $coupon->get_code() !== $coupon_code ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s coupon code */
__( '"%s" is an invalid coupon code.', 'woo-gutenberg-products-block' ),
esc_html( $coupon_code )
),
400
);
}
if ( $this->has_coupon( $coupon_code ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s coupon code */
__( 'Coupon code "%s" has already been applied.', 'woo-gutenberg-products-block' ),
esc_html( $coupon_code )
),
400
);
}
if ( ! $coupon->is_valid() ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
wp_strip_all_tags( $coupon->get_error_message() ),
400
);
}
// Prevents new coupons being added if individual use coupons are already in the cart.
$individual_use_coupons = $this->get_cart_coupons(
function( $code ) {
$coupon = new \WC_Coupon( $code );
return $coupon->get_individual_use();
}
);
foreach ( $individual_use_coupons as $code ) {
$individual_use_coupon = new \WC_Coupon( $code );
/**
* Filters if a coupon can be applied alongside other individual use coupons.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param boolean $apply_with_individual_use_coupon Defaults to false.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Coupon $individual_use_coupon Individual use coupon already applied to the cart.
* @param array $applied_coupons Array of applied coupons already applied to the cart.
* @return boolean
*/
if ( false === apply_filters( 'woocommerce_apply_with_individual_use_coupon', false, $coupon, $individual_use_coupon, $applied_coupons ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s: coupon code */
__( '"%s" has already been applied and cannot be used in conjunction with other coupons.', 'woo-gutenberg-products-block' ),
$code
),
400
);
}
}
if ( $coupon->get_individual_use() ) {
/**
* Filter coupons to remove when applying an individual use coupon.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $coupons Array of coupons to remove from the cart.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param array $applied_coupons Array of applied coupons already applied to the cart.
* @return array
*/
$coupons_to_remove = array_diff( $applied_coupons, apply_filters( 'woocommerce_apply_individual_use_coupon', array(), $coupon, $applied_coupons ) );
foreach ( $coupons_to_remove as $code ) {
$cart->remove_coupon( $code );
}
$applied_coupons = array_diff( $applied_coupons, $coupons_to_remove );
}
$applied_coupons[] = $coupon_code;
$cart->set_applied_coupons( $applied_coupons );
/**
* Fires after a coupon has been applied to the cart.
*
* @since 2.6.0
*
* @internal Matches action name in WooCommerce core.
*
* @param string $coupon_code The coupon code that was applied.
*/
do_action( 'woocommerce_applied_coupon', $coupon_code );
}
/**
* Validates an existing cart coupon and returns any errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Coupon $coupon Coupon object applied to the cart.
*/
protected function validate_cart_coupon( \WC_Coupon $coupon ) {
if ( ! $coupon->is_valid() ) {
$cart = $this->get_cart_instance();
$cart->remove_coupon( $coupon->get_code() );
$cart->calculate_totals();
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %1$s coupon code, %2$s reason. */
__( 'The "%1$s" coupon has been removed from your cart: %2$s', 'woo-gutenberg-products-block' ),
$coupon->get_code(),
wp_strip_all_tags( $coupon->get_error_message() )
),
409
);
}
}
/**
* Gets the qty of a product across line items.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_product_quantity_in_cart( $product ) {
$cart = $this->get_cart_instance();
$product_quantities = $cart->get_cart_item_quantities();
$product_id = $product->get_stock_managed_by_id();
return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
}
/**
* Gets remaining stock for a product.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_remaining_stock_for_product( $product ) {
$reserve_stock = new ReserveStock();
$qty_reserved = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
return $product->get_stock_quantity() - $qty_reserved;
}
/**
* Get a product object to be added to the cart.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return \WC_Product|Error Returns a product object if purchasable.
*/
protected function get_product_for_cart( $request ) {
$product = wc_get_product( $request['id'] );
if ( ! $product || 'trash' === $product->get_status() ) {
throw new RouteException(
'woocommerce_rest_cart_invalid_product',
__( 'This product cannot be added to the cart.', 'woo-gutenberg-products-block' ),
400
);
}
return $product;
}
/**
* For a given product, get the product ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_product_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
}
/**
* For a given product, get the variation ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_variation_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_id() : 0;
}
/**
* Default exception thrown when an item cannot be added to the cart.
*
* @throws RouteException Exception with code woocommerce_rest_product_not_purchasable.
*
* @param \WC_Product $product Product object associated with the cart item.
*/
protected function throw_default_product_exception( \WC_Product $product ) {
throw new RouteException(
'woocommerce_rest_product_not_purchasable',
sprintf(
/* translators: %s: product name */
__( '&quot;%s&quot; is not available for purchase.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
400
);
}
/**
* Filter data for add to cart requests.
*
* @param array $request Add to cart request params.
* @return array Updated request array.
*/
protected function filter_request_data( $request ) {
$product_id = $request['id'];
$variation_id = 0;
$product = wc_get_product( $product_id );
if ( $product->is_type( 'variation' ) ) {
$product_id = $product->get_parent_id();
$variation_id = $product->get_id();
}
/**
* Filter cart item data for add to cart requests.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_item_data Array of other cart item data.
* @param integer $product_id ID of the product added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param integer $quantity Quantity of the item added to the cart.
* @return array
*/
$request['cart_item_data'] = (array) apply_filters(
'woocommerce_add_cart_item_data',
$request['cart_item_data'],
$product_id,
$variation_id,
$request['quantity']
);
if ( $product->is_sold_individually() ) {
/**
* Filter sold individually quantity for add to cart requests.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param integer $sold_individually_quantity Defaults to 1.
* @param integer $quantity Quantity of the item added to the cart.
* @param integer $product_id ID of the product added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param array $cart_item_data Array of other cart item data.
* @return integer
*/
$request['quantity'] = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $request['quantity'], $product_id, $variation_id, $request['cart_item_data'] );
}
return $request;
}
/**
* If variations are set, validate and format the values ready to add to the cart.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return array Updated request array.
*/
protected function parse_variation_data( $request ) {
$product = $this->get_product_for_cart( $request );
// Remove variation request if not needed.
if ( ! $product->is_type( array( 'variation', 'variable' ) ) ) {
$request['variation'] = [];
return $request;
}
// Flatten data and format posted values.
$variable_product_attributes = $this->get_variable_product_attributes( $product );
$request['variation'] = $this->sanitize_variation_data( wp_list_pluck( $request['variation'], 'value', 'attribute' ), $variable_product_attributes );
// If we have a parent product, find the variation ID.
if ( $product->is_type( 'variable' ) ) {
$request['id'] = $this->get_variation_id_from_variation_data( $request, $product );
}
// Now we have a variation ID, get the valid set of attributes for this variation. They will have an attribute_ prefix since they are from meta.
$expected_attributes = wc_get_product_variation_attributes( $request['id'] );
$missing_attributes = [];
foreach ( $variable_product_attributes as $attribute ) {
if ( ! $attribute['is_variation'] ) {
continue;
}
$prefixed_attribute_name = 'attribute_' . sanitize_title( $attribute['name'] );
$expected_value = isset( $expected_attributes[ $prefixed_attribute_name ] ) ? $expected_attributes[ $prefixed_attribute_name ] : '';
$attribute_label = wc_attribute_label( $attribute['name'] );
if ( isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
$given_value = $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ];
if ( $expected_value === $given_value ) {
continue;
}
// If valid values are empty, this is an 'any' variation so get all possible values.
if ( '' === $expected_value && in_array( $given_value, $attribute->get_slugs(), true ) ) {
continue;
}
throw new RouteException(
'woocommerce_rest_invalid_variation_data',
/* translators: %1$s: Attribute name, %2$s: Allowed values. */
sprintf( __( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woo-gutenberg-products-block' ), $attribute_label, implode( ', ', $attribute->get_slugs() ) ),
400
);
}
// Fills request array with unspecified attributes that have default values. This ensures the variation always has full data.
if ( '' !== $expected_value && ! isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
$request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] = $expected_value;
}
// If no attribute was posted, only error if the variation has an 'any' attribute which requires a value.
if ( '' === $expected_value ) {
$missing_attributes[] = $attribute_label;
}
}
if ( ! empty( $missing_attributes ) ) {
throw new RouteException(
'woocommerce_rest_missing_variation_data',
/* translators: %s: Attribute name. */
__( 'Missing variation data for variable product.', 'woo-gutenberg-products-block' ) . ' ' . sprintf( _n( '%s is a required field', '%s are required fields', count( $missing_attributes ), 'woo-gutenberg-products-block' ), wc_format_list_of_items( $missing_attributes ) ),
400
);
}
ksort( $request['variation'] );
return $request;
}
/**
* Try to match request data to a variation ID and return the ID.
*
* @throws RouteException Exception if variation cannot be found.
*
* @param array $request Add to cart request params.
* @param \WC_Product $product Product being added to the cart.
* @return int Matching variation ID.
*/
protected function get_variation_id_from_variation_data( $request, $product ) {
$data_store = \WC_Data_Store::load( 'product' );
$match_attributes = $request['variation'];
$variation_id = $data_store->find_matching_product_variation( $product, $match_attributes );
if ( empty( $variation_id ) ) {
throw new RouteException(
'woocommerce_rest_variation_id_from_variation_data',
__( 'No matching variation found.', 'woo-gutenberg-products-block' ),
400
);
}
return $variation_id;
}
/**
* Format and sanitize variation data posted to the API.
*
* Labels are converted to names (e.g. Size to pa_size), and values are cleaned.
*
* @throws RouteException Exception if variation cannot be found.
*
* @param array $variation_data Key value pairs of attributes and values.
* @param array $variable_product_attributes Product attributes we're expecting.
* @return array
*/
protected function sanitize_variation_data( $variation_data, $variable_product_attributes ) {
$return = [];
foreach ( $variable_product_attributes as $attribute ) {
if ( ! $attribute['is_variation'] ) {
continue;
}
$attribute_label = wc_attribute_label( $attribute['name'] );
$variation_attribute_name = wc_variation_attribute_name( $attribute['name'] );
// Attribute labels e.g. Size.
if ( isset( $variation_data[ $attribute_label ] ) ) {
$return[ $variation_attribute_name ] =
$attribute['is_taxonomy']
?
sanitize_title( $variation_data[ $attribute_label ] )
:
html_entity_decode(
wc_clean( $variation_data[ $attribute_label ] ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
continue;
}
// Attribute slugs e.g. pa_size.
if ( isset( $variation_data[ $attribute['name'] ] ) ) {
$return[ $variation_attribute_name ] =
$attribute['is_taxonomy']
?
sanitize_title( $variation_data[ $attribute['name'] ] )
:
html_entity_decode(
wc_clean( $variation_data[ $attribute['name'] ] ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
}
}
return $return;
}
/**
* Get product attributes from the variable product (which may be the parent if the product object is a variation).
*
* @throws RouteException Exception if product is invalid.
*
* @param \WC_Product $product Product being added to the cart.
* @return array
*/
protected function get_variable_product_attributes( $product ) {
if ( $product->is_type( 'variation' ) ) {
$product = wc_get_product( $product->get_parent_id() );
}
if ( ! $product || 'trash' === $product->get_status() ) {
throw new RouteException(
'woocommerce_rest_cart_invalid_parent_product',
__( 'This product cannot be added to the cart.', 'woo-gutenberg-products-block' ),
400
);
}
return $product->get_attributes();
}
}
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
/**
* CheckoutTrait
*
* Shared functionality for checkout route.
*/
trait CheckoutTrait {
/**
* Prepare a single item for response. Handles setting the status based on the payment result.
*
* @param mixed $item Item to format to schema.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
$response = parent::prepare_item_for_response( $item, $request );
$status_codes = [
'success' => 200,
'pending' => 202,
'failure' => 400,
'error' => 500,
];
if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) {
$response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 );
}
return $response;
}
/**
* For orders which do not require payment, just update status.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
*/
private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
// Transition the order to pending, and then completed. This ensures transactional emails fire for pending_to_complete events.
$this->order->update_status( 'pending' );
$this->order->payment_complete();
// Mark the payment as successful.
$payment_result->set_status( 'success' );
$payment_result->set_redirect_url( $this->order->get_checkout_order_received_url() );
}
/**
* Fires an action hook instructing active payment gateways to process the payment for an order and provide a result.
*
* @throws RouteException On error.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
*/
private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
try {
// Transition the order to pending before making payment.
$this->order->update_status( 'pending' );
// Prepare the payment context object to pass through payment hooks.
$context = new PaymentContext();
$context->set_payment_method( $this->get_request_payment_method_id( $request ) );
$context->set_payment_data( $this->get_request_payment_data( $request ) );
$context->set_order( $this->order );
/**
* Process payment with context.
*
* @hook woocommerce_rest_checkout_process_payment_with_context
*
* @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message.
*
* @param PaymentContext $context Holds context for the payment, including order ID and payment method.
* @param PaymentResult $payment_result Result object for the transaction.
*/
do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] );
if ( ! $payment_result instanceof PaymentResult ) {
throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woo-gutenberg-products-block' ), 500 );
}
} catch ( \Exception $e ) {
throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', $e->getMessage(), 402 );
}
}
/**
* Gets the chosen payment method ID from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return string
*/
private function get_request_payment_method_id( \WP_REST_Request $request ) {
$payment_method = $this->get_request_payment_method( $request );
return is_null( $payment_method ) ? '' : $payment_method->id;
}
/**
* Gets and formats payment request data.
*
* @param \WP_REST_Request $request Request object.
* @return array
*/
private function get_request_payment_data( \WP_REST_Request $request ) {
static $payment_data = [];
if ( ! empty( $payment_data ) ) {
return $payment_data;
}
if ( ! empty( $request['payment_data'] ) ) {
foreach ( $request['payment_data'] as $data ) {
$payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] );
}
}
return $payment_data;
}
/**
* Update the current order using the posted values from the request.
*
* @param \WP_REST_Request $request Full details about the request.
*/
private function update_order_from_request( \WP_REST_Request $request ) {
$this->order->set_customer_note( $request['customer_note'] ?? '' );
$this->order->set_payment_method( $this->get_request_payment_method_id( $request ) );
$this->order->set_payment_method_title( $this->get_request_payment_method_title( $request ) );
if ( isset( $request['gift_wrapping'] ) ) {
$this->order->update_meta_data( 'wc_blocks_gift_wrapping_selected', (bool) $request['gift_wrapping'] );
}
if ( isset( $request['gift_wrapping_note'] ) ) {
$this->order->update_meta_data( 'wc_blocks_gift_wrapping_note', (string) $request['gift_wrapping_note'] );
}
wc_do_deprecated_action(
'__experimental_woocommerce_blocks_checkout_update_order_from_request',
array(
$this->order,
$request,
),
'6.3.0',
'woocommerce_store_api_checkout_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
);
wc_do_deprecated_action(
'woocommerce_blocks_checkout_update_order_from_request',
array(
$this->order,
$request,
),
'7.2.0',
'woocommerce_store_api_checkout_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
);
/**
* Fires when the Checkout Block/Store API updates an order's from the API request data.
*
* This hook gives extensions the chance to update orders based on the data in the request. This can be used in
* conjunction with the ExtendSchema class to post custom data and then process it.
*
* @since 7.2.0
*
* @param \WC_Order $order Order object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_checkout_update_order_from_request', $this->order, $request );
$this->order->save();
}
/**
* Gets the chosen payment method title from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return string
*/
private function get_request_payment_method_title( \WP_REST_Request $request ) {
$payment_method = $this->get_request_payment_method( $request );
return is_null( $payment_method ) ? '' : $payment_method->get_title();
}
}
<?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* Utils class
*/
class Utils {
/**
* Compare the current WordPress version with a given version.
*
* @param string $version The version to compare against.
* @param string|null $operator Optional. The comparison operator. Defaults to null.
* @return bool|int Returns true if the current WordPress version satisfies the comparison, false otherwise.
*/
public static function wp_version_compare( $version, $operator = null ) {
$current_wp_version = get_bloginfo( 'version' );
if ( preg_match( '/^([0-9]+\.[0-9]+)/', $current_wp_version, $matches ) ) {
$current_wp_version = (float) $matches[1];
}
return version_compare( $current_wp_version, $version, $operator );
}
/**
* Checks whether the given blocks are present in the post content.
*
* @param \WP_Post $post A post to check for block presence.
* @param array $block_names An array of block names to look for.
* @param string $operator Either OR, or AND. Determines whether all blocks must be present, or just one.
*
* @return bool True if the blocks are present and the operator is satisfied, false otherwise.
*/
public static function is_block_present( $post, $block_names = [], $operator = 'OR' ) {
// Reset the operator to 'OR' if an invalid one is passed.
if ( in_array( $operator, [ 'AND', 'OR' ], true ) ) {
_doing_it_wrong( __METHOD__, esc_html__( 'Invalid operator passed. Defaulting to OR.', 'woo-gutenberg-products-block' ), 'X.X.X' );
$operator = 'OR';
}
if ( ! has_blocks( $post->post_content ) ) {
return false;
}
$blocks = parse_blocks( $post->post_content );
$present_block_names = array_map(
function ( $block ) {
return $block['blockName'];
},
$blocks
);
$intersected_block_names = array_intersect( $block_names, $present_block_names );
if ( 'OR' === $operator ) {
return $intersected_block_names > 0;
}
return count( $intersected_block_names ) === count( $block_names );
}
/**
* Get the block with the given name from the parsed blocks.
*
* @param array $parsed_blocks The parsed blocks.
* @param string $block_name The name of the block to get.
* @return array|null The block if found, null otherwise.
*/
public static function get_block( $parsed_blocks, $block_name ) {
foreach ( $parsed_blocks as $block ) {
if ( $block['blockName'] === $block_name ) {
return $block;
}
if ( count( $block['innerBlocks'] ) > 0 ) {
$inner_block = self::get_block( $block['innerBlocks'], $block_name );
if ( $inner_block ) {
return $inner_block;
}
}
}
// We got to the end of the list of blocks and no block was found.
return null;
}
}
<!-- wp:template-part {"slug":"checkout-header","theme":"woocommerce/woocommerce"} /-->
<!-- wp:group {"layout":{"inherit":true,"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:pattern {"slug":"woocommerce/checkout-heading"} /-->
<!-- wp:woocommerce/checkout {"className":"wc-block-checkout"} -->
<div class="wp-block-woocommerce-checkout alignwide wc-block-checkout is-loading">
<!-- wp:woocommerce/checkout-fields-block -->
<div class="wp-block-woocommerce-checkout-fields-block"><!-- wp:woocommerce/checkout-express-payment-block -->
<div class="wp-block-woocommerce-checkout-express-payment-block"></div>
<!-- /wp:woocommerce/checkout-express-payment-block -->
<!-- wp:woocommerce/checkout-contact-information-block -->
<div class="wp-block-woocommerce-checkout-contact-information-block"></div>
<!-- /wp:woocommerce/checkout-contact-information-block -->
<!-- wp:woocommerce/checkout-shipping-method-block -->
<div class="wp-block-woocommerce-checkout-shipping-method-block"></div>
<!-- /wp:woocommerce/checkout-shipping-method-block -->
<!-- wp:woocommerce/checkout-pickup-options-block -->
<div class="wp-block-woocommerce-checkout-pickup-options-block"></div>
<!-- /wp:woocommerce/checkout-pickup-options-block -->
<!-- wp:woocommerce/checkout-shipping-address-block -->
<div class="wp-block-woocommerce-checkout-shipping-address-block"></div>
<!-- /wp:woocommerce/checkout-shipping-address-block -->
<!-- wp:woocommerce/checkout-billing-address-block -->
<div class="wp-block-woocommerce-checkout-billing-address-block"></div>
<!-- /wp:woocommerce/checkout-billing-address-block -->
<!-- wp:woocommerce/checkout-shipping-methods-block -->
<div class="wp-block-woocommerce-checkout-shipping-methods-block"></div>
<!-- /wp:woocommerce/checkout-shipping-methods-block -->
<!-- wp:woocommerce/checkout-payment-block -->
<div class="wp-block-woocommerce-checkout-payment-block"></div>
<!-- /wp:woocommerce/checkout-payment-block -->
<!-- wp:woocommerce/checkout-order-note-block -->
<div class="wp-block-woocommerce-checkout-order-note-block"></div>
<!-- /wp:woocommerce/checkout-order-note-block -->
<!-- wp:woocommerce/checkout-gift-wrapping-block -->
<div class="wp-block-woocommerce-checkout-gift-wrapping-block"></div>
<!-- /wp:woocommerce/checkout-gift-wrapping-block -->
<!-- wp:woocommerce/checkout-terms-block -->
<div class="wp-block-woocommerce-checkout-terms-block"></div>
<!-- /wp:woocommerce/checkout-terms-block -->
<!-- wp:woocommerce/checkout-actions-block -->
<div class="wp-block-woocommerce-checkout-actions-block"></div>
<!-- /wp:woocommerce/checkout-actions-block -->
</div>
<!-- /wp:woocommerce/checkout-fields-block -->
<!-- wp:woocommerce/checkout-totals-block -->
<div class="wp-block-woocommerce-checkout-totals-block"><!-- wp:woocommerce/checkout-order-summary-block -->
<div class="wp-block-woocommerce-checkout-order-summary-block">
<!-- wp:woocommerce/checkout-order-summary-cart-items-block -->
<div class="wp-block-woocommerce-checkout-order-summary-cart-items-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-cart-items-block -->
<!-- wp:woocommerce/checkout-order-summary-coupon-form-block -->
<div class="wp-block-woocommerce-checkout-order-summary-coupon-form-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-coupon-form-block -->
<!-- wp:woocommerce/checkout-order-summary-subtotal-block -->
<div class="wp-block-woocommerce-checkout-order-summary-subtotal-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-subtotal-block -->
<!-- wp:woocommerce/checkout-order-summary-fee-block -->
<div class="wp-block-woocommerce-checkout-order-summary-fee-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-fee-block -->
<!-- wp:woocommerce/checkout-order-summary-discount-block -->
<div class="wp-block-woocommerce-checkout-order-summary-discount-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-discount-block -->
<!-- wp:woocommerce/checkout-order-summary-shipping-block -->
<div class="wp-block-woocommerce-checkout-order-summary-shipping-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-shipping-block -->
<!-- wp:woocommerce/checkout-order-summary-taxes-block -->
<div class="wp-block-woocommerce-checkout-order-summary-taxes-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-taxes-block -->
</div>
<!-- /wp:woocommerce/checkout-order-summary-block -->
</div>
<!-- /wp:woocommerce/checkout-totals-block -->
</div>
<!-- /wp:woocommerce/checkout -->
</div>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"checkout-header","theme":"woocommerce/woocommerce"} /-->
<!-- wp:group {"layout":{"inherit":true,"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:pattern {"slug":"woocommerce/checkout-heading"} /-->
<!-- wp:woocommerce/checkout {"className":"wc-block-checkout"} -->
<div class="wp-block-woocommerce-checkout alignwide wc-block-checkout is-loading">
<!-- wp:woocommerce/checkout-fields-block -->
<div class="wp-block-woocommerce-checkout-fields-block"><!-- wp:woocommerce/checkout-express-payment-block -->
<div class="wp-block-woocommerce-checkout-express-payment-block"></div>
<!-- /wp:woocommerce/checkout-express-payment-block -->
<!-- wp:woocommerce/checkout-contact-information-block -->
<div class="wp-block-woocommerce-checkout-contact-information-block"></div>
<!-- /wp:woocommerce/checkout-contact-information-block -->
<!-- wp:woocommerce/checkout-shipping-method-block -->
<div class="wp-block-woocommerce-checkout-shipping-method-block"></div>
<!-- /wp:woocommerce/checkout-shipping-method-block -->
<!-- wp:woocommerce/checkout-pickup-options-block -->
<div class="wp-block-woocommerce-checkout-pickup-options-block"></div>
<!-- /wp:woocommerce/checkout-pickup-options-block -->
<!-- wp:woocommerce/checkout-shipping-address-block -->
<div class="wp-block-woocommerce-checkout-shipping-address-block"></div>
<!-- /wp:woocommerce/checkout-shipping-address-block -->
<!-- wp:woocommerce/checkout-billing-address-block -->
<div class="wp-block-woocommerce-checkout-billing-address-block"></div>
<!-- /wp:woocommerce/checkout-billing-address-block -->
<!-- wp:woocommerce/checkout-shipping-methods-block -->
<div class="wp-block-woocommerce-checkout-shipping-methods-block"></div>
<!-- /wp:woocommerce/checkout-shipping-methods-block -->
<!-- wp:woocommerce/checkout-payment-block -->
<div class="wp-block-woocommerce-checkout-payment-block"></div>
<!-- /wp:woocommerce/checkout-payment-block -->
<!-- wp:woocommerce/checkout-order-note-block -->
<div class="wp-block-woocommerce-checkout-order-note-block"></div>
<!-- /wp:woocommerce/checkout-order-note-block -->
<!-- wp:woocommerce/checkout-gift-wrapping-block -->
<div class="wp-block-woocommerce-checkout-gift-wrapping-block"></div>
<!-- /wp:woocommerce/checkout-gift-wrapping-block -->
<!-- wp:woocommerce/checkout-terms-block -->
<div class="wp-block-woocommerce-checkout-terms-block"></div>
<!-- /wp:woocommerce/checkout-terms-block -->
<!-- wp:woocommerce/checkout-actions-block -->
<div class="wp-block-woocommerce-checkout-actions-block"></div>
<!-- /wp:woocommerce/checkout-actions-block -->
</div>
<!-- /wp:woocommerce/checkout-fields-block -->
<!-- wp:woocommerce/checkout-totals-block -->
<div class="wp-block-woocommerce-checkout-totals-block"><!-- wp:woocommerce/checkout-order-summary-block -->
<div class="wp-block-woocommerce-checkout-order-summary-block">
<!-- wp:woocommerce/checkout-order-summary-cart-items-block -->
<div class="wp-block-woocommerce-checkout-order-summary-cart-items-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-cart-items-block -->
<!-- wp:woocommerce/checkout-order-summary-coupon-form-block -->
<div class="wp-block-woocommerce-checkout-order-summary-coupon-form-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-coupon-form-block -->
<!-- wp:woocommerce/checkout-order-summary-subtotal-block -->
<div class="wp-block-woocommerce-checkout-order-summary-subtotal-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-subtotal-block -->
<!-- wp:woocommerce/checkout-order-summary-fee-block -->
<div class="wp-block-woocommerce-checkout-order-summary-fee-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-fee-block -->
<!-- wp:woocommerce/checkout-order-summary-discount-block -->
<div class="wp-block-woocommerce-checkout-order-summary-discount-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-discount-block -->
<!-- wp:woocommerce/checkout-order-summary-shipping-block -->
<div class="wp-block-woocommerce-checkout-order-summary-shipping-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-shipping-block -->
<!-- wp:woocommerce/checkout-order-summary-taxes-block -->
<div class="wp-block-woocommerce-checkout-order-summary-taxes-block"></div>
<!-- /wp:woocommerce/checkout-order-summary-taxes-block -->
</div>
<!-- /wp:woocommerce/checkout-order-summary-block -->
</div>
<!-- /wp:woocommerce/checkout-totals-block -->
</div>
<!-- /wp:woocommerce/checkout -->
</div>
<!-- /wp:group -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment