initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,254 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import {
BillingCountryInput,
ShippingCountryInput,
} from '@woocommerce/base-components/country-input';
import {
BillingStateInput,
ShippingStateInput,
} from '@woocommerce/base-components/state-input';
import { useValidationContext } from '@woocommerce/base-context';
import { useEffect, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { withInstanceId } from '@wordpress/compose';
import { useShallowEqual } from '@woocommerce/base-hooks';
import {
AddressField,
AddressFields,
AddressType,
defaultAddressFields,
EnteredAddress,
} from '@woocommerce/settings';
/**
* Internal dependencies
*/
import prepareAddressFields from './prepare-address-fields';
// 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: EnteredAddress,
setValidationErrors: ( errors: Record< string, unknown > ) => void,
clearValidationError: ( error: string ) => void,
hasValidationError: boolean
): void => {
if (
! hasValidationError &&
! values.country &&
( values.city || values.state || values.postcode )
) {
setValidationErrors( {
'shipping-missing-country': {
message: __(
'Please select a country to calculate rates.',
'woo-gutenberg-products-block'
),
hidden: false,
},
} );
}
if ( hasValidationError && values.country ) {
clearValidationError( 'shipping-missing-country' );
}
};
interface AddressFormProps {
id: string;
instanceId: string;
fields: ( keyof AddressFields )[];
fieldConfig: Record< keyof AddressFields, Partial< AddressField > >;
onChange: ( newValue: EnteredAddress ) => void;
type: AddressType;
values: EnteredAddress;
}
/**
* Checkout address form.
*
* @param {Object} props Incoming props for component.
* @param {string} props.id Id for component.
* @param {Array} props.fields Array of fields in form.
* @param {Object} props.fieldConfig Field configuration for fields in form.
* @param {string} props.instanceId Unique id for form.
* @param {function(any):any} props.onChange Function to all for an form onChange event.
* @param {string} props.type Type of form.
* @param {Object} props.values Values for fields.
*/
const AddressForm = ( {
id,
fields = ( Object.keys(
defaultAddressFields
) as unknown ) as ( keyof AddressFields )[],
fieldConfig = {} as Record< keyof AddressFields, Partial< AddressField > >,
instanceId,
onChange,
type = 'shipping',
values,
}: AddressFormProps ): JSX.Element => {
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();
const currentFields = useShallowEqual( fields );
const countryValidationError = ( getValidationError(
'shipping-missing-country'
) || {} ) as {
message: string;
hidden: boolean;
};
const addressFormFields = useMemo( () => {
return prepareAddressFields(
currentFields,
fieldConfig,
values.country
);
}, [ currentFields, fieldConfig, values.country ] );
// Clear values for hidden fields.
useEffect( () => {
addressFormFields.forEach( ( field ) => {
if ( field.hidden && values[ field.key ] ) {
onChange( {
...values,
[ field.key ]: '',
} );
}
} );
}, [ addressFormFields, onChange, values ] );
useEffect( () => {
if ( type === 'shipping' ) {
validateShippingCountry(
values,
setValidationErrors,
clearValidationError,
!! countryValidationError.message &&
! countryValidationError.hidden
);
}
}, [
values,
countryValidationError.message,
countryValidationError.hidden,
setValidationErrors,
clearValidationError,
type,
] );
id = id || instanceId;
return (
<div id={ id } className="wc-block-components-address-form">
{ addressFormFields.map( ( field ) => {
if ( field.hidden ) {
return null;
}
if ( field.key === 'country' ) {
const Tag =
type === 'shipping'
? ShippingCountryInput
: BillingCountryInput;
return (
<Tag
key={ field.key }
id={ `${ id }-${ field.key }` }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.country }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
country: newValue,
state: '',
} )
}
errorId={
type === 'shipping'
? 'shipping-missing-country'
: null
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
if ( field.key === 'state' ) {
const Tag =
type === 'shipping'
? ShippingStateInput
: BillingStateInput;
return (
<Tag
key={ field.key }
id={ `${ id }-${ field.key }` }
country={ values.country }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.state }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
state: newValue,
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
return (
<ValidatedTextInput
key={ field.key }
id={ `${ id }-${ field.key }` }
className={ `wc-block-components-address-form__${ field.key }` }
label={
field.required ? field.label : field.optionalLabel
}
value={ values[ field.key ] }
autoCapitalize={ field.autocapitalize }
autoComplete={ field.autocomplete }
onChange={ ( newValue: string ) =>
onChange( {
...values,
[ field.key ]: newValue,
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
} ) }
</div>
);
};
AddressForm.propTypes = {
onChange: PropTypes.func.isRequired,
values: PropTypes.object.isRequired,
fields: PropTypes.arrayOf(
PropTypes.oneOf( Object.keys( defaultAddressFields ) )
),
fieldConfig: PropTypes.object,
type: PropTypes.oneOf( [ 'billing', 'shipping' ] ),
};
export default withInstanceId( AddressForm );

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,
getSetting,
KeyedAddressField,
LocaleSpecificAddressField,
} from '@woocommerce/settings';
import { __, sprintf } from '@wordpress/i18n';
import { isNumber, isString } from '@woocommerce/types';
/**
* This 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 coreLocale = getSetting< CountryAddressFields >( 'countryLocale', {} );
/**
* 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;
};
const countryAddressFields: CountryAddressFields = Object.entries( coreLocale )
.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,169 @@
/**
* 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,
setShippingFields,
shippingFields,
} = useCheckoutAddress();
return (
<AddressForm
type={ type }
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( defaultAddressFields ) }
/>
);
};
const ShippingFields = () => {
const { shippingFields } = useCheckoutAddress();
return (
<ul>
{ Object.keys( shippingFields ).map( ( key ) => (
<li key={ key }>{ key + ': ' + shippingFields[ 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,95 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import PropTypes from 'prop-types';
import Title from '@woocommerce/base-components/title';
/**
* Internal dependencies
*/
import './style.scss';
const StepHeading = ( { title, stepHeadingContent } ) => (
<div className="wc-block-components-checkout-step__heading">
<Title
aria-hidden="true"
className="wc-block-components-checkout-step__title"
headingLevel="2"
>
{ title }
</Title>
{ !! stepHeadingContent && (
<span className="wc-block-components-checkout-step__heading-content">
{ stepHeadingContent }
</span>
) }
</div>
);
const FormStep = ( {
id,
className,
title,
legend,
description,
children,
disabled = false,
showStepNumber = true,
stepHeadingContent = () => {},
} ) => {
// If the form step doesn't have a legend or title, render a <div> instead
// of a <fieldset>.
const Element = legend || title ? 'fieldset' : 'div';
return (
<Element
className={ classnames(
className,
'wc-block-components-checkout-step',
{
'wc-block-components-checkout-step--with-step-number': showStepNumber,
'wc-block-components-checkout-step--disabled': disabled,
}
) }
id={ id }
disabled={ disabled }
>
{ !! ( legend || title ) && (
<legend className="screen-reader-text">
{ legend || title }
</legend>
) }
{ !! title && (
<StepHeading
title={ title }
stepHeadingContent={ stepHeadingContent() }
/>
) }
<div className="wc-block-components-checkout-step__container">
{ !! description && (
<p className="wc-block-components-checkout-step__description">
{ description }
</p>
) }
<div className="wc-block-components-checkout-step__content">
{ children }
</div>
</div>
</Element>
);
};
FormStep.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.string,
children: PropTypes.node,
showStepNumber: PropTypes.bool,
stepHeadingContent: PropTypes.func,
disabled: PropTypes.bool,
legend: PropTypes.string,
};
export default FormStep;

View File

@ -0,0 +1,129 @@
.wc-block-components-form {
counter-reset: checkout-step;
}
.wc-block-components-form .wc-block-components-checkout-step {
position: relative;
border: none;
padding: 0 0 0 $gap-large;
background: none;
margin: 0;
.is-mobile &,
.is-small & {
padding-left: 0;
}
}
.wc-block-components-checkout-step--disabled {
opacity: 0.6;
}
.wc-block-components-checkout-step__container {
position: relative;
}
.wc-block-components-checkout-step__content > * {
margin-bottom: em($gap);
}
.wc-block-components-checkout-step--with-step-number .wc-block-components-checkout-step__content > :last-child {
margin-bottom: 0;
padding-bottom: em($gap-large);
}
.wc-block-components-checkout-step__heading {
display: flex;
justify-content: space-between;
align-content: center;
flex-wrap: wrap;
margin: em($gap-small) 0 em($gap);
position: relative;
align-items: center;
gap: em($gap);
.wc-block-components-express-payment-continue-rule + .wc-block-components-checkout-step & {
margin-top: 0;
}
}
.wc-block-components-checkout-step:first-child .wc-block-components-checkout-step__heading {
margin-top: 0;
}
.wc-block-components-checkout-step__title {
margin: 0 $gap-small 0 0;
}
.wc-block-components-checkout-step__heading-content {
@include font-size(smaller);
a {
font-weight: bold;
color: inherit;
}
}
.wc-block-components-checkout-step__description {
@include font-size(small);
line-height: 1.25;
margin-bottom: $gap;
}
.wc-block-components-checkout-step--with-step-number {
.wc-block-components-checkout-step__title::before {
@include reset-box();
background: transparent;
counter-increment: checkout-step;
content: "\00a0" counter(checkout-step) ".";
content: "\00a0" counter(checkout-step) "." / "";
position: absolute;
width: $gap-large;
left: -$gap-large;
top: 0;
text-align: center;
transform: translateX(-50%);
.is-mobile &,
.is-small & {
position: static;
transform: none;
left: auto;
top: auto;
content: counter(checkout-step) ".\00a0";
content: counter(checkout-step) ".\00a0" / "";
}
}
.wc-block-components-checkout-step__container::after {
content: "";
height: 100%;
border-left: 1px solid;
opacity: 0.3;
position: absolute;
left: -$gap-large;
top: 0;
}
.is-mobile &,
.is-small & {
.wc-block-components-checkout-step__title::before {
position: static;
transform: none;
left: auto;
top: auto;
content: counter(checkout-step) ".\00a0";
content: counter(checkout-step) ".\00a0" / "";
}
.wc-block-components-checkout-step__container::after {
content: unset;
}
}
}
.editor-styles-wrapper {
.wp-block h4.wc-block-components-checkout-step__title {
@include font-size(regular);
line-height: 24px;
margin: 0 $gap-small 0 0;
}
}

View File

@ -0,0 +1,262 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormStep fieldset legend should default to legend prop when title and legend are defined 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum 2
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should apply id and className props 1`] = `
<div
className="my-classname wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
id="my-id"
>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</div>
`;
exports[`FormStep should remove step number CSS class if prop is false 1`] = `
<fieldset
className="wc-block-components-checkout-step"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render a div if no title or legend is provided 1`] = `
<div
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</div>
`;
exports[`FormStep should render a fieldset if a legend is provided 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum 2
</legend>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render a fieldset with heading if a title is provided 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render step description 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<p
className="wc-block-components-checkout-step__description"
>
This is the description
</p>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render step heading content 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
<span
className="wc-block-components-checkout-step__heading-content"
>
<span>
Some context to render next to the heading
</span>
</span>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should set disabled prop to the fieldset element when disabled is true 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number wc-block-components-checkout-step--disabled"
disabled={true}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import FormStep from '..';
describe( 'FormStep', () => {
test( 'should render a div if no title or legend is provided', () => {
const component = TestRenderer.create(
<FormStep>Dolor sit amet</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should apply id and className props', () => {
const component = TestRenderer.create(
<FormStep id="my-id" className="my-classname">
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a fieldset if a legend is provided', () => {
const component = TestRenderer.create(
<FormStep legend="Lorem Ipsum 2">Dolor sit amet</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a fieldset with heading if a title is provided', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum">Dolor sit amet</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'fieldset legend should default to legend prop when title and legend are defined', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" legend="Lorem Ipsum 2">
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should remove step number CSS class if prop is false', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" showStepNumber={ false }>
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render step heading content', () => {
const component = TestRenderer.create(
<FormStep
title="Lorem Ipsum"
stepHeadingContent={ () => (
<span>Some context to render next to the heading</span>
) }
>
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render step description', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" description="This is the description">
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should set disabled prop to the fieldset element when disabled is true', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" disabled={ true }>
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );

View File

@ -0,0 +1,20 @@
export * from './address-form';
export { default as FormStep } from './form-step';
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,54 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { useContainerWidthContext } from '@woocommerce/base-context';
import { Panel } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import OrderSummaryItem from './order-summary-item.js';
import './style.scss';
const OrderSummary = ( { cartItems = [] } ) => {
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', 'woocommerce' ) }
</span>
}
titleTag="h2"
>
<div className="wc-block-components-order-summary__content">
{ cartItems.map( ( cartItem ) => {
return (
<OrderSummaryItem
key={ cartItem.key }
cartItem={ cartItem }
/>
);
} ) }
</div>
</Panel>
);
};
OrderSummary.propTypes = {
cartItems: PropTypes.arrayOf(
PropTypes.shape( { key: PropTypes.string.isRequired } )
),
};
export default OrderSummary;

View File

@ -0,0 +1,206 @@
/**
* External dependencies
*/
import { sprintf, _n } from '@wordpress/i18n';
import Label from '@woocommerce/base-components/label';
import ProductPrice from '@woocommerce/base-components/product-price';
import ProductName from '@woocommerce/base-components/product-name';
import {
getCurrencyFromPriceResponse,
formatPrice,
} from '@woocommerce/price-format';
import {
__experimentalApplyCheckoutFilter,
mustContain,
} from '@woocommerce/blocks-checkout';
import PropTypes from 'prop-types';
import Dinero from 'dinero.js';
import { getSetting } from '@woocommerce/settings';
import { useMemo } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context/hooks';
/**
* 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 ) => mustContain( value, '<price/>' );
const OrderSummaryItem = ( { cartItem } ) => {
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 __experimentalApplyCheckoutFilter 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 = __experimentalApplyCheckoutFilter( {
filterName: 'itemName',
defaultValue: initialName,
extensions,
arg,
} );
const regularPriceSingle = Dinero( {
amount: parseInt( prices.raw_prices.regular_price, 10 ),
precision: parseInt( prices.raw_prices.precision, 10 ),
} )
.convertPrecision( priceCurrency.minorUnit )
.getAmount();
const priceSingle = Dinero( {
amount: parseInt( prices.raw_prices.price, 10 ),
precision: parseInt( prices.raw_prices.precision, 10 ),
} )
.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 = __experimentalApplyCheckoutFilter( {
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 = __experimentalApplyCheckoutFilter( {
filterName: 'cartItemPrice',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
return (
<div className="wc-block-components-order-summary-item">
<div className="wc-block-components-order-summary-item__image">
<div className="wc-block-components-order-summary-item__quantity">
<Label
label={ quantity }
screenReaderLabel={ sprintf(
/* translators: %d number of products of the same type in the cart */
_n(
'%d item',
'%d items',
quantity,
'woocommerce'
),
quantity
) }
/>
</div>
<ProductImage image={ images.length ? images[ 0 ] : {} } />
</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,
'woocommerce'
),
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>
);
};
OrderSummaryItem.propTypes = {
cartItems: PropTypes.shape( {
images: PropTypes.array,
low_stock_remaining: PropTypes.number,
name: PropTypes.string.isRequired,
permalink: PropTypes.string,
prices: PropTypes.shape( {
price: PropTypes.string,
regular_price: PropTypes.string,
} ),
quantity: PropTypes.number,
summary: PropTypes.string,
variation: PropTypes.array,
} ),
};
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/type-defs/payments';
/**
* 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,65 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type { PaymentMethodIcons as PaymentMethodIconsType } from '@woocommerce/type-defs/payments';
/**
* 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';
}
/**
* For a given list of icons, render each as a list item, using common icons
* where available.
*
* @param {Object} props Component props.
* @param {Array} props.icons Array of icons object configs or ids as strings.
* @param {string} props.align How to align the icon.
*/
export const PaymentMethodIcons = ( {
icons = [],
align = 'center',
}: 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',
}
);
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,43 @@
/**
* External dependencies
*/
import type {
PaymentMethodIcon,
PaymentMethodIcons,
} from '@woocommerce/type-defs/payments';
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,72 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { Icon, bank, bill, card, checkPayment } from '@woocommerce/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 srcElement={ 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,43 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useCheckoutSubmit } from '@woocommerce/base-context/hooks';
import { Icon, done } from '@woocommerce/icons';
import Button from '@woocommerce/base-components/button';
const PlaceOrderButton = (): JSX.Element => {
const {
submitButtonText,
onSubmit,
isCalculating,
isDisabled,
waitingForProcessing,
waitingForRedirect,
} = useCheckoutSubmit();
return (
<Button
className="wc-block-components-checkout-place-order-button"
onClick={ onSubmit }
disabled={
isCalculating ||
isDisabled ||
waitingForProcessing ||
waitingForRedirect
}
showSpinner={ waitingForProcessing }
>
{ waitingForRedirect ? (
<Icon
srcElement={ done }
alt={ __( 'Done', 'woo-gutenberg-products-block' ) }
/>
) : (
submitButtonText
) }
</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: 2px;
border: 1px solid;
display: inline-block;
font-weight: 600;
padding: 0 0.66em;
text-transform: uppercase;
white-space: nowrap;
}

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { kebabCase } from 'lodash';
import { decodeEntities } from '@wordpress/html-entities';
import type { ProductResponseItemData } from '@woocommerce/type-defs/product-response';
/**
* 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 ) => {
const className = detail.name
? `wc-block-components-product-details__${ kebabCase(
detail.name
) }`
: '';
return (
<li
key={ detail.name + ( detail.display || detail.value ) }
className={ className }
>
{ detail.name && (
<>
<span className="wc-block-components-product-details__name">
{ decodeEntities( detail.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,76 @@
// 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,25 @@
/**
* External dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
interface ProductImageProps {
image: { alt?: string; thumbnail?: string };
}
/**
* Formats and returns an image element.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.image Image properties.
*/
const ProductImage = ( { image = {} }: ProductImageProps ): JSX.Element => {
const imageProps = {
src: image.thumbnail || PLACEHOLDER_IMG_SRC,
alt: decodeEntities( image.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,45 @@
/**
* External dependencies
*/
import { ProductResponseItemData } from '@woocommerce/type-defs/product-response';
import { CartVariationItem } from '@woocommerce/type-defs/cart';
/**
* 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 } ) => ( {
name: 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/base-components/formatted-monetary-amount';
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,25 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CART_URL } from '@woocommerce/block-settings';
import { Icon, arrowBack } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import './style.scss';
const ReturnToCartButton = ( { link } ) => {
return (
<a
href={ link || CART_URL }
className="wc-block-components-checkout-return-to-cart-button"
>
<Icon srcElement={ arrowBack } />
{ __( 'Return to Cart', 'woocommerce' ) }
</a>
);
};
export default ReturnToCartButton;

View File

@ -0,0 +1,14 @@
.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%;
}
}

View File

@ -0,0 +1,63 @@
/**
* 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 { useValidationContext } from '@woocommerce/base-context';
import type { EnteredAddress, AddressFields } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './style.scss';
import { AddressForm } from '../address-form';
interface ShippingCalculatorAddressProps {
address: EnteredAddress;
onUpdate: ( address: EnteredAddress ) => void;
addressFields: Partial< keyof AddressFields >[];
}
const ShippingCalculatorAddress = ( {
address: initialAddress,
onUpdate,
addressFields,
}: ShippingCalculatorAddressProps ): JSX.Element => {
const [ address, setAddress ] = useState( initialAddress );
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
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={ isShallowEqual( address, initialAddress ) }
onClick={ ( e ) => {
e.preventDefault();
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,39 @@
/**
* External dependencies
*/
import { useShippingDataContext } from '@woocommerce/base-context';
import type { EnteredAddress } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import ShippingCalculatorAddress from './address';
import './style.scss';
interface ShippingCalculatorProps {
onUpdate?: ( newAddress: EnteredAddress ) => void;
addressFields?: Partial< keyof EnteredAddress >[];
}
const ShippingCalculator = ( {
onUpdate = () => {
/* Do nothing */
},
addressFields = [ 'country', 'state', 'city', 'postcode' ],
}: ShippingCalculatorProps ): JSX.Element => {
const { shippingAddress, setShippingAddress } = useShippingDataContext();
return (
<div className="wc-block-components-shipping-calculator">
<ShippingCalculatorAddress
address={ shippingAddress }
addressFields={ addressFields }
onUpdate={ ( newAddress ) => {
setShippingAddress( newAddress );
onUpdate( newAddress );
} }
/>
</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,70 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { EnteredAddress, getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
interface ShippingLocationProps {
address: EnteredAddress;
}
/**
* Shows a formatted shipping location.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.address Incoming address information.
*/
const ShippingLocation = ( {
address,
}: ShippingLocationProps ): JSX.Element | null => {
// we bail early if we don't have an address.
if ( Object.values( address ).length === 0 ) {
return null;
}
const shippingCountries = getSetting( 'shippingCountries', {} ) as Record<
string,
string
>;
const shippingStates = getSetting( 'shippingStates', {} ) as Record<
string,
Record< string, string >
>;
const formattedCountry =
typeof shippingCountries[ address.country ] === 'string'
? decodeEntities( shippingCountries[ address.country ] )
: '';
const formattedState =
typeof shippingStates[ address.country ] === 'object' &&
typeof shippingStates[ address.country ][ address.state ] === 'string'
? decodeEntities(
shippingStates[ address.country ][ address.state ]
)
: address.state;
const addressParts = [];
addressParts.push( address.postcode.toUpperCase() );
addressParts.push( address.city );
addressParts.push( formattedState );
addressParts.push( formattedCountry );
const formattedLocation = addressParts.filter( Boolean ).join( ', ' );
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,149 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { _n, sprintf } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import type { ReactElement } from 'react';
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
import { Panel } from '@woocommerce/blocks-checkout';
import Label from '@woocommerce/base-components/label';
import { useSelectShippingRate } from '@woocommerce/base-context/hooks';
import type { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import PackageRates from './package-rates';
import './style.scss';
interface PackageItem {
name: string;
key: string;
quantity: number;
}
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;
interface PackageProps {
/* PackageId can be a string, WooCommerce Subscriptions uses strings for example, but WooCommerce core uses numbers */
packageId: string | number;
renderOption: PackageRateRenderOption;
collapse?: boolean;
packageData: PackageData;
className?: string;
collapsible?: boolean;
noResultsMessage: ReactElement;
showItems?: boolean;
}
export const ShippingRatesControlPackage = ( {
packageId,
className,
noResultsMessage,
renderOption,
packageData,
collapsible = false,
collapse = false,
showItems = false,
}: PackageProps ): ReactElement => {
const { selectShippingRate, selectedShippingRate } = useSelectShippingRate(
packageId,
packageData.shipping_rates
);
const header = (
<>
{ ( showItems || collapsible ) && (
<div className="wc-block-components-shipping-rates-control__package-title">
{ packageData.name }
</div>
) }
{ showItems && (
<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 body = (
<PackageRates
className={ className }
noResultsMessage={ noResultsMessage }
rates={ packageData.shipping_rates }
onSelectRate={ selectShippingRate }
selected={ selectedShippingRate }
renderOption={ renderOption }
/>
);
if ( collapsible ) {
return (
<Panel
className="wc-block-components-shipping-rates-control__package"
initialOpen={ ! collapse }
title={ header }
>
{ body }
</Panel>
);
}
return (
<div
className={ classNames(
'wc-block-components-shipping-rates-control__package',
className
) }
>
{ header }
{ body }
</div>
);
};
export default ShippingRatesControlPackage;

View File

@ -0,0 +1,69 @@
/**
* External dependencies
*/
import RadioControl, {
RadioControlOptionLayout,
} from '@woocommerce/base-components/radio-control';
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
import type { ReactElement } from 'react';
import type { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import { renderPackageRateOption } from './render-package-rate-option';
interface PackageRates {
onSelectRate: ( selectedRateId: string ) => void;
rates: CartShippingPackageShippingRate[];
renderOption?: (
option: CartShippingPackageShippingRate
) => PackageRateOption;
className?: string;
noResultsMessage: ReactElement;
selected?: string;
}
const PackageRates = ( {
className,
noResultsMessage,
onSelectRate,
rates,
renderOption = renderPackageRateOption,
selected,
}: PackageRates ): ReactElement => {
if ( rates.length === 0 ) {
return noResultsMessage;
}
if ( rates.length > 1 ) {
return (
<RadioControl
className={ className }
onChange={ ( selectedRateId: string ) => {
onSelectRate( selectedRateId );
} }
selected={ selected }
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/base-components/formatted-monetary-amount';
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
import { getSetting } from '@woocommerce/settings';
import { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* 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,43 @@
.wc-block-components-shipping-rates-control__package {
.wc-block-components-panel__button {
margin-bottom: 0;
margin-top: 0;
padding-bottom: em($gap-small);
padding-top: em($gap-small);
}
// Remove panel padding because we are adding bottom padding to `.wc-block-components-radio-control`
// and `.wc-block-components-radio-control__option-layout` in the next ruleset.
.wc-block-components-panel__content {
padding-bottom: 0;
}
.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-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,200 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import { speak } from '@wordpress/a11y';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { ExperimentalOrderShippingPackages } from '@woocommerce/blocks-checkout';
import {
getShippingRatesPackageCount,
getShippingRatesRateCount,
} from '@woocommerce/base-utils';
import { useStoreCart, useEditorContext } from '@woocommerce/base-context';
import { CartResponseShippingRate } from '@woocommerce/type-defs/cart-response';
import { ReactElement } from 'react';
/**
* Internal dependencies
*/
import ShippingRatesControlPackage, {
PackageRateRenderOption,
} from '../shipping-rates-control-package';
interface PackagesProps {
packages: CartResponseShippingRate[];
collapse?: boolean;
collapsible?: boolean;
showItems?: boolean;
noResultsMessage: ReactElement;
renderOption: PackageRateRenderOption;
}
/**
* Renders multiple packages within the slotfill.
*
* @param {Object} props Incoming props.
* @param {Array} props.packages Array of packages.
* @param {boolean} props.collapsible If the package should be rendered as a
* @param {ReactElement} props.noResultsMessage Rendered when there are no rates in a package.
* collapsible panel.
* @param {boolean} props.collapse If the panel should be collapsed by default,
* only works if collapsible is true.
* @param {boolean} props.showItems If we should items below the package name.
* @param {PackageRateRenderOption} [props.renderOption] Function to render a shipping rate.
* @return {JSX.Element|null} Rendered components.
*/
const Packages = ( {
packages,
collapse,
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 }
collapse={ collapse }
showItems={ showItems }
noResultsMessage={ noResultsMessage }
renderOption={ renderOption }
/>
) ) }
</>
);
};
interface ShippingRatesControlProps {
collapsible?: boolean;
shippingRates: CartResponseShippingRate[];
className?: string;
shippingRatesLoading: boolean;
noResultsMessage: ReactElement;
renderOption: PackageRateRenderOption;
}
/**
* Renders the shipping rates control element.
*
* @param {Object} props Incoming props.
* @param {Array} props.shippingRates Array of packages containing shipping rates.
* @param {boolean} props.shippingRatesLoading True when rates are being loaded.
* @param {string} props.className Class name for package rates.
* @param {boolean} [props.collapsible] If true, when multiple packages are rendered they can be toggled open and closed.
* @param {ReactElement} props.noResultsMessage Rendered when there are no packages.
* @param {Function} [props.renderOption] Function to render a shipping rate.
*/
const ShippingRatesControl = ( {
shippingRates,
shippingRatesLoading,
className,
collapsible = false,
noResultsMessage,
renderOption,
}: ShippingRatesControlProps ): JSX.Element => {
useEffect( () => {
if ( shippingRatesLoading ) {
return;
}
const packageCount = getShippingRatesPackageCount( shippingRates );
const shippingOptions = getShippingRatesRateCount( shippingRates );
if ( packageCount === 1 ) {
speak(
sprintf(
/* translators: %d number of shipping options found. */
_n(
'%d shipping option was found.',
'%d shipping options were found.',
shippingOptions,
'woo-gutenberg-products-block'
),
shippingOptions
)
);
} 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',
shippingOptions,
'woo-gutenberg-products-block'
),
shippingOptions
)
);
}
}, [ shippingRatesLoading, 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,
noResultsMessage,
renderOption,
extensions,
cart,
components: {
ShippingRatesControlPackage,
},
};
const { isEditor } = useEditorContext();
return (
<LoadingMask
isLoading={ shippingRatesLoading }
screenReaderLabel={ __(
'Loading shipping rates…',
'woo-gutenberg-products-block'
) }
showSpinner={ true }
>
{ isEditor ? (
<Packages
packages={ shippingRates }
noResultsMessage={ noResultsMessage }
renderOption={ renderOption }
/>
) : (
<>
<ExperimentalOrderShippingPackages.Slot
{ ...slotFillProps }
/>
<ExperimentalOrderShippingPackages>
<Packages
packages={ shippingRates }
noResultsMessage={ noResultsMessage }
renderOption={ renderOption }
/>
</ExperimentalOrderShippingPackages>
</>
) }
</LoadingMask>
);
};
export default ShippingRatesControl;

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element';
import Button from '@woocommerce/base-components/button';
import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import Label from '@woocommerce/base-components/label';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import PropTypes from 'prop-types';
import { withInstanceId } from '@wordpress/compose';
import {
ValidationInputError,
useValidationContext,
} from '@woocommerce/base-context';
import { Panel } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import './style.scss';
const TotalsCoupon = ( {
instanceId,
isLoading = false,
initialOpen = false,
onSubmit = () => {},
} ) => {
const [ couponValue, setCouponValue ] = useState( '' );
const currentIsLoading = useRef( false );
const { getValidationError, getValidationErrorId } = useValidationContext();
const validationError = getValidationError( 'coupon' );
useEffect( () => {
if ( currentIsLoading.current !== isLoading ) {
if ( ! isLoading && couponValue && ! validationError ) {
setCouponValue( '' );
}
currentIsLoading.current = isLoading;
}
}, [ isLoading, couponValue, validationError ] );
const textInputId = `wc-block-components-totals-coupon__input-${ instanceId }`;
return (
<Panel
className="wc-block-components-totals-coupon"
hasBorder={ false }
initialOpen={ initialOpen }
title={
<Label
label={ __(
'Coupon code',
'woocommerce'
) }
screenReaderLabel={ __(
'Apply a coupon code',
'woocommerce'
) }
htmlFor={ textInputId }
/>
}
>
<LoadingMask
screenReaderLabel={ __(
'Applying coupon…',
'woocommerce'
) }
isLoading={ isLoading }
showSpinner={ false }
>
<div className="wc-block-components-totals-coupon__content">
<form className="wc-block-components-totals-coupon__form">
<ValidatedTextInput
id={ textInputId }
errorId="coupon"
className="wc-block-components-totals-coupon__input"
label={ __(
'Enter code',
'woocommerce'
) }
value={ couponValue }
ariaDescribedBy={ getValidationErrorId(
textInputId
) }
onChange={ ( newCouponValue ) => {
setCouponValue( newCouponValue );
} }
validateOnMount={ false }
focusOnMount={ true }
showError={ false }
/>
<Button
className="wc-block-components-totals-coupon__button"
disabled={ isLoading || ! couponValue }
showSpinner={ isLoading }
onClick={ ( e ) => {
e.preventDefault();
onSubmit( couponValue );
} }
type="submit"
>
{ __( 'Apply', 'woocommerce' ) }
</Button>
</form>
<ValidationInputError
propertyName="coupon"
elementId={ textInputId }
/>
</div>
</LoadingMask>
</Panel>
);
};
TotalsCoupon.propTypes = {
onSubmit: PropTypes.func,
isLoading: PropTypes.bool,
};
export default withInstanceId( TotalsCoupon );

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { text, boolean } from '@storybook/addon-knobs';
import {
useValidationContext,
ValidationContextProvider,
} from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import TotalsCoupon from '../';
export default {
title:
'WooCommerce Blocks/@base-components/cart-checkout/totals/TotalsCoupon',
component: TotalsCoupon,
};
const StoryComponent = ( { validCoupon, isLoading, invalidCouponText } ) => {
const { setValidationErrors } = useValidationContext();
const onSubmit = ( coupon ) => {
if ( coupon !== validCoupon ) {
setValidationErrors( { coupon: invalidCouponText } );
}
};
return <TotalsCoupon isLoading={ isLoading } onSubmit={ onSubmit } />;
};
export const Default = () => {
const validCoupon = text( 'A valid coupon code', 'validcoupon' );
const invalidCouponText = text(
'Error message for invalid code',
'Invalid coupon code.'
);
const isLoading = boolean( 'Toggle isLoading state', false );
return (
<ValidationContextProvider>
<StoryComponent
validCoupon={ validCoupon }
isLoading={ isLoading }
invalidCouponText={ invalidCouponText }
/>
</ValidationContextProvider>
);
};

View File

@ -0,0 +1,41 @@
.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,128 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { RemovableChip } from '@woocommerce/base-components/chip';
import PropTypes from 'prop-types';
import {
__experimentalApplyCheckoutFilter,
TotalsItem,
} from '@woocommerce/blocks-checkout';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './style.scss';
const filteredCartCouponsFilterArg = {
context: 'summary',
};
const TotalsDiscount = ( {
cartCoupons = [],
currency,
isRemovingCoupon,
removeCoupon,
values,
} ) => {
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 = __experimentalApplyCheckoutFilter( {
arg: filteredCartCouponsFilterArg,
filterName: 'coupons',
defaultValue: cartCoupons,
} );
return (
<TotalsItem
className="wc-block-components-totals-discount"
currency={ currency }
description={
filteredCartCoupons.length !== 0 && (
<LoadingMask
screenReaderLabel={ __(
'Removing coupon…',
'woocommerce'
) }
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',
'woocommerce'
),
cartCoupon.label
) }
disabled={ isRemovingCoupon }
onRemove={ () => {
removeCoupon( cartCoupon.code );
} }
radius="large"
ariaLabel={ sprintf(
/* translators: %s is a coupon code. */
__(
'Remove coupon "%s"',
'woocommerce'
),
cartCoupon.label
) }
/>
);
} ) }
</ul>
</LoadingMask>
)
}
label={
!! discountTotalValue
? __( 'Discount', 'woocommerce' )
: __( 'Coupons', 'woocommerce' )
}
value={ discountTotalValue ? discountTotalValue * -1 : '-' }
/>
);
};
TotalsDiscount.propTypes = {
cartCoupons: PropTypes.arrayOf(
PropTypes.shape( {
code: PropTypes.string.isRequired,
} )
),
currency: PropTypes.object.isRequired,
isRemovingCoupon: PropTypes.bool.isRequired,
removeCoupon: PropTypes.func.isRequired,
values: PropTypes.shape( {
total_discount: PropTypes.string,
total_discount_tax: PropTypes.string,
} ).isRequired,
};
export default TotalsDiscount;

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { text, boolean } from '@storybook/addon-knobs';
import { currencyKnob } from '@woocommerce/knobs';
/**
* Internal dependencies
*/
import TotalsDiscount from '../';
export default {
title:
'WooCommerce Blocks/@base-components/cart-checkout/totals/TotalsDiscount',
component: TotalsDiscount,
};
export const Default = () => {
const cartCoupons = [ { code: 'COUPON' } ];
const currency = currencyKnob();
const isRemovingCoupon = boolean( 'Toggle isRemovingCoupon state', false );
const totalDiscount = text( 'Total discount', '1000' );
const totalDiscountTax = text( 'Total discount tax', '200' );
return (
<TotalsDiscount
cartCoupons={ cartCoupons }
currency={ currency }
isRemovingCoupon={ isRemovingCoupon }
removeCoupon={ () => void null }
values={ {
total_discount: totalDiscount,
total_discount_tax: totalDiscountTax,
} }
/>
);
};

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,80 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import PropTypes from 'prop-types';
import {
__experimentalApplyCheckoutFilter,
TotalsItem,
} from '@woocommerce/blocks-checkout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './style.scss';
const TotalsFooterItem = ( { currency, values } ) => {
const SHOW_TAXES =
getSetting( 'taxesEnabled', true ) &&
getSetting( 'displayCartPricesIncludingTax', false );
const { total_price: totalPrice, total_tax: totalTax } = values;
// Prepare props to pass to the __experimentalApplyCheckoutFilter filter.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { receiveCart, ...cart } = useStoreCart();
const label = __experimentalApplyCheckoutFilter( {
filterName: 'totalLabel',
defaultValue: __( 'Total', 'woocommerce' ),
extensions: cart.extensions,
arg: { cart },
} );
const parsedTaxValue = parseInt( totalTax, 10 );
return (
<TotalsItem
className="wc-block-components-totals-footer-item"
currency={ currency }
label={ label }
value={ parseInt( totalPrice, 10 ) }
description={
SHOW_TAXES &&
parsedTaxValue !== 0 && (
<p className="wc-block-components-totals-footer-item-tax">
{ createInterpolateElement(
__(
'Including <TaxAmount/> in taxes',
'woocommerce'
),
{
TaxAmount: (
<FormattedMonetaryAmount
className="wc-block-components-totals-footer-item-tax-value"
currency={ currency }
value={ parsedTaxValue }
/>
),
}
) }
</p>
)
}
/>
);
};
TotalsFooterItem.propTypes = {
currency: PropTypes.object.isRequired,
values: PropTypes.shape( {
total_price: PropTypes.string,
total_tax: PropTypes.string,
} ).isRequired,
};
export default TotalsFooterItem;

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { text } from '@storybook/addon-knobs';
import { currencyKnob } from '@woocommerce/knobs';
/**
* Internal dependencies
*/
import TotalsFooterItem from '../';
export default {
title:
'WooCommerce Blocks/@base-components/cart-checkout/totals/TotalsFooterItem',
component: TotalsFooterItem,
};
export const Default = () => {
const currency = currencyKnob();
const totalPrice = text( 'Total price', '1200' );
const totalTax = text( 'Total tax', '200' );
return (
<TotalsFooterItem
currency={ currency }
values={ {
total_price: totalPrice,
total_tax: totalTax,
} }
/>
);
};

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,79 @@
// 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 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,81 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import TotalsFooterItem from '../index';
import { allSettings } from '../../../../../../settings/shared/settings-init';
describe( 'TotalsFooterItem', () => {
beforeEach( () => {
allSettings.taxesEnabled = true;
allSettings.displayCartPricesIncludingTax = true;
} );
const currency = {
code: 'GBP',
decimalSeparator: '.',
minorUnit: 2,
prefix: '£',
suffix: '',
symbol: '£',
thousandSeparator: ',',
};
const values = {
currency_code: 'GBP',
currency_decimal_separator: '.',
currency_minor_unit: 2,
currency_prefix: '£',
currency_suffix: '',
currency_symbol: '£',
currency_thousand_separator: ',',
tax_lines: [],
length: 2,
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();
} );
} );

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,14 @@
/**
* 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.
*/
const hasShippingRate = ( shippingRatePackages ) => {
return shippingRatePackages.some(
( shippingRatePackage ) => shippingRatePackage.shipping_rates.length
);
};
export default hasShippingRate;

View File

@ -0,0 +1,213 @@
/**
* 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-checkout';
import type { Currency } from '@woocommerce/price-format';
import type { ReactElement } from 'react';
import { getSetting, EnteredAddress } from '@woocommerce/settings';
import { ShippingVia } from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-via';
/**
* Internal dependencies
*/
import ShippingRateSelector from './shipping-rate-selector';
import hasShippingRate from './has-shipping-rate';
import ShippingCalculator from '../../shipping-calculator';
import ShippingLocation from '../../shipping-location';
import './style.scss';
interface CalculatorButtonProps {
label?: string;
isShippingCalculatorOpen: boolean;
setIsShippingCalculatorOpen: ( isShippingCalculatorOpen: boolean ) => void;
}
const CalculatorButton = ( {
label = __( 'Calculate', 'woo-gutenberg-products-block' ),
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
}: CalculatorButtonProps ): ReactElement => {
return (
<button
className="wc-block-components-totals-shipping__change-address-button"
onClick={ () => {
setIsShippingCalculatorOpen( ! isShippingCalculatorOpen );
} }
aria-expanded={ isShippingCalculatorOpen }
>
{ label }
</button>
);
};
interface ShippingAddressProps {
showCalculator: boolean;
isShippingCalculatorOpen: boolean;
setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ];
shippingAddress: EnteredAddress;
}
const ShippingAddress = ( {
showCalculator,
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
shippingAddress,
}: ShippingAddressProps ): ReactElement | null => {
return (
<>
<ShippingLocation address={ shippingAddress } />
{ showCalculator && (
<CalculatorButton
label={ __(
'(change address)',
'woo-gutenberg-products-block'
) }
isShippingCalculatorOpen={ isShippingCalculatorOpen }
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
/>
) }
</>
);
};
interface NoShippingPlaceholderProps {
showCalculator: boolean;
isShippingCalculatorOpen: boolean;
setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ];
}
const NoShippingPlaceholder = ( {
showCalculator,
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
}: NoShippingPlaceholderProps ): ReactElement => {
if ( ! showCalculator ) {
return (
<em>
{ __(
'Calculated during checkout',
'woo-gutenberg-products-block'
) }
</em>
);
}
return (
<CalculatorButton
isShippingCalculatorOpen={ isShippingCalculatorOpen }
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
/>
);
};
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;
}
const TotalsShipping = ( {
currency,
values,
showCalculator = true,
showRateSelector = true,
className,
}: TotalShippingProps ): ReactElement => {
const [ isShippingCalculatorOpen, setIsShippingCalculatorOpen ] = useState(
false
);
const {
shippingAddress,
cartHasCalculatedShipping,
shippingRates,
shippingRatesLoading,
} = useStoreCart();
const totalShippingValue = getSetting(
'displayCartPricesIncludingTax',
false
)
? parseInt( values.total_shipping, 10 ) +
parseInt( values.total_shipping_tax, 10 )
: parseInt( values.total_shipping, 10 );
const hasRates = hasShippingRate( shippingRates ) || totalShippingValue;
const calculatorButtonProps = {
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
};
const selectedShippingRates = shippingRates.flatMap(
( shippingPackage ) => {
return shippingPackage.shipping_rates
.filter( ( rate ) => rate.selected )
.flatMap( ( rate ) => rate.name );
}
);
return (
<div
className={ classnames(
'wc-block-components-totals-shipping',
className
) }
>
<TotalsItem
label={ __( 'Shipping', 'woo-gutenberg-products-block' ) }
value={
cartHasCalculatedShipping ? (
totalShippingValue
) : (
<NoShippingPlaceholder
showCalculator={ showCalculator }
{ ...calculatorButtonProps }
/>
)
}
description={
<>
{ cartHasCalculatedShipping && (
<>
<ShippingVia
selectedShippingRates={
selectedShippingRates
}
/>
<ShippingAddress
shippingAddress={ shippingAddress }
showCalculator={ showCalculator }
{ ...calculatorButtonProps }
/>
</>
) }
</>
}
currency={ currency }
/>
{ showCalculator && isShippingCalculatorOpen && (
<ShippingCalculator
onUpdate={ () => {
setIsShippingCalculatorOpen( false );
} }
/>
) }
{ showRateSelector && cartHasCalculatedShipping && (
<ShippingRateSelector
hasRates={ hasRates }
shippingRates={ shippingRates }
shippingRatesLoading={ shippingRatesLoading }
/>
) }
</div>
);
};
export default TotalsShipping;

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Notice } from 'wordpress-components';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import ShippingRatesControl from '../../shipping-rates-control';
const ShippingRateSelector = ( {
hasRates,
shippingRates,
shippingRatesLoading,
} ) => {
const legend = hasRates
? __( 'Shipping options', 'woocommerce' )
: __( 'Choose a shipping option', 'woocommerce' );
return (
<fieldset className="wc-block-components-totals-shipping__fieldset">
<legend className="screen-reader-text">{ legend }</legend>
<ShippingRatesControl
className="wc-block-components-totals-shipping__options"
collapsible={ true }
noResultsMessage={
<Notice
isDismissible={ false }
className={ classnames(
'wc-block-components-shipping-rates-control__no-results-notice',
'woocommerce-error'
) }
>
{ __(
'No shipping options were found.',
'woocommerce'
) }
</Notice>
}
shippingRates={ shippingRates }
shippingRatesLoading={ shippingRatesLoading }
/>
</fieldset>
);
};
export default ShippingRateSelector;

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const ShippingVia = ( {
selectedShippingRates,
}: {
selectedShippingRates: string[];
} ): JSX.Element => {
return (
<div className="wc-block-components-totals-item__description wc-block-components-totals-shipping__via">
{ __( 'via', 'woo-gutenberg-products-block' ) }{ ' ' }
{ selectedShippingRates.join( ', ' ) }
</div>
);
};

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { boolean, text } from '@storybook/addon-knobs';
import { currencyKnob } from '@woocommerce/knobs';
/**
* Internal dependencies
*/
import TotalsShipping from '../';
export default {
title: 'WooCommerce Blocks/@blocks-checkout/TotalsShipping',
component: TotalsShipping,
};
export const Default = () => {
const currency = currencyKnob();
const showCalculator = boolean( 'Show calculator', true );
const showRateSelector = boolean( 'Show rate selector', true );
const totalShipping = text( 'Total shipping', '1000' );
const totalShippingTax = text( 'Total shipping tax', '200' );
return (
<TotalsShipping
currency={ currency }
showCalculator={ showCalculator }
showRateSelector={ showRateSelector }
values={ {
total_shipping: totalShipping,
total_shipping_tax: totalShippingTax,
} }
/>
);
};

View File

@ -0,0 +1,42 @@
.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-totals-shipping__via {
margin-bottom: $gap;
}
.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;
}
}
.wc-block-components-shipping-rates-control__no-results-notice {
margin: 0 0 em($gap-small);
}
.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();
}