initial commit
This commit is contained in:
@ -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 );
|
@ -0,0 +1 @@
|
||||
export { default as AddressForm } from './address-form';
|
@ -0,0 +1,131 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/address-fields').CountryAddressFields } CountryAddressFields */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
CountryAddressFields,
|
||||
defaultAddressFields,
|
||||
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;
|
@ -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( '' );
|
||||
} );
|
||||
} );
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
} );
|
||||
} );
|
@ -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';
|
@ -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;
|
@ -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;
|
@ -0,0 +1,104 @@
|
||||
.wc-block-components-order-summary {
|
||||
|
||||
.wc-block-components-panel__button {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-panel__content {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary__content {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item {
|
||||
@include with-translucent-border(0 0 1px);
|
||||
@include font-size(small);
|
||||
display: flex;
|
||||
padding-bottom: 1px;
|
||||
padding-top: $gap;
|
||||
width: 100%;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
> div {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-metadata {
|
||||
@include font-size(regular);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__image,
|
||||
.wc-block-components-order-summary-item__description {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__image {
|
||||
width: #{$gap-large * 2};
|
||||
padding-bottom: $gap;
|
||||
position: relative;
|
||||
|
||||
> img {
|
||||
width: #{$gap-large * 2};
|
||||
max-width: #{$gap-large * 2};
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__quantity {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 2px solid;
|
||||
border-radius: 1em;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
line-height: 1;
|
||||
min-height: 20px;
|
||||
padding: 0 0.4em;
|
||||
position: absolute;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__description {
|
||||
padding-left: $gap-large;
|
||||
padding-right: $gap-small;
|
||||
padding-bottom: $gap;
|
||||
|
||||
p,
|
||||
.wc-block-components-product-metadata {
|
||||
line-height: 1.375;
|
||||
margin-top: #{ ($gap-large - $gap) * 0.5 };
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__total-price {
|
||||
font-weight: bold;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.wc-block-components-order-summary-item__individual-prices {
|
||||
display: block;
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import OrderSummary from '../index';
|
||||
|
||||
jest.mock( '@woocommerce/base-context', () => ( {
|
||||
...jest.requireActual( '@woocommerce/base-context' ),
|
||||
useContainerWidthContext: () => ( {
|
||||
isLarge: true,
|
||||
hasContainerWidth: true,
|
||||
} ),
|
||||
} ) );
|
||||
|
||||
describe( 'Order Summary', () => {
|
||||
it( 'renders correct cart line subtotal when currency has 0 decimals', async () => {
|
||||
render(
|
||||
<OrderSummary
|
||||
cartItems={ [
|
||||
{
|
||||
...previewCart.items[ 0 ],
|
||||
totals: {
|
||||
...previewCart.items[ 0 ].totals,
|
||||
// Change price format so there are no decimals.
|
||||
currency_minor_unit: 0,
|
||||
currency_prefix: '',
|
||||
currency_suffix: '€',
|
||||
line_subtotal: '16',
|
||||
line_total: '18',
|
||||
},
|
||||
},
|
||||
] }
|
||||
/>
|
||||
);
|
||||
|
||||
expect( screen.getByText( '16€' ) ).toBeTruthy();
|
||||
} );
|
||||
} );
|
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
|
||||
import type { PaymentMethodIcon } from '@woocommerce/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;
|
||||
} ) || {}
|
||||
);
|
||||
};
|
@ -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;
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Get a class name for an icon.
|
||||
*
|
||||
* @param {string} id Icon ID.
|
||||
*/
|
||||
const getIconClassName = ( id: string ): string => {
|
||||
return `wc-block-components-payment-method-icon wc-block-components-payment-method-icon--${ id }`;
|
||||
};
|
||||
|
||||
interface PaymentMethodIconProps {
|
||||
id: string;
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
}
|
||||
/**
|
||||
* Return an element for an icon.
|
||||
*
|
||||
* @param {Object} props Incoming props for component.
|
||||
* @param {string} props.id Id for component.
|
||||
* @param {string|null} props.src Optional src value for icon.
|
||||
* @param {string} props.alt Optional alt value for icon.
|
||||
*/
|
||||
const PaymentMethodIcon = ( {
|
||||
id,
|
||||
src = null,
|
||||
alt = '',
|
||||
}: PaymentMethodIconProps ): JSX.Element | null => {
|
||||
if ( ! src ) {
|
||||
return null;
|
||||
}
|
||||
return <img className={ getIconClassName( id ) } src={ src } alt={ alt } />;
|
||||
};
|
||||
|
||||
export default PaymentMethodIcon;
|
@ -0,0 +1,48 @@
|
||||
.wc-block-components-payment-method-icons {
|
||||
margin: 0 0 #{$gap - 2px};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.wc-block-components-payment-method-icon {
|
||||
display: inline-block;
|
||||
margin: 0 4px 2px;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
max-width: 38px;
|
||||
height: 24px;
|
||||
max-height: 24px;
|
||||
}
|
||||
|
||||
&--align-left {
|
||||
justify-content: flex-start;
|
||||
|
||||
.wc-block-components-payment-method-icon {
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&--align-right {
|
||||
justify-content: flex-end;
|
||||
|
||||
.wc-block-components-payment-method-icon {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-mobile,
|
||||
.is-small {
|
||||
.wc-block-components-payment-method-icons {
|
||||
.wc-block-components-payment-method-icon {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,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 );
|
||||
};
|
@ -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;
|
@ -0,0 +1,19 @@
|
||||
.wc-block-components-payment-method-label--with-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
> img,
|
||||
> svg {
|
||||
vertical-align: middle;
|
||||
margin: -2px 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-mobile,
|
||||
.is-small {
|
||||
.wc-block-components-payment-method-label--with-icon {
|
||||
> img,
|
||||
> svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,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;
|
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
PRIVACY_URL,
|
||||
TERMS_URL,
|
||||
PRIVACY_PAGE_NAME,
|
||||
TERMS_PAGE_NAME,
|
||||
} from '@woocommerce/block-settings';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const Policies = (): JSX.Element => {
|
||||
return (
|
||||
<ul className="wc-block-components-checkout-policies">
|
||||
{ PRIVACY_URL && (
|
||||
<li className="wc-block-components-checkout-policies__item">
|
||||
<a
|
||||
href={ PRIVACY_URL }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ PRIVACY_PAGE_NAME
|
||||
? decodeEntities( PRIVACY_PAGE_NAME )
|
||||
: __(
|
||||
'Privacy Policy',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</a>
|
||||
</li>
|
||||
) }
|
||||
{ TERMS_URL && (
|
||||
<li className="wc-block-components-checkout-policies__item">
|
||||
<a
|
||||
href={ TERMS_URL }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ TERMS_PAGE_NAME
|
||||
? decodeEntities( TERMS_PAGE_NAME )
|
||||
: __(
|
||||
'Terms and Conditions',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</a>
|
||||
</li>
|
||||
) }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default Policies;
|
@ -0,0 +1,24 @@
|
||||
.editor-styles-wrapper .wc-block-components-checkout-policies,
|
||||
.wc-block-components-checkout-policies {
|
||||
@include font-size(smaller);
|
||||
text-align: center;
|
||||
list-style: none outside;
|
||||
line-height: 1;
|
||||
margin: $gap-large 0;
|
||||
}
|
||||
|
||||
.wc-block-components-checkout-policies__item {
|
||||
list-style: none outside;
|
||||
display: inline-block;
|
||||
padding: 0 0.25em;
|
||||
margin: 0;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-left: 1px solid $gray-400;
|
||||
}
|
||||
|
||||
> a {
|
||||
color: inherit;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBadge from '../product-badge';
|
||||
|
||||
/**
|
||||
* Returns a backorder badge.
|
||||
*/
|
||||
const ProductBackorderBadge = (): JSX.Element => {
|
||||
return (
|
||||
<ProductBadge className="wc-block-components-product-backorder-badge">
|
||||
{ __( 'Available on backorder', 'woo-gutenberg-products-block' ) }
|
||||
</ProductBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductBackorderBadge;
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface ProductBadgeProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
const ProductBadge = ( {
|
||||
children,
|
||||
className,
|
||||
}: ProductBadgeProps ): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={ classNames(
|
||||
'wc-block-components-product-badge',
|
||||
className
|
||||
) }
|
||||
>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductBadge;
|
@ -0,0 +1,10 @@
|
||||
.wc-block-components-product-badge {
|
||||
@include font-size(smaller);
|
||||
border-radius: 2px;
|
||||
border: 1px solid;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
padding: 0 0.66em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
@ -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;
|
@ -0,0 +1,25 @@
|
||||
// Extra class added for specificity so styles are applied in the editor.
|
||||
.wc-block-components-product-details.wc-block-components-product-details {
|
||||
list-style: none;
|
||||
margin: 0.5em 0;
|
||||
padding: 0;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-details__name,
|
||||
.wc-block-components-product-details__value {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.is-large:not(.wc-block-checkout) {
|
||||
.wc-block-components-product-details__name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
@ -0,0 +1,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>
|
||||
`;
|
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductDetails from '..';
|
||||
|
||||
describe( 'ProductDetails', () => {
|
||||
test( 'should render details', () => {
|
||||
const details = [
|
||||
{ name: 'Lorem', value: 'Ipsum' },
|
||||
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM' },
|
||||
{ value: 'Ipsum' },
|
||||
];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should not render hidden details', () => {
|
||||
const details = [
|
||||
{ name: 'Lorem', value: 'Ipsum', hidden: true },
|
||||
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM' },
|
||||
];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should not rendering anything if all details are hidden', () => {
|
||||
const details = [
|
||||
{ name: 'Lorem', value: 'Ipsum', hidden: true },
|
||||
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM', hidden: true },
|
||||
];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should not rendering anything if details is an empty array', () => {
|
||||
const details = [];
|
||||
const component = TestRenderer.create(
|
||||
<ProductDetails details={ details } />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
} );
|
@ -0,0 +1,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;
|
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductBadge from '../product-badge';
|
||||
|
||||
interface ProductLowStockBadgeProps {
|
||||
lowStockRemaining: number | null;
|
||||
}
|
||||
/**
|
||||
* Returns a low stock badge.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {number} props.lowStockRemaining Whether or not there is low stock remaining.
|
||||
*/
|
||||
const ProductLowStockBadge = ( {
|
||||
lowStockRemaining,
|
||||
}: ProductLowStockBadgeProps ): JSX.Element | null => {
|
||||
if ( ! lowStockRemaining ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductBadge className="wc-block-components-product-low-stock-badge">
|
||||
{ sprintf(
|
||||
/* translators: %d stock amount (number of items in stock for product) */
|
||||
__( '%d left in stock', 'woo-gutenberg-products-block' ),
|
||||
lowStockRemaining
|
||||
) }
|
||||
</ProductBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductLowStockBadge;
|
@ -0,0 +1,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;
|
@ -0,0 +1,8 @@
|
||||
.wc-block-components-product-metadata {
|
||||
@include font-size(smaller);
|
||||
|
||||
.wc-block-components-product-metadata__description > p,
|
||||
.wc-block-components-product-metadata__variation-data {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createInterpolateElement } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import FormattedMonetaryAmount from '@woocommerce/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;
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import Summary from '@woocommerce/base-components/summary';
|
||||
import { blocksConfig } from '@woocommerce/block-settings';
|
||||
|
||||
interface ProductSummaryProps {
|
||||
className?: string;
|
||||
shortDescription?: string;
|
||||
fullDescription?: string;
|
||||
}
|
||||
/**
|
||||
* Returns an element containing a summary of the product.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {string} props.className CSS class name used.
|
||||
* @param {string} props.shortDescription Short description for the product.
|
||||
* @param {string} props.fullDescription Full description for the product.
|
||||
*/
|
||||
const ProductSummary = ( {
|
||||
className,
|
||||
shortDescription = '',
|
||||
fullDescription = '',
|
||||
}: ProductSummaryProps ): JSX.Element | null => {
|
||||
const source = shortDescription ? shortDescription : fullDescription;
|
||||
|
||||
if ( ! source ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Summary
|
||||
className={ className }
|
||||
source={ source }
|
||||
maxLength={ 15 }
|
||||
countType={ blocksConfig.wordCountType || 'words' }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSummary;
|
@ -0,0 +1,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;
|
@ -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%;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -0,0 +1,12 @@
|
||||
.wc-block-components-shipping-calculator-address {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-calculator-address__button {
|
||||
width: 100%;
|
||||
margin-top: em($gap-large);
|
||||
}
|
||||
|
||||
.wc-block-components-shipping-calculator {
|
||||
padding: em($gap-smaller) 0 em($gap-small);
|
||||
}
|
@ -0,0 +1,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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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 );
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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,
|
||||
} }
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
.wc-block-components-totals-discount__coupon-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-discount .wc-block-components-totals-item__value {
|
||||
color: $discount-color;
|
||||
}
|
@ -0,0 +1,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;
|
@ -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,
|
||||
} }
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
.wc-block-components-totals-footer-item {
|
||||
.wc-block-components-totals-item__value,
|
||||
.wc-block-components-totals-item__label {
|
||||
@include font-size(large);
|
||||
}
|
||||
|
||||
.wc-block-components-totals-item__label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-footer-item-tax {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,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>
|
||||
`;
|
@ -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();
|
||||
} );
|
||||
} );
|
@ -0,0 +1,4 @@
|
||||
export { default as TotalsCoupon } from './coupon';
|
||||
export { default as TotalsDiscount } from './discount';
|
||||
export { default as TotalsFooterItem } from './footer-item';
|
||||
export { default as TotalsShipping } from './shipping';
|
@ -0,0 +1,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;
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
} }
|
||||
/>
|
||||
);
|
||||
};
|
@ -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();
|
||||
}
|
Reference in New Issue
Block a user