rebase from live enviornment

This commit is contained in:
Rachit Bhargava
2024-01-09 22:14:20 -05:00
parent ff0b49a046
commit 3a22fcaa4a
15968 changed files with 2344674 additions and 45234 deletions

View File

@@ -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;

View File

@@ -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>:&nbsp;
{ 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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default as AddressForm } from './address-form';

View File

@@ -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;

View File

@@ -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( '' );
} );
} );

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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' );
} );
} );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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();
} );
} );

View File

@@ -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;
} ) || {}
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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 );
};

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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();
} );
} );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>
`;

View File

@@ -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();
} );
} );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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';
}

View File

@@ -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
)
);
}
};

View File

@@ -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 );

View File

@@ -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 />;
},
];

View File

@@ -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;
}

View File

@@ -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();
} );
} );

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 />;
},
];

View File

@@ -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;
}
}

View File

@@ -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>
`;

View File

@@ -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();
} );
} );

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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();
}

View File

@@ -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 &times; 1, Beanie &times; 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 &times; 1, Beanie &times; 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 &times; 1, Beanie &times; 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();
} );
} );

View File

@@ -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();
} );
} );

View File

@@ -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();
} );
} );

View File

@@ -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;
};

View File

@@ -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 );

View File

@@ -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;
}
}
}
}
}

View File

@@ -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 >;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -0,0 +1,251 @@
export const countries = {
AX: '&#197;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&ccedil;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&atilde;o Tom&eacute; and Pr&iacute;ncipe',
BL: 'Saint Barth&eacute;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;

View File

@@ -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,
};

View File

@@ -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%;
}
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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