rebase from live enviornment
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { BlockErrorProps } from './types';
|
||||
|
||||
const BlockError = ( {
|
||||
imageUrl = `${ WC_BLOCKS_IMAGE_URL }/block-error.svg`,
|
||||
header = __( 'Oops!', 'woo-gutenberg-products-block' ),
|
||||
text = __(
|
||||
'There was an error loading the content.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
errorMessage,
|
||||
errorMessagePrefix = __( 'Error:', 'woo-gutenberg-products-block' ),
|
||||
button,
|
||||
showErrorBlock = true,
|
||||
}: BlockErrorProps ): JSX.Element | null => {
|
||||
return showErrorBlock ? (
|
||||
<div className="wc-block-error wc-block-components-error">
|
||||
{ imageUrl && (
|
||||
// The alt text is left empty on purpose, as it's considered a decorative image.
|
||||
// More can be found here: https://www.w3.org/WAI/tutorials/images/decorative/.
|
||||
// Github discussion for a context: https://github.com/woocommerce/woocommerce-blocks/pull/7651#discussion_r1019560494.
|
||||
<img
|
||||
className="wc-block-error__image wc-block-components-error__image"
|
||||
src={ imageUrl }
|
||||
alt=""
|
||||
/>
|
||||
) }
|
||||
<div className="wc-block-error__content wc-block-components-error__content">
|
||||
{ header && (
|
||||
<p className="wc-block-error__header wc-block-components-error__header">
|
||||
{ header }
|
||||
</p>
|
||||
) }
|
||||
{ text && (
|
||||
<p className="wc-block-error__text wc-block-components-error__text">
|
||||
{ text }
|
||||
</p>
|
||||
) }
|
||||
{ errorMessage && (
|
||||
<p className="wc-block-error__message wc-block-components-error__message">
|
||||
{ errorMessagePrefix ? errorMessagePrefix + ' ' : '' }
|
||||
{ errorMessage }
|
||||
</p>
|
||||
) }
|
||||
{ button && (
|
||||
<p className="wc-block-error__button wc-block-components-error__button">
|
||||
{ button }
|
||||
</p>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default BlockError;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Component } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import BlockError from './block-error';
|
||||
import './style.scss';
|
||||
|
||||
import type {
|
||||
DerivedStateReturn,
|
||||
ReactError,
|
||||
BlockErrorBoundaryProps,
|
||||
} from './types';
|
||||
|
||||
class BlockErrorBoundary extends Component< BlockErrorBoundaryProps > {
|
||||
state = { errorMessage: '', hasError: false };
|
||||
|
||||
static getDerivedStateFromError( error: ReactError ): DerivedStateReturn {
|
||||
if (
|
||||
typeof error.statusText !== 'undefined' &&
|
||||
typeof error.status !== 'undefined'
|
||||
) {
|
||||
return {
|
||||
errorMessage: (
|
||||
<>
|
||||
<strong>{ error.status }</strong>:
|
||||
{ error.statusText }
|
||||
</>
|
||||
),
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { errorMessage: error.message, hasError: true };
|
||||
}
|
||||
|
||||
render(): JSX.Element | React.ReactNode {
|
||||
const {
|
||||
header,
|
||||
imageUrl,
|
||||
showErrorMessage = true,
|
||||
showErrorBlock = true,
|
||||
text,
|
||||
errorMessagePrefix,
|
||||
renderError,
|
||||
button,
|
||||
} = this.props;
|
||||
const { errorMessage, hasError } = this.state;
|
||||
|
||||
if ( hasError ) {
|
||||
if ( typeof renderError === 'function' ) {
|
||||
return renderError( { errorMessage } );
|
||||
}
|
||||
return (
|
||||
<BlockError
|
||||
showErrorBlock={ showErrorBlock }
|
||||
errorMessage={ showErrorMessage ? errorMessage : null }
|
||||
header={ header }
|
||||
imageUrl={ imageUrl }
|
||||
text={ text }
|
||||
errorMessagePrefix={ errorMessagePrefix }
|
||||
button={ button }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default BlockErrorBoundary;
|
||||
@@ -0,0 +1,34 @@
|
||||
.wc-block-components-error {
|
||||
display: flex;
|
||||
padding: $gap-largest 0;
|
||||
margin: $gap-largest 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: $gray-700;
|
||||
text-align: center;
|
||||
}
|
||||
.wc-block-components-error__header {
|
||||
@include font-size(larger);
|
||||
margin: 0;
|
||||
color: $studio-gray-50;
|
||||
}
|
||||
.wc-block-components-error__image {
|
||||
width: 25%;
|
||||
margin: 0 0 $gap-large 0;
|
||||
}
|
||||
.wc-block-components-error__text {
|
||||
margin: 1em 0 0;
|
||||
color: $studio-gray-30;
|
||||
@include font-size(large);
|
||||
max-width: 60ch;
|
||||
}
|
||||
.wc-block-components-error__message {
|
||||
margin: 1em auto 0;
|
||||
font-style: italic;
|
||||
color: $studio-gray-30;
|
||||
max-width: 60ch;
|
||||
}
|
||||
.wc-block-error__button {
|
||||
margin: $gap-largest 0 0 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
interface BlockErrorBase {
|
||||
/**
|
||||
* URL of the image to display.
|
||||
* If it's `null` or an empty string, no image will be displayed.
|
||||
* If it's not defined, the default image will be used.
|
||||
*/
|
||||
imageUrl?: string | undefined;
|
||||
/**
|
||||
* Text to display as the heading of the error block.
|
||||
* If it's `null` or an empty string, no header will be displayed.
|
||||
* If it's not defined, the default header will be used.
|
||||
*/
|
||||
header?: string | undefined;
|
||||
/**
|
||||
* Text to display in the error block below the header.
|
||||
* If it's `null` or an empty string, nothing will be displayed.
|
||||
* If it's not defined, the default text will be used.
|
||||
*/
|
||||
text?: React.ReactNode | undefined;
|
||||
/**
|
||||
* Text preceeding the error message.
|
||||
*/
|
||||
errorMessagePrefix?: string | undefined;
|
||||
/**
|
||||
* Button cta.
|
||||
*/
|
||||
button?: React.ReactNode;
|
||||
/**
|
||||
* Controls wether to show the error block or fail silently
|
||||
*/
|
||||
showErrorBlock?: boolean;
|
||||
}
|
||||
|
||||
export interface BlockErrorProps extends BlockErrorBase {
|
||||
/**
|
||||
* Error message to display below the content.
|
||||
*/
|
||||
errorMessage: React.ReactNode;
|
||||
}
|
||||
|
||||
export type RenderErrorProps = {
|
||||
errorMessage: React.ReactNode;
|
||||
};
|
||||
|
||||
export interface BlockErrorBoundaryProps extends BlockErrorBase {
|
||||
/**
|
||||
* Override the default error with a function that takes the error message and returns a React component
|
||||
*/
|
||||
renderError?: ( props: RenderErrorProps ) => React.ReactNode;
|
||||
showErrorMessage?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface DerivedStateReturn {
|
||||
errorMessage: JSX.Element | string;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export interface ReactError {
|
||||
status: string;
|
||||
statusText: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Button as WPButton } from 'wordpress-components';
|
||||
import type { Button as WPButtonType } from '@wordpress/components';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import Spinner from '../../../../../packages/components/spinner';
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit< WPButtonType.ButtonProps, 'variant' | 'href' > {
|
||||
/**
|
||||
* Show spinner
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
showSpinner?: boolean | undefined;
|
||||
/**
|
||||
* Button variant
|
||||
*/
|
||||
variant?: 'text' | 'contained' | 'outlined';
|
||||
/**
|
||||
* The URL the button should link to.
|
||||
*/
|
||||
href?: string | undefined;
|
||||
}
|
||||
|
||||
export interface AnchorProps extends Omit< ButtonProps, 'href' > {
|
||||
/**
|
||||
* Button href
|
||||
*/
|
||||
href?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that visually renders a button but semantically might be `<button>` or `<a>` depending
|
||||
* on the props.
|
||||
*/
|
||||
const Button = ( {
|
||||
className,
|
||||
showSpinner = false,
|
||||
children,
|
||||
variant = 'contained',
|
||||
...props
|
||||
}: ButtonProps ): JSX.Element => {
|
||||
const buttonClassName = classNames(
|
||||
'wc-block-components-button',
|
||||
'wp-element-button',
|
||||
className,
|
||||
variant,
|
||||
{
|
||||
'wc-block-components-button--loading': showSpinner,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<WPButton className={ buttonClassName } { ...props }>
|
||||
{ showSpinner && <Spinner /> }
|
||||
<span className="wc-block-components-button__text">
|
||||
{ children }
|
||||
</span>
|
||||
</WPButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Story, Meta } from '@storybook/react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Button, { ButtonProps } from '..';
|
||||
const availableTypes = [ 'button', 'input', 'submit' ];
|
||||
|
||||
export default {
|
||||
title: 'Base Components/Button',
|
||||
argTypes: {
|
||||
children: {
|
||||
control: 'text',
|
||||
},
|
||||
type: {
|
||||
control: 'radio',
|
||||
options: availableTypes,
|
||||
},
|
||||
},
|
||||
component: Button,
|
||||
} as Meta< ButtonProps >;
|
||||
|
||||
const Template: Story< ButtonProps > = ( args ) => {
|
||||
return <Button { ...args } />;
|
||||
};
|
||||
|
||||
export const Default = Template.bind( {} );
|
||||
Default.args = {
|
||||
children: 'Buy Now',
|
||||
disabled: false,
|
||||
showSpinner: false,
|
||||
type: 'button',
|
||||
};
|
||||
|
||||
export const Disabled = Template.bind( {} );
|
||||
Disabled.args = {
|
||||
...Default.args,
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
export const Loading = Template.bind( {} );
|
||||
Loading.args = {
|
||||
...Default.args,
|
||||
disabled: true,
|
||||
showSpinner: true,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
.wc-block-components-button:not(.is-link) {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transition: box-shadow 0.1s linear;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px $studio-blue;
|
||||
box-shadow: inset 0 0 0 1px $white, 0 0 0 2px $studio-blue;
|
||||
outline: 3px solid transparent;
|
||||
}
|
||||
|
||||
.wc-block-components-button__text {
|
||||
display: block;
|
||||
|
||||
> svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
.wc-block-components-spinner + .wc-block-components-button__text {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.text {
|
||||
color: $gray-900;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&.outlined {
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
|
||||
&:not(:focus) {
|
||||
box-shadow: inset 0 0 0 1px currentColor;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: $gray-900;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-900;
|
||||
color: $white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:not(.woocommerce-block-theme-has-button-styles) .wc-block-components-button:not(.is-link) {
|
||||
min-height: 3em;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px $studio-blue;
|
||||
box-shadow: inset 0 0 0 1px $white, 0 0 0 2px $studio-blue;
|
||||
outline: 3px solid transparent;
|
||||
}
|
||||
|
||||
&.text {
|
||||
color: $gray-900;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isPostcode } from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
ValidatedTextInput,
|
||||
type ValidatedTextInputHandle,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import {
|
||||
BillingCountryInput,
|
||||
ShippingCountryInput,
|
||||
} from '@woocommerce/base-components/country-input';
|
||||
import {
|
||||
BillingStateInput,
|
||||
ShippingStateInput,
|
||||
} from '@woocommerce/base-components/state-input';
|
||||
import { useEffect, useMemo, useRef } from '@wordpress/element';
|
||||
import { useInstanceId } from '@wordpress/compose';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import { defaultAddressFields } from '@woocommerce/settings';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
AddressFormProps,
|
||||
FieldType,
|
||||
FieldConfig,
|
||||
AddressFormFields,
|
||||
} from './types';
|
||||
import prepareAddressFields from './prepare-address-fields';
|
||||
import validateShippingCountry from './validate-shipping-country';
|
||||
import customValidationHandler from './custom-validation-handler';
|
||||
|
||||
const defaultFields = Object.keys(
|
||||
defaultAddressFields
|
||||
) as unknown as FieldType[];
|
||||
|
||||
/**
|
||||
* Checkout address form.
|
||||
*/
|
||||
const AddressForm = ( {
|
||||
id = '',
|
||||
fields = defaultFields,
|
||||
fieldConfig = {} as FieldConfig,
|
||||
onChange,
|
||||
type = 'shipping',
|
||||
values,
|
||||
}: AddressFormProps ): JSX.Element => {
|
||||
const instanceId = useInstanceId( AddressForm );
|
||||
|
||||
// Track incoming props.
|
||||
const currentFields = useShallowEqual( fields );
|
||||
const currentFieldConfig = useShallowEqual( fieldConfig );
|
||||
const currentCountry = useShallowEqual( values.country );
|
||||
|
||||
// Memoize the address form fields passed in from the parent component.
|
||||
const addressFormFields = useMemo( (): AddressFormFields => {
|
||||
const preparedFields = prepareAddressFields(
|
||||
currentFields,
|
||||
currentFieldConfig,
|
||||
currentCountry
|
||||
);
|
||||
return {
|
||||
fields: preparedFields,
|
||||
type,
|
||||
required: preparedFields.filter( ( field ) => field.required ),
|
||||
hidden: preparedFields.filter( ( field ) => field.hidden ),
|
||||
};
|
||||
}, [ currentFields, currentFieldConfig, currentCountry, type ] );
|
||||
|
||||
// Stores refs for rendered fields so we can access them later.
|
||||
const fieldsRef = useRef<
|
||||
Record< string, ValidatedTextInputHandle | null >
|
||||
>( {} );
|
||||
|
||||
// Clear values for hidden fields.
|
||||
useEffect( () => {
|
||||
const newValues = {
|
||||
...values,
|
||||
...Object.fromEntries(
|
||||
addressFormFields.hidden.map( ( field ) => [ field.key, '' ] )
|
||||
),
|
||||
};
|
||||
if ( ! isShallowEqual( values, newValues ) ) {
|
||||
onChange( newValues );
|
||||
}
|
||||
}, [ onChange, addressFormFields, values ] );
|
||||
|
||||
// Maybe validate country when other fields change so user is notified that it's required.
|
||||
useEffect( () => {
|
||||
if ( type === 'shipping' ) {
|
||||
validateShippingCountry( values );
|
||||
}
|
||||
}, [ values, type ] );
|
||||
|
||||
// Changing country may change format for postcodes.
|
||||
useEffect( () => {
|
||||
fieldsRef.current?.postcode?.revalidate();
|
||||
}, [ currentCountry ] );
|
||||
|
||||
id = id || `${ instanceId }`;
|
||||
|
||||
return (
|
||||
<div id={ id } className="wc-block-components-address-form">
|
||||
{ addressFormFields.fields.map( ( field ) => {
|
||||
if ( field.hidden ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldProps = {
|
||||
id: `${ id }-${ field.key }`,
|
||||
errorId: `${ type }_${ field.key }`,
|
||||
label: field.required ? field.label : field.optionalLabel,
|
||||
autoCapitalize: field.autocapitalize,
|
||||
autoComplete: field.autocomplete,
|
||||
errorMessage: field.errorMessage,
|
||||
required: field.required,
|
||||
className: `wc-block-components-address-form__${ field.key }`,
|
||||
};
|
||||
|
||||
if ( field.key === 'country' ) {
|
||||
const Tag =
|
||||
type === 'shipping'
|
||||
? ShippingCountryInput
|
||||
: BillingCountryInput;
|
||||
return (
|
||||
<Tag
|
||||
key={ field.key }
|
||||
{ ...fieldProps }
|
||||
value={ values.country }
|
||||
onChange={ ( newCountry ) => {
|
||||
const newValues = {
|
||||
...values,
|
||||
country: newCountry,
|
||||
state: '',
|
||||
};
|
||||
// Country will impact postcode too. Do we need to clear it?
|
||||
if (
|
||||
values.postcode &&
|
||||
! isPostcode( {
|
||||
postcode: values.postcode,
|
||||
country: newCountry,
|
||||
} )
|
||||
) {
|
||||
newValues.postcode = '';
|
||||
}
|
||||
onChange( newValues );
|
||||
} }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ( field.key === 'state' ) {
|
||||
const Tag =
|
||||
type === 'shipping'
|
||||
? ShippingStateInput
|
||||
: BillingStateInput;
|
||||
return (
|
||||
<Tag
|
||||
key={ field.key }
|
||||
{ ...fieldProps }
|
||||
country={ values.country }
|
||||
value={ values.state }
|
||||
onChange={ ( newValue ) =>
|
||||
onChange( {
|
||||
...values,
|
||||
state: newValue,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ValidatedTextInput
|
||||
key={ field.key }
|
||||
ref={ ( el ) =>
|
||||
( fieldsRef.current[ field.key ] = el )
|
||||
}
|
||||
{ ...fieldProps }
|
||||
type={ field.type }
|
||||
value={ values[ field.key ] }
|
||||
onChange={ ( newValue: string ) =>
|
||||
onChange( {
|
||||
...values,
|
||||
[ field.key ]: newValue,
|
||||
} )
|
||||
}
|
||||
customFormatter={ ( value: string ) => {
|
||||
if ( field.key === 'postcode' ) {
|
||||
return value.trimStart().toUpperCase();
|
||||
}
|
||||
return value;
|
||||
} }
|
||||
customValidation={ ( inputObject: HTMLInputElement ) =>
|
||||
customValidationHandler(
|
||||
inputObject,
|
||||
field.key,
|
||||
values
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressForm;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { isPostcode } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Custom validation handler for fields with field specific handling.
|
||||
*/
|
||||
const customValidationHandler = (
|
||||
inputObject: HTMLInputElement,
|
||||
field: string,
|
||||
customValues: {
|
||||
country: string;
|
||||
}
|
||||
): boolean => {
|
||||
// Pass validation if the field is not required and is empty.
|
||||
if ( ! inputObject.required && ! inputObject.value ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
field === 'postcode' &&
|
||||
customValues.country &&
|
||||
! isPostcode( {
|
||||
postcode: inputObject.value,
|
||||
country: customValues.country,
|
||||
} )
|
||||
) {
|
||||
inputObject.setCustomValidity(
|
||||
__(
|
||||
'Please enter a valid postcode',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default customValidationHandler;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AddressForm } from './address-form';
|
||||
@@ -0,0 +1,131 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/address-fields').CountryAddressFields } CountryAddressFields */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
CountryAddressFields,
|
||||
defaultAddressFields,
|
||||
KeyedAddressField,
|
||||
LocaleSpecificAddressField,
|
||||
} from '@woocommerce/settings';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { isNumber, isString } from '@woocommerce/types';
|
||||
import { COUNTRY_LOCALE } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Gets props from the core locale, then maps them to the shape we require in the client.
|
||||
*
|
||||
* Ignores "class", "type", "placeholder", and "autocomplete" props from core.
|
||||
*
|
||||
* @param {Object} localeField Locale fields from WooCommerce.
|
||||
* @return {Object} Supported locale fields.
|
||||
*/
|
||||
const getSupportedCoreLocaleProps = (
|
||||
localeField: LocaleSpecificAddressField
|
||||
): Partial< AddressField > => {
|
||||
const fields: Partial< AddressField > = {};
|
||||
|
||||
if ( localeField.label !== undefined ) {
|
||||
fields.label = localeField.label;
|
||||
}
|
||||
|
||||
if ( localeField.required !== undefined ) {
|
||||
fields.required = localeField.required;
|
||||
}
|
||||
|
||||
if ( localeField.hidden !== undefined ) {
|
||||
fields.hidden = localeField.hidden;
|
||||
}
|
||||
|
||||
if ( localeField.label !== undefined && ! localeField.optionalLabel ) {
|
||||
fields.optionalLabel = sprintf(
|
||||
/* translators: %s Field label. */
|
||||
__( '%s (optional)', 'woo-gutenberg-products-block' ),
|
||||
localeField.label
|
||||
);
|
||||
}
|
||||
|
||||
if ( localeField.priority ) {
|
||||
if ( isNumber( localeField.priority ) ) {
|
||||
fields.index = localeField.priority;
|
||||
}
|
||||
if ( isString( localeField.priority ) ) {
|
||||
fields.index = parseInt( localeField.priority, 10 );
|
||||
}
|
||||
}
|
||||
|
||||
if ( localeField.hidden ) {
|
||||
fields.required = false;
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* COUNTRY_LOCALE is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
|
||||
* but we can import part of it to set which fields are required.
|
||||
*
|
||||
* This supports new properties such as optionalLabel which are not used by core (yet).
|
||||
*/
|
||||
const countryAddressFields: CountryAddressFields = Object.entries(
|
||||
COUNTRY_LOCALE
|
||||
)
|
||||
.map( ( [ country, countryLocale ] ) => [
|
||||
country,
|
||||
Object.entries( countryLocale )
|
||||
.map( ( [ localeFieldKey, localeField ] ) => [
|
||||
localeFieldKey,
|
||||
getSupportedCoreLocaleProps( localeField ),
|
||||
] )
|
||||
.reduce( ( obj, [ key, val ] ) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - Ignoring because it should be fine as long as the data from the server is correct. TS won't catch it anyway if it's not.
|
||||
obj[ key ] = val;
|
||||
return obj;
|
||||
}, {} ),
|
||||
] )
|
||||
.reduce( ( obj, [ key, val ] ) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - Ignoring because it should be fine as long as the data from the server is correct. TS won't catch it anyway if it's not.
|
||||
obj[ key ] = val;
|
||||
return obj;
|
||||
}, {} );
|
||||
|
||||
/**
|
||||
* Combines address fields, including fields from the locale, and sorts them by index.
|
||||
*
|
||||
* @param {Array} fields List of field keys--only address fields matching these will be returned.
|
||||
* @param {Object} fieldConfigs Fields config contains field specific overrides at block level which may, for example, hide a field.
|
||||
* @param {string} addressCountry Address country code. If unknown, locale fields will not be merged.
|
||||
* @return {CountryAddressFields} Object containing address fields.
|
||||
*/
|
||||
const prepareAddressFields = (
|
||||
fields: ( keyof AddressFields )[],
|
||||
fieldConfigs: Record< string, Partial< AddressField > >,
|
||||
addressCountry = ''
|
||||
): KeyedAddressField[] => {
|
||||
const localeConfigs: AddressFields =
|
||||
addressCountry && countryAddressFields[ addressCountry ] !== undefined
|
||||
? countryAddressFields[ addressCountry ]
|
||||
: ( {} as AddressFields );
|
||||
|
||||
return fields
|
||||
.map( ( field ) => {
|
||||
const defaultConfig = defaultAddressFields[ field ] || {};
|
||||
const localeConfig = localeConfigs[ field ] || {};
|
||||
const fieldConfig = fieldConfigs[ field ] || {};
|
||||
|
||||
return {
|
||||
key: field,
|
||||
...defaultConfig,
|
||||
...localeConfig,
|
||||
...fieldConfig,
|
||||
};
|
||||
} )
|
||||
.sort( ( a, b ) => a.index - b.index );
|
||||
};
|
||||
|
||||
export default prepareAddressFields;
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CheckoutProvider } from '@woocommerce/base-context';
|
||||
import { useCheckoutAddress } from '@woocommerce/base-context/hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import AddressForm from '../address-form';
|
||||
|
||||
const renderInCheckoutProvider = ( ui, options = {} ) => {
|
||||
const Wrapper = ( { children } ) => {
|
||||
return <CheckoutProvider>{ children }</CheckoutProvider>;
|
||||
};
|
||||
return render( ui, { wrapper: Wrapper, ...options } );
|
||||
};
|
||||
|
||||
// Countries used in testing addresses must be in the wcSettings global.
|
||||
// See: tests/js/setup-globals.js
|
||||
const primaryAddress = {
|
||||
country: 'United Kingdom',
|
||||
countryKey: 'GB',
|
||||
city: 'London',
|
||||
state: 'Greater London',
|
||||
postcode: 'ABCD',
|
||||
};
|
||||
const secondaryAddress = {
|
||||
country: 'Austria', // We use Austria because it doesn't have states.
|
||||
countryKey: 'AU',
|
||||
city: 'Vienna',
|
||||
postcode: 'DCBA',
|
||||
};
|
||||
const tertiaryAddress = {
|
||||
country: 'Canada', // We use Canada because it has a select for the state.
|
||||
countryKey: 'CA',
|
||||
city: 'Toronto',
|
||||
state: 'Ontario',
|
||||
postcode: 'EFGH',
|
||||
};
|
||||
|
||||
const countryRegExp = /country/i;
|
||||
const cityRegExp = /city/i;
|
||||
const stateRegExp = /county|province|state/i;
|
||||
const postalCodeRegExp = /postal code|postcode|zip/i;
|
||||
|
||||
const inputAddress = async ( {
|
||||
country = null,
|
||||
city = null,
|
||||
state = null,
|
||||
postcode = null,
|
||||
} ) => {
|
||||
if ( country ) {
|
||||
const countryInput = screen.getByLabelText( countryRegExp );
|
||||
userEvent.type( countryInput, country + '{arrowdown}{enter}' );
|
||||
}
|
||||
if ( city ) {
|
||||
const cityInput = screen.getByLabelText( cityRegExp );
|
||||
userEvent.type( cityInput, city );
|
||||
}
|
||||
if ( state ) {
|
||||
const stateButton = screen.queryByRole( 'combobox', {
|
||||
name: stateRegExp,
|
||||
} );
|
||||
// State input might be a select or a text input.
|
||||
if ( stateButton ) {
|
||||
userEvent.click( stateButton );
|
||||
userEvent.click( screen.getByRole( 'option', { name: state } ) );
|
||||
} else {
|
||||
const stateInput = screen.getByLabelText( stateRegExp );
|
||||
userEvent.type( stateInput, state );
|
||||
}
|
||||
}
|
||||
if ( postcode ) {
|
||||
const postcodeInput = screen.getByLabelText( postalCodeRegExp );
|
||||
userEvent.type( postcodeInput, postcode );
|
||||
}
|
||||
};
|
||||
|
||||
describe( 'AddressForm Component', () => {
|
||||
const WrappedAddressForm = ( { type } ) => {
|
||||
const { defaultAddressFields, setShippingAddress, shippingAddress } =
|
||||
useCheckoutAddress();
|
||||
|
||||
return (
|
||||
<AddressForm
|
||||
type={ type }
|
||||
onChange={ setShippingAddress }
|
||||
values={ shippingAddress }
|
||||
fields={ Object.keys( defaultAddressFields ) }
|
||||
/>
|
||||
);
|
||||
};
|
||||
const ShippingFields = () => {
|
||||
const { shippingAddress } = useCheckoutAddress();
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{ Object.keys( shippingAddress ).map( ( key ) => (
|
||||
<li key={ key }>{ key + ': ' + shippingAddress[ key ] }</li>
|
||||
) ) }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
it( 'updates context value when interacting with form elements', () => {
|
||||
renderInCheckoutProvider(
|
||||
<>
|
||||
<WrappedAddressForm type="shipping" />
|
||||
<ShippingFields />
|
||||
</>
|
||||
);
|
||||
|
||||
inputAddress( primaryAddress );
|
||||
|
||||
expect( screen.getByText( /country/ ) ).toHaveTextContent(
|
||||
`country: ${ primaryAddress.countryKey }`
|
||||
);
|
||||
expect( screen.getByText( /city/ ) ).toHaveTextContent(
|
||||
`city: ${ primaryAddress.city }`
|
||||
);
|
||||
expect( screen.getByText( /state/ ) ).toHaveTextContent(
|
||||
`state: ${ primaryAddress.state }`
|
||||
);
|
||||
expect( screen.getByText( /postcode/ ) ).toHaveTextContent(
|
||||
`postcode: ${ primaryAddress.postcode }`
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'input fields update when changing the country', () => {
|
||||
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
|
||||
|
||||
inputAddress( primaryAddress );
|
||||
|
||||
// Verify correct labels are used.
|
||||
expect( screen.getByLabelText( /City/ ) ).toBeInTheDocument();
|
||||
expect( screen.getByLabelText( /County/ ) ).toBeInTheDocument();
|
||||
expect( screen.getByLabelText( /Postcode/ ) ).toBeInTheDocument();
|
||||
|
||||
inputAddress( secondaryAddress );
|
||||
|
||||
// Verify state input has been removed.
|
||||
expect( screen.queryByText( stateRegExp ) ).not.toBeInTheDocument();
|
||||
|
||||
inputAddress( tertiaryAddress );
|
||||
|
||||
// Verify postal code input label changed.
|
||||
expect( screen.getByLabelText( /Postal code/ ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'input values are reset after changing the country', () => {
|
||||
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
|
||||
|
||||
inputAddress( secondaryAddress );
|
||||
// Only update `country` to verify other values are reset.
|
||||
inputAddress( { country: primaryAddress.country } );
|
||||
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
|
||||
|
||||
// Repeat the test with an address which has a select for the state.
|
||||
inputAddress( tertiaryAddress );
|
||||
inputAddress( { country: primaryAddress.country } );
|
||||
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
AddressType,
|
||||
ShippingAddress,
|
||||
KeyedAddressField,
|
||||
} from '@woocommerce/settings';
|
||||
|
||||
export type FieldConfig = Record<
|
||||
keyof AddressFields,
|
||||
Partial< AddressField >
|
||||
>;
|
||||
|
||||
export type FieldType = keyof AddressFields;
|
||||
|
||||
export type AddressFormFields = {
|
||||
fields: KeyedAddressField[];
|
||||
type: AddressType;
|
||||
required: KeyedAddressField[];
|
||||
hidden: KeyedAddressField[];
|
||||
};
|
||||
|
||||
export interface AddressFormProps {
|
||||
// Id for component.
|
||||
id?: string;
|
||||
// Type of form (billing or shipping).
|
||||
type?: AddressType;
|
||||
// Array of fields in form.
|
||||
fields: FieldType[];
|
||||
// Field configuration for fields in form.
|
||||
fieldConfig?: FieldConfig;
|
||||
// Called with the new address data when the address form changes. This is only called when all required fields are filled and there are no validation errors.
|
||||
onChange: ( newValue: ShippingAddress ) => void;
|
||||
// Values for fields.
|
||||
values: ShippingAddress;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { type ShippingAddress } from '@woocommerce/settings';
|
||||
import { select, dispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
// If it's the shipping address form and the user starts entering address
|
||||
// values without having set the country first, show an error.
|
||||
const validateShippingCountry = ( values: ShippingAddress ): void => {
|
||||
const validationErrorId = 'shipping_country';
|
||||
const hasValidationError =
|
||||
select( VALIDATION_STORE_KEY ).getValidationError( validationErrorId );
|
||||
if (
|
||||
! values.country &&
|
||||
( values.city || values.state || values.postcode )
|
||||
) {
|
||||
if ( hasValidationError ) {
|
||||
dispatch( VALIDATION_STORE_KEY ).showValidationError(
|
||||
validationErrorId
|
||||
);
|
||||
} else {
|
||||
dispatch( VALIDATION_STORE_KEY ).setValidationErrors( {
|
||||
[ validationErrorId ]: {
|
||||
message: __(
|
||||
'Please select your country',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
if ( hasValidationError && values.country ) {
|
||||
dispatch( VALIDATION_STORE_KEY ).clearValidationError(
|
||||
validationErrorId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default validateShippingCountry;
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { speak } from '@wordpress/a11y';
|
||||
import QuantitySelector from '@woocommerce/base-components/quantity-selector';
|
||||
import ProductPrice from '@woocommerce/base-components/product-price';
|
||||
import ProductName from '@woocommerce/base-components/product-name';
|
||||
import {
|
||||
useStoreCartItemQuantity,
|
||||
useStoreEvents,
|
||||
useStoreCart,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
|
||||
import { applyCheckoutFilter, mustContain } from '@woocommerce/blocks-checkout';
|
||||
import Dinero from 'dinero.js';
|
||||
import { forwardRef, useMemo } from '@wordpress/element';
|
||||
import type { CartItem } from '@woocommerce/types';
|
||||
import { objectHasProp, Currency } from '@woocommerce/types';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBackorderBadge from '../product-backorder-badge';
|
||||
import ProductImage from '../product-image';
|
||||
import ProductLowStockBadge from '../product-low-stock-badge';
|
||||
import ProductMetadata from '../product-metadata';
|
||||
import ProductSaleBadge from '../product-sale-badge';
|
||||
|
||||
/**
|
||||
* Convert a Dinero object with precision to store currency minor unit.
|
||||
*
|
||||
* @param {Dinero} priceObject Price object to convert.
|
||||
* @param {Object} currency Currency data.
|
||||
* @return {number} Amount with new minor unit precision.
|
||||
*/
|
||||
const getAmountFromRawPrice = (
|
||||
priceObject: Dinero.Dinero,
|
||||
currency: Currency
|
||||
) => {
|
||||
return priceObject.convertPrecision( currency.minorUnit ).getAmount();
|
||||
};
|
||||
|
||||
const productPriceValidation = ( value: string ) =>
|
||||
mustContain( value, '<price/>' );
|
||||
|
||||
interface CartLineItemRowProps {
|
||||
lineItem: CartItem | Record< string, never >;
|
||||
onRemove?: () => void;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cart line item table row component.
|
||||
*/
|
||||
const CartLineItemRow: React.ForwardRefExoticComponent<
|
||||
CartLineItemRowProps & React.RefAttributes< HTMLTableRowElement >
|
||||
> = forwardRef< HTMLTableRowElement, CartLineItemRowProps >(
|
||||
(
|
||||
{ lineItem, onRemove = () => void null, tabIndex },
|
||||
ref
|
||||
): JSX.Element => {
|
||||
const {
|
||||
name: initialName = '',
|
||||
catalog_visibility: catalogVisibility = 'visible',
|
||||
short_description: shortDescription = '',
|
||||
description: fullDescription = '',
|
||||
low_stock_remaining: lowStockRemaining = null,
|
||||
show_backorder_badge: showBackorderBadge = false,
|
||||
quantity_limits: quantityLimits = {
|
||||
minimum: 1,
|
||||
maximum: 99,
|
||||
multiple_of: 1,
|
||||
editable: true,
|
||||
},
|
||||
sold_individually: soldIndividually = false,
|
||||
permalink = '',
|
||||
images = [],
|
||||
variation = [],
|
||||
item_data: itemData = [],
|
||||
prices = {
|
||||
currency_code: 'USD',
|
||||
currency_minor_unit: 2,
|
||||
currency_symbol: '$',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
price: '0',
|
||||
regular_price: '0',
|
||||
sale_price: '0',
|
||||
price_range: null,
|
||||
raw_prices: {
|
||||
precision: 6,
|
||||
price: '0',
|
||||
regular_price: '0',
|
||||
sale_price: '0',
|
||||
},
|
||||
},
|
||||
totals = {
|
||||
currency_code: 'USD',
|
||||
currency_minor_unit: 2,
|
||||
currency_symbol: '$',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
line_subtotal: '0',
|
||||
line_subtotal_tax: '0',
|
||||
},
|
||||
extensions,
|
||||
} = lineItem;
|
||||
|
||||
const { quantity, setItemQuantity, removeItem, isPendingDelete } =
|
||||
useStoreCartItemQuantity( lineItem );
|
||||
const { dispatchStoreEvent } = useStoreEvents();
|
||||
|
||||
// Prepare props to pass to the applyCheckoutFilter filter.
|
||||
// We need to pluck out receiveCart.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { receiveCart, ...cart } = useStoreCart();
|
||||
const arg = useMemo(
|
||||
() => ( {
|
||||
context: 'cart',
|
||||
cartItem: lineItem,
|
||||
cart,
|
||||
} ),
|
||||
[ lineItem, cart ]
|
||||
);
|
||||
const priceCurrency = getCurrencyFromPriceResponse( prices );
|
||||
const name = applyCheckoutFilter( {
|
||||
filterName: 'itemName',
|
||||
defaultValue: initialName,
|
||||
extensions,
|
||||
arg,
|
||||
} );
|
||||
|
||||
const regularAmountSingle = Dinero( {
|
||||
amount: parseInt( prices.raw_prices.regular_price, 10 ),
|
||||
precision: prices.raw_prices.precision,
|
||||
} );
|
||||
const purchaseAmountSingle = Dinero( {
|
||||
amount: parseInt( prices.raw_prices.price, 10 ),
|
||||
precision: prices.raw_prices.precision,
|
||||
} );
|
||||
const saleAmountSingle =
|
||||
regularAmountSingle.subtract( purchaseAmountSingle );
|
||||
const saleAmount = saleAmountSingle.multiply( quantity );
|
||||
const totalsCurrency = getCurrencyFromPriceResponse( totals );
|
||||
let lineSubtotal = parseInt( totals.line_subtotal, 10 );
|
||||
if ( getSetting( 'displayCartPricesIncludingTax', false ) ) {
|
||||
lineSubtotal += parseInt( totals.line_subtotal_tax, 10 );
|
||||
}
|
||||
const subtotalPrice = Dinero( {
|
||||
amount: lineSubtotal,
|
||||
precision: totalsCurrency.minorUnit,
|
||||
} );
|
||||
|
||||
const firstImage = images.length ? images[ 0 ] : {};
|
||||
const isProductHiddenFromCatalog =
|
||||
catalogVisibility === 'hidden' || catalogVisibility === 'search';
|
||||
|
||||
const cartItemClassNameFilter = applyCheckoutFilter( {
|
||||
filterName: 'cartItemClass',
|
||||
defaultValue: '',
|
||||
extensions,
|
||||
arg,
|
||||
} );
|
||||
|
||||
// Allow extensions to filter how the price is displayed. Ie: prepending or appending some values.
|
||||
const productPriceFormat = applyCheckoutFilter( {
|
||||
filterName: 'cartItemPrice',
|
||||
defaultValue: '<price/>',
|
||||
extensions,
|
||||
arg,
|
||||
validation: productPriceValidation,
|
||||
} );
|
||||
|
||||
const subtotalPriceFormat = applyCheckoutFilter( {
|
||||
filterName: 'subtotalPriceFormat',
|
||||
defaultValue: '<price/>',
|
||||
extensions,
|
||||
arg,
|
||||
validation: productPriceValidation,
|
||||
} );
|
||||
|
||||
const saleBadgePriceFormat = applyCheckoutFilter( {
|
||||
filterName: 'saleBadgePriceFormat',
|
||||
defaultValue: '<price/>',
|
||||
extensions,
|
||||
arg,
|
||||
validation: productPriceValidation,
|
||||
} );
|
||||
|
||||
const showRemoveItemLink = applyCheckoutFilter( {
|
||||
filterName: 'showRemoveItemLink',
|
||||
defaultValue: true,
|
||||
extensions,
|
||||
arg,
|
||||
} );
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={ classnames(
|
||||
'wc-block-cart-items__row',
|
||||
cartItemClassNameFilter,
|
||||
{
|
||||
'is-disabled': isPendingDelete,
|
||||
}
|
||||
) }
|
||||
ref={ ref }
|
||||
tabIndex={ tabIndex }
|
||||
>
|
||||
{ /* If the image has no alt text, this link is unnecessary and can be hidden. */ }
|
||||
<td
|
||||
className="wc-block-cart-item__image"
|
||||
aria-hidden={
|
||||
! objectHasProp( firstImage, 'alt' ) || ! firstImage.alt
|
||||
}
|
||||
>
|
||||
{ /* We don't need to make it focusable, because product name has the same link. */ }
|
||||
{ isProductHiddenFromCatalog ? (
|
||||
<ProductImage
|
||||
image={ firstImage }
|
||||
fallbackAlt={ name }
|
||||
/>
|
||||
) : (
|
||||
<a href={ permalink } tabIndex={ -1 }>
|
||||
<ProductImage
|
||||
image={ firstImage }
|
||||
fallbackAlt={ name }
|
||||
/>
|
||||
</a>
|
||||
) }
|
||||
</td>
|
||||
<td className="wc-block-cart-item__product">
|
||||
<div className="wc-block-cart-item__wrap">
|
||||
<ProductName
|
||||
disabled={
|
||||
isPendingDelete || isProductHiddenFromCatalog
|
||||
}
|
||||
name={ name }
|
||||
permalink={ permalink }
|
||||
/>
|
||||
{ showBackorderBadge ? (
|
||||
<ProductBackorderBadge />
|
||||
) : (
|
||||
!! lowStockRemaining && (
|
||||
<ProductLowStockBadge
|
||||
lowStockRemaining={ lowStockRemaining }
|
||||
/>
|
||||
)
|
||||
) }
|
||||
|
||||
<div className="wc-block-cart-item__prices">
|
||||
<ProductPrice
|
||||
currency={ priceCurrency }
|
||||
regularPrice={ getAmountFromRawPrice(
|
||||
regularAmountSingle,
|
||||
priceCurrency
|
||||
) }
|
||||
price={ getAmountFromRawPrice(
|
||||
purchaseAmountSingle,
|
||||
priceCurrency
|
||||
) }
|
||||
format={ subtotalPriceFormat }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductSaleBadge
|
||||
currency={ priceCurrency }
|
||||
saleAmount={ getAmountFromRawPrice(
|
||||
saleAmountSingle,
|
||||
priceCurrency
|
||||
) }
|
||||
format={ saleBadgePriceFormat }
|
||||
/>
|
||||
|
||||
<ProductMetadata
|
||||
shortDescription={ shortDescription }
|
||||
fullDescription={ fullDescription }
|
||||
itemData={ itemData }
|
||||
variation={ variation }
|
||||
/>
|
||||
|
||||
<div className="wc-block-cart-item__quantity">
|
||||
{ ! soldIndividually &&
|
||||
!! quantityLimits.editable && (
|
||||
<QuantitySelector
|
||||
disabled={ isPendingDelete }
|
||||
quantity={ quantity }
|
||||
minimum={ quantityLimits.minimum }
|
||||
maximum={ quantityLimits.maximum }
|
||||
step={ quantityLimits.multiple_of }
|
||||
onChange={ ( newQuantity ) => {
|
||||
setItemQuantity( newQuantity );
|
||||
dispatchStoreEvent(
|
||||
'cart-set-item-quantity',
|
||||
{
|
||||
product: lineItem,
|
||||
quantity: newQuantity,
|
||||
}
|
||||
);
|
||||
} }
|
||||
itemName={ name }
|
||||
/>
|
||||
) }
|
||||
{ showRemoveItemLink && (
|
||||
<button
|
||||
className="wc-block-cart-item__remove-link"
|
||||
aria-label={ sprintf(
|
||||
/* translators: %s refers to the item's name in the cart. */
|
||||
__(
|
||||
'Remove %s from cart',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
name
|
||||
) }
|
||||
onClick={ () => {
|
||||
onRemove();
|
||||
removeItem();
|
||||
dispatchStoreEvent(
|
||||
'cart-remove-item',
|
||||
{
|
||||
product: lineItem,
|
||||
quantity,
|
||||
}
|
||||
);
|
||||
speak(
|
||||
sprintf(
|
||||
/* translators: %s refers to the item name in the cart. */
|
||||
__(
|
||||
'%s has been removed from your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
name
|
||||
)
|
||||
);
|
||||
} }
|
||||
disabled={ isPendingDelete }
|
||||
>
|
||||
{ __(
|
||||
'Remove item',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</button>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="wc-block-cart-item__total">
|
||||
<div className="wc-block-cart-item__total-price-and-sale-badge-wrapper">
|
||||
<ProductPrice
|
||||
currency={ totalsCurrency }
|
||||
format={ productPriceFormat }
|
||||
price={ subtotalPrice.getAmount() }
|
||||
/>
|
||||
|
||||
{ quantity > 1 && (
|
||||
<ProductSaleBadge
|
||||
currency={ priceCurrency }
|
||||
saleAmount={ getAmountFromRawPrice(
|
||||
saleAmount,
|
||||
priceCurrency
|
||||
) }
|
||||
format={ saleBadgePriceFormat }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
export default CartLineItemRow;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { CartResponseItem } from '@woocommerce/types';
|
||||
import { createRef, useEffect, useRef } from '@wordpress/element';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CartLineItemRow from './cart-line-item-row';
|
||||
import './style.scss';
|
||||
|
||||
const placeholderRows = [ ...Array( 3 ) ].map( ( _x, i ) => (
|
||||
<CartLineItemRow lineItem={ {} } key={ i } />
|
||||
) );
|
||||
|
||||
interface CartLineItemsTableProps {
|
||||
lineItems: CartResponseItem[];
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const setRefs = ( lineItems: CartResponseItem[] ) => {
|
||||
const refs = {} as Record< string, RefObject< HTMLTableRowElement > >;
|
||||
lineItems.forEach( ( { key } ) => {
|
||||
refs[ key ] = createRef();
|
||||
} );
|
||||
return refs;
|
||||
};
|
||||
|
||||
const CartLineItemsTable = ( {
|
||||
lineItems = [],
|
||||
isLoading = false,
|
||||
className,
|
||||
}: CartLineItemsTableProps ): JSX.Element => {
|
||||
const tableRef = useRef< HTMLTableElement | null >( null );
|
||||
const rowRefs = useRef( setRefs( lineItems ) );
|
||||
useEffect( () => {
|
||||
rowRefs.current = setRefs( lineItems );
|
||||
}, [ lineItems ] );
|
||||
|
||||
const onRemoveRow = ( nextItemKey: string | null ) => () => {
|
||||
if (
|
||||
rowRefs?.current &&
|
||||
nextItemKey &&
|
||||
rowRefs.current[ nextItemKey ].current instanceof HTMLElement
|
||||
) {
|
||||
( rowRefs.current[ nextItemKey ].current as HTMLElement ).focus();
|
||||
} else if ( tableRef.current instanceof HTMLElement ) {
|
||||
tableRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const products = isLoading
|
||||
? placeholderRows
|
||||
: lineItems.map( ( lineItem, i ) => {
|
||||
const nextItemKey =
|
||||
lineItems.length > i + 1 ? lineItems[ i + 1 ].key : null;
|
||||
return (
|
||||
<CartLineItemRow
|
||||
key={ lineItem.key }
|
||||
lineItem={ lineItem }
|
||||
onRemove={ onRemoveRow( nextItemKey ) }
|
||||
ref={ rowRefs.current[ lineItem.key ] }
|
||||
tabIndex={ -1 }
|
||||
/>
|
||||
);
|
||||
} );
|
||||
|
||||
return (
|
||||
<table
|
||||
className={ classnames( 'wc-block-cart-items', className ) }
|
||||
ref={ tableRef }
|
||||
tabIndex={ -1 }
|
||||
>
|
||||
<thead>
|
||||
<tr className="wc-block-cart-items__header">
|
||||
<th className="wc-block-cart-items__header-image">
|
||||
<span>
|
||||
{ __( 'Product', 'woo-gutenberg-products-block' ) }
|
||||
</span>
|
||||
</th>
|
||||
<th className="wc-block-cart-items__header-product">
|
||||
<span>
|
||||
{ __( 'Details', 'woo-gutenberg-products-block' ) }
|
||||
</span>
|
||||
</th>
|
||||
<th className="wc-block-cart-items__header-total">
|
||||
<span>
|
||||
{ __( 'Total', 'woo-gutenberg-products-block' ) }
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{ products }</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartLineItemsTable;
|
||||
@@ -0,0 +1,142 @@
|
||||
table.wc-block-cart-items,
|
||||
table.wc-block-cart-items th,
|
||||
table.wc-block-cart-items td {
|
||||
// Override Storefront theme gray table background.
|
||||
background: none !important;
|
||||
// Remove borders on default themes.
|
||||
border: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-styles-wrapper table.wc-block-cart-items,
|
||||
table.wc-block-cart-items {
|
||||
width: 100%;
|
||||
|
||||
.wc-block-cart-items__header {
|
||||
@include font-size( smaller );
|
||||
text-transform: uppercase;
|
||||
|
||||
.wc-block-cart-items__header-image {
|
||||
width: 100px;
|
||||
}
|
||||
.wc-block-cart-items__header-product {
|
||||
visibility: hidden;
|
||||
}
|
||||
.wc-block-cart-items__header-total {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.wc-block-cart-items__row {
|
||||
.wc-block-cart-item__image img {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.wc-block-cart-item__quantity {
|
||||
.wc-block-cart-item__remove-link {
|
||||
@include link-button();
|
||||
@include hover-effect();
|
||||
@include font-size( smaller );
|
||||
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.wc-block-components-product-name {
|
||||
display: block;
|
||||
max-width: max-content;
|
||||
}
|
||||
.wc-block-cart-item__total {
|
||||
@include font-size( regular );
|
||||
text-align: right;
|
||||
line-height: inherit;
|
||||
}
|
||||
.wc-block-components-product-metadata {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-medium,
|
||||
.is-small,
|
||||
.is-mobile {
|
||||
table.wc-block-cart-items {
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
.wc-block-cart-items__header {
|
||||
display: none;
|
||||
}
|
||||
.wc-block-cart-item__remove-link {
|
||||
display: none;
|
||||
}
|
||||
&:not(.wc-block-mini-cart-items):not(:last-child) {
|
||||
.wc-block-cart-items__row {
|
||||
@include with-translucent-border( 0 0 1px );
|
||||
}
|
||||
}
|
||||
.wc-block-cart-items__row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 132px;
|
||||
padding: $gap 0;
|
||||
|
||||
.wc-block-cart-item__image {
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 1;
|
||||
padding-right: $gap;
|
||||
}
|
||||
.wc-block-cart-item__product {
|
||||
grid-column-start: 2;
|
||||
grid-column-end: 4;
|
||||
grid-row-start: 1;
|
||||
justify-self: stretch;
|
||||
padding: 0 $gap $gap 0;
|
||||
}
|
||||
.wc-block-cart-item__quantity {
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 2;
|
||||
vertical-align: bottom;
|
||||
padding-right: $gap;
|
||||
align-self: end;
|
||||
padding-top: $gap;
|
||||
}
|
||||
.wc-block-cart-item__total {
|
||||
grid-row-start: 1;
|
||||
|
||||
.wc-block-components-formatted-money-amount {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-large.wc-block-cart {
|
||||
margin-bottom: 3em;
|
||||
|
||||
.wc-block-cart-items {
|
||||
@include with-translucent-border( 0 0 1px );
|
||||
|
||||
th {
|
||||
padding: 0.25rem $gap 0.25rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
@include with-translucent-border( 1px 0 0 );
|
||||
padding: $gap 0 $gap $gap;
|
||||
vertical-align: top;
|
||||
}
|
||||
th:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
td:last-child {
|
||||
padding-right: $gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from './address-form';
|
||||
export { default as CartLineItemsTable } from './cart-line-items-table';
|
||||
export { default as OrderSummary } from './order-summary';
|
||||
export { default as PlaceOrderButton } from './place-order-button';
|
||||
export { default as Policies } from './policies';
|
||||
export { default as ProductBackorderBadge } from './product-backorder-badge';
|
||||
export { default as ProductDetails } from './product-details';
|
||||
export { default as ProductImage } from './product-image';
|
||||
export { default as ProductLowStockBadge } from './product-low-stock-badge';
|
||||
export { default as ProductSummary } from './product-summary';
|
||||
export { default as ProductMetadata } from './product-metadata';
|
||||
export { default as ProductSaleBadge } from './product-sale-badge';
|
||||
export { default as ReturnToCartButton } from './return-to-cart-button';
|
||||
export { default as ShippingCalculator } from './shipping-calculator';
|
||||
export { default as ShippingLocation } from './shipping-location';
|
||||
export { default as ShippingRatesControl } from './shipping-rates-control';
|
||||
export { default as ShippingRatesControlPackage } from './shipping-rates-control-package';
|
||||
export { default as PaymentMethodIcons } from './payment-method-icons';
|
||||
export { default as PaymentMethodLabel } from './payment-method-label';
|
||||
export * from './totals';
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
RadioControl,
|
||||
RadioControlOptionType,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import { CartShippingPackageShippingRate } from '@woocommerce/types';
|
||||
|
||||
interface LocalPickupSelectProps {
|
||||
title?: string | undefined;
|
||||
setSelectedOption: ( value: string ) => void;
|
||||
selectedOption: string;
|
||||
pickupLocations: CartShippingPackageShippingRate[];
|
||||
onSelectRate: ( value: string ) => void;
|
||||
renderPickupLocation: (
|
||||
location: CartShippingPackageShippingRate,
|
||||
pickupLocationsCount: number
|
||||
) => RadioControlOptionType;
|
||||
packageCount: number;
|
||||
}
|
||||
/**
|
||||
* Local pickup select component, used to render a package title and local pickup options.
|
||||
*/
|
||||
export const LocalPickupSelect = ( {
|
||||
title,
|
||||
setSelectedOption,
|
||||
selectedOption,
|
||||
pickupLocations,
|
||||
onSelectRate,
|
||||
renderPickupLocation,
|
||||
packageCount,
|
||||
}: LocalPickupSelectProps ) => {
|
||||
// Hacky way to check if there are multiple packages, this way is borrowed from `assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx`
|
||||
// We have no built-in way of checking if other extensions have added packages.
|
||||
const multiplePackages =
|
||||
document.querySelectorAll(
|
||||
'.wc-block-components-local-pickup-select .wc-block-components-radio-control'
|
||||
).length > 1;
|
||||
return (
|
||||
<div className="wc-block-components-local-pickup-select">
|
||||
{ multiplePackages && title ? <div>{ title }</div> : false }
|
||||
<RadioControl
|
||||
onChange={ ( value ) => {
|
||||
setSelectedOption( value );
|
||||
onSelectRate( value );
|
||||
} }
|
||||
selected={ selectedOption }
|
||||
options={ pickupLocations.map( ( location ) =>
|
||||
renderPickupLocation( location, packageCount )
|
||||
) }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default LocalPickupSelect;
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import LocalPickupSelect from '..';
|
||||
|
||||
describe( 'LocalPickupSelect', () => {
|
||||
const TestComponent = ( {
|
||||
selectedOptionOverride = null,
|
||||
onSelectRateOverride = null,
|
||||
}: {
|
||||
selectedOptionOverride?: null | ( ( value: string ) => void );
|
||||
onSelectRateOverride?: null | ( ( value: string ) => void );
|
||||
} ) => (
|
||||
<LocalPickupSelect
|
||||
title="Package 1"
|
||||
setSelectedOption={ selectedOptionOverride || jest.fn() }
|
||||
selectedOption=""
|
||||
pickupLocations={ [
|
||||
{
|
||||
rate_id: '1',
|
||||
currency_code: 'USD',
|
||||
currency_decimal_separator: '.',
|
||||
currency_minor_unit: 2,
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
currency_thousand_separator: ',',
|
||||
currency_symbol: '$',
|
||||
name: 'Store 1',
|
||||
description: 'Store 1 description',
|
||||
delivery_time: '1 day',
|
||||
price: '0',
|
||||
taxes: '0',
|
||||
instance_id: 1,
|
||||
method_id: 'test_shipping:0',
|
||||
meta_data: [],
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
rate_id: '2',
|
||||
currency_code: 'USD',
|
||||
currency_decimal_separator: '.',
|
||||
currency_minor_unit: 2,
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
currency_thousand_separator: ',',
|
||||
currency_symbol: '$',
|
||||
name: 'Store 2',
|
||||
description: 'Store 2 description',
|
||||
delivery_time: '2 days',
|
||||
price: '0',
|
||||
taxes: '0',
|
||||
instance_id: 1,
|
||||
method_id: 'test_shipping:1',
|
||||
meta_data: [],
|
||||
selected: false,
|
||||
},
|
||||
] }
|
||||
onSelectRate={ onSelectRateOverride || jest.fn() }
|
||||
packageCount={ 1 }
|
||||
renderPickupLocation={ ( location ) => {
|
||||
return {
|
||||
value: `${ location.rate_id }`,
|
||||
onChange: jest.fn(),
|
||||
label: `${ location.name }`,
|
||||
description: `${ location.description }`,
|
||||
};
|
||||
} }
|
||||
/>
|
||||
);
|
||||
it( 'Does not render the title if only one package is present on the page', () => {
|
||||
render( <TestComponent /> );
|
||||
expect( screen.queryByText( 'Package 1' ) ).not.toBeInTheDocument();
|
||||
} );
|
||||
it( 'Does render the title if more than one package is present on the page', () => {
|
||||
const { rerender } = render(
|
||||
<div className="wc-block-components-local-pickup-select">
|
||||
<div className="wc-block-components-radio-control"></div>
|
||||
</div>
|
||||
);
|
||||
// Render twice so our component can check the DOM correctly.
|
||||
rerender(
|
||||
<>
|
||||
<div className="wc-block-components-local-pickup-select">
|
||||
<div className="wc-block-components-radio-control"></div>
|
||||
</div>
|
||||
<TestComponent />
|
||||
</>
|
||||
);
|
||||
rerender(
|
||||
<>
|
||||
<div className="wc-block-components-local-pickup-select">
|
||||
<div className="wc-block-components-radio-control"></div>
|
||||
</div>
|
||||
<TestComponent />
|
||||
</>
|
||||
);
|
||||
|
||||
expect( screen.getByText( 'Package 1' ) ).toBeInTheDocument();
|
||||
} );
|
||||
it( 'Calls the correct functions when changing selected option', () => {
|
||||
const setSelectedOption = jest.fn();
|
||||
const onSelectRate = jest.fn();
|
||||
render(
|
||||
<TestComponent
|
||||
selectedOptionOverride={ setSelectedOption }
|
||||
onSelectRateOverride={ onSelectRate }
|
||||
/>
|
||||
);
|
||||
userEvent.click( screen.getByText( 'Store 2' ) );
|
||||
expect( setSelectedOption ).toHaveBeenLastCalledWith( '2' );
|
||||
expect( onSelectRate ).toHaveBeenLastCalledWith( '2' );
|
||||
userEvent.click( screen.getByText( 'Store 1' ) );
|
||||
expect( setSelectedOption ).toHaveBeenLastCalledWith( '1' );
|
||||
expect( onSelectRate ).toHaveBeenLastCalledWith( '1' );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useContainerWidthContext } from '@woocommerce/base-context';
|
||||
import { Panel } from '@woocommerce/blocks-components';
|
||||
import type { CartItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import OrderSummaryItem from './order-summary-item';
|
||||
import './style.scss';
|
||||
|
||||
interface OrderSummaryProps {
|
||||
cartItems: CartItem[];
|
||||
}
|
||||
|
||||
const OrderSummary = ( {
|
||||
cartItems = [],
|
||||
}: OrderSummaryProps ): null | JSX.Element => {
|
||||
const { isLarge, hasContainerWidth } = useContainerWidthContext();
|
||||
|
||||
if ( ! hasContainerWidth ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
className="wc-block-components-order-summary"
|
||||
initialOpen={ isLarge }
|
||||
hasBorder={ false }
|
||||
title={
|
||||
<span className="wc-block-components-order-summary__button-text">
|
||||
{ __( 'Order summary', 'woo-gutenberg-products-block' ) }
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="wc-block-components-order-summary__content">
|
||||
{ cartItems.map( ( cartItem ) => {
|
||||
return (
|
||||
<OrderSummaryItem
|
||||
key={ cartItem.key }
|
||||
cartItem={ cartItem }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSummary;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { sprintf, _n } from '@wordpress/i18n';
|
||||
import { Label } from '@woocommerce/blocks-components';
|
||||
import ProductPrice from '@woocommerce/base-components/product-price';
|
||||
import ProductName from '@woocommerce/base-components/product-name';
|
||||
import {
|
||||
getCurrencyFromPriceResponse,
|
||||
formatPrice,
|
||||
} from '@woocommerce/price-format';
|
||||
import { applyCheckoutFilter, mustContain } from '@woocommerce/blocks-checkout';
|
||||
import Dinero from 'dinero.js';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import { useMemo } from '@wordpress/element';
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
import { CartItem, isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBackorderBadge from '../product-backorder-badge';
|
||||
import ProductImage from '../product-image';
|
||||
import ProductLowStockBadge from '../product-low-stock-badge';
|
||||
import ProductMetadata from '../product-metadata';
|
||||
|
||||
const productPriceValidation = ( value: string ): true | never =>
|
||||
mustContain( value, '<price/>' );
|
||||
|
||||
interface OrderSummaryProps {
|
||||
cartItem: CartItem;
|
||||
}
|
||||
|
||||
const OrderSummaryItem = ( { cartItem }: OrderSummaryProps ): JSX.Element => {
|
||||
const {
|
||||
images,
|
||||
low_stock_remaining: lowStockRemaining,
|
||||
show_backorder_badge: showBackorderBadge,
|
||||
name: initialName,
|
||||
permalink,
|
||||
prices,
|
||||
quantity,
|
||||
short_description: shortDescription,
|
||||
description: fullDescription,
|
||||
item_data: itemData,
|
||||
variation,
|
||||
totals,
|
||||
extensions,
|
||||
} = cartItem;
|
||||
|
||||
// Prepare props to pass to the applyCheckoutFilter filter.
|
||||
// We need to pluck out receiveCart.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { receiveCart, ...cart } = useStoreCart();
|
||||
|
||||
const arg = useMemo(
|
||||
() => ( {
|
||||
context: 'summary',
|
||||
cartItem,
|
||||
cart,
|
||||
} ),
|
||||
[ cartItem, cart ]
|
||||
);
|
||||
|
||||
const priceCurrency = getCurrencyFromPriceResponse( prices );
|
||||
|
||||
const name = applyCheckoutFilter( {
|
||||
filterName: 'itemName',
|
||||
defaultValue: initialName,
|
||||
extensions,
|
||||
arg,
|
||||
} );
|
||||
|
||||
const regularPriceSingle = Dinero( {
|
||||
amount: parseInt( prices.raw_prices.regular_price, 10 ),
|
||||
precision: isString( prices.raw_prices.precision )
|
||||
? parseInt( prices.raw_prices.precision, 10 )
|
||||
: prices.raw_prices.precision,
|
||||
} )
|
||||
.convertPrecision( priceCurrency.minorUnit )
|
||||
.getAmount();
|
||||
const priceSingle = Dinero( {
|
||||
amount: parseInt( prices.raw_prices.price, 10 ),
|
||||
precision: isString( prices.raw_prices.precision )
|
||||
? parseInt( prices.raw_prices.precision, 10 )
|
||||
: prices.raw_prices.precision,
|
||||
} )
|
||||
.convertPrecision( priceCurrency.minorUnit )
|
||||
.getAmount();
|
||||
const totalsCurrency = getCurrencyFromPriceResponse( totals );
|
||||
|
||||
let lineSubtotal = parseInt( totals.line_subtotal, 10 );
|
||||
if ( getSetting( 'displayCartPricesIncludingTax', false ) ) {
|
||||
lineSubtotal += parseInt( totals.line_subtotal_tax, 10 );
|
||||
}
|
||||
const subtotalPrice = Dinero( {
|
||||
amount: lineSubtotal,
|
||||
precision: totalsCurrency.minorUnit,
|
||||
} ).getAmount();
|
||||
const subtotalPriceFormat = applyCheckoutFilter( {
|
||||
filterName: 'subtotalPriceFormat',
|
||||
defaultValue: '<price/>',
|
||||
extensions,
|
||||
arg,
|
||||
validation: productPriceValidation,
|
||||
} );
|
||||
|
||||
// Allow extensions to filter how the price is displayed. Ie: prepending or appending some values.
|
||||
const productPriceFormat = applyCheckoutFilter( {
|
||||
filterName: 'cartItemPrice',
|
||||
defaultValue: '<price/>',
|
||||
extensions,
|
||||
arg,
|
||||
validation: productPriceValidation,
|
||||
} );
|
||||
|
||||
const cartItemClassNameFilter = applyCheckoutFilter( {
|
||||
filterName: 'cartItemClass',
|
||||
defaultValue: '',
|
||||
extensions,
|
||||
arg,
|
||||
} );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
'wc-block-components-order-summary-item',
|
||||
cartItemClassNameFilter
|
||||
) }
|
||||
>
|
||||
<div className="wc-block-components-order-summary-item__image">
|
||||
<div className="wc-block-components-order-summary-item__quantity">
|
||||
<Label
|
||||
label={ quantity.toString() }
|
||||
screenReaderLabel={ sprintf(
|
||||
/* translators: %d number of products of the same type in the cart */
|
||||
_n(
|
||||
'%d item',
|
||||
'%d items',
|
||||
quantity,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
quantity
|
||||
) }
|
||||
/>
|
||||
</div>
|
||||
<ProductImage
|
||||
image={ images.length ? images[ 0 ] : {} }
|
||||
fallbackAlt={ name }
|
||||
/>
|
||||
</div>
|
||||
<div className="wc-block-components-order-summary-item__description">
|
||||
<ProductName
|
||||
disabled={ true }
|
||||
name={ name }
|
||||
permalink={ permalink }
|
||||
/>
|
||||
<ProductPrice
|
||||
currency={ priceCurrency }
|
||||
price={ priceSingle }
|
||||
regularPrice={ regularPriceSingle }
|
||||
className="wc-block-components-order-summary-item__individual-prices"
|
||||
priceClassName="wc-block-components-order-summary-item__individual-price"
|
||||
regularPriceClassName="wc-block-components-order-summary-item__regular-individual-price"
|
||||
format={ subtotalPriceFormat }
|
||||
/>
|
||||
{ showBackorderBadge ? (
|
||||
<ProductBackorderBadge />
|
||||
) : (
|
||||
!! lowStockRemaining && (
|
||||
<ProductLowStockBadge
|
||||
lowStockRemaining={ lowStockRemaining }
|
||||
/>
|
||||
)
|
||||
) }
|
||||
<ProductMetadata
|
||||
shortDescription={ shortDescription }
|
||||
fullDescription={ fullDescription }
|
||||
itemData={ itemData }
|
||||
variation={ variation }
|
||||
/>
|
||||
</div>
|
||||
<span className="screen-reader-text">
|
||||
{ sprintf(
|
||||
/* translators: %1$d is the number of items, %2$s is the item name and %3$s is the total price including the currency symbol. */
|
||||
_n(
|
||||
'Total price for %1$d %2$s item: %3$s',
|
||||
'Total price for %1$d %2$s items: %3$s',
|
||||
quantity,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
quantity,
|
||||
name,
|
||||
formatPrice( subtotalPrice, totalsCurrency )
|
||||
) }
|
||||
</span>
|
||||
<div
|
||||
className="wc-block-components-order-summary-item__total-price"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ProductPrice
|
||||
currency={ totalsCurrency }
|
||||
format={ productPriceFormat }
|
||||
price={ subtotalPrice }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSummaryItem;
|
||||
@@ -0,0 +1,104 @@
|
||||
.wc-block-components-order-summary {
|
||||
|
||||
.wc-block-components-panel__button {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-panel__content {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary__content {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item {
|
||||
@include with-translucent-border(0 0 1px);
|
||||
@include font-size(small);
|
||||
display: flex;
|
||||
padding-bottom: 1px;
|
||||
padding-top: $gap;
|
||||
width: 100%;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
> div {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-metadata {
|
||||
@include font-size(regular);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__image,
|
||||
.wc-block-components-order-summary-item__description {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__image {
|
||||
width: #{$gap-large * 2};
|
||||
padding-bottom: $gap;
|
||||
position: relative;
|
||||
|
||||
> img {
|
||||
width: #{$gap-large * 2};
|
||||
max-width: #{$gap-large * 2};
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__quantity {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 2px solid;
|
||||
border-radius: 1em;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
line-height: 1;
|
||||
min-height: 20px;
|
||||
padding: 0 0.4em;
|
||||
position: absolute;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__description {
|
||||
padding-left: $gap-large;
|
||||
padding-right: $gap-small;
|
||||
padding-bottom: $gap;
|
||||
|
||||
p,
|
||||
.wc-block-components-product-metadata {
|
||||
line-height: 1.375;
|
||||
margin-top: #{ ($gap-large - $gap) * 0.5 };
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__total-price {
|
||||
font-weight: bold;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.wc-block-components-order-summary-item__individual-prices {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import OrderSummary from '../index';
|
||||
|
||||
jest.mock( '@woocommerce/base-context', () => ( {
|
||||
...jest.requireActual( '@woocommerce/base-context' ),
|
||||
useContainerWidthContext: () => ( {
|
||||
isLarge: true,
|
||||
hasContainerWidth: true,
|
||||
} ),
|
||||
} ) );
|
||||
|
||||
describe( 'Order Summary', () => {
|
||||
it( 'renders correct cart line subtotal when currency has 0 decimals', async () => {
|
||||
render(
|
||||
<OrderSummary
|
||||
cartItems={ [
|
||||
{
|
||||
...previewCart.items[ 0 ],
|
||||
totals: {
|
||||
...previewCart.items[ 0 ].totals,
|
||||
// Change price format so there are no decimals.
|
||||
currency_minor_unit: 0,
|
||||
currency_prefix: '',
|
||||
currency_suffix: '€',
|
||||
line_subtotal: '16',
|
||||
line_total: '18',
|
||||
},
|
||||
},
|
||||
] }
|
||||
/>
|
||||
);
|
||||
|
||||
expect( screen.getByText( '16€' ) ).toBeTruthy();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
|
||||
import type { PaymentMethodIcon } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Array of common assets.
|
||||
*/
|
||||
export const commonIcons: PaymentMethodIcon[] = [
|
||||
{
|
||||
id: 'alipay',
|
||||
alt: 'Alipay',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/alipay.svg',
|
||||
},
|
||||
{
|
||||
id: 'amex',
|
||||
alt: 'American Express',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/amex.svg',
|
||||
},
|
||||
{
|
||||
id: 'bancontact',
|
||||
alt: 'Bancontact',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/bancontact.svg',
|
||||
},
|
||||
{
|
||||
id: 'diners',
|
||||
alt: 'Diners Club',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/diners.svg',
|
||||
},
|
||||
{
|
||||
id: 'discover',
|
||||
alt: 'Discover',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/discover.svg',
|
||||
},
|
||||
{
|
||||
id: 'eps',
|
||||
alt: 'EPS',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/eps.svg',
|
||||
},
|
||||
{
|
||||
id: 'giropay',
|
||||
alt: 'Giropay',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/giropay.svg',
|
||||
},
|
||||
{
|
||||
id: 'ideal',
|
||||
alt: 'iDeal',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/ideal.svg',
|
||||
},
|
||||
{
|
||||
id: 'jcb',
|
||||
alt: 'JCB',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/jcb.svg',
|
||||
},
|
||||
{
|
||||
id: 'laser',
|
||||
alt: 'Laser',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/laser.svg',
|
||||
},
|
||||
{
|
||||
id: 'maestro',
|
||||
alt: 'Maestro',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/maestro.svg',
|
||||
},
|
||||
{
|
||||
id: 'mastercard',
|
||||
alt: 'Mastercard',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/mastercard.svg',
|
||||
},
|
||||
{
|
||||
id: 'multibanco',
|
||||
alt: 'Multibanco',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/multibanco.svg',
|
||||
},
|
||||
{
|
||||
id: 'p24',
|
||||
alt: 'Przelewy24',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/p24.svg',
|
||||
},
|
||||
{
|
||||
id: 'sepa',
|
||||
alt: 'Sepa',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/sepa.svg',
|
||||
},
|
||||
{
|
||||
id: 'sofort',
|
||||
alt: 'Sofort',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/sofort.svg',
|
||||
},
|
||||
{
|
||||
id: 'unionpay',
|
||||
alt: 'Union Pay',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/unionpay.svg',
|
||||
},
|
||||
{
|
||||
id: 'visa',
|
||||
alt: 'Visa',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/visa.svg',
|
||||
},
|
||||
{
|
||||
id: 'wechat',
|
||||
alt: 'WeChat',
|
||||
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/wechat.svg',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* For a given ID, see if a common icon exists and return it's props.
|
||||
*
|
||||
* @param {string} id Icon ID.
|
||||
*/
|
||||
export const getCommonIconProps = (
|
||||
id: string
|
||||
): PaymentMethodIcon | Record< string, unknown > => {
|
||||
return (
|
||||
commonIcons.find( ( icon ) => {
|
||||
return icon.id === id;
|
||||
} ) || {}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import type { PaymentMethodIcons as PaymentMethodIconsType } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PaymentMethodIcon from './payment-method-icon';
|
||||
import { getCommonIconProps } from './common-icons';
|
||||
import { normalizeIconConfig } from './utils';
|
||||
import './style.scss';
|
||||
|
||||
interface PaymentMethodIconsProps {
|
||||
icons: PaymentMethodIconsType;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
className?: string;
|
||||
}
|
||||
/**
|
||||
* For a given list of icons, render each as a list item, using common icons
|
||||
* where available.
|
||||
*/
|
||||
export const PaymentMethodIcons = ( {
|
||||
icons = [],
|
||||
align = 'center',
|
||||
className,
|
||||
}: PaymentMethodIconsProps ): JSX.Element | null => {
|
||||
const iconConfigs = normalizeIconConfig( icons );
|
||||
|
||||
if ( iconConfigs.length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerClass = classnames(
|
||||
'wc-block-components-payment-method-icons',
|
||||
{
|
||||
'wc-block-components-payment-method-icons--align-left':
|
||||
align === 'left',
|
||||
'wc-block-components-payment-method-icons--align-right':
|
||||
align === 'right',
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={ containerClass }>
|
||||
{ iconConfigs.map( ( icon ) => {
|
||||
const iconProps = {
|
||||
...icon,
|
||||
...getCommonIconProps( icon.id ),
|
||||
};
|
||||
return (
|
||||
<PaymentMethodIcon
|
||||
key={ 'payment-method-icon-' + icon.id }
|
||||
{ ...iconProps }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodIcons;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Get a class name for an icon.
|
||||
*
|
||||
* @param {string} id Icon ID.
|
||||
*/
|
||||
const getIconClassName = ( id: string ): string => {
|
||||
return `wc-block-components-payment-method-icon wc-block-components-payment-method-icon--${ id }`;
|
||||
};
|
||||
|
||||
interface PaymentMethodIconProps {
|
||||
id: string;
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
}
|
||||
/**
|
||||
* Return an element for an icon.
|
||||
*
|
||||
* @param {Object} props Incoming props for component.
|
||||
* @param {string} props.id Id for component.
|
||||
* @param {string|null} props.src Optional src value for icon.
|
||||
* @param {string} props.alt Optional alt value for icon.
|
||||
*/
|
||||
const PaymentMethodIcon = ( {
|
||||
id,
|
||||
src = null,
|
||||
alt = '',
|
||||
}: PaymentMethodIconProps ): JSX.Element | null => {
|
||||
if ( ! src ) {
|
||||
return null;
|
||||
}
|
||||
return <img className={ getIconClassName( id ) } src={ src } alt={ alt } />;
|
||||
};
|
||||
|
||||
export default PaymentMethodIcon;
|
||||
@@ -0,0 +1,48 @@
|
||||
.wc-block-components-payment-method-icons {
|
||||
margin: 0 0 #{$gap - 2px};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.wc-block-components-payment-method-icon {
|
||||
display: inline-block;
|
||||
margin: 0 4px 2px;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
max-width: 38px;
|
||||
height: 24px;
|
||||
max-height: 24px;
|
||||
}
|
||||
|
||||
&--align-left {
|
||||
justify-content: flex-start;
|
||||
|
||||
.wc-block-components-payment-method-icon {
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&--align-right {
|
||||
justify-content: flex-end;
|
||||
|
||||
.wc-block-components-payment-method-icon {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-mobile,
|
||||
.is-small {
|
||||
.wc-block-components-payment-method-icons {
|
||||
.wc-block-components-payment-method-icon {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { PaymentMethodIcon, PaymentMethodIcons } from '@woocommerce/types';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* For an array of icons, normalize into objects and remove duplicates.
|
||||
*/
|
||||
export const normalizeIconConfig = (
|
||||
icons: PaymentMethodIcons
|
||||
): PaymentMethodIcon[] => {
|
||||
const normalizedIcons: Record< string, PaymentMethodIcon > = {};
|
||||
|
||||
icons.forEach( ( raw ) => {
|
||||
let icon: Partial< PaymentMethodIcon > = {};
|
||||
|
||||
if ( typeof raw === 'string' ) {
|
||||
icon = {
|
||||
id: raw,
|
||||
alt: raw,
|
||||
src: null,
|
||||
};
|
||||
}
|
||||
|
||||
if ( typeof raw === 'object' ) {
|
||||
icon = {
|
||||
id: raw.id || '',
|
||||
alt: raw.alt || '',
|
||||
src: raw.src || null,
|
||||
};
|
||||
}
|
||||
|
||||
if ( icon.id && isString( icon.id ) && ! normalizedIcons[ icon.id ] ) {
|
||||
normalizedIcons[ icon.id ] = <PaymentMethodIcon>icon;
|
||||
}
|
||||
} );
|
||||
|
||||
return Object.values( normalizedIcons );
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { checkPayment } from '@woocommerce/icons';
|
||||
import {
|
||||
Icon,
|
||||
institution as bank,
|
||||
currencyDollar as bill,
|
||||
payment as card,
|
||||
} from '@wordpress/icons';
|
||||
import { isString, objectHasProp } from '@woocommerce/types';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface NamedIcons {
|
||||
bank: JSX.Element;
|
||||
bill: JSX.Element;
|
||||
card: JSX.Element;
|
||||
checkPayment: JSX.Element;
|
||||
}
|
||||
|
||||
const namedIcons: NamedIcons = {
|
||||
bank,
|
||||
bill,
|
||||
card,
|
||||
checkPayment,
|
||||
};
|
||||
|
||||
interface PaymentMethodLabelProps {
|
||||
icon: '' | keyof NamedIcons | SVGElement;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed to payment methods for the label shown on checkout. Allows icons to be added as well as
|
||||
* text.
|
||||
*
|
||||
* @param {Object} props Component props.
|
||||
* @param {*} props.icon Show an icon beside the text if provided. Can be a string to use a named
|
||||
* icon, or an SVG element.
|
||||
* @param {string} props.text Text shown next to icon.
|
||||
*/
|
||||
export const PaymentMethodLabel = ( {
|
||||
icon = '',
|
||||
text = '',
|
||||
}: PaymentMethodLabelProps ): JSX.Element => {
|
||||
const hasIcon = !! icon;
|
||||
const hasNamedIcon = useCallback(
|
||||
(
|
||||
iconToCheck: '' | keyof NamedIcons | SVGElement
|
||||
): iconToCheck is keyof NamedIcons =>
|
||||
hasIcon &&
|
||||
isString( iconToCheck ) &&
|
||||
objectHasProp( namedIcons, iconToCheck ),
|
||||
[ hasIcon ]
|
||||
);
|
||||
const className = classnames( 'wc-block-components-payment-method-label', {
|
||||
'wc-block-components-payment-method-label--with-icon': hasIcon,
|
||||
} );
|
||||
|
||||
return (
|
||||
<span className={ className }>
|
||||
{ hasNamedIcon( icon ) ? (
|
||||
<Icon icon={ namedIcons[ icon ] } />
|
||||
) : (
|
||||
icon
|
||||
) }
|
||||
{ text }
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodLabel;
|
||||
@@ -0,0 +1,19 @@
|
||||
.wc-block-components-payment-method-label--with-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
> img,
|
||||
> svg {
|
||||
vertical-align: middle;
|
||||
margin: -2px 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-mobile,
|
||||
.is-small {
|
||||
.wc-block-components-payment-method-label--with-icon {
|
||||
> img,
|
||||
> svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { isObject, objectHasProp } from '@woocommerce/types';
|
||||
import { isPackageRateCollectable } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Shows a formatted pickup location.
|
||||
*/
|
||||
const PickupLocation = (): JSX.Element | null => {
|
||||
const { pickupAddress } = useSelect( ( select ) => {
|
||||
const cartShippingRates = select( 'wc/store/cart' ).getShippingRates();
|
||||
|
||||
const flattenedRates = cartShippingRates.flatMap(
|
||||
( cartShippingRate ) => cartShippingRate.shipping_rates
|
||||
);
|
||||
const selectedCollectableRate = flattenedRates.find(
|
||||
( rate ) => rate.selected && isPackageRateCollectable( rate )
|
||||
);
|
||||
|
||||
// If the rate has an address specified in its metadata.
|
||||
if (
|
||||
isObject( selectedCollectableRate ) &&
|
||||
objectHasProp( selectedCollectableRate, 'meta_data' )
|
||||
) {
|
||||
const selectedRateMetaData = selectedCollectableRate.meta_data.find(
|
||||
( meta ) => meta.key === 'pickup_address'
|
||||
);
|
||||
if (
|
||||
isObject( selectedRateMetaData ) &&
|
||||
objectHasProp( selectedRateMetaData, 'value' ) &&
|
||||
selectedRateMetaData.value
|
||||
) {
|
||||
const selectedRatePickupAddress = selectedRateMetaData.value;
|
||||
return {
|
||||
pickupAddress: selectedRatePickupAddress,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ( isObject( selectedCollectableRate ) ) {
|
||||
return {
|
||||
pickupAddress: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pickupAddress: undefined,
|
||||
};
|
||||
} );
|
||||
|
||||
// If the method does not contain an address, or the method supporting collection was not found, return early.
|
||||
if ( typeof pickupAddress === 'undefined' ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show the pickup method's name if we don't have an address to show.
|
||||
return (
|
||||
<span className="wc-block-components-shipping-address">
|
||||
{ sprintf(
|
||||
/* translators: %s: shipping method name, e.g. "Amazon Locker" */
|
||||
__( 'Collection from %s', 'woo-gutenberg-products-block' ),
|
||||
pickupAddress
|
||||
) + ' ' }
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default PickupLocation;
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import PickupLocation from '@woocommerce/base-components/cart-checkout/pickup-location';
|
||||
|
||||
jest.mock( '@woocommerce/settings', () => {
|
||||
const originalModule = jest.requireActual( '@woocommerce/settings' );
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore We know @woocommerce/settings is an object.
|
||||
...originalModule,
|
||||
getSetting: ( setting: string, ...rest: unknown[] ) => {
|
||||
if ( setting === 'localPickupEnabled' ) {
|
||||
return true;
|
||||
}
|
||||
if ( setting === 'collectableMethodIds' ) {
|
||||
return [ 'pickup_location' ];
|
||||
}
|
||||
return originalModule.getSetting( setting, ...rest );
|
||||
},
|
||||
};
|
||||
} );
|
||||
describe( 'PickupLocation', () => {
|
||||
it( `renders an address if one is set in the methods metadata`, async () => {
|
||||
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
|
||||
|
||||
// Deselect the default selected rate and select pickup_location:1 rate.
|
||||
const currentlySelectedIndex =
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates.findIndex(
|
||||
( rate ) => rate.selected
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
currentlySelectedIndex
|
||||
].selected = false;
|
||||
const pickupRateIndex =
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates.findIndex(
|
||||
( rate ) => rate.method_id === 'pickup_location'
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
pickupRateIndex
|
||||
].selected = true;
|
||||
|
||||
dispatch( CART_STORE_KEY ).receiveCart( previewCart );
|
||||
|
||||
render( <PickupLocation /> );
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Collection from 123 Easy Street, New York, 12345/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
it( 'renders no address if one is not set in the methods metadata', async () => {
|
||||
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
|
||||
|
||||
// Deselect the default selected rate and select pickup_location:1 rate.
|
||||
const currentlySelectedIndex =
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates.findIndex(
|
||||
( rate ) => rate.selected
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
currentlySelectedIndex
|
||||
].selected = false;
|
||||
const pickupRateIndex =
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates.findIndex(
|
||||
( rate ) => rate.rate_id === 'pickup_location:2'
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
pickupRateIndex
|
||||
].selected = true;
|
||||
|
||||
// Set the pickup_location metadata value to an empty string in the selected pickup rate.
|
||||
const addressKeyIndex = previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
pickupRateIndex
|
||||
].meta_data.findIndex(
|
||||
( metaData ) => metaData.key === 'pickup_address'
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
pickupRateIndex
|
||||
].meta_data[ addressKeyIndex ].value = '';
|
||||
|
||||
dispatch( CART_STORE_KEY ).receiveCart( previewCart );
|
||||
|
||||
render( <PickupLocation /> );
|
||||
expect(
|
||||
screen.queryByText( /Collection from / )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { useCheckoutSubmit } from '@woocommerce/base-context/hooks';
|
||||
import { Icon, check } from '@wordpress/icons';
|
||||
import Button from '@woocommerce/base-components/button';
|
||||
|
||||
interface PlaceOrderButton {
|
||||
label: string;
|
||||
fullWidth?: boolean | undefined;
|
||||
}
|
||||
|
||||
const PlaceOrderButton = ( {
|
||||
label,
|
||||
fullWidth = false,
|
||||
}: PlaceOrderButton ): JSX.Element => {
|
||||
const {
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
isDisabled,
|
||||
waitingForProcessing,
|
||||
waitingForRedirect,
|
||||
} = useCheckoutSubmit();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={ classnames(
|
||||
'wc-block-components-checkout-place-order-button',
|
||||
{
|
||||
'wc-block-components-checkout-place-order-button--full-width':
|
||||
fullWidth,
|
||||
}
|
||||
) }
|
||||
onClick={ onSubmit }
|
||||
disabled={
|
||||
isCalculating ||
|
||||
isDisabled ||
|
||||
waitingForProcessing ||
|
||||
waitingForRedirect
|
||||
}
|
||||
showSpinner={ waitingForProcessing }
|
||||
>
|
||||
{ waitingForRedirect ? <Icon icon={ check } /> : label }
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceOrderButton;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
PRIVACY_URL,
|
||||
TERMS_URL,
|
||||
PRIVACY_PAGE_NAME,
|
||||
TERMS_PAGE_NAME,
|
||||
} from '@woocommerce/block-settings';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const Policies = (): JSX.Element => {
|
||||
return (
|
||||
<ul className="wc-block-components-checkout-policies">
|
||||
{ PRIVACY_URL && (
|
||||
<li className="wc-block-components-checkout-policies__item">
|
||||
<a
|
||||
href={ PRIVACY_URL }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ PRIVACY_PAGE_NAME
|
||||
? decodeEntities( PRIVACY_PAGE_NAME )
|
||||
: __(
|
||||
'Privacy Policy',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</a>
|
||||
</li>
|
||||
) }
|
||||
{ TERMS_URL && (
|
||||
<li className="wc-block-components-checkout-policies__item">
|
||||
<a
|
||||
href={ TERMS_URL }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ TERMS_PAGE_NAME
|
||||
? decodeEntities( TERMS_PAGE_NAME )
|
||||
: __(
|
||||
'Terms and Conditions',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</a>
|
||||
</li>
|
||||
) }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default Policies;
|
||||
@@ -0,0 +1,24 @@
|
||||
.editor-styles-wrapper .wc-block-components-checkout-policies,
|
||||
.wc-block-components-checkout-policies {
|
||||
@include font-size(smaller);
|
||||
text-align: center;
|
||||
list-style: none outside;
|
||||
line-height: 1;
|
||||
margin: $gap-large 0;
|
||||
}
|
||||
|
||||
.wc-block-components-checkout-policies__item {
|
||||
list-style: none outside;
|
||||
display: inline-block;
|
||||
padding: 0 0.25em;
|
||||
margin: 0;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-left: 1px solid $gray-400;
|
||||
}
|
||||
|
||||
> a {
|
||||
color: inherit;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBadge from '../product-badge';
|
||||
|
||||
/**
|
||||
* Returns a backorder badge.
|
||||
*/
|
||||
const ProductBackorderBadge = (): JSX.Element => {
|
||||
return (
|
||||
<ProductBadge className="wc-block-components-product-backorder-badge">
|
||||
{ __( 'Available on backorder', 'woo-gutenberg-products-block' ) }
|
||||
</ProductBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductBackorderBadge;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface ProductBadgeProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
const ProductBadge = ( {
|
||||
children,
|
||||
className,
|
||||
}: ProductBadgeProps ): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={ classNames(
|
||||
'wc-block-components-product-badge',
|
||||
className
|
||||
) }
|
||||
>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductBadge;
|
||||
@@ -0,0 +1,10 @@
|
||||
.wc-block-components-product-badge {
|
||||
@include font-size(smaller);
|
||||
border-radius: $universal-border-radius;
|
||||
border: 1px solid;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
padding: 0 0.66em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { paramCase as kebabCase } from 'change-case';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { ProductResponseItemData } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface ProductDetailsProps {
|
||||
details: ProductResponseItemData[];
|
||||
}
|
||||
// Component to display cart item data and variations.
|
||||
const ProductDetails = ( {
|
||||
details = [],
|
||||
}: ProductDetailsProps ): JSX.Element | null => {
|
||||
if ( ! Array.isArray( details ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
details = details.filter( ( detail ) => ! detail.hidden );
|
||||
|
||||
if ( details.length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="wc-block-components-product-details">
|
||||
{ details.map( ( detail ) => {
|
||||
// Support both `key` and `name` props
|
||||
const name = detail?.key || detail.name || '';
|
||||
const className =
|
||||
detail?.className ||
|
||||
( name
|
||||
? `wc-block-components-product-details__${ kebabCase(
|
||||
name
|
||||
) }`
|
||||
: '' );
|
||||
return (
|
||||
<li
|
||||
key={ name + ( detail.display || detail.value ) }
|
||||
className={ className }
|
||||
>
|
||||
{ name && (
|
||||
<>
|
||||
<span className="wc-block-components-product-details__name">
|
||||
{ decodeEntities( name ) }:
|
||||
</span>{ ' ' }
|
||||
</>
|
||||
) }
|
||||
<span className="wc-block-components-product-details__value">
|
||||
{ decodeEntities( detail.display || detail.value ) }
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
} ) }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetails;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Extra class added for specificity so styles are applied in the editor.
|
||||
.wc-block-components-product-details.wc-block-components-product-details {
|
||||
list-style: none;
|
||||
margin: 0.5em 0;
|
||||
padding: 0;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-details__name,
|
||||
.wc-block-components-product-details__value {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.is-large:not(.wc-block-checkout) {
|
||||
.wc-block-components-product-details__name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductDetails should not render hidden details 1`] = `
|
||||
<ul
|
||||
className="wc-block-components-product-details"
|
||||
>
|
||||
<li
|
||||
className="wc-block-components-product-details__lorem"
|
||||
>
|
||||
<span
|
||||
className="wc-block-components-product-details__name"
|
||||
>
|
||||
LOREM
|
||||
:
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="wc-block-components-product-details__value"
|
||||
>
|
||||
IPSUM
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
exports[`ProductDetails should not rendering anything if all details are hidden 1`] = `null`;
|
||||
|
||||
exports[`ProductDetails should not rendering anything if details is an empty array 1`] = `null`;
|
||||
|
||||
exports[`ProductDetails should render details 1`] = `
|
||||
<ul
|
||||
className="wc-block-components-product-details"
|
||||
>
|
||||
<li
|
||||
className="wc-block-components-product-details__lorem"
|
||||
>
|
||||
<span
|
||||
className="wc-block-components-product-details__name"
|
||||
>
|
||||
Lorem
|
||||
:
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="wc-block-components-product-details__value"
|
||||
>
|
||||
Ipsum
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
className="wc-block-components-product-details__lorem"
|
||||
>
|
||||
<span
|
||||
className="wc-block-components-product-details__name"
|
||||
>
|
||||
LOREM
|
||||
:
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="wc-block-components-product-details__value"
|
||||
>
|
||||
IPSUM
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
>
|
||||
|
||||
<span
|
||||
className="wc-block-components-product-details__value"
|
||||
>
|
||||
Ipsum
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductDetails from '..';
|
||||
|
||||
describe( 'ProductDetails', () => {
|
||||
test( 'should render details', () => {
|
||||
const details = [
|
||||
{ name: 'Lorem', value: 'Ipsum' },
|
||||
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM' },
|
||||
{ value: 'Ipsum' },
|
||||
];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should not render hidden details', () => {
|
||||
const details = [
|
||||
{ name: 'Lorem', value: 'Ipsum', hidden: true },
|
||||
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM' },
|
||||
];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should not rendering anything if all details are hidden', () => {
|
||||
const details = [
|
||||
{ name: 'Lorem', value: 'Ipsum', hidden: true },
|
||||
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM', hidden: true },
|
||||
];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should not rendering anything if details is an empty array', () => {
|
||||
const details = [];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
|
||||
|
||||
interface ProductImageProps {
|
||||
image: { alt?: string; thumbnail?: string };
|
||||
fallbackAlt: string;
|
||||
}
|
||||
/**
|
||||
* Formats and returns an image element.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {Object} props.image Image properties.
|
||||
*/
|
||||
|
||||
const ProductImage = ( {
|
||||
image = {},
|
||||
fallbackAlt = '',
|
||||
}: ProductImageProps ): JSX.Element => {
|
||||
const imageProps = image.thumbnail
|
||||
? {
|
||||
src: image.thumbnail,
|
||||
alt:
|
||||
decodeEntities( image.alt ) ||
|
||||
fallbackAlt ||
|
||||
'Product Image',
|
||||
}
|
||||
: {
|
||||
src: PLACEHOLDER_IMG_SRC,
|
||||
alt: '',
|
||||
};
|
||||
|
||||
return <img { ...imageProps } alt={ imageProps.alt } />;
|
||||
};
|
||||
|
||||
export default ProductImage;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBadge from '../product-badge';
|
||||
|
||||
interface ProductLowStockBadgeProps {
|
||||
lowStockRemaining: number | null;
|
||||
}
|
||||
/**
|
||||
* Returns a low stock badge.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {number} props.lowStockRemaining Whether or not there is low stock remaining.
|
||||
*/
|
||||
const ProductLowStockBadge = ( {
|
||||
lowStockRemaining,
|
||||
}: ProductLowStockBadgeProps ): JSX.Element | null => {
|
||||
if ( ! lowStockRemaining ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductBadge className="wc-block-components-product-low-stock-badge">
|
||||
{ sprintf(
|
||||
/* translators: %d stock amount (number of items in stock for product) */
|
||||
__( '%d left in stock', 'woo-gutenberg-products-block' ),
|
||||
lowStockRemaining
|
||||
) }
|
||||
</ProductBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductLowStockBadge;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ProductResponseItemData, CartVariationItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductDetails from '../product-details';
|
||||
import ProductSummary from '../product-summary';
|
||||
import './style.scss';
|
||||
|
||||
interface ProductMetadataProps {
|
||||
shortDescription?: string;
|
||||
fullDescription?: string;
|
||||
itemData: ProductResponseItemData[];
|
||||
variation?: CartVariationItem[];
|
||||
}
|
||||
|
||||
const ProductMetadata = ( {
|
||||
shortDescription = '',
|
||||
fullDescription = '',
|
||||
itemData = [],
|
||||
variation = [],
|
||||
}: ProductMetadataProps ): JSX.Element => {
|
||||
return (
|
||||
<div className="wc-block-components-product-metadata">
|
||||
<ProductSummary
|
||||
className="wc-block-components-product-metadata__description"
|
||||
shortDescription={ shortDescription }
|
||||
fullDescription={ fullDescription }
|
||||
/>
|
||||
<ProductDetails details={ itemData } />
|
||||
<ProductDetails
|
||||
details={ variation.map( ( { attribute = '', value } ) => ( {
|
||||
key: attribute,
|
||||
value,
|
||||
} ) ) }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductMetadata;
|
||||
@@ -0,0 +1,8 @@
|
||||
.wc-block-components-product-metadata {
|
||||
@include font-size(smaller);
|
||||
|
||||
.wc-block-components-product-metadata__description > p,
|
||||
.wc-block-components-product-metadata__variation-data {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createInterpolateElement } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
|
||||
import type { Currency } from '@woocommerce/price-format';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBadge from '../product-badge';
|
||||
|
||||
interface ProductSaleBadgeProps {
|
||||
currency: Currency;
|
||||
saleAmount: number;
|
||||
format: string;
|
||||
}
|
||||
/**
|
||||
* ProductSaleBadge
|
||||
*
|
||||
* @param {Object} props Incoming props.
|
||||
* @param {Object} props.currency Currency object.
|
||||
* @param {number} props.saleAmount Discounted amount.
|
||||
* @param {string} [props.format] Format to change the price.
|
||||
* @return {*} The component.
|
||||
*/
|
||||
const ProductSaleBadge = ( {
|
||||
currency,
|
||||
saleAmount,
|
||||
format = '<price/>',
|
||||
}: ProductSaleBadgeProps ): JSX.Element | null => {
|
||||
if ( ! saleAmount || saleAmount <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
if ( ! format.includes( '<price/>' ) ) {
|
||||
format = '<price/>';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( 'Price formats need to include the `<price/>` tag.' );
|
||||
}
|
||||
|
||||
const formattedMessage = sprintf(
|
||||
/* translators: %s will be replaced by the discount amount */
|
||||
__( `Save %s`, 'woo-gutenberg-products-block' ),
|
||||
format
|
||||
);
|
||||
|
||||
return (
|
||||
<ProductBadge className="wc-block-components-sale-badge">
|
||||
{ createInterpolateElement( formattedMessage, {
|
||||
price: (
|
||||
<FormattedMonetaryAmount
|
||||
currency={ currency }
|
||||
value={ saleAmount }
|
||||
/>
|
||||
),
|
||||
} ) }
|
||||
</ProductBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSaleBadge;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import Summary from '@woocommerce/base-components/summary';
|
||||
import { blocksConfig } from '@woocommerce/block-settings';
|
||||
|
||||
interface ProductSummaryProps {
|
||||
className?: string;
|
||||
shortDescription?: string;
|
||||
fullDescription?: string;
|
||||
}
|
||||
/**
|
||||
* Returns an element containing a summary of the product.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {string} props.className CSS class name used.
|
||||
* @param {string} props.shortDescription Short description for the product.
|
||||
* @param {string} props.fullDescription Full description for the product.
|
||||
*/
|
||||
const ProductSummary = ( {
|
||||
className,
|
||||
shortDescription = '',
|
||||
fullDescription = '',
|
||||
}: ProductSummaryProps ): JSX.Element | null => {
|
||||
const source = shortDescription ? shortDescription : fullDescription;
|
||||
|
||||
if ( ! source ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Summary
|
||||
className={ className }
|
||||
source={ source }
|
||||
maxLength={ 15 }
|
||||
countType={ blocksConfig.wordCountType || 'words' }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSummary;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { CART_URL } from '@woocommerce/block-settings';
|
||||
import { Icon, arrowLeft } from '@wordpress/icons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface ReturnToCartButtonProps {
|
||||
link?: string | undefined;
|
||||
}
|
||||
|
||||
const ReturnToCartButton = ( {
|
||||
link,
|
||||
}: ReturnToCartButtonProps ): JSX.Element | null => {
|
||||
const cartLink = link || CART_URL;
|
||||
if ( ! cartLink ) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={ cartLink }
|
||||
className="wc-block-components-checkout-return-to-cart-button"
|
||||
>
|
||||
<Icon icon={ arrowLeft } />
|
||||
{ __( 'Return to Cart', 'woo-gutenberg-products-block' ) }
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReturnToCartButton;
|
||||
@@ -0,0 +1,23 @@
|
||||
.wc-block-components-checkout-return-to-cart-button {
|
||||
box-shadow: none;
|
||||
color: inherit;
|
||||
padding-left: calc(24px + 0.25em);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
svg {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
top: 50%;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.rtl {
|
||||
.wc-block-components-checkout-return-to-cart-button {
|
||||
svg {
|
||||
transform: translateY(-50%) scale(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import Button from '@woocommerce/base-components/button';
|
||||
import { useState } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import type { ShippingAddress, AddressFields } from '@woocommerce/settings';
|
||||
import { VALIDATION_STORE_KEY, CART_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import { AddressForm } from '../address-form';
|
||||
|
||||
interface ShippingCalculatorAddressProps {
|
||||
address: ShippingAddress;
|
||||
onUpdate: ( address: ShippingAddress ) => void;
|
||||
onCancel: () => void;
|
||||
addressFields: Partial< keyof AddressFields >[];
|
||||
}
|
||||
const ShippingCalculatorAddress = ( {
|
||||
address: initialAddress,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
addressFields,
|
||||
}: ShippingCalculatorAddressProps ): JSX.Element => {
|
||||
const [ address, setAddress ] = useState( initialAddress );
|
||||
const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
const { hasValidationErrors, isCustomerDataUpdating } = useSelect(
|
||||
( select ) => {
|
||||
return {
|
||||
hasValidationErrors:
|
||||
select( VALIDATION_STORE_KEY ).hasValidationErrors,
|
||||
isCustomerDataUpdating:
|
||||
select( CART_STORE_KEY ).isCustomerDataUpdating(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const validateSubmit = () => {
|
||||
showAllValidationErrors();
|
||||
return ! hasValidationErrors();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="wc-block-components-shipping-calculator-address">
|
||||
<AddressForm
|
||||
fields={ addressFields }
|
||||
onChange={ setAddress }
|
||||
values={ address }
|
||||
/>
|
||||
<Button
|
||||
className="wc-block-components-shipping-calculator-address__button"
|
||||
disabled={ isCustomerDataUpdating }
|
||||
onClick={ ( e ) => {
|
||||
e.preventDefault();
|
||||
const addressChanged = ! isShallowEqual(
|
||||
address,
|
||||
initialAddress
|
||||
);
|
||||
|
||||
if ( ! addressChanged ) {
|
||||
return onCancel();
|
||||
}
|
||||
|
||||
const isAddressValid = validateSubmit();
|
||||
|
||||
if ( isAddressValid ) {
|
||||
return onUpdate( address );
|
||||
}
|
||||
} }
|
||||
type="submit"
|
||||
>
|
||||
{ __( 'Update', 'woo-gutenberg-products-block' ) }
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingCalculatorAddress;
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { ShippingAddress } from '@woocommerce/settings';
|
||||
import { useCustomerData } from '@woocommerce/base-context/hooks';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { CART_STORE_KEY, processErrorResponse } from '@woocommerce/block-data';
|
||||
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
|
||||
import { removeNoticesWithContext } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingCalculatorAddress from './address';
|
||||
import './style.scss';
|
||||
|
||||
interface ShippingCalculatorProps {
|
||||
onUpdate?: ( newAddress: ShippingAddress ) => void;
|
||||
onCancel?: () => void;
|
||||
addressFields?: Partial< keyof ShippingAddress >[];
|
||||
}
|
||||
|
||||
const ShippingCalculator = ( {
|
||||
onUpdate = () => {
|
||||
/* Do nothing */
|
||||
},
|
||||
onCancel = () => {
|
||||
/* Do nothing */
|
||||
},
|
||||
addressFields = [ 'country', 'state', 'city', 'postcode' ],
|
||||
}: ShippingCalculatorProps ): JSX.Element => {
|
||||
const { shippingAddress } = useCustomerData();
|
||||
const noticeContext = 'wc/cart/shipping-calculator';
|
||||
return (
|
||||
<div className="wc-block-components-shipping-calculator">
|
||||
<StoreNoticesContainer context={ noticeContext } />
|
||||
<ShippingCalculatorAddress
|
||||
address={ shippingAddress }
|
||||
addressFields={ addressFields }
|
||||
onCancel={ onCancel }
|
||||
onUpdate={ ( newAddress ) => {
|
||||
// Updates the address and waits for the result.
|
||||
dispatch( CART_STORE_KEY )
|
||||
.updateCustomerData(
|
||||
{
|
||||
shipping_address: newAddress,
|
||||
},
|
||||
false
|
||||
)
|
||||
.then( () => {
|
||||
removeNoticesWithContext( noticeContext );
|
||||
onUpdate( newAddress );
|
||||
} )
|
||||
.catch( ( response ) => {
|
||||
processErrorResponse( response, noticeContext );
|
||||
} );
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingCalculator;
|
||||
@@ -0,0 +1,12 @@
|
||||
.wc-block-components-shipping-calculator-address {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-calculator-address__button {
|
||||
width: 100%;
|
||||
margin-top: em($gap-large);
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-calculator {
|
||||
padding: em($gap-smaller) 0 em($gap-small);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
interface ShippingLocationProps {
|
||||
formattedLocation: string | null;
|
||||
}
|
||||
|
||||
// Shows a formatted shipping location.
|
||||
const ShippingLocation = ( {
|
||||
formattedLocation,
|
||||
}: ShippingLocationProps ): JSX.Element | null => {
|
||||
if ( ! formattedLocation ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="wc-block-components-shipping-address">
|
||||
{ sprintf(
|
||||
/* translators: %s location. */
|
||||
__( 'Shipping to %s', 'woo-gutenberg-products-block' ),
|
||||
formattedLocation
|
||||
) + ' ' }
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingLocation;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classNames from 'classnames';
|
||||
import { _n, sprintf } from '@wordpress/i18n';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { Label, Panel } from '@woocommerce/blocks-components';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import { useShippingData } from '@woocommerce/base-context/hooks';
|
||||
import { sanitizeHTML } from '@woocommerce/utils';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PackageRates from './package-rates';
|
||||
import type { PackageProps } from './types';
|
||||
import './style.scss';
|
||||
|
||||
export const ShippingRatesControlPackage = ( {
|
||||
packageId,
|
||||
className = '',
|
||||
noResultsMessage,
|
||||
renderOption,
|
||||
packageData,
|
||||
collapsible,
|
||||
showItems,
|
||||
}: PackageProps ): ReactElement => {
|
||||
const { selectShippingRate, isSelectingRate } = useShippingData();
|
||||
const multiplePackages =
|
||||
document.querySelectorAll(
|
||||
'.wc-block-components-shipping-rates-control__package'
|
||||
).length > 1;
|
||||
|
||||
// If showItems is not set, we check if we have multiple packages.
|
||||
// We sometimes don't want to show items even if we have multiple packages.
|
||||
const shouldShowItems = showItems ?? multiplePackages;
|
||||
|
||||
// If collapsible is not set, we check if we have multiple packages.
|
||||
// We sometimes don't want to collapse even if we have multiple packages.
|
||||
const shouldBeCollapsible = collapsible ?? multiplePackages;
|
||||
|
||||
const header = (
|
||||
<>
|
||||
{ ( shouldBeCollapsible || shouldShowItems ) && (
|
||||
<div
|
||||
className="wc-block-components-shipping-rates-control__package-title"
|
||||
dangerouslySetInnerHTML={ {
|
||||
__html: sanitizeHTML( packageData.name ),
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
{ shouldShowItems && (
|
||||
<ul className="wc-block-components-shipping-rates-control__package-items">
|
||||
{ Object.values( packageData.items ).map( ( v ) => {
|
||||
const name = decodeEntities( v.name );
|
||||
const quantity = v.quantity;
|
||||
return (
|
||||
<li
|
||||
key={ v.key }
|
||||
className="wc-block-components-shipping-rates-control__package-item"
|
||||
>
|
||||
<Label
|
||||
label={
|
||||
quantity > 1
|
||||
? `${ name } × ${ quantity }`
|
||||
: `${ name }`
|
||||
}
|
||||
screenReaderLabel={ sprintf(
|
||||
/* translators: %1$s name of the product (ie: Sunglasses), %2$d number of units in the current cart package */
|
||||
_n(
|
||||
'%1$s (%2$d unit)',
|
||||
'%1$s (%2$d units)',
|
||||
quantity,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
name,
|
||||
quantity
|
||||
) }
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
} ) }
|
||||
</ul>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
|
||||
const onSelectRate = useCallback(
|
||||
( newShippingRateId: string ) => {
|
||||
selectShippingRate( newShippingRateId, packageId );
|
||||
},
|
||||
[ packageId, selectShippingRate ]
|
||||
);
|
||||
const packageRatesProps = {
|
||||
className,
|
||||
noResultsMessage,
|
||||
rates: packageData.shipping_rates,
|
||||
onSelectRate,
|
||||
selectedRate: packageData.shipping_rates.find(
|
||||
( rate ) => rate.selected
|
||||
),
|
||||
renderOption,
|
||||
disabled: isSelectingRate,
|
||||
};
|
||||
|
||||
if ( shouldBeCollapsible ) {
|
||||
return (
|
||||
<Panel
|
||||
className={ classNames(
|
||||
'wc-block-components-shipping-rates-control__package',
|
||||
className,
|
||||
{
|
||||
'wc-block-components-shipping-rates-control__package--disabled':
|
||||
isSelectingRate,
|
||||
}
|
||||
) }
|
||||
// initialOpen remembers only the first value provided to it, so by the
|
||||
// time we know we have several packages, initialOpen would be hardcoded to true.
|
||||
// If we're rendering a panel, we're more likely rendering several
|
||||
// packages and we want to them to be closed initially.
|
||||
initialOpen={ false }
|
||||
title={ header }
|
||||
>
|
||||
<PackageRates { ...packageRatesProps } />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classNames(
|
||||
'wc-block-components-shipping-rates-control__package',
|
||||
className,
|
||||
{
|
||||
'wc-block-components-shipping-rates-control__package--disabled':
|
||||
isSelectingRate,
|
||||
}
|
||||
) }
|
||||
>
|
||||
{ header }
|
||||
<PackageRates { ...packageRatesProps } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingRatesControlPackage;
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
import {
|
||||
RadioControl,
|
||||
RadioControlOptionLayout,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import type { CartShippingPackageShippingRate } from '@woocommerce/types';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { renderPackageRateOption } from './render-package-rate-option';
|
||||
import type { PackageRateRenderOption } from '../shipping-rates-control-package';
|
||||
|
||||
interface PackageRates {
|
||||
onSelectRate: ( selectedRateId: string ) => void;
|
||||
rates: CartShippingPackageShippingRate[];
|
||||
renderOption?: PackageRateRenderOption | undefined;
|
||||
className?: string;
|
||||
noResultsMessage: JSX.Element;
|
||||
selectedRate: CartShippingPackageShippingRate | undefined;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PackageRates = ( {
|
||||
className = '',
|
||||
noResultsMessage,
|
||||
onSelectRate,
|
||||
rates,
|
||||
renderOption = renderPackageRateOption,
|
||||
selectedRate,
|
||||
disabled = false,
|
||||
}: PackageRates ): JSX.Element => {
|
||||
const selectedRateId = selectedRate?.rate_id || '';
|
||||
const previousSelectedRateId = usePrevious( selectedRateId );
|
||||
|
||||
// Store selected rate ID in local state so shipping rates changes are shown in the UI instantly.
|
||||
const [ selectedOption, setSelectedOption ] = useState( () => {
|
||||
if ( selectedRateId ) {
|
||||
return selectedRateId;
|
||||
}
|
||||
// Default to first rate if no rate is selected.
|
||||
return rates[ 0 ]?.rate_id;
|
||||
} );
|
||||
|
||||
// Update the selected option if cart state changes in the data store.
|
||||
useEffect( () => {
|
||||
if (
|
||||
selectedRateId &&
|
||||
selectedRateId !== previousSelectedRateId &&
|
||||
selectedRateId !== selectedOption
|
||||
) {
|
||||
setSelectedOption( selectedRateId );
|
||||
}
|
||||
}, [ selectedRateId, selectedOption, previousSelectedRateId ] );
|
||||
|
||||
// Update the data store when the local selected rate changes.
|
||||
useEffect( () => {
|
||||
if ( selectedOption ) {
|
||||
onSelectRate( selectedOption );
|
||||
}
|
||||
}, [ onSelectRate, selectedOption ] );
|
||||
|
||||
if ( rates.length === 0 ) {
|
||||
return noResultsMessage;
|
||||
}
|
||||
|
||||
if ( rates.length > 1 ) {
|
||||
return (
|
||||
<RadioControl
|
||||
className={ className }
|
||||
onChange={ ( value: string ) => {
|
||||
setSelectedOption( value );
|
||||
onSelectRate( value );
|
||||
} }
|
||||
disabled={ disabled }
|
||||
selected={ selectedOption }
|
||||
options={ rates.map( renderOption ) }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { label, secondaryLabel, description, secondaryDescription } =
|
||||
renderOption( rates[ 0 ] );
|
||||
|
||||
return (
|
||||
<RadioControlOptionLayout
|
||||
label={ label }
|
||||
secondaryLabel={ secondaryLabel }
|
||||
description={ description }
|
||||
secondaryDescription={ secondaryDescription }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageRates;
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
|
||||
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
|
||||
import type { PackageRateOption } from '@woocommerce/types';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import { CartShippingPackageShippingRate } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Default render function for package rate options.
|
||||
*
|
||||
* @param {Object} rate Rate data.
|
||||
*/
|
||||
export const renderPackageRateOption = (
|
||||
rate: CartShippingPackageShippingRate
|
||||
): PackageRateOption => {
|
||||
const priceWithTaxes: number = getSetting(
|
||||
'displayCartPricesIncludingTax',
|
||||
false
|
||||
)
|
||||
? parseInt( rate.price, 10 ) + parseInt( rate.taxes, 10 )
|
||||
: parseInt( rate.price, 10 );
|
||||
|
||||
return {
|
||||
label: decodeEntities( rate.name ),
|
||||
value: rate.rate_id,
|
||||
description: (
|
||||
<>
|
||||
{ Number.isFinite( priceWithTaxes ) && (
|
||||
<FormattedMonetaryAmount
|
||||
currency={ getCurrencyFromPriceResponse( rate ) }
|
||||
value={ priceWithTaxes }
|
||||
/>
|
||||
) }
|
||||
{ Number.isFinite( priceWithTaxes ) && rate.delivery_time
|
||||
? ' — '
|
||||
: null }
|
||||
{ decodeEntities( rate.delivery_time ) }
|
||||
</>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export default renderPackageRateOption;
|
||||
@@ -0,0 +1,68 @@
|
||||
.wc-block-components-shipping-rates-control__package {
|
||||
margin: 0;
|
||||
@include with-translucent-border( 0 0 1px );
|
||||
|
||||
&.wc-block-components-panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-panel__button {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
padding-bottom: em($gap-small);
|
||||
padding-top: em($gap-small);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@include with-translucent-border( 0 );
|
||||
|
||||
.wc-block-components-panel__button {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-panel__content {
|
||||
padding-bottom: em($gap-small);
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control,
|
||||
.wc-block-components-radio-control__option-layout {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control .wc-block-components-radio-control__option-layout {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control__label-group {
|
||||
@include font-size(small);
|
||||
}
|
||||
.wc-block-components-radio-control__description-group {
|
||||
@include font-size(smaller);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-rates-control__package-items {
|
||||
@include font-size(small);
|
||||
display: block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-rates-control__package-item {
|
||||
@include wrap-break-word();
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-rates-control__package-item:not(:last-child)::after {
|
||||
content: ", ";
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
|
||||
import type { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
|
||||
|
||||
export interface PackageItem {
|
||||
name: string;
|
||||
key: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Destination {
|
||||
address_1: string;
|
||||
address_2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface PackageData {
|
||||
destination: Destination;
|
||||
name: string;
|
||||
shipping_rates: CartShippingPackageShippingRate[];
|
||||
items: PackageItem[];
|
||||
}
|
||||
|
||||
export type PackageRateRenderOption = (
|
||||
option: CartShippingPackageShippingRate
|
||||
) => PackageRateOption;
|
||||
|
||||
// A flag can be ternary if true, false, and undefined are all valid options.
|
||||
// In our case, we use this for collapsible and showItems, having a boolean will force that
|
||||
// option, having undefined will let the component decide the logic based on other factors.
|
||||
export type TernaryFlag = boolean | undefined;
|
||||
|
||||
export interface PackageProps {
|
||||
/* PackageId can be a string, WooCommerce Subscriptions uses strings for example, but WooCommerce core uses numbers */
|
||||
packageId: string | number;
|
||||
renderOption?: PackageRateRenderOption | undefined;
|
||||
collapse?: boolean;
|
||||
packageData: PackageData;
|
||||
className?: string;
|
||||
collapsible?: TernaryFlag;
|
||||
noResultsMessage: ReactElement;
|
||||
showItems?: TernaryFlag;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import { ExperimentalOrderShippingPackages } from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
getShippingRatesPackageCount,
|
||||
getShippingRatesRateCount,
|
||||
} from '@woocommerce/base-utils';
|
||||
import {
|
||||
useStoreCart,
|
||||
useEditorContext,
|
||||
useShippingData,
|
||||
} from '@woocommerce/base-context';
|
||||
import NoticeBanner from '@woocommerce/base-components/notice-banner';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingRatesControlPackage from '../shipping-rates-control-package';
|
||||
import { speakFoundShippingOptions } from './utils';
|
||||
import type { PackagesProps, ShippingRatesControlProps } from './types';
|
||||
|
||||
/**
|
||||
* Renders multiple packages within the slotfill.
|
||||
*/
|
||||
const Packages = ( {
|
||||
packages,
|
||||
showItems,
|
||||
collapsible,
|
||||
noResultsMessage,
|
||||
renderOption,
|
||||
}: PackagesProps ): JSX.Element | null => {
|
||||
// If there are no packages, return nothing.
|
||||
if ( ! packages.length ) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{ packages.map( ( { package_id: packageId, ...packageData } ) => (
|
||||
<ShippingRatesControlPackage
|
||||
key={ packageId }
|
||||
packageId={ packageId }
|
||||
packageData={ packageData }
|
||||
collapsible={ collapsible }
|
||||
showItems={ showItems }
|
||||
noResultsMessage={ noResultsMessage }
|
||||
renderOption={ renderOption }
|
||||
/>
|
||||
) ) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the shipping rates control element.
|
||||
*/
|
||||
const ShippingRatesControl = ( {
|
||||
shippingRates,
|
||||
isLoadingRates,
|
||||
className,
|
||||
collapsible,
|
||||
showItems,
|
||||
noResultsMessage,
|
||||
renderOption,
|
||||
context,
|
||||
}: ShippingRatesControlProps ): JSX.Element => {
|
||||
useEffect( () => {
|
||||
if ( isLoadingRates ) {
|
||||
return;
|
||||
}
|
||||
speakFoundShippingOptions(
|
||||
getShippingRatesPackageCount( shippingRates ),
|
||||
getShippingRatesRateCount( shippingRates )
|
||||
);
|
||||
}, [ isLoadingRates, shippingRates ] );
|
||||
|
||||
// Prepare props to pass to the ExperimentalOrderShippingPackages slot fill.
|
||||
// We need to pluck out receiveCart.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { extensions, receiveCart, ...cart } = useStoreCart();
|
||||
const slotFillProps = {
|
||||
className,
|
||||
collapsible,
|
||||
showItems,
|
||||
noResultsMessage,
|
||||
renderOption,
|
||||
extensions,
|
||||
cart,
|
||||
components: {
|
||||
ShippingRatesControlPackage,
|
||||
},
|
||||
context,
|
||||
};
|
||||
const { isEditor } = useEditorContext();
|
||||
const { hasSelectedLocalPickup, selectedRates } = useShippingData();
|
||||
|
||||
// Check if all rates selected are the same.
|
||||
const selectedRateIds = isObject( selectedRates )
|
||||
? ( Object.values( selectedRates ) as string[] )
|
||||
: [];
|
||||
const allPackagesHaveSameRate = selectedRateIds.every( ( rate: string ) => {
|
||||
return rate === selectedRateIds[ 0 ];
|
||||
} );
|
||||
|
||||
return (
|
||||
<LoadingMask
|
||||
isLoading={ isLoadingRates }
|
||||
screenReaderLabel={ __(
|
||||
'Loading shipping rates…',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
showSpinner={ true }
|
||||
>
|
||||
{ hasSelectedLocalPickup &&
|
||||
context === 'woocommerce/cart' &&
|
||||
shippingRates.length > 1 &&
|
||||
! allPackagesHaveSameRate &&
|
||||
! isEditor && (
|
||||
<NoticeBanner
|
||||
className="wc-block-components-notice"
|
||||
isDismissible={ false }
|
||||
status="warning"
|
||||
>
|
||||
{ __(
|
||||
'Multiple shipments must have the same pickup location',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</NoticeBanner>
|
||||
) }
|
||||
<ExperimentalOrderShippingPackages.Slot { ...slotFillProps } />
|
||||
<ExperimentalOrderShippingPackages>
|
||||
<Packages
|
||||
packages={ shippingRates }
|
||||
noResultsMessage={ noResultsMessage }
|
||||
renderOption={ renderOption }
|
||||
/>
|
||||
</ExperimentalOrderShippingPackages>
|
||||
</LoadingMask>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingRatesControl;
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { CartResponseShippingRate } from '@woocommerce/type-defs/cart-response';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
PackageRateRenderOption,
|
||||
TernaryFlag,
|
||||
} from '../shipping-rates-control-package';
|
||||
|
||||
export interface PackagesProps {
|
||||
// Array of packages
|
||||
packages: CartResponseShippingRate[];
|
||||
|
||||
// If the package should be rendered as a collapsible panel
|
||||
collapsible?: TernaryFlag;
|
||||
|
||||
// If we should items below the package name
|
||||
showItems?: TernaryFlag;
|
||||
|
||||
// Rendered when there are no rates in a package
|
||||
noResultsMessage: ReactElement;
|
||||
|
||||
// Function to render a shipping rate
|
||||
renderOption: PackageRateRenderOption;
|
||||
}
|
||||
|
||||
export interface ShippingRatesControlProps {
|
||||
// If true, when multiple packages are rendered, you can see each package's items
|
||||
showItems?: TernaryFlag;
|
||||
|
||||
// If true, when multiple packages are rendered they can be toggled open and closed
|
||||
collapsible?: TernaryFlag;
|
||||
|
||||
// Array of packages containing shipping rates
|
||||
shippingRates: CartResponseShippingRate[];
|
||||
|
||||
// Class name for package rates
|
||||
className?: string | undefined;
|
||||
|
||||
// True when rates are being loaded
|
||||
isLoadingRates: boolean;
|
||||
|
||||
// Rendered when there are no packages
|
||||
noResultsMessage: ReactElement;
|
||||
|
||||
// Function to render a shipping rate
|
||||
renderOption?: PackageRateRenderOption | undefined;
|
||||
|
||||
// String equal to the block name where the Slot is rendered
|
||||
context: 'woocommerce/cart' | 'woocommerce/checkout';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { _n, sprintf } from '@wordpress/i18n';
|
||||
import { speak } from '@wordpress/a11y';
|
||||
|
||||
export const speakFoundShippingOptions = (
|
||||
packageCount: number,
|
||||
rateCount: number
|
||||
) => {
|
||||
if ( packageCount === 1 ) {
|
||||
speak(
|
||||
sprintf(
|
||||
/* translators: %d number of shipping options found. */
|
||||
_n(
|
||||
'%d shipping option was found.',
|
||||
'%d shipping options were found.',
|
||||
rateCount,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
rateCount
|
||||
)
|
||||
);
|
||||
} else {
|
||||
speak(
|
||||
sprintf(
|
||||
/* translators: %d number of shipping packages packages. */
|
||||
_n(
|
||||
'Shipping option searched for %d package.',
|
||||
'Shipping options searched for %d packages.',
|
||||
packageCount,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
packageCount
|
||||
) +
|
||||
' ' +
|
||||
sprintf(
|
||||
/* translators: %d number of shipping options available. */
|
||||
_n(
|
||||
'%d shipping option was found',
|
||||
'%d shipping options were found',
|
||||
rateCount,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
rateCount
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import Button from '@woocommerce/base-components/button';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import {
|
||||
ValidatedTextInput,
|
||||
ValidationInputError,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
import classnames from 'classnames';
|
||||
import type { MouseEvent, MouseEventHandler } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
export interface TotalsCouponProps {
|
||||
/**
|
||||
* Instance id of the input
|
||||
*/
|
||||
instanceId: string;
|
||||
/**
|
||||
* Whether the component is in a loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Whether the coupon form is hidden
|
||||
*/
|
||||
displayCouponForm?: boolean;
|
||||
/**
|
||||
* Submit handler
|
||||
*/
|
||||
onSubmit?: ( couponValue: string ) => Promise< boolean > | undefined;
|
||||
}
|
||||
|
||||
export const TotalsCoupon = ( {
|
||||
instanceId,
|
||||
isLoading = false,
|
||||
onSubmit,
|
||||
displayCouponForm = false,
|
||||
}: TotalsCouponProps ): JSX.Element => {
|
||||
const [ couponValue, setCouponValue ] = useState( '' );
|
||||
const [ isCouponFormHidden, setIsCouponFormHidden ] = useState(
|
||||
! displayCouponForm
|
||||
);
|
||||
const textInputId = `wc-block-components-totals-coupon__input-${ instanceId }`;
|
||||
const formWrapperClass = classnames(
|
||||
'wc-block-components-totals-coupon__content',
|
||||
{
|
||||
'screen-reader-text': isCouponFormHidden,
|
||||
}
|
||||
);
|
||||
const { validationErrorId } = useSelect( ( select ) => {
|
||||
const store = select( VALIDATION_STORE_KEY );
|
||||
return {
|
||||
validationErrorId: store.getValidationErrorId( textInputId ),
|
||||
};
|
||||
} );
|
||||
const handleCouponAnchorClick: MouseEventHandler< HTMLAnchorElement > = (
|
||||
e: MouseEvent< HTMLAnchorElement >
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setIsCouponFormHidden( false );
|
||||
};
|
||||
const handleCouponSubmit: MouseEventHandler< HTMLButtonElement > = (
|
||||
e: MouseEvent< HTMLButtonElement >
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if ( typeof onSubmit !== 'undefined' ) {
|
||||
onSubmit( couponValue )?.then( ( result ) => {
|
||||
if ( result ) {
|
||||
setCouponValue( '' );
|
||||
setIsCouponFormHidden( true );
|
||||
}
|
||||
} );
|
||||
} else {
|
||||
setCouponValue( '' );
|
||||
setIsCouponFormHidden( true );
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wc-block-components-totals-coupon">
|
||||
{ isCouponFormHidden ? (
|
||||
<a
|
||||
role="button"
|
||||
href="#wc-block-components-totals-coupon__form"
|
||||
className="wc-block-components-totals-coupon-link"
|
||||
aria-label={ __(
|
||||
'Add a coupon',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
onClick={ handleCouponAnchorClick }
|
||||
>
|
||||
{ __( 'Add a coupon', 'woo-gutenberg-products-block' ) }
|
||||
</a>
|
||||
) : (
|
||||
<LoadingMask
|
||||
screenReaderLabel={ __(
|
||||
'Applying coupon…',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
isLoading={ isLoading }
|
||||
showSpinner={ false }
|
||||
>
|
||||
<div className={ formWrapperClass }>
|
||||
<form
|
||||
className="wc-block-components-totals-coupon__form"
|
||||
id="wc-block-components-totals-coupon__form"
|
||||
>
|
||||
<ValidatedTextInput
|
||||
id={ textInputId }
|
||||
errorId="coupon"
|
||||
className="wc-block-components-totals-coupon__input"
|
||||
label={ __(
|
||||
'Enter code',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
value={ couponValue }
|
||||
ariaDescribedBy={ validationErrorId }
|
||||
onChange={ ( newCouponValue ) => {
|
||||
setCouponValue( newCouponValue );
|
||||
} }
|
||||
focusOnMount={ true }
|
||||
validateOnMount={ false }
|
||||
showError={ false }
|
||||
/>
|
||||
<Button
|
||||
className="wc-block-components-totals-coupon__button"
|
||||
disabled={ isLoading || ! couponValue }
|
||||
showSpinner={ isLoading }
|
||||
onClick={ handleCouponSubmit }
|
||||
type="submit"
|
||||
>
|
||||
{ __(
|
||||
'Apply',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</Button>
|
||||
</form>
|
||||
<ValidationInputError
|
||||
propertyName="coupon"
|
||||
elementId={ textInputId }
|
||||
/>
|
||||
</div>
|
||||
</LoadingMask>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withInstanceId( TotalsCoupon );
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useArgs } from '@storybook/client-api';
|
||||
import type { Story, Meta } from '@storybook/react';
|
||||
import { INTERACTION_TIMEOUT } from '@woocommerce/storybook-controls';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TotalsCoupon, TotalsCouponProps } from '..';
|
||||
|
||||
export default {
|
||||
title: 'Base Components/Totals/Coupon',
|
||||
component: TotalsCoupon,
|
||||
args: {
|
||||
initialOpen: true,
|
||||
},
|
||||
} as Meta< TotalsCouponProps >;
|
||||
|
||||
const INVALID_COUPON_ERROR = {
|
||||
hidden: false,
|
||||
message: 'Invalid coupon code',
|
||||
};
|
||||
|
||||
const Template: Story< TotalsCouponProps > = ( args ) => {
|
||||
const [ {}, setArgs ] = useArgs();
|
||||
|
||||
const onSubmit = ( code: string ) => {
|
||||
args.onSubmit?.( code );
|
||||
setArgs( { isLoading: true } );
|
||||
return new Promise( ( resolve ) => {
|
||||
setTimeout( () => {
|
||||
setArgs( { isLoading: false } );
|
||||
resolve( true );
|
||||
}, INTERACTION_TIMEOUT );
|
||||
} );
|
||||
};
|
||||
|
||||
return <TotalsCoupon { ...args } onSubmit={ onSubmit } />;
|
||||
};
|
||||
|
||||
export const Default = Template.bind( {} );
|
||||
Default.args = {};
|
||||
|
||||
export const LoadingState = Template.bind( {} );
|
||||
LoadingState.args = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
export const ErrorState: Story< TotalsCouponProps > = ( args ) => {
|
||||
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
setValidationErrors( { coupon: INVALID_COUPON_ERROR } );
|
||||
|
||||
return <TotalsCoupon { ...args } />;
|
||||
};
|
||||
|
||||
ErrorState.decorators = [
|
||||
( StoryComponent ) => {
|
||||
return <StoryComponent />;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
.wc-block-components-totals-coupon {
|
||||
|
||||
.wc-block-components-panel__button {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-panel__content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-totals-coupon__form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
|
||||
.wc-block-components-totals-coupon__input {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-coupon__button {
|
||||
height: em(48px);
|
||||
flex-shrink: 0;
|
||||
margin-left: $gap-smaller;
|
||||
padding-left: $gap-large;
|
||||
padding-right: $gap-large;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-coupon__button.no-margin {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-totals-coupon__content {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TotalsCoupon } from '..';
|
||||
|
||||
describe( 'TotalsCoupon', () => {
|
||||
it( "Shows a validation error when one is in the wc/store/validation data store and doesn't show one when there isn't", () => {
|
||||
const { rerender } = render( <TotalsCoupon instanceId={ 'coupon' } /> );
|
||||
const openCouponFormButton = screen.getByText( 'Add a coupon' );
|
||||
expect( openCouponFormButton ).toBeInTheDocument();
|
||||
userEvent.click( openCouponFormButton );
|
||||
expect(
|
||||
screen.queryByText( 'Invalid coupon code' )
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const { setValidationErrors } = dispatch( VALIDATION_STORE_KEY );
|
||||
act( () => {
|
||||
setValidationErrors( {
|
||||
coupon: {
|
||||
hidden: false,
|
||||
message: 'Invalid coupon code',
|
||||
},
|
||||
} );
|
||||
} );
|
||||
rerender( <TotalsCoupon instanceId={ 'coupon' } /> );
|
||||
expect( screen.getByText( 'Invalid coupon code' ) ).toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import { RemovableChip, TotalsItem } from '@woocommerce/blocks-components';
|
||||
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import {
|
||||
CartResponseCouponItemWithLabel,
|
||||
CartTotalsItem,
|
||||
Currency,
|
||||
LooselyMustHave,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
export interface TotalsDiscountProps {
|
||||
cartCoupons: LooselyMustHave<
|
||||
CartResponseCouponItemWithLabel,
|
||||
'code' | 'label' | 'totals'
|
||||
>[];
|
||||
currency: Currency;
|
||||
isRemovingCoupon: boolean;
|
||||
removeCoupon: ( couponCode: string ) => void;
|
||||
values: LooselyMustHave<
|
||||
CartTotalsItem,
|
||||
'total_discount' | 'total_discount_tax'
|
||||
>;
|
||||
}
|
||||
|
||||
const filteredCartCouponsFilterArg = {
|
||||
context: 'summary',
|
||||
};
|
||||
|
||||
const TotalsDiscount = ( {
|
||||
cartCoupons = [],
|
||||
currency,
|
||||
isRemovingCoupon,
|
||||
removeCoupon,
|
||||
values,
|
||||
}: TotalsDiscountProps ): JSX.Element | null => {
|
||||
const {
|
||||
total_discount: totalDiscount,
|
||||
total_discount_tax: totalDiscountTax,
|
||||
} = values;
|
||||
const discountValue = parseInt( totalDiscount, 10 );
|
||||
|
||||
if ( ! discountValue && cartCoupons.length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const discountTaxValue = parseInt( totalDiscountTax, 10 );
|
||||
const discountTotalValue = getSetting(
|
||||
'displayCartPricesIncludingTax',
|
||||
false
|
||||
)
|
||||
? discountValue + discountTaxValue
|
||||
: discountValue;
|
||||
|
||||
const filteredCartCoupons = applyCheckoutFilter( {
|
||||
arg: filteredCartCouponsFilterArg,
|
||||
filterName: 'coupons',
|
||||
defaultValue: cartCoupons,
|
||||
} );
|
||||
|
||||
return (
|
||||
<TotalsItem
|
||||
className="wc-block-components-totals-discount"
|
||||
currency={ currency }
|
||||
description={
|
||||
filteredCartCoupons.length !== 0 && (
|
||||
<LoadingMask
|
||||
screenReaderLabel={ __(
|
||||
'Removing coupon…',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
isLoading={ isRemovingCoupon }
|
||||
showSpinner={ false }
|
||||
>
|
||||
<ul className="wc-block-components-totals-discount__coupon-list">
|
||||
{ filteredCartCoupons.map( ( cartCoupon ) => {
|
||||
return (
|
||||
<RemovableChip
|
||||
key={ 'coupon-' + cartCoupon.code }
|
||||
className="wc-block-components-totals-discount__coupon-list-item"
|
||||
text={ cartCoupon.label }
|
||||
screenReaderText={ sprintf(
|
||||
/* translators: %s Coupon code. */
|
||||
__(
|
||||
'Coupon: %s',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
cartCoupon.label
|
||||
) }
|
||||
disabled={ isRemovingCoupon }
|
||||
onRemove={ () => {
|
||||
removeCoupon( cartCoupon.code );
|
||||
} }
|
||||
radius="large"
|
||||
ariaLabel={ sprintf(
|
||||
/* translators: %s is a coupon code. */
|
||||
__(
|
||||
'Remove coupon "%s"',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
cartCoupon.label
|
||||
) }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
</ul>
|
||||
</LoadingMask>
|
||||
)
|
||||
}
|
||||
label={
|
||||
!! discountTotalValue
|
||||
? __( 'Discount', 'woo-gutenberg-products-block' )
|
||||
: __( 'Coupons', 'woo-gutenberg-products-block' )
|
||||
}
|
||||
value={ discountTotalValue ? discountTotalValue * -1 : '-' }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalsDiscount;
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useArgs } from '@storybook/client-api';
|
||||
import type { Story, Meta } from '@storybook/react';
|
||||
import {
|
||||
currenciesAPIShape as currencies,
|
||||
currencyControl,
|
||||
INTERACTION_TIMEOUT,
|
||||
} from '@woocommerce/storybook-controls';
|
||||
import {
|
||||
CartResponseCouponItemWithLabel,
|
||||
CartTotalsItem,
|
||||
LooselyMustHave,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Discount, { TotalsDiscountProps } from '..';
|
||||
|
||||
const EXAMPLE_COUPONS: CartResponseCouponItemWithLabel[] = [
|
||||
{
|
||||
code: 'AWSMSB',
|
||||
discount_type: '',
|
||||
label: 'Awesome Storybook coupon',
|
||||
totals: {
|
||||
...currencies.EUR,
|
||||
total_discount: '5000',
|
||||
total_discount_tax: '250',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'STONKS',
|
||||
discount_type: '',
|
||||
label: 'Most valuable coupon',
|
||||
totals: {
|
||||
...currencies.EUR,
|
||||
total_discount: '10000',
|
||||
total_discount_tax: '1000',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function extractValuesFromCoupons(
|
||||
coupons: LooselyMustHave< CartResponseCouponItemWithLabel, 'totals' >[]
|
||||
) {
|
||||
return coupons.reduce(
|
||||
( acc, curr ) => {
|
||||
const totalDiscount =
|
||||
Number( acc.total_discount ) +
|
||||
Number( curr.totals.total_discount );
|
||||
const totalDiscountTax =
|
||||
Number( acc.total_discount_tax ) +
|
||||
Number( curr.totals.total_discount_tax );
|
||||
|
||||
return {
|
||||
total_discount: String( totalDiscount ),
|
||||
total_discount_tax: String( totalDiscountTax ),
|
||||
};
|
||||
},
|
||||
{ total_discount: '0', total_discount_tax: '0' } as LooselyMustHave<
|
||||
CartTotalsItem,
|
||||
'total_discount' | 'total_discount_tax'
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Base Components/Totals/Discount',
|
||||
component: Discount,
|
||||
argTypes: {
|
||||
currency: currencyControl,
|
||||
removeCoupon: { action: 'Removing coupon with code' },
|
||||
},
|
||||
args: {
|
||||
cartCoupons: EXAMPLE_COUPONS,
|
||||
isRemovingCoupon: false,
|
||||
values: extractValuesFromCoupons( EXAMPLE_COUPONS ),
|
||||
},
|
||||
} as Meta< TotalsDiscountProps >;
|
||||
|
||||
const Template: Story< TotalsDiscountProps > = ( args ) => {
|
||||
const [ {}, setArgs ] = useArgs();
|
||||
|
||||
const removeCoupon = ( code: string ) => {
|
||||
args.removeCoupon( code );
|
||||
setArgs( { isRemovingCoupon: true } );
|
||||
|
||||
const cartCoupons = args.cartCoupons.filter(
|
||||
( coupon ) => coupon.code !== code
|
||||
);
|
||||
|
||||
const values = extractValuesFromCoupons( cartCoupons );
|
||||
|
||||
setTimeout(
|
||||
() => setArgs( { cartCoupons, values, isRemovingCoupon: false } ),
|
||||
INTERACTION_TIMEOUT
|
||||
);
|
||||
};
|
||||
|
||||
return <Discount { ...args } removeCoupon={ removeCoupon } />;
|
||||
};
|
||||
|
||||
export const Default = Template.bind( {} );
|
||||
Default.args = {};
|
||||
|
||||
export const RemovingCoupon = Template.bind( {} );
|
||||
RemovingCoupon.args = {
|
||||
isRemovingCoupon: true,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.wc-block-components-totals-discount__coupon-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-discount .wc-block-components-totals-item__value {
|
||||
color: $discount-color;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import classNames from 'classnames';
|
||||
import { createInterpolateElement } from '@wordpress/element';
|
||||
import {
|
||||
FormattedMonetaryAmount,
|
||||
TotalsItem,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import {
|
||||
CartResponseTotals,
|
||||
Currency,
|
||||
LooselyMustHave,
|
||||
} from '@woocommerce/types';
|
||||
import { formatPrice } from '@woocommerce/price-format';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
export interface TotalsFooterItemProps {
|
||||
/**
|
||||
* The currency object with which to display the item
|
||||
*/
|
||||
currency: Currency;
|
||||
/**
|
||||
* An object containing the total price and the total tax
|
||||
*
|
||||
* It accepts the entire `CartResponseTotals` to be passed, for
|
||||
* convenience, but will use only these two properties.
|
||||
*/
|
||||
values: LooselyMustHave< CartResponseTotals, 'total_price' | 'total_tax' >;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total at the bottom of the cart
|
||||
*
|
||||
* Can show how much of the total is in taxes if the settings
|
||||
* `taxesEnabled` and `displayCartPricesIncludingTax` are both
|
||||
* enabled.
|
||||
*/
|
||||
const TotalsFooterItem = ( {
|
||||
currency,
|
||||
values,
|
||||
className,
|
||||
}: TotalsFooterItemProps ): JSX.Element => {
|
||||
const SHOW_TAXES =
|
||||
getSetting< boolean >( 'taxesEnabled', true ) &&
|
||||
getSetting< boolean >( 'displayCartPricesIncludingTax', false );
|
||||
|
||||
const {
|
||||
total_price: totalPrice,
|
||||
total_tax: totalTax,
|
||||
tax_lines: taxLines,
|
||||
} = values;
|
||||
|
||||
// Prepare props to pass to the applyCheckoutFilter filter.
|
||||
// We need to pluck out receiveCart.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { receiveCart, ...cart } = useStoreCart();
|
||||
const label = applyCheckoutFilter( {
|
||||
filterName: 'totalLabel',
|
||||
defaultValue: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
extensions: cart.extensions,
|
||||
arg: { cart },
|
||||
} );
|
||||
|
||||
const parsedTaxValue = parseInt( totalTax, 10 );
|
||||
const description =
|
||||
taxLines && taxLines.length > 0
|
||||
? sprintf(
|
||||
/* translators: %s is a list of tax rates */
|
||||
__( 'Including %s', 'woo-gutenberg-products-block' ),
|
||||
taxLines
|
||||
.map( ( { name, price } ) => {
|
||||
return `${ formatPrice(
|
||||
price,
|
||||
currency
|
||||
) } ${ name }`;
|
||||
} )
|
||||
.join( ', ' )
|
||||
)
|
||||
: __(
|
||||
'Including <TaxAmount/> in taxes',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
|
||||
return (
|
||||
<TotalsItem
|
||||
className={ classNames(
|
||||
'wc-block-components-totals-footer-item',
|
||||
className
|
||||
) }
|
||||
currency={ currency }
|
||||
label={ label }
|
||||
value={ parseInt( totalPrice, 10 ) }
|
||||
description={
|
||||
SHOW_TAXES &&
|
||||
parsedTaxValue !== 0 && (
|
||||
<p className="wc-block-components-totals-footer-item-tax">
|
||||
{ createInterpolateElement( description, {
|
||||
TaxAmount: (
|
||||
<FormattedMonetaryAmount
|
||||
className="wc-block-components-totals-footer-item-tax-value"
|
||||
currency={ currency }
|
||||
value={ parsedTaxValue }
|
||||
/>
|
||||
),
|
||||
} ) }
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalsFooterItem;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Story, Meta } from '@storybook/react';
|
||||
import { allSettings } from '@woocommerce/settings';
|
||||
import { Currency } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import FooterItem, { TotalsFooterItemProps } from '..';
|
||||
|
||||
const NZD: Currency = {
|
||||
code: 'NZD',
|
||||
symbol: '$',
|
||||
thousandSeparator: ' ',
|
||||
decimalSeparator: '.',
|
||||
minorUnit: 2,
|
||||
prefix: '$',
|
||||
suffix: '',
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Base Components/Totals/FooterItem',
|
||||
component: FooterItem,
|
||||
args: {
|
||||
currency: NZD,
|
||||
values: { total_price: '2500', total_tax: '550' },
|
||||
},
|
||||
} as Meta< TotalsFooterItemProps >;
|
||||
|
||||
const Template: Story< TotalsFooterItemProps > = ( args ) => (
|
||||
<FooterItem { ...args } />
|
||||
);
|
||||
|
||||
export const Default = Template.bind( {} );
|
||||
Default.decorators = [
|
||||
( StoryComponent ) => {
|
||||
allSettings.displayCartPricesIncludingTax = false;
|
||||
|
||||
return <StoryComponent />;
|
||||
},
|
||||
];
|
||||
|
||||
export const NoTaxLabel = Template.bind( {} );
|
||||
NoTaxLabel.decorators = [
|
||||
( StoryComponent ) => {
|
||||
allSettings.displayCartPricesIncludingTax = true;
|
||||
|
||||
return <StoryComponent />;
|
||||
},
|
||||
];
|
||||
|
||||
export const SingleTaxLabel = Template.bind( {} );
|
||||
SingleTaxLabel.args = {
|
||||
values: {
|
||||
total_price: '2500',
|
||||
total_tax: '550',
|
||||
tax_lines: [ { name: '10% VAT', price: '550', rate: '10.00' } ],
|
||||
},
|
||||
};
|
||||
SingleTaxLabel.decorators = [
|
||||
( StoryComponent ) => {
|
||||
allSettings.displayCartPricesIncludingTax = true;
|
||||
|
||||
return <StoryComponent />;
|
||||
},
|
||||
];
|
||||
|
||||
export const MultipleTaxLabels = Template.bind( {} );
|
||||
MultipleTaxLabels.args = {
|
||||
values: {
|
||||
total_price: '2500',
|
||||
total_tax: '550',
|
||||
tax_lines: [
|
||||
{ name: '10% VAT', price: '300', rate: '10.00' },
|
||||
{ name: '5% VAT', price: '250', rate: '5.00' },
|
||||
],
|
||||
},
|
||||
};
|
||||
MultipleTaxLabels.decorators = [
|
||||
( StoryComponent ) => {
|
||||
allSettings.displayCartPricesIncludingTax = true;
|
||||
|
||||
return <StoryComponent />;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
.wc-block-components-totals-footer-item {
|
||||
.wc-block-components-totals-item__value,
|
||||
.wc-block-components-totals-item__label {
|
||||
@include font-size(large);
|
||||
}
|
||||
|
||||
.wc-block-components-totals-item__label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-footer-item-tax {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TotalsFooterItem Does not show the "including %s of tax" line if tax is 0 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
|
||||
>
|
||||
<span
|
||||
class="wc-block-components-totals-item__label"
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
|
||||
>
|
||||
£85.00
|
||||
</span>
|
||||
<div
|
||||
class="wc-block-components-totals-item__description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TotalsFooterItem Does not show the "including %s of tax" line if tax is disabled 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
|
||||
>
|
||||
<span
|
||||
class="wc-block-components-totals-item__label"
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
|
||||
>
|
||||
£85.00
|
||||
</span>
|
||||
<div
|
||||
class="wc-block-components-totals-item__description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TotalsFooterItem Shows the "including %s TAX LABEL" line with single tax label 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
|
||||
>
|
||||
<span
|
||||
class="wc-block-components-totals-item__label"
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
|
||||
>
|
||||
£85.00
|
||||
</span>
|
||||
<div
|
||||
class="wc-block-components-totals-item__description"
|
||||
>
|
||||
<p
|
||||
class="wc-block-components-totals-footer-item-tax"
|
||||
>
|
||||
Including £1.00 10% VAT
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TotalsFooterItem Shows the "including %s TAX LABELS" line with multiple tax labels 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
|
||||
>
|
||||
<span
|
||||
class="wc-block-components-totals-item__label"
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
|
||||
>
|
||||
£85.00
|
||||
</span>
|
||||
<div
|
||||
class="wc-block-components-totals-item__description"
|
||||
>
|
||||
<p
|
||||
class="wc-block-components-totals-footer-item-tax"
|
||||
>
|
||||
Including £0.50 10% VAT, £0.50 5% VAT
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TotalsFooterItem Shows the "including %s of tax" line if tax is greater than 0 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
|
||||
>
|
||||
<span
|
||||
class="wc-block-components-totals-item__label"
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
|
||||
>
|
||||
£85.00
|
||||
</span>
|
||||
<div
|
||||
class="wc-block-components-totals-item__description"
|
||||
>
|
||||
<p
|
||||
class="wc-block-components-totals-footer-item-tax"
|
||||
>
|
||||
Including
|
||||
<span
|
||||
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-footer-item-tax-value"
|
||||
>
|
||||
£1.00
|
||||
</span>
|
||||
in taxes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { allSettings } from '@woocommerce/settings';
|
||||
import { Currency, CartResponseTotals } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TotalsFooterItem from '../index';
|
||||
|
||||
describe( 'TotalsFooterItem', () => {
|
||||
beforeEach( () => {
|
||||
allSettings.taxesEnabled = true;
|
||||
allSettings.displayCartPricesIncludingTax = true;
|
||||
} );
|
||||
const currency: Currency = {
|
||||
code: 'GBP',
|
||||
decimalSeparator: '.',
|
||||
minorUnit: 2,
|
||||
prefix: '£',
|
||||
suffix: '',
|
||||
symbol: '£',
|
||||
thousandSeparator: ',',
|
||||
};
|
||||
|
||||
const values: CartResponseTotals = {
|
||||
currency_code: 'GBP',
|
||||
currency_decimal_separator: '.',
|
||||
currency_minor_unit: 2,
|
||||
currency_prefix: '£',
|
||||
currency_suffix: '',
|
||||
currency_symbol: '£',
|
||||
currency_thousand_separator: ',',
|
||||
tax_lines: [],
|
||||
total_discount: '0',
|
||||
total_discount_tax: '0',
|
||||
total_fees: '0',
|
||||
total_fees_tax: '0',
|
||||
total_items: '7100',
|
||||
total_items_tax: '0',
|
||||
total_price: '8500',
|
||||
total_shipping: '0',
|
||||
total_shipping_tax: '0',
|
||||
total_tax: '0',
|
||||
};
|
||||
|
||||
it( 'Does not show the "including %s of tax" line if tax is 0', () => {
|
||||
const { container } = render(
|
||||
<TotalsFooterItem currency={ currency } values={ values } />
|
||||
);
|
||||
expect( container ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
it( 'Does not show the "including %s of tax" line if tax is disabled', () => {
|
||||
allSettings.taxesEnabled = false;
|
||||
/* This shouldn't ever happen if taxes are disabled, but this is to test whether the taxesEnabled setting works */
|
||||
const valuesWithTax = {
|
||||
...values,
|
||||
total_tax: '100',
|
||||
total_items_tax: '100',
|
||||
};
|
||||
const { container } = render(
|
||||
<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
|
||||
);
|
||||
expect( container ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
it( 'Shows the "including %s of tax" line if tax is greater than 0', () => {
|
||||
const valuesWithTax = {
|
||||
...values,
|
||||
total_tax: '100',
|
||||
total_items_tax: '100',
|
||||
};
|
||||
const { container } = render(
|
||||
<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
|
||||
);
|
||||
expect( container ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
it( 'Shows the "including %s TAX LABEL" line with single tax label', () => {
|
||||
const valuesWithTax = {
|
||||
...values,
|
||||
total_tax: '100',
|
||||
total_items_tax: '100',
|
||||
tax_lines: [ { name: '10% VAT', price: '100', rate: '10.000' } ],
|
||||
};
|
||||
const { container } = render(
|
||||
<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
|
||||
);
|
||||
expect( container ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
it( 'Shows the "including %s TAX LABELS" line with multiple tax labels', () => {
|
||||
const valuesWithTax = {
|
||||
...values,
|
||||
total_tax: '100',
|
||||
total_items_tax: '100',
|
||||
tax_lines: [
|
||||
{ name: '10% VAT', price: '50', rate: '10.000' },
|
||||
{ name: '5% VAT', price: '50', rate: '5.000' },
|
||||
],
|
||||
};
|
||||
const { container } = render(
|
||||
<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
|
||||
);
|
||||
expect( container ).toMatchSnapshot();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as TotalsCoupon } from './coupon';
|
||||
export { default as TotalsDiscount } from './discount';
|
||||
export { default as TotalsFooterItem } from './footer-item';
|
||||
export { default as TotalsShipping } from './shipping';
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export interface CalculatorButtonProps {
|
||||
label?: string;
|
||||
isShippingCalculatorOpen: boolean;
|
||||
setIsShippingCalculatorOpen: ( isShippingCalculatorOpen: boolean ) => void;
|
||||
}
|
||||
|
||||
export const CalculatorButton = ( {
|
||||
label = __( 'Calculate', 'woo-gutenberg-products-block' ),
|
||||
isShippingCalculatorOpen,
|
||||
setIsShippingCalculatorOpen,
|
||||
}: CalculatorButtonProps ): JSX.Element => {
|
||||
return (
|
||||
<a
|
||||
role="button"
|
||||
href="#wc-block-components-shipping-calculator-address__link"
|
||||
className="wc-block-components-totals-shipping__change-address__link"
|
||||
id="wc-block-components-totals-shipping__change-address__link"
|
||||
onClick={ ( e ) => {
|
||||
e.preventDefault();
|
||||
setIsShippingCalculatorOpen( ! isShippingCalculatorOpen );
|
||||
} }
|
||||
aria-label={ label }
|
||||
aria-expanded={ isShippingCalculatorOpen }
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalculatorButton;
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
import { TotalsItem } from '@woocommerce/blocks-components';
|
||||
import type { Currency } from '@woocommerce/types';
|
||||
import { ShippingVia } from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-via';
|
||||
import {
|
||||
isAddressComplete,
|
||||
isPackageRateCollectable,
|
||||
} from '@woocommerce/base-utils';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingCalculator from '../../shipping-calculator';
|
||||
import {
|
||||
hasShippingRate,
|
||||
getTotalShippingValue,
|
||||
areShippingMethodsMissing,
|
||||
} from './utils';
|
||||
import ShippingPlaceholder from './shipping-placeholder';
|
||||
import ShippingAddress from './shipping-address';
|
||||
import ShippingRateSelector from './shipping-rate-selector';
|
||||
import './style.scss';
|
||||
|
||||
export interface TotalShippingProps {
|
||||
currency: Currency;
|
||||
values: {
|
||||
total_shipping: string;
|
||||
total_shipping_tax: string;
|
||||
}; // Values in use
|
||||
showCalculator?: boolean; //Whether to display the rate selector below the shipping total.
|
||||
showRateSelector?: boolean; // Whether to show shipping calculator or not.
|
||||
className?: string;
|
||||
isCheckout?: boolean;
|
||||
}
|
||||
export const TotalsShipping = ( {
|
||||
currency,
|
||||
values,
|
||||
showCalculator = true,
|
||||
showRateSelector = true,
|
||||
isCheckout = false,
|
||||
className,
|
||||
}: TotalShippingProps ): JSX.Element => {
|
||||
const [ isShippingCalculatorOpen, setIsShippingCalculatorOpen ] =
|
||||
useState( false );
|
||||
const {
|
||||
shippingAddress,
|
||||
cartHasCalculatedShipping,
|
||||
shippingRates,
|
||||
isLoadingRates,
|
||||
} = useStoreCart();
|
||||
const totalShippingValue = getTotalShippingValue( values );
|
||||
const hasRates = hasShippingRate( shippingRates ) || totalShippingValue > 0;
|
||||
const showShippingCalculatorForm =
|
||||
showCalculator && isShippingCalculatorOpen;
|
||||
const prefersCollection = useSelect( ( select ) => {
|
||||
return select( CHECKOUT_STORE_KEY ).prefersCollection();
|
||||
} );
|
||||
const selectedShippingRates = shippingRates.flatMap(
|
||||
( shippingPackage ) => {
|
||||
return shippingPackage.shipping_rates
|
||||
.filter(
|
||||
( rate ) =>
|
||||
// If the shopper prefers collection, the rate is collectable AND selected.
|
||||
( prefersCollection &&
|
||||
isPackageRateCollectable( rate ) &&
|
||||
rate.selected ) ||
|
||||
// Or the shopper does not prefer collection and the rate is selected
|
||||
( ! prefersCollection && rate.selected )
|
||||
)
|
||||
.flatMap( ( rate ) => rate.name );
|
||||
}
|
||||
);
|
||||
const addressComplete = isAddressComplete( shippingAddress );
|
||||
const shippingMethodsMissing = areShippingMethodsMissing(
|
||||
hasRates,
|
||||
prefersCollection,
|
||||
shippingRates
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
'wc-block-components-totals-shipping',
|
||||
className
|
||||
) }
|
||||
>
|
||||
<TotalsItem
|
||||
label={ __( 'Shipping', 'woo-gutenberg-products-block' ) }
|
||||
value={
|
||||
! shippingMethodsMissing && cartHasCalculatedShipping
|
||||
? // if address is not complete, display the link to add an address.
|
||||
totalShippingValue
|
||||
: ( ! addressComplete || isCheckout ) && (
|
||||
<ShippingPlaceholder
|
||||
showCalculator={ showCalculator }
|
||||
isCheckout={ isCheckout }
|
||||
isShippingCalculatorOpen={
|
||||
isShippingCalculatorOpen
|
||||
}
|
||||
setIsShippingCalculatorOpen={
|
||||
setIsShippingCalculatorOpen
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
description={
|
||||
( ! shippingMethodsMissing && cartHasCalculatedShipping ) ||
|
||||
// If address is complete, display the shipping address.
|
||||
( addressComplete && ! isCheckout ) ? (
|
||||
<>
|
||||
<ShippingVia
|
||||
selectedShippingRates={ selectedShippingRates }
|
||||
/>
|
||||
<ShippingAddress
|
||||
shippingAddress={ shippingAddress }
|
||||
showCalculator={ showCalculator }
|
||||
isShippingCalculatorOpen={
|
||||
isShippingCalculatorOpen
|
||||
}
|
||||
setIsShippingCalculatorOpen={
|
||||
setIsShippingCalculatorOpen
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
currency={ currency }
|
||||
/>
|
||||
{ showShippingCalculatorForm && (
|
||||
<ShippingCalculator
|
||||
onUpdate={ () => {
|
||||
setIsShippingCalculatorOpen( false );
|
||||
} }
|
||||
onCancel={ () => {
|
||||
setIsShippingCalculatorOpen( false );
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
{ showRateSelector &&
|
||||
cartHasCalculatedShipping &&
|
||||
! showShippingCalculatorForm && (
|
||||
<ShippingRateSelector
|
||||
hasRates={ hasRates }
|
||||
shippingRates={ shippingRates }
|
||||
isLoadingRates={ isLoadingRates }
|
||||
isAddressComplete={ addressComplete }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalsShipping;
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { formatShippingAddress } from '@woocommerce/base-utils';
|
||||
import { useEditorContext } from '@woocommerce/base-context';
|
||||
import { ShippingAddress as ShippingAddressType } from '@woocommerce/settings';
|
||||
import PickupLocation from '@woocommerce/base-components/cart-checkout/pickup-location';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingLocation from '../../shipping-location';
|
||||
import { CalculatorButton, CalculatorButtonProps } from './calculator-button';
|
||||
|
||||
export interface ShippingAddressProps {
|
||||
showCalculator: boolean;
|
||||
isShippingCalculatorOpen: boolean;
|
||||
setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ];
|
||||
shippingAddress: ShippingAddressType;
|
||||
}
|
||||
|
||||
export const ShippingAddress = ( {
|
||||
showCalculator,
|
||||
isShippingCalculatorOpen,
|
||||
setIsShippingCalculatorOpen,
|
||||
shippingAddress,
|
||||
}: ShippingAddressProps ): JSX.Element | null => {
|
||||
const { isEditor } = useEditorContext();
|
||||
const prefersCollection = useSelect( ( select ) =>
|
||||
select( CHECKOUT_STORE_KEY ).prefersCollection()
|
||||
);
|
||||
const hasFormattedAddress = !! formatShippingAddress( shippingAddress );
|
||||
|
||||
// If there is no default customer location set in the store, the customer hasn't provided their address,
|
||||
// but a default shipping method is available for all locations,
|
||||
// then the shipping calculator will be hidden to avoid confusion.
|
||||
if ( ! hasFormattedAddress && ! isEditor ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedLocation = formatShippingAddress( shippingAddress );
|
||||
return (
|
||||
<>
|
||||
{ prefersCollection ? (
|
||||
<PickupLocation />
|
||||
) : (
|
||||
<ShippingLocation formattedLocation={ formattedLocation } />
|
||||
) }
|
||||
{ showCalculator && (
|
||||
<CalculatorButton
|
||||
label={ __(
|
||||
'Change address',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
isShippingCalculatorOpen={ isShippingCalculatorOpen }
|
||||
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingAddress;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CalculatorButton, CalculatorButtonProps } from './calculator-button';
|
||||
|
||||
export interface ShippingPlaceholderProps {
|
||||
showCalculator: boolean;
|
||||
isShippingCalculatorOpen: boolean;
|
||||
isCheckout?: boolean;
|
||||
setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ];
|
||||
}
|
||||
|
||||
export const ShippingPlaceholder = ( {
|
||||
showCalculator,
|
||||
isShippingCalculatorOpen,
|
||||
setIsShippingCalculatorOpen,
|
||||
isCheckout = false,
|
||||
}: ShippingPlaceholderProps ): JSX.Element => {
|
||||
if ( ! showCalculator ) {
|
||||
return (
|
||||
<em>
|
||||
{ isCheckout
|
||||
? __(
|
||||
'No shipping options available',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
: __(
|
||||
'Calculated during checkout',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalculatorButton
|
||||
label={ __(
|
||||
'Add an address for shipping options',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
isShippingCalculatorOpen={ isShippingCalculatorOpen }
|
||||
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingPlaceholder;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import type { CartResponseShippingRate } from '@woocommerce/types';
|
||||
import NoticeBanner from '@woocommerce/base-components/notice-banner';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingRatesControl from '../../shipping-rates-control';
|
||||
|
||||
export interface ShippingRateSelectorProps {
|
||||
hasRates: boolean;
|
||||
shippingRates: CartResponseShippingRate[];
|
||||
isLoadingRates: boolean;
|
||||
isAddressComplete: boolean;
|
||||
}
|
||||
|
||||
export const ShippingRateSelector = ( {
|
||||
hasRates,
|
||||
shippingRates,
|
||||
isLoadingRates,
|
||||
isAddressComplete,
|
||||
}: ShippingRateSelectorProps ): JSX.Element => {
|
||||
const legend = hasRates
|
||||
? __( 'Shipping options', 'woo-gutenberg-products-block' )
|
||||
: __( 'Choose a shipping option', 'woo-gutenberg-products-block' );
|
||||
return (
|
||||
<fieldset className="wc-block-components-totals-shipping__fieldset">
|
||||
<legend className="screen-reader-text">{ legend }</legend>
|
||||
<ShippingRatesControl
|
||||
className="wc-block-components-totals-shipping__options"
|
||||
noResultsMessage={
|
||||
<>
|
||||
{ isAddressComplete && (
|
||||
<NoticeBanner
|
||||
isDismissible={ false }
|
||||
className="wc-block-components-shipping-rates-control__no-results-notice"
|
||||
status="warning"
|
||||
>
|
||||
{ __(
|
||||
'There are no shipping options available. Please check your shipping address.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</NoticeBanner>
|
||||
) }
|
||||
</>
|
||||
}
|
||||
shippingRates={ shippingRates }
|
||||
isLoadingRates={ isLoadingRates }
|
||||
context="woocommerce/cart"
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShippingRateSelector;
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
export const ShippingVia = ( {
|
||||
selectedShippingRates,
|
||||
}: {
|
||||
selectedShippingRates: string[];
|
||||
} ): JSX.Element => {
|
||||
return (
|
||||
<div className="wc-block-components-totals-item__description wc-block-components-totals-shipping__via">
|
||||
{ decodeEntities(
|
||||
selectedShippingRates
|
||||
.filter(
|
||||
( item, index ) =>
|
||||
selectedShippingRates.indexOf( item ) === index
|
||||
)
|
||||
.join( ', ' )
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
.wc-block-components-totals-shipping {
|
||||
// Added extra label for specificity.
|
||||
fieldset.wc-block-components-totals-shipping__fieldset {
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-address {
|
||||
margin-top: $gap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-shipping__options {
|
||||
.wc-block-components-radio-control__label,
|
||||
.wc-block-components-radio-control__description,
|
||||
.wc-block-components-radio-control__secondary-label,
|
||||
.wc-block-components-radio-control__secondary-description {
|
||||
flex-basis: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
margin-top: ($gap-small);
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-rates-control__no-results-notice {
|
||||
margin: 0 0 em($gap-small);
|
||||
}
|
||||
|
||||
.wc-block-components-totals-shipping__change-address__link {
|
||||
font-weight: normal;
|
||||
}
|
||||
.wc-block-components-totals-shipping__change-address-button {
|
||||
@include link-button();
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra classes for specificity.
|
||||
.theme-twentytwentyone.theme-twentytwentyone.theme-twentytwentyone .wc-block-components-totals-shipping__change-address-button {
|
||||
@include link-button();
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
|
||||
import { previewCart as mockPreviewCart } from '@woocommerce/resource-previews';
|
||||
import * as wpData from '@wordpress/data';
|
||||
import * as baseContextHooks from '@woocommerce/base-context/hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TotalsShipping } from '../index';
|
||||
|
||||
jest.mock( '@wordpress/data', () => ( {
|
||||
__esModule: true,
|
||||
...jest.requireActual( '@wordpress/data' ),
|
||||
useSelect: jest.fn(),
|
||||
} ) );
|
||||
|
||||
// Mock use select so we can override it when wc/store/checkout is accessed, but return the original select function if any other store is accessed.
|
||||
wpData.useSelect.mockImplementation(
|
||||
jest.fn().mockImplementation( ( passedMapSelect ) => {
|
||||
const mockedSelect = jest.fn().mockImplementation( ( storeName ) => {
|
||||
if ( storeName === 'wc/store/checkout' ) {
|
||||
return {
|
||||
prefersCollection() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
return jest.requireActual( '@wordpress/data' ).select( storeName );
|
||||
} );
|
||||
passedMapSelect( mockedSelect, {
|
||||
dispatch: jest.requireActual( '@wordpress/data' ).dispatch,
|
||||
} );
|
||||
} )
|
||||
);
|
||||
|
||||
const shippingAddress = {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
company: 'Company',
|
||||
address_1: '409 Main Street',
|
||||
address_2: 'Apt 1',
|
||||
city: 'London',
|
||||
postcode: 'W1T 4JG',
|
||||
country: 'GB',
|
||||
state: '',
|
||||
email: 'john.doe@company',
|
||||
phone: '+1234567890',
|
||||
};
|
||||
|
||||
jest.mock( '@woocommerce/base-context/hooks', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
...jest.requireActual( '@woocommerce/base-context/hooks' ),
|
||||
useShippingData: jest.fn(),
|
||||
useStoreCart: jest.fn(),
|
||||
};
|
||||
} );
|
||||
baseContextHooks.useShippingData.mockReturnValue( {
|
||||
needsShipping: true,
|
||||
selectShippingRate: jest.fn(),
|
||||
shippingRates: [
|
||||
{
|
||||
package_id: 0,
|
||||
name: 'Shipping method',
|
||||
destination: {
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
key: 'fb0c0a746719a7596f296344b80cb2b6',
|
||||
name: 'Hoodie - Blue, Yes',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
key: '1f0e3dad99908345f7439f8ffabdffc4',
|
||||
name: 'Beanie',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
shipping_rates: [
|
||||
{
|
||||
rate_id: 'flat_rate:1',
|
||||
name: 'Flat rate',
|
||||
description: '',
|
||||
delivery_time: '',
|
||||
price: '500',
|
||||
taxes: '0',
|
||||
instance_id: 1,
|
||||
method_id: 'flat_rate',
|
||||
meta_data: [
|
||||
{
|
||||
key: 'Items',
|
||||
value: 'Hoodie - Blue, Yes × 1, Beanie × 1',
|
||||
},
|
||||
],
|
||||
selected: false,
|
||||
currency_code: 'USD',
|
||||
currency_symbol: '$',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
},
|
||||
{
|
||||
rate_id: 'local_pickup:2',
|
||||
name: 'Local pickup',
|
||||
description: '',
|
||||
delivery_time: '',
|
||||
price: '0',
|
||||
taxes: '0',
|
||||
instance_id: 2,
|
||||
method_id: 'local_pickup',
|
||||
meta_data: [
|
||||
{
|
||||
key: 'Items',
|
||||
value: 'Hoodie - Blue, Yes × 1, Beanie × 1',
|
||||
},
|
||||
],
|
||||
selected: false,
|
||||
currency_code: 'USD',
|
||||
currency_symbol: '$',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
},
|
||||
{
|
||||
rate_id: 'free_shipping:5',
|
||||
name: 'Free shipping',
|
||||
description: '',
|
||||
delivery_time: '',
|
||||
price: '0',
|
||||
taxes: '0',
|
||||
instance_id: 5,
|
||||
method_id: 'free_shipping',
|
||||
meta_data: [
|
||||
{
|
||||
key: 'Items',
|
||||
value: 'Hoodie - Blue, Yes × 1, Beanie × 1',
|
||||
},
|
||||
],
|
||||
selected: true,
|
||||
currency_code: 'USD',
|
||||
currency_symbol: '$',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} );
|
||||
baseContextHooks.useStoreCart.mockReturnValue( {
|
||||
cartItems: mockPreviewCart.items,
|
||||
cartTotals: [ mockPreviewCart.totals ],
|
||||
cartCoupons: mockPreviewCart.coupons,
|
||||
cartFees: mockPreviewCart.fees,
|
||||
cartNeedsShipping: mockPreviewCart.needs_shipping,
|
||||
shippingRates: [],
|
||||
shippingAddress,
|
||||
billingAddress: mockPreviewCart.billing_address,
|
||||
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
|
||||
isLoadingRates: false,
|
||||
} );
|
||||
|
||||
describe( 'TotalsShipping', () => {
|
||||
it( 'should show correct calculator button label if address is complete', () => {
|
||||
render(
|
||||
<SlotFillProvider>
|
||||
<TotalsShipping
|
||||
currency={ {
|
||||
code: 'USD',
|
||||
symbol: '$',
|
||||
position: 'left',
|
||||
precision: 2,
|
||||
} }
|
||||
values={ {
|
||||
total_shipping: '0',
|
||||
total_shipping_tax: '0',
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Shipping to W1T 4JG, London, United Kingdom (UK)'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect( screen.getByText( 'Change address' ) ).toBeInTheDocument();
|
||||
} );
|
||||
it( 'should show correct calculator button label if address is incomplete', () => {
|
||||
baseContextHooks.useStoreCart.mockReturnValue( {
|
||||
cartItems: mockPreviewCart.items,
|
||||
cartTotals: [ mockPreviewCart.totals ],
|
||||
cartCoupons: mockPreviewCart.coupons,
|
||||
cartFees: mockPreviewCart.fees,
|
||||
cartNeedsShipping: mockPreviewCart.needs_shipping,
|
||||
shippingRates: [],
|
||||
shippingAddress: {
|
||||
...shippingAddress,
|
||||
city: '',
|
||||
country: '',
|
||||
postcode: '',
|
||||
},
|
||||
billingAddress: mockPreviewCart.billing_address,
|
||||
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
|
||||
isLoadingRates: false,
|
||||
} );
|
||||
render(
|
||||
<SlotFillProvider>
|
||||
<TotalsShipping
|
||||
currency={ {
|
||||
code: 'USD',
|
||||
symbol: '$',
|
||||
minorUnit: 2,
|
||||
decimalSeparator: '.',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
thousandSeparator: ', ',
|
||||
} }
|
||||
values={ {
|
||||
total_shipping: '0',
|
||||
total_shipping_tax: '0',
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect(
|
||||
screen.queryByText( 'Change address' )
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText( 'Add an address for shipping options' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
it( 'does not show the calculator button when default rates are available and no address has been entered', () => {
|
||||
baseContextHooks.useStoreCart.mockReturnValue( {
|
||||
cartItems: mockPreviewCart.items,
|
||||
cartTotals: [ mockPreviewCart.totals ],
|
||||
cartCoupons: mockPreviewCart.coupons,
|
||||
cartFees: mockPreviewCart.fees,
|
||||
cartNeedsShipping: mockPreviewCart.needs_shipping,
|
||||
shippingRates: mockPreviewCart.shipping_rates,
|
||||
shippingAddress: {
|
||||
...shippingAddress,
|
||||
city: '',
|
||||
country: '',
|
||||
postcode: '',
|
||||
},
|
||||
billingAddress: mockPreviewCart.billing_address,
|
||||
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
|
||||
isLoadingRates: false,
|
||||
} );
|
||||
render(
|
||||
<SlotFillProvider>
|
||||
<TotalsShipping
|
||||
currency={ {
|
||||
code: 'USD',
|
||||
symbol: '$',
|
||||
minorUnit: 2,
|
||||
decimalSeparator: '.',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
thousandSeparator: ', ',
|
||||
} }
|
||||
values={ {
|
||||
total_shipping: '0',
|
||||
total_shipping_tax: '0',
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect(
|
||||
screen.queryByText( 'Change address' )
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( 'Add an address for shipping options' )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
it( 'does show the calculator button when default rates are available and has formatted address', () => {
|
||||
baseContextHooks.useStoreCart.mockReturnValue( {
|
||||
cartItems: mockPreviewCart.items,
|
||||
cartTotals: [ mockPreviewCart.totals ],
|
||||
cartCoupons: mockPreviewCart.coupons,
|
||||
cartFees: mockPreviewCart.fees,
|
||||
cartNeedsShipping: mockPreviewCart.needs_shipping,
|
||||
shippingRates: mockPreviewCart.shipping_rates,
|
||||
shippingAddress: {
|
||||
...shippingAddress,
|
||||
city: '',
|
||||
state: 'California',
|
||||
country: 'US',
|
||||
postcode: '',
|
||||
},
|
||||
billingAddress: mockPreviewCart.billing_address,
|
||||
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
|
||||
isLoadingRates: false,
|
||||
} );
|
||||
render(
|
||||
<SlotFillProvider>
|
||||
<TotalsShipping
|
||||
currency={ {
|
||||
code: 'USD',
|
||||
symbol: '$',
|
||||
minorUnit: 2,
|
||||
decimalSeparator: '.',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
thousandSeparator: ', ',
|
||||
} }
|
||||
values={ {
|
||||
total_shipping: '0',
|
||||
total_shipping_tax: '0',
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect( screen.queryByText( 'Change address' ) ).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( 'Add an address for shipping options' )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ShippingAddress from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-address';
|
||||
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
|
||||
jest.mock( '@woocommerce/settings', () => {
|
||||
const originalModule = jest.requireActual( '@woocommerce/settings' );
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore We know @woocommerce/settings is an object.
|
||||
...originalModule,
|
||||
getSetting: ( setting: string, ...rest: unknown[] ) => {
|
||||
if ( setting === 'localPickupEnabled' ) {
|
||||
return true;
|
||||
}
|
||||
if ( setting === 'collectableMethodIds' ) {
|
||||
return [ 'pickup_location' ];
|
||||
}
|
||||
return originalModule.getSetting( setting, ...rest );
|
||||
},
|
||||
};
|
||||
} );
|
||||
describe( 'ShippingAddress', () => {
|
||||
const testShippingAddress = {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
company: 'Automattic',
|
||||
address_1: '123 Main St',
|
||||
address_2: '',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
postcode: '94107',
|
||||
country: 'US',
|
||||
phone: '555-555-5555',
|
||||
};
|
||||
|
||||
it( 'renders ShippingLocation if user does not prefer collection', () => {
|
||||
render(
|
||||
<ShippingAddress
|
||||
showCalculator={ false }
|
||||
isShippingCalculatorOpen={ false }
|
||||
setIsShippingCalculatorOpen={ jest.fn() }
|
||||
shippingAddress={ testShippingAddress }
|
||||
/>
|
||||
);
|
||||
expect( screen.getByText( /Shipping to 94107/ ) ).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( /Collection from/ )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
it( 'renders PickupLocation if shopper prefers collection', async () => {
|
||||
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
|
||||
|
||||
// Deselect the default selected rate and select pickup_location:1 rate.
|
||||
const currentlySelectedIndex =
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates.findIndex(
|
||||
( rate ) => rate.selected
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
currentlySelectedIndex
|
||||
].selected = false;
|
||||
const pickupRateIndex =
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates.findIndex(
|
||||
( rate ) => rate.method_id === 'pickup_location'
|
||||
);
|
||||
previewCart.shipping_rates[ 0 ].shipping_rates[
|
||||
pickupRateIndex
|
||||
].selected = true;
|
||||
|
||||
dispatch( CART_STORE_KEY ).receiveCart( previewCart );
|
||||
|
||||
render(
|
||||
<ShippingAddress
|
||||
showCalculator={ false }
|
||||
isShippingCalculatorOpen={ false }
|
||||
setIsShippingCalculatorOpen={ jest.fn() }
|
||||
shippingAddress={ testShippingAddress }
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Collection from 123 Easy Street, New York, 12345/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { screen, render } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingPlaceholder from '../shipping-placeholder';
|
||||
|
||||
describe( 'ShippingPlaceholder', () => {
|
||||
it( 'should show correct text if showCalculator is false', () => {
|
||||
const { rerender } = render(
|
||||
<ShippingPlaceholder
|
||||
showCalculator={ false }
|
||||
isCheckout={ true }
|
||||
isShippingCalculatorOpen={ false }
|
||||
setIsShippingCalculatorOpen={ jest.fn() }
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText( 'No shipping options available' )
|
||||
).toBeInTheDocument();
|
||||
rerender(
|
||||
<ShippingPlaceholder
|
||||
showCalculator={ false }
|
||||
isCheckout={ false }
|
||||
isShippingCalculatorOpen={ false }
|
||||
setIsShippingCalculatorOpen={ jest.fn() }
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText( 'Calculated during checkout' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import type { CartResponseShippingRate } from '@woocommerce/type-defs/cart-response';
|
||||
import { hasCollectableRate } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Searches an array of packages/rates to see if there are actually any rates
|
||||
* available.
|
||||
*
|
||||
* @param {Array} shippingRatePackages An array of packages and rates.
|
||||
* @return {boolean} True if a rate exists.
|
||||
*/
|
||||
export const hasShippingRate = (
|
||||
shippingRatePackages: CartResponseShippingRate[]
|
||||
): boolean => {
|
||||
return shippingRatePackages.some(
|
||||
( shippingRatePackage ) => shippingRatePackage.shipping_rates.length
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the total shipping value based on store settings.
|
||||
*/
|
||||
export const getTotalShippingValue = ( values: {
|
||||
total_shipping: string;
|
||||
total_shipping_tax: string;
|
||||
} ): number => {
|
||||
return getSetting( 'displayCartPricesIncludingTax', false )
|
||||
? parseInt( values.total_shipping, 10 ) +
|
||||
parseInt( values.total_shipping_tax, 10 )
|
||||
: parseInt( values.total_shipping, 10 );
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if no shipping methods are available or if all available shipping methods are local pickup
|
||||
* only.
|
||||
*/
|
||||
export const areShippingMethodsMissing = (
|
||||
hasRates: boolean,
|
||||
prefersCollection: boolean | undefined,
|
||||
shippingRates: CartResponseShippingRate[]
|
||||
) => {
|
||||
if ( ! hasRates ) {
|
||||
// No shipping methods available
|
||||
return true;
|
||||
}
|
||||
|
||||
// We check for the availability of shipping options if the shopper selected "Shipping"
|
||||
if ( ! prefersCollection ) {
|
||||
return shippingRates.some(
|
||||
( shippingRatePackage ) =>
|
||||
! shippingRatePackage.shipping_rates.some(
|
||||
( shippingRate ) =>
|
||||
! hasCollectableRate( shippingRate.method_id )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useEffect, useRef } from '@wordpress/element';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { ComboboxControl } from 'wordpress-components';
|
||||
import { ValidationInputError } from '@woocommerce/blocks-components';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
export interface ComboboxControlOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ComboboxProps {
|
||||
autoComplete?: string;
|
||||
className?: string;
|
||||
errorId: string | null;
|
||||
errorMessage?: string | undefined;
|
||||
id: string;
|
||||
instanceId?: string;
|
||||
label: string;
|
||||
onChange: ( filterValue: string ) => void;
|
||||
options: ComboboxControlOption[];
|
||||
required?: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for the WordPress ComboboxControl which supports validation.
|
||||
*/
|
||||
const Combobox = ( {
|
||||
id,
|
||||
className,
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
required = false,
|
||||
errorMessage = __(
|
||||
'Please select a value.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
errorId: incomingErrorId,
|
||||
instanceId = '0',
|
||||
autoComplete = 'off',
|
||||
}: ComboboxProps ): JSX.Element => {
|
||||
const controlRef = useRef< HTMLDivElement >( null );
|
||||
const controlId = id || 'control-' + instanceId;
|
||||
const errorId = incomingErrorId || controlId;
|
||||
|
||||
const { setValidationErrors, clearValidationError } =
|
||||
useDispatch( VALIDATION_STORE_KEY );
|
||||
const error = useSelect( ( select ) => {
|
||||
const store = select( VALIDATION_STORE_KEY );
|
||||
return store.getValidationError( errorId );
|
||||
} );
|
||||
|
||||
useEffect( () => {
|
||||
if ( ! required || value ) {
|
||||
clearValidationError( errorId );
|
||||
} else {
|
||||
setValidationErrors( {
|
||||
[ errorId ]: {
|
||||
message: errorMessage,
|
||||
hidden: true,
|
||||
},
|
||||
} );
|
||||
}
|
||||
return () => {
|
||||
clearValidationError( errorId );
|
||||
};
|
||||
}, [
|
||||
clearValidationError,
|
||||
value,
|
||||
errorId,
|
||||
errorMessage,
|
||||
required,
|
||||
setValidationErrors,
|
||||
] );
|
||||
|
||||
// @todo Remove patch for ComboboxControl once https://github.com/WordPress/gutenberg/pull/33928 is released
|
||||
// Also see https://github.com/WordPress/gutenberg/pull/34090
|
||||
return (
|
||||
<div
|
||||
id={ controlId }
|
||||
className={ classnames( 'wc-block-components-combobox', className, {
|
||||
'is-active': value,
|
||||
'has-error': error?.message && ! error?.hidden,
|
||||
} ) }
|
||||
ref={ controlRef }
|
||||
>
|
||||
<ComboboxControl
|
||||
className={ 'wc-block-components-combobox-control' }
|
||||
label={ label }
|
||||
onChange={ onChange }
|
||||
onFilterValueChange={ ( filterValue: string ) => {
|
||||
if ( filterValue.length ) {
|
||||
// If we have a value and the combobox is not focussed, this could be from browser autofill.
|
||||
const activeElement = isObject( controlRef.current )
|
||||
? controlRef.current.ownerDocument.activeElement
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
activeElement &&
|
||||
isObject( controlRef.current ) &&
|
||||
controlRef.current.contains( activeElement )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to match.
|
||||
const normalizedFilterValue =
|
||||
filterValue.toLocaleUpperCase();
|
||||
|
||||
// Try to find an exact match first using values.
|
||||
const foundValue = options.find(
|
||||
( option ) =>
|
||||
option.value.toLocaleUpperCase() ===
|
||||
normalizedFilterValue
|
||||
);
|
||||
|
||||
if ( foundValue ) {
|
||||
onChange( foundValue.value );
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to a label match.
|
||||
const foundOption = options.find( ( option ) =>
|
||||
option.label
|
||||
.toLocaleUpperCase()
|
||||
.startsWith( normalizedFilterValue )
|
||||
);
|
||||
|
||||
if ( foundOption ) {
|
||||
onChange( foundOption.value );
|
||||
}
|
||||
}
|
||||
} }
|
||||
options={ options }
|
||||
value={ value || '' }
|
||||
allowReset={ false }
|
||||
autoComplete={ autoComplete }
|
||||
aria-invalid={ error?.message && ! error?.hidden }
|
||||
/>
|
||||
<ValidationInputError propertyName={ errorId } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withInstanceId( Combobox );
|
||||
@@ -0,0 +1,167 @@
|
||||
.wc-block-components-form .wc-block-components-combobox,
|
||||
.wc-block-components-combobox {
|
||||
.wc-block-components-combobox-control {
|
||||
@include reset-typography();
|
||||
@include reset-box();
|
||||
|
||||
.components-base-control__field {
|
||||
@include reset-box();
|
||||
position: relative;
|
||||
}
|
||||
.components-combobox-control__suggestions-container {
|
||||
@include reset-typography();
|
||||
@include reset-box();
|
||||
position: relative;
|
||||
}
|
||||
input.components-combobox-control__input {
|
||||
@include reset-typography();
|
||||
@include font-size(regular);
|
||||
padding: em($gap + $gap-smaller) em($gap-smaller) em($gap-smaller);
|
||||
line-height: em($gap);
|
||||
box-sizing: border-box;
|
||||
outline: inherit;
|
||||
border: 1px solid $input-border-gray;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
color: $input-text-active;
|
||||
font-family: inherit;
|
||||
font-weight: normal;
|
||||
letter-spacing: inherit;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
opacity: initial;
|
||||
border-radius: $universal-border-radius;
|
||||
|
||||
&[aria-expanded="true"],
|
||||
&:focus {
|
||||
background-color: #fff;
|
||||
color: $input-text-active;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $input-border-gray;
|
||||
}
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.has-dark-controls & {
|
||||
background-color: $input-background-dark;
|
||||
border-color: $input-border-dark;
|
||||
color: $input-text-dark;
|
||||
|
||||
&:focus {
|
||||
background-color: $input-background-dark;
|
||||
color: $input-text-dark;
|
||||
box-shadow: 0 0 0 1px $input-border-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
.components-form-token-field__suggestions-list {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background-color: $select-dropdown-light;
|
||||
border: 1px solid $input-border-gray;
|
||||
border-top: 0;
|
||||
margin: 3em 0 0 -1px;
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
min-width: 100%;
|
||||
overflow: auto;
|
||||
color: $input-text-active;
|
||||
border-bottom-left-radius: $universal-border-radius;
|
||||
border-bottom-right-radius: $universal-border-radius;
|
||||
|
||||
.has-dark-controls & {
|
||||
background-color: $select-dropdown-dark;
|
||||
color: $input-text-dark;
|
||||
}
|
||||
|
||||
.components-form-token-field__suggestion {
|
||||
@include font-size(regular);
|
||||
color: $gray-700;
|
||||
cursor: default;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: em($gap-smallest) $gap;
|
||||
|
||||
&.is-selected {
|
||||
background-color: $gray-300;
|
||||
.has-dark-controls & {
|
||||
background-color: $select-item-dark;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.is-highlighted,
|
||||
&:active {
|
||||
background-color: #00669e;
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label.components-base-control__label {
|
||||
@include reset-typography();
|
||||
@include font-size(regular);
|
||||
position: absolute;
|
||||
transform: translateY(em($gap));
|
||||
line-height: 1.25; // =20px when font-size is 16px.
|
||||
left: em($gap-smaller);
|
||||
top: 0;
|
||||
transform-origin: top left;
|
||||
transition: all 200ms ease;
|
||||
color: $universal-body-low-emphasis;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - #{2 * $gap});
|
||||
white-space: nowrap;
|
||||
|
||||
.has-dark-controls & {
|
||||
color: $input-placeholder-dark;
|
||||
}
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-combobox-control:has(input:-webkit-autofill) {
|
||||
label {
|
||||
transform: translateY(em($gap-smaller)) scale(0.875);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&:focus-within {
|
||||
.wc-block-components-combobox-control label.components-base-control__label {
|
||||
transform: translateY(em($gap-smaller)) scale(0.875);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
.wc-block-components-combobox-control {
|
||||
label.components-base-control__label {
|
||||
color: $alert-red;
|
||||
}
|
||||
input.components-combobox-control__input {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: $alert-red;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px $alert-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ComboboxProps } from '../combobox';
|
||||
import { countries } from './stories/countries-filler';
|
||||
|
||||
export interface CountryInputProps extends Omit< ComboboxProps, 'options' > {
|
||||
/**
|
||||
* Classes to assign to the wrapper component of the input
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Whether input elements can by default have their values automatically completed by the browser.
|
||||
*
|
||||
* This value gets assigned to both the wrapper `Combobox` and the wrapped input element.
|
||||
*/
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
export interface CountryInputWithCountriesProps extends CountryInputProps {
|
||||
/**
|
||||
* List of countries to allow in the selection
|
||||
*
|
||||
* Object shape should be: `{ [Alpha-2 Country Code]: 'Full country name' }`
|
||||
*/
|
||||
countries: Partial< typeof countries >;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ALLOWED_COUNTRIES } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CountryInput from './country-input';
|
||||
import type { CountryInputProps } from './CountryInputProps';
|
||||
|
||||
const BillingCountryInput = ( props: CountryInputProps ): JSX.Element => {
|
||||
return <CountryInput countries={ ALLOWED_COUNTRIES } { ...props } />;
|
||||
};
|
||||
|
||||
export default BillingCountryInput;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Combobox from '../combobox';
|
||||
import './style.scss';
|
||||
import type { CountryInputWithCountriesProps } from './CountryInputProps';
|
||||
|
||||
export const CountryInput = ( {
|
||||
className,
|
||||
countries,
|
||||
id,
|
||||
label,
|
||||
onChange,
|
||||
value = '',
|
||||
autoComplete = 'off',
|
||||
required = false,
|
||||
errorId,
|
||||
errorMessage = __(
|
||||
'Please select a country',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
}: CountryInputWithCountriesProps ): JSX.Element => {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.entries( countries ).map(
|
||||
( [ countryCode, countryName ] ) => ( {
|
||||
value: countryCode,
|
||||
label: decodeEntities( countryName ),
|
||||
} )
|
||||
),
|
||||
[ countries ]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
className,
|
||||
'wc-block-components-country-input'
|
||||
) }
|
||||
>
|
||||
<Combobox
|
||||
id={ id }
|
||||
label={ label }
|
||||
onChange={ onChange }
|
||||
options={ options }
|
||||
value={ value }
|
||||
errorId={ errorId }
|
||||
errorMessage={ errorMessage }
|
||||
required={ required }
|
||||
autoComplete={ autoComplete }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountryInput;
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
CountryInputProps,
|
||||
CountryInputWithCountriesProps,
|
||||
} from './CountryInputProps';
|
||||
export { CountryInput } from './country-input';
|
||||
export { default as BillingCountryInput } from './billing-country-input';
|
||||
export { default as ShippingCountryInput } from './shipping-country-input';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { SHIPPING_COUNTRIES } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CountryInput from './country-input';
|
||||
import { CountryInputProps } from './CountryInputProps';
|
||||
|
||||
const ShippingCountryInput = ( props: CountryInputProps ): JSX.Element => {
|
||||
return <CountryInput countries={ SHIPPING_COUNTRIES } { ...props } />;
|
||||
};
|
||||
|
||||
export default ShippingCountryInput;
|
||||
@@ -0,0 +1,251 @@
|
||||
export const countries = {
|
||||
AX: 'Åland Islands',
|
||||
AF: 'Afghanistan',
|
||||
AL: 'Albania',
|
||||
DZ: 'Algeria',
|
||||
AS: 'American Samoa',
|
||||
AD: 'Andorra',
|
||||
AO: 'Angola',
|
||||
AI: 'Anguilla',
|
||||
AQ: 'Antarctica',
|
||||
AG: 'Antigua and Barbuda',
|
||||
AR: 'Argentina',
|
||||
AM: 'Armenia',
|
||||
AW: 'Aruba',
|
||||
AU: 'Australia',
|
||||
AT: 'Austria',
|
||||
AZ: 'Azerbaijan',
|
||||
BS: 'Bahamas',
|
||||
BH: 'Bahrain',
|
||||
BD: 'Bangladesh',
|
||||
BB: 'Barbados',
|
||||
BY: 'Belarus',
|
||||
PW: 'Belau',
|
||||
BE: 'Belgium',
|
||||
BZ: 'Belize',
|
||||
BJ: 'Benin',
|
||||
BM: 'Bermuda',
|
||||
BT: 'Bhutan',
|
||||
BO: 'Bolivia',
|
||||
BQ: 'Bonaire, Saint Eustatius and Saba',
|
||||
BA: 'Bosnia and Herzegovina',
|
||||
BW: 'Botswana',
|
||||
BV: 'Bouvet Island',
|
||||
BR: 'Brazil',
|
||||
IO: 'British Indian Ocean Territory',
|
||||
BN: 'Brunei',
|
||||
BG: 'Bulgaria',
|
||||
BF: 'Burkina Faso',
|
||||
BI: 'Burundi',
|
||||
KH: 'Cambodia',
|
||||
CM: 'Cameroon',
|
||||
CA: 'Canada',
|
||||
CV: 'Cape Verde',
|
||||
KY: 'Cayman Islands',
|
||||
CF: 'Central African Republic',
|
||||
TD: 'Chad',
|
||||
CL: 'Chile',
|
||||
CN: 'China',
|
||||
CX: 'Christmas Island',
|
||||
CC: 'Cocos (Keeling) Islands',
|
||||
CO: 'Colombia',
|
||||
KM: 'Comoros',
|
||||
CG: 'Congo (Brazzaville)',
|
||||
CD: 'Congo (Kinshasa)',
|
||||
CK: 'Cook Islands',
|
||||
CR: 'Costa Rica',
|
||||
HR: 'Croatia',
|
||||
CU: 'Cuba',
|
||||
CW: 'Curaçao',
|
||||
CY: 'Cyprus',
|
||||
CZ: 'Czech Republic',
|
||||
DK: 'Denmark',
|
||||
DJ: 'Djibouti',
|
||||
DM: 'Dominica',
|
||||
DO: 'Dominican Republic',
|
||||
EC: 'Ecuador',
|
||||
EG: 'Egypt',
|
||||
SV: 'El Salvador',
|
||||
GQ: 'Equatorial Guinea',
|
||||
ER: 'Eritrea',
|
||||
EE: 'Estonia',
|
||||
ET: 'Ethiopia',
|
||||
FK: 'Falkland Islands',
|
||||
FO: 'Faroe Islands',
|
||||
FJ: 'Fiji',
|
||||
FI: 'Finland',
|
||||
FR: 'France',
|
||||
GF: 'French Guiana',
|
||||
PF: 'French Polynesia',
|
||||
TF: 'French Southern Territories',
|
||||
GA: 'Gabon',
|
||||
GM: 'Gambia',
|
||||
GE: 'Georgia',
|
||||
DE: 'Germany',
|
||||
GH: 'Ghana',
|
||||
GI: 'Gibraltar',
|
||||
GR: 'Greece',
|
||||
GL: 'Greenland',
|
||||
GD: 'Grenada',
|
||||
GP: 'Guadeloupe',
|
||||
GU: 'Guam',
|
||||
GT: 'Guatemala',
|
||||
GG: 'Guernsey',
|
||||
GN: 'Guinea',
|
||||
GW: 'Guinea-Bissau',
|
||||
GY: 'Guyana',
|
||||
HT: 'Haiti',
|
||||
HM: 'Heard Island and McDonald Islands',
|
||||
HN: 'Honduras',
|
||||
HK: 'Hong Kong',
|
||||
HU: 'Hungary',
|
||||
IS: 'Iceland',
|
||||
IN: 'India',
|
||||
ID: 'Indonesia',
|
||||
IR: 'Iran',
|
||||
IQ: 'Iraq',
|
||||
IE: 'Ireland',
|
||||
IM: 'Isle of Man',
|
||||
IL: 'Israel',
|
||||
IT: 'Italy',
|
||||
CI: 'Ivory Coast',
|
||||
JM: 'Jamaica',
|
||||
JP: 'Japan',
|
||||
JE: 'Jersey',
|
||||
JO: 'Jordan',
|
||||
KZ: 'Kazakhstan',
|
||||
KE: 'Kenya',
|
||||
KI: 'Kiribati',
|
||||
KW: 'Kuwait',
|
||||
KG: 'Kyrgyzstan',
|
||||
LA: 'Laos',
|
||||
LV: 'Latvia',
|
||||
LB: 'Lebanon',
|
||||
LS: 'Lesotho',
|
||||
LR: 'Liberia',
|
||||
LY: 'Libya',
|
||||
LI: 'Liechtenstein',
|
||||
LT: 'Lithuania',
|
||||
LU: 'Luxembourg',
|
||||
MO: 'Macao',
|
||||
MG: 'Madagascar',
|
||||
MW: 'Malawi',
|
||||
MY: 'Malaysia',
|
||||
MV: 'Maldives',
|
||||
ML: 'Mali',
|
||||
MT: 'Malta',
|
||||
MH: 'Marshall Islands',
|
||||
MQ: 'Martinique',
|
||||
MR: 'Mauritania',
|
||||
MU: 'Mauritius',
|
||||
YT: 'Mayotte',
|
||||
MX: 'Mexico',
|
||||
FM: 'Micronesia',
|
||||
MD: 'Moldova',
|
||||
MC: 'Monaco',
|
||||
MN: 'Mongolia',
|
||||
ME: 'Montenegro',
|
||||
MS: 'Montserrat',
|
||||
MA: 'Morocco',
|
||||
MZ: 'Mozambique',
|
||||
MM: 'Myanmar',
|
||||
NA: 'Namibia',
|
||||
NR: 'Nauru',
|
||||
NP: 'Nepal',
|
||||
NL: 'Netherlands',
|
||||
NC: 'New Caledonia',
|
||||
NZ: 'New Zealand',
|
||||
NI: 'Nicaragua',
|
||||
NE: 'Niger',
|
||||
NG: 'Nigeria',
|
||||
NU: 'Niue',
|
||||
NF: 'Norfolk Island',
|
||||
KP: 'North Korea',
|
||||
MK: 'North Macedonia',
|
||||
MP: 'Northern Mariana Islands',
|
||||
NO: 'Norway',
|
||||
OM: 'Oman',
|
||||
PK: 'Pakistan',
|
||||
PS: 'Palestinian Territory',
|
||||
PA: 'Panama',
|
||||
PG: 'Papua New Guinea',
|
||||
PY: 'Paraguay',
|
||||
PE: 'Peru',
|
||||
PH: 'Philippines',
|
||||
PN: 'Pitcairn',
|
||||
PL: 'Poland',
|
||||
PT: 'Portugal',
|
||||
PR: 'Puerto Rico',
|
||||
QA: 'Qatar',
|
||||
RE: 'Reunion',
|
||||
RO: 'Romania',
|
||||
RU: 'Russia',
|
||||
RW: 'Rwanda',
|
||||
ST: 'São Tomé and Príncipe',
|
||||
BL: 'Saint Barthélemy',
|
||||
SH: 'Saint Helena',
|
||||
KN: 'Saint Kitts and Nevis',
|
||||
LC: 'Saint Lucia',
|
||||
SX: 'Saint Martin (Dutch part)',
|
||||
MF: 'Saint Martin (French part)',
|
||||
PM: 'Saint Pierre and Miquelon',
|
||||
VC: 'Saint Vincent and the Grenadines',
|
||||
WS: 'Samoa',
|
||||
SM: 'San Marino',
|
||||
SA: 'Saudi Arabia',
|
||||
SN: 'Senegal',
|
||||
RS: 'Serbia',
|
||||
SC: 'Seychelles',
|
||||
SL: 'Sierra Leone',
|
||||
SG: 'Singapore',
|
||||
SK: 'Slovakia',
|
||||
SI: 'Slovenia',
|
||||
SB: 'Solomon Islands',
|
||||
SO: 'Somalia',
|
||||
ZA: 'South Africa',
|
||||
GS: 'South Georgia/Sandwich Islands',
|
||||
KR: 'South Korea',
|
||||
SS: 'South Sudan',
|
||||
ES: 'Spain',
|
||||
LK: 'Sri Lanka',
|
||||
SD: 'Sudan',
|
||||
SR: 'Suriname',
|
||||
SJ: 'Svalbard and Jan Mayen',
|
||||
SZ: 'Swaziland',
|
||||
SE: 'Sweden',
|
||||
CH: 'Switzerland',
|
||||
SY: 'Syria',
|
||||
TW: 'Taiwan',
|
||||
TJ: 'Tajikistan',
|
||||
TZ: 'Tanzania',
|
||||
TH: 'Thailand',
|
||||
TL: 'Timor-Leste',
|
||||
TG: 'Togo',
|
||||
TK: 'Tokelau',
|
||||
TO: 'Tonga',
|
||||
TT: 'Trinidad and Tobago',
|
||||
TN: 'Tunisia',
|
||||
TR: 'Turkey',
|
||||
TM: 'Turkmenistan',
|
||||
TC: 'Turks and Caicos Islands',
|
||||
TV: 'Tuvalu',
|
||||
UG: 'Uganda',
|
||||
UA: 'Ukraine',
|
||||
AE: 'United Arab Emirates',
|
||||
GB: 'United Kingdom (UK)',
|
||||
US: 'United States (US)',
|
||||
UM: 'United States (US) Minor Outlying Islands',
|
||||
UY: 'Uruguay',
|
||||
UZ: 'Uzbekistan',
|
||||
VU: 'Vanuatu',
|
||||
VA: 'Vatican',
|
||||
VE: 'Venezuela',
|
||||
VN: 'Vietnam',
|
||||
VG: 'Virgin Islands (British)',
|
||||
VI: 'Virgin Islands (US)',
|
||||
WF: 'Wallis and Futuna',
|
||||
EH: 'Western Sahara',
|
||||
YE: 'Yemen',
|
||||
ZM: 'Zambia',
|
||||
ZW: 'Zimbabwe',
|
||||
} as const;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Story, Meta } from '@storybook/react';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CountryInput, CountryInputWithCountriesProps } from '..';
|
||||
import { countries } from './countries-filler';
|
||||
|
||||
type CountryCode = keyof typeof countries;
|
||||
|
||||
export default {
|
||||
title: 'Base Components/CountryInput',
|
||||
component: CountryInput,
|
||||
args: {
|
||||
countries,
|
||||
autoComplete: 'off',
|
||||
id: 'country',
|
||||
label: 'Countries: ',
|
||||
required: false,
|
||||
},
|
||||
argTypes: {
|
||||
countries: { control: false },
|
||||
options: { table: { disable: true } },
|
||||
value: { control: false },
|
||||
},
|
||||
decorators: [ ( StoryComponent ) => <StoryComponent /> ],
|
||||
} as Meta< CountryInputWithCountriesProps >;
|
||||
|
||||
const Template: Story< CountryInputWithCountriesProps > = ( args ) => {
|
||||
const [ selectedCountry, selectCountry ] = useState< CountryCode | '' >(
|
||||
''
|
||||
);
|
||||
const { clearValidationError, showValidationError } =
|
||||
useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
useEffect( () => {
|
||||
showValidationError( 'country' );
|
||||
}, [ showValidationError ] );
|
||||
|
||||
function updateCountry( country: CountryCode ) {
|
||||
clearValidationError( 'country' );
|
||||
selectCountry( country );
|
||||
}
|
||||
|
||||
return (
|
||||
<CountryInput
|
||||
{ ...args }
|
||||
onChange={ ( value ) => updateCountry( value as CountryCode ) }
|
||||
value={ selectedCountry }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = Template.bind( {} );
|
||||
|
||||
export const WithError = Template.bind( {} );
|
||||
WithError.args = {
|
||||
errorId: 'country',
|
||||
errorMessage: 'Please select a country',
|
||||
required: true,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
@import "node_modules/@wordpress/base-styles/breakpoints";
|
||||
@import "node_modules/@wordpress/base-styles/mixins";
|
||||
@import "node_modules/wordpress-components/src/combobox-control/style";
|
||||
|
||||
.wc-block-components-country-input {
|
||||
margin-top: $gap;
|
||||
|
||||
// Fixes width in the editor.
|
||||
.components-flex {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const DrawerCloseButton = () => {
|
||||
// The Drawer component will use a portal to render the close button inside
|
||||
// this div.
|
||||
return <div className="wc-block-components-drawer__close-wrapper"></div>;
|
||||
};
|
||||
|
||||
export default DrawerCloseButton;
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Some code of the Drawer component is based on the Modal component from Gutenberg:
|
||||
* https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/modal/index.tsx
|
||||
*/
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classNames from 'classnames';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import type { ForwardedRef, KeyboardEvent, RefObject } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
createPortal,
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
} from '@wordpress/element';
|
||||
import { close } from '@wordpress/icons';
|
||||
import {
|
||||
useFocusReturn,
|
||||
useFocusOnMount,
|
||||
useConstrainedTabbing,
|
||||
useMergeRefs,
|
||||
} from '@wordpress/compose';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Button from '../button';
|
||||
import * as ariaHelper from './utils/aria-helper';
|
||||
import './style.scss';
|
||||
|
||||
interface DrawerProps {
|
||||
children: JSX.Element;
|
||||
className?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
slideIn?: boolean;
|
||||
slideOut?: boolean;
|
||||
}
|
||||
|
||||
interface CloseButtonPortalProps {
|
||||
onClick: () => void;
|
||||
contentRef: RefObject< HTMLDivElement >;
|
||||
}
|
||||
|
||||
const CloseButtonPortal = ( {
|
||||
onClick,
|
||||
contentRef,
|
||||
}: CloseButtonPortalProps ) => {
|
||||
const closeButtonWrapper = contentRef?.current?.querySelector(
|
||||
'.wc-block-components-drawer__close-wrapper'
|
||||
);
|
||||
|
||||
return closeButtonWrapper
|
||||
? createPortal(
|
||||
<Button
|
||||
className="wc-block-components-drawer__close"
|
||||
icon={ close }
|
||||
onClick={ onClick }
|
||||
label={ __( 'Close', 'woo-gutenberg-products-block' ) }
|
||||
showTooltip={ false }
|
||||
/>,
|
||||
closeButtonWrapper
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const UnforwardedDrawer = (
|
||||
{
|
||||
children,
|
||||
className,
|
||||
isOpen,
|
||||
onClose,
|
||||
slideIn = true,
|
||||
slideOut = true,
|
||||
}: DrawerProps,
|
||||
forwardedRef: ForwardedRef< HTMLDivElement >
|
||||
): JSX.Element | null => {
|
||||
const [ debouncedIsOpen ] = useDebounce< boolean >( isOpen, 300 );
|
||||
const isClosing = ! isOpen && debouncedIsOpen;
|
||||
const bodyOpenClassName = 'drawer-open';
|
||||
|
||||
const onRequestClose = () => {
|
||||
document.body.classList.remove( bodyOpenClassName );
|
||||
ariaHelper.showApp();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const ref = useRef< HTMLDivElement >();
|
||||
const focusOnMountRef = useFocusOnMount();
|
||||
const constrainedTabbingRef = useConstrainedTabbing();
|
||||
const focusReturnRef = useFocusReturn();
|
||||
const contentRef = useRef< HTMLDivElement >( null );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isOpen ) {
|
||||
ariaHelper.hideApp( ref.current );
|
||||
document.body.classList.add( bodyOpenClassName );
|
||||
}
|
||||
}, [ isOpen, bodyOpenClassName ] );
|
||||
|
||||
const overlayRef = useMergeRefs( [ ref, forwardedRef ] );
|
||||
const drawerRef = useMergeRefs( [
|
||||
constrainedTabbingRef,
|
||||
focusReturnRef,
|
||||
focusOnMountRef,
|
||||
] );
|
||||
|
||||
if ( ! isOpen && ! isClosing ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleEscapeKeyDown( event: KeyboardEvent< HTMLDivElement > ) {
|
||||
if (
|
||||
// Ignore keydowns from IMEs
|
||||
event.nativeEvent.isComposing ||
|
||||
// Workaround for Mac Safari where the final Enter/Backspace of an IME composition
|
||||
// is `isComposing=false`, even though it's technically still part of the composition.
|
||||
// These can only be detected by keyCode.
|
||||
event.keyCode === 229
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( event.code === 'Escape' && ! event.defaultPrevented ) {
|
||||
event.preventDefault();
|
||||
onRequestClose();
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={ overlayRef }
|
||||
className={ classNames(
|
||||
'wc-block-components-drawer__screen-overlay',
|
||||
{
|
||||
'wc-block-components-drawer__screen-overlay--is-hidden':
|
||||
! isOpen,
|
||||
'wc-block-components-drawer__screen-overlay--with-slide-in':
|
||||
slideIn,
|
||||
'wc-block-components-drawer__screen-overlay--with-slide-out':
|
||||
slideOut,
|
||||
}
|
||||
) }
|
||||
onKeyDown={ handleEscapeKeyDown }
|
||||
onClick={ ( e ) => {
|
||||
// If click was done directly in the overlay element and not one
|
||||
// of its descendants, close the drawer.
|
||||
if ( e.target === ref.current ) {
|
||||
onRequestClose();
|
||||
}
|
||||
} }
|
||||
>
|
||||
<div
|
||||
className={ classNames(
|
||||
className,
|
||||
'wc-block-components-drawer'
|
||||
) }
|
||||
ref={ drawerRef }
|
||||
role="dialog"
|
||||
tabIndex={ -1 }
|
||||
>
|
||||
<div
|
||||
className="wc-block-components-drawer__content"
|
||||
role="document"
|
||||
ref={ contentRef }
|
||||
>
|
||||
<CloseButtonPortal
|
||||
contentRef={ contentRef }
|
||||
onClick={ onRequestClose }
|
||||
/>
|
||||
{ children }
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const Drawer = forwardRef( UnforwardedDrawer );
|
||||
|
||||
export default Drawer;
|
||||
export { default as DrawerCloseButton } from './close-button';
|
||||
@@ -0,0 +1,167 @@
|
||||
:root {
|
||||
/* This value might be overridden in PHP based on the attribute set by the user. */
|
||||
--drawer-width: 480px;
|
||||
--neg-drawer-width: calc(var(--drawer-width) * -1);
|
||||
}
|
||||
|
||||
$drawer-animation-duration: 0.3s;
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slidein {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(max(-100%, var(--neg-drawer-width)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rtlslidein {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(min(100%, var(--drawer-width)));
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay {
|
||||
background-color: rgba(95, 95, 95, 0.35);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: opacity $drawer-animation-duration;
|
||||
z-index: 9999;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--with-slide-out {
|
||||
transition: opacity $drawer-animation-duration;
|
||||
}
|
||||
|
||||
// We can't use transition for the slide-in animation because the element
|
||||
// doesn't exist in the DOM when not open. Instead, use an animation that
|
||||
// is triggered when the element is appended to the DOM.
|
||||
.wc-block-components-drawer__screen-overlay--with-slide-in {
|
||||
animation-duration: $drawer-animation-duration;
|
||||
animation-name: fadein;
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--is-hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-drawer {
|
||||
@include with-translucent-border(0 0 0 1px);
|
||||
background: #fff;
|
||||
display: block;
|
||||
height: 100%;
|
||||
left: 100%;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(max(-100%, var(--neg-drawer-width)));
|
||||
width: var(--drawer-width);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rtl .wc-block-components-drawer {
|
||||
transform: translateX(min(100%, var(--drawer-width)));
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--with-slide-out .wc-block-components-drawer {
|
||||
transition: transform $drawer-animation-duration;
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--with-slide-in .wc-block-components-drawer {
|
||||
animation-duration: $drawer-animation-duration;
|
||||
animation-name: slidein;
|
||||
}
|
||||
|
||||
.rtl .wc-block-components-drawer__screen-overlay--with-slide-in .wc-block-components-drawer {
|
||||
animation-name: rtlslidein;
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--is-hidden .wc-block-components-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
.wc-block-components-drawer__screen-overlay {
|
||||
animation-name: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.wc-block-components-drawer {
|
||||
animation-name: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Important rules are needed to reset button styles.
|
||||
.wc-block-components-drawer__close {
|
||||
@include reset-box();
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
position: absolute !important;
|
||||
top: $gap-small;
|
||||
right: $gap-small;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
// Increase clickable area.
|
||||
padding: 1em !important;
|
||||
margin: -1em;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Don't show focus styles if the close button hasn't been focused by the
|
||||
// user directly. This is done to prevent focus styles to appear when
|
||||
// opening the drawer with the mouse, as the focus is moved inside
|
||||
// programmatically.
|
||||
&:focus:not(:focus-visible) {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
> span {
|
||||
@include visually-hidden();
|
||||
}
|
||||
svg {
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__content {
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-bar .wc-block-components-drawer__content {
|
||||
margin-top: 46px;
|
||||
height: calc(100dvh - 46px);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 783px) {
|
||||
.admin-bar .wc-block-components-drawer__content {
|
||||
margin-top: 32px;
|
||||
height: calc(100dvh - 32px);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user