Migrating pricing section in product editor to slot fills (#36500)

* Adding changelogs

* Migrating pricing section in product editor to slot fills

* Adding slot and plugarea to variation form

* Removing obsolete pricing section files
This commit is contained in:
Joel Thiessen 2023-01-24 00:24:45 -08:00 committed by GitHub
parent 0beb6b7503
commit 4341a53144
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 675 additions and 490 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Altering styles to correctly target fields within slot fills on product editor.

View File

@ -11,9 +11,9 @@
&__body { &__body {
padding: $gap-large; padding: $gap-large;
> .components-base-control, .components-base-control,
> .components-dropdown, .components-dropdown,
> .woocommerce-rich-text-editor { .woocommerce-rich-text-editor {
&:not(:first-child):not(.components-radio-control) { &:not(:first-child):not(.components-radio-control) {
margin-top: $gap-large - $gap-smaller; margin-top: $gap-large - $gap-smaller;
margin-bottom: 0; margin-bottom: 0;

View File

@ -1,5 +1,5 @@
export const PRODUCT_DETAILS_SLUG = 'product-details'; export const PRICING_SECTION_BASIC_ID = 'pricing/basic';
export const PRICING_SECTION_TAXES_ID = 'pricing/taxes';
export const DETAILS_SECTION_ID = 'general/details'; export const DETAILS_SECTION_ID = 'general/details';
export const INVENTORY_SECTION_ID = 'inventory/inventory'; export const INVENTORY_SECTION_ID = 'inventory/inventory';
export const INVENTORY_SECTION_ADVANCED_ID = 'inventory/advanced'; export const INVENTORY_SECTION_ADVANCED_ID = 'inventory/advanced';
@ -11,5 +11,7 @@ export const SHIPPING_SECTION_DIMENSIONS_ID = 'shipping/dimensions';
export const TAB_INVENTORY_ID = 'tab/inventory'; export const TAB_INVENTORY_ID = 'tab/inventory';
export const TAB_GENERAL_ID = 'tab/general'; export const TAB_GENERAL_ID = 'tab/general';
export const TAB_SHIPPING_ID = 'tab/shipping'; export const TAB_SHIPPING_ID = 'tab/shipping';
export const TAB_PRICING_ID = 'tab/pricing';
export const PLUGIN_ID = 'woocommerce'; export const PLUGIN_ID = 'woocommerce';
export const PRODUCT_DETAILS_SLUG = 'product-details';

View File

@ -8,3 +8,4 @@ export * from './details-section/details-section-fills';
export * from './images-section/images-section-fills'; export * from './images-section/images-section-fills';
export * from './attributes-section/attributes-section-fills'; export * from './attributes-section/attributes-section-fills';
export * from './inventory-section/inventory-section-fills'; export * from './inventory-section/inventory-section-fills';
export * from './pricing-section/pricing-section-fills';

View File

@ -0,0 +1,4 @@
export * from './pricing-field-list';
export * from './pricing-field-sale';
export * from './pricing-field-taxes-charge';
export * from './pricing-field-taxes-class';

View File

@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext, Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { useContext } from '@wordpress/element';
import { Product, SETTINGS_STORE_NAME } from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import interpolateComponents from '@automattic/interpolate-components';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { CurrencyInputProps } from './pricing-section-fills';
import { formatCurrencyDisplayValue } from '../../sections/utils';
import { CurrencyContext } from '../../../lib/currency-context';
import { ADMIN_URL } from '~/utils/admin-settings';
type PricingListFieldProps = {
currencyInputProps: CurrencyInputProps;
};
export const PricingListField: React.FC< PricingListFieldProps > = ( {
currencyInputProps,
} ) => {
const { getInputProps } = useFormContext< Product >();
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const { isResolving: isTaxSettingsResolving, taxSettings } = useSelect(
( select ) => {
const { getSettings, hasFinishedResolution } =
select( SETTINGS_STORE_NAME );
return {
isResolving: ! hasFinishedResolution( 'getSettings', [
'tax',
] ),
taxSettings: getSettings( 'tax' ).tax || {},
taxesEnabled:
getSettings( 'general' )?.general
?.woocommerce_calc_taxes === 'yes',
};
}
);
const regularPriceProps = getInputProps(
'regular_price',
currencyInputProps
);
const taxIncludedInPriceText = __(
'Per your {{link}}store settings{{/link}}, tax is {{strong}}included{{/strong}} in the price.',
'woocommerce'
);
const taxNotIncludedInPriceText = __(
'Per your {{link}}store settings{{/link}}, tax is {{strong}}not included{{/strong}} in the price.',
'woocommerce'
);
const pricesIncludeTax =
taxSettings.woocommerce_prices_include_tax === 'yes';
const taxSettingsElement = interpolateComponents( {
mixedString: pricesIncludeTax
? taxIncludedInPriceText
: taxNotIncludedInPriceText,
components: {
link: (
<Link
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=tax` }
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'product_pricing_list_price_help_tax_settings_click'
);
} }
>
<></>
</Link>
),
strong: <strong />,
},
} );
return (
<>
<BaseControl
id="product_pricing_regular_price"
help={ regularPriceProps?.help ?? '' }
>
<InputControl
{ ...regularPriceProps }
name="regular_price"
label={ __( 'List price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( regularPriceProps?.value ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
{ ! isTaxSettingsResolving && (
<span className="woocommerce-product-form__secondary-text">
{ taxSettingsElement }
</span>
) }
</>
);
};

View File

@ -0,0 +1,213 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
useFormContext,
Link,
__experimentalTooltip as Tooltip,
DateTimePickerControl,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { useContext, useState, useEffect } from '@wordpress/element';
import { Product, OPTIONS_STORE_NAME } from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import interpolateComponents from '@automattic/interpolate-components';
import { format as formatDate } from '@wordpress/date';
import moment from 'moment';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
ToggleControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { CurrencyInputProps } from './pricing-section-fills';
import { formatCurrencyDisplayValue } from '../../sections/utils';
import { CurrencyContext } from '../../../lib/currency-context';
type PricingListFieldProps = {
currencyInputProps: CurrencyInputProps;
};
const PRODUCT_SCHEDULED_SALE_SLUG = 'product-scheduled-sale';
export const PricingSaleField: React.FC< PricingListFieldProps > = ( {
currencyInputProps,
} ) => {
const { getInputProps, values, setValues } = useFormContext< Product >();
const { dateFormat, timeFormat } = useSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
return {
dateFormat: ( getOption( 'date_format' ) as string ) || 'F j, Y',
timeFormat: ( getOption( 'time_format' ) as string ) || 'H:i',
};
} );
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const [ showSaleSchedule, setShowSaleSchedule ] = useState( false );
const [ userToggledSaleSchedule, setUserToggledSaleSchedule ] =
useState( false );
const [ autoToggledSaleSchedule, setAutoToggledSaleSchedule ] =
useState( false );
useEffect( () => {
if ( userToggledSaleSchedule || autoToggledSaleSchedule ) {
return;
}
const hasDateOnSaleFrom =
typeof values.date_on_sale_from_gmt === 'string' &&
values.date_on_sale_from_gmt.length > 0;
const hasDateOnSaleTo =
typeof values.date_on_sale_to_gmt === 'string' &&
values.date_on_sale_to_gmt.length > 0;
const hasSaleSchedule = hasDateOnSaleFrom || hasDateOnSaleTo;
if ( hasSaleSchedule ) {
setAutoToggledSaleSchedule( true );
setShowSaleSchedule( true );
}
}, [ userToggledSaleSchedule, autoToggledSaleSchedule, values ] );
const salePriceProps = getInputProps( 'sale_price', currencyInputProps );
const dateTimePickerProps = {
className: 'woocommerce-product__date-time-picker',
isDateOnlyPicker: true,
dateTimeFormat: dateFormat,
};
const onSaleScheduleToggleChange = ( value: boolean ) => {
recordEvent( 'product_pricing_schedule_sale_toggle_click', {
enabled: value,
} );
setUserToggledSaleSchedule( true );
setShowSaleSchedule( value );
if ( value ) {
setValues( {
date_on_sale_from_gmt: moment().startOf( 'day' ).toISOString(),
date_on_sale_to_gmt: null,
} as Product );
} else {
setValues( {
date_on_sale_from_gmt: null,
date_on_sale_to_gmt: null,
} as Product );
}
};
return (
<>
<BaseControl
id="product_pricing_sale_price"
help={ salePriceProps?.help ?? '' }
>
<InputControl
{ ...salePriceProps }
name="sale_price"
label={ __( 'Sale price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( salePriceProps?.value ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
<ToggleControl
label={
<>
{ __( 'Schedule sale', 'woocommerce' ) }
<Tooltip
text={ interpolateComponents( {
mixedString: __(
'The sale will start at the beginning of the "From" date ({{startTime/}}) and expire at the end of the "To" date ({{endTime/}}). {{moreLink/}}',
'woocommerce'
),
components: {
startTime: (
<span>
{ formatDate(
timeFormat,
moment().startOf( 'day' )
) }
</span>
),
endTime: (
<span>
{ formatDate(
timeFormat,
moment().endOf( 'day' )
) }
</span>
),
moreLink: (
<Link
href="https://woocommerce.com/document/managing-products/#product-data"
target="_blank"
type="external"
onClick={ () =>
recordEvent(
'add_product_learn_more',
{
category:
PRODUCT_SCHEDULED_SALE_SLUG,
}
)
}
>
{ __(
'Learn more',
'woocommerce'
) }
</Link>
),
},
} ) }
/>
</>
}
checked={ showSaleSchedule }
onChange={ onSaleScheduleToggleChange }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore disabled prop exists
disabled={ ! ( values.sale_price?.length > 0 ) }
/>
{ showSaleSchedule && (
<>
<DateTimePickerControl
label={ __( 'From', 'woocommerce' ) }
placeholder={ __( 'Now', 'woocommerce' ) }
timeForDateOnly={ 'start-of-day' }
currentDate={ values.date_on_sale_from_gmt }
{ ...getInputProps( 'date_on_sale_from_gmt', {
...dateTimePickerProps,
} ) }
/>
<DateTimePickerControl
label={ __( 'To', 'woocommerce' ) }
placeholder={ __( 'No end date', 'woocommerce' ) }
timeForDateOnly={ 'end-of-day' }
currentDate={ values.date_on_sale_to_gmt }
{ ...getInputProps( 'date_on_sale_to_gmt', {
...dateTimePickerProps,
} ) }
/>
</>
) }
</>
);
};

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { RadioControl } from '@wordpress/components';
export const PricingTaxesChargeField = () => {
const { getInputProps } = useFormContext< Product >();
const taxStatusProps = getInputProps( 'tax_status' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete taxStatusProps.checked;
delete taxStatusProps.value;
return (
<RadioControl
{ ...taxStatusProps }
label={ __( 'Charge sales tax on', 'woocommerce' ) }
options={ [
{
label: __( 'Product and shipping', 'woocommerce' ),
value: 'taxable',
},
{
label: __( 'Only shipping', 'woocommerce' ),
value: 'shipping',
},
{
label: __( `Don't charge tax`, 'woocommerce' ),
value: 'none',
},
] }
/>
);
};

View File

@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
useFormContext,
CollapsibleContent,
Link,
} from '@woocommerce/components';
import {
Product,
EXPERIMENTAL_TAX_CLASSES_STORE_NAME,
TaxClass,
} from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import { RadioControl } from '@wordpress/components';
import interpolateComponents from '@automattic/interpolate-components';
/**
* Internal dependencies
*/
import { STANDARD_RATE_TAX_CLASS_SLUG } from '../../constants';
export const PricingTaxesClassField = () => {
const { getInputProps } = useFormContext< Product >();
const { isResolving: isTaxClassesResolving, taxClasses } = useSelect(
( select ) => {
const { hasFinishedResolution, getTaxClasses } = select(
EXPERIMENTAL_TAX_CLASSES_STORE_NAME
);
return {
isResolving: ! hasFinishedResolution( 'getTaxClasses' ),
taxClasses: getTaxClasses< TaxClass[] >(),
};
}
);
const taxClassProps = getInputProps( 'tax_class' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete taxClassProps.checked;
delete taxClassProps.value;
return (
<CollapsibleContent toggleText={ __( 'Advanced', 'woocommerce' ) }>
{ ! isTaxClassesResolving && taxClasses.length > 0 && (
<RadioControl
{ ...taxClassProps }
label={
<>
<span>{ __( 'Tax class', 'woocommerce' ) }</span>
<span className="woocommerce-product-form__secondary-text">
{ interpolateComponents( {
mixedString: __(
'Apply a tax rate if this product qualifies for tax reduction or exemption. {{link}}Learn more{{/link}}',
'woocommerce'
),
components: {
link: (
<Link
href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class"
target="_blank"
type="external"
>
<></>
</Link>
),
},
} ) }
</span>
</>
}
options={ taxClasses.map( ( taxClass ) => ( {
label: taxClass.name,
value:
taxClass.slug === STANDARD_RATE_TAX_CLASS_SLUG
? ''
: taxClass.slug,
} ) ) }
/>
) }
</CollapsibleContent>
);
};

View File

@ -0,0 +1,187 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
__experimentalWooProductSectionItem as WooProductSectionItem,
__experimentalWooProductFieldItem as WooProductFieldItem,
__experimentalProductSectionLayout as ProductSectionLayout,
Link,
useFormContext,
} from '@woocommerce/components';
import { registerPlugin } from '@wordpress/plugins';
import { recordEvent } from '@woocommerce/tracks';
import { Product } from '@woocommerce/data';
import { useContext } from '@wordpress/element';
import { Card, CardBody } from '@wordpress/components';
/**
* Internal dependencies
*/
import {
PricingListField,
PricingSaleField,
PricingTaxesClassField,
PricingTaxesChargeField,
} from './index';
import { useProductHelper } from '../../use-product-helper';
import {
PRICING_SECTION_BASIC_ID,
PRICING_SECTION_TAXES_ID,
TAB_PRICING_ID,
PLUGIN_ID,
} from '../constants';
import { CurrencyContext } from '../../../lib/currency-context';
import './pricing-section.scss';
export type CurrencyInputProps = {
prefix: string;
className: string;
sanitize: ( value: Product[ keyof Product ] ) => string;
onFocus: ( event: React.FocusEvent< HTMLInputElement > ) => void;
onKeyUp: ( event: React.KeyboardEvent< HTMLInputElement > ) => void;
};
const PricingSection = () => {
const { setValues, values } = useFormContext< Product >();
const { sanitizePrice } = useProductHelper();
const context = useContext( CurrencyContext );
const { getCurrencyConfig } = context;
const currencyConfig = getCurrencyConfig();
const currencyInputProps: CurrencyInputProps = {
prefix: currencyConfig.symbol,
className: 'half-width-field components-currency-control',
sanitize: ( value: Product[ keyof Product ] ) => {
return sanitizePrice( String( value ) );
},
onFocus( event: React.FocusEvent< HTMLInputElement > ) {
// In some browsers like safari .select() function inside
// the onFocus event doesn't work as expected because it
// conflicts with onClick the first time user click the
// input. Using setTimeout defers the text selection and
// avoid the unexpected behaviour.
setTimeout(
function deferSelection( element: HTMLInputElement ) {
element.select();
},
0,
event.currentTarget
);
},
onKeyUp( event: React.KeyboardEvent< HTMLInputElement > ) {
const name = event.currentTarget.name as keyof Pick<
Product,
'regular_price' | 'sale_price'
>;
const amount = Number.parseFloat(
sanitizePrice( values[ name ] || '0' )
);
const step = Number( event.currentTarget.step || '1' );
if ( event.code === 'ArrowUp' ) {
setValues( {
[ name ]: String( amount + step ),
} as unknown as Product );
}
if ( event.code === 'ArrowDown' ) {
setValues( {
[ name ]: String( amount - step ),
} as unknown as Product );
}
},
};
return (
<>
<WooProductSectionItem
id={ PRICING_SECTION_BASIC_ID }
location={ TAB_PRICING_ID }
pluginId={ PLUGIN_ID }
order={ 1 }
>
<ProductSectionLayout
title={ __( 'Pricing', 'woocommerce' ) }
description={
<>
<span>
{ __(
'Set a competitive price, put the product on sale, and manage tax calculations.',
'woocommerce'
) }
</span>
<Link
className="woocommerce-form-section__header-link"
href="https://woocommerce.com/posts/how-to-price-products-strategies-expert-tips/"
target="_blank"
type="external"
onClick={ () => {
recordEvent( 'add_product_pricing_help' );
} }
>
{ __(
'How to price your product: expert tips',
'woocommerce'
) }
</Link>
</>
}
>
<Card>
<CardBody>
<WooProductFieldItem.Slot
section={ PRICING_SECTION_BASIC_ID }
/>
</CardBody>
</Card>
<Card>
<CardBody>
<WooProductFieldItem.Slot
section={ PRICING_SECTION_TAXES_ID }
/>
</CardBody>
</Card>
</ProductSectionLayout>
</WooProductSectionItem>
<WooProductFieldItem
id="pricing/list"
section={ PRICING_SECTION_BASIC_ID }
pluginId={ PLUGIN_ID }
order={ 1 }
>
<PricingListField currencyInputProps={ currencyInputProps } />
</WooProductFieldItem>
<WooProductFieldItem
id="pricing/sale"
section={ PRICING_SECTION_BASIC_ID }
pluginId={ PLUGIN_ID }
order={ 3 }
>
<PricingSaleField currencyInputProps={ currencyInputProps } />
</WooProductFieldItem>
<WooProductFieldItem
id="pricing/taxes/charge"
section={ PRICING_SECTION_TAXES_ID }
pluginId={ PLUGIN_ID }
order={ 1 }
>
<PricingTaxesChargeField />
</WooProductFieldItem>
<WooProductFieldItem
id="pricing/taxes/class"
section={ PRICING_SECTION_TAXES_ID }
pluginId={ PLUGIN_ID }
order={ 3 }
>
<PricingTaxesClassField />
</WooProductFieldItem>
</>
);
};
registerPlugin( 'wc-admin-product-editor-pricing-section', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => <PricingSection />,
} );

View File

@ -16,7 +16,6 @@ import { Ref } from 'react';
*/ */
import { ProductFormHeader } from './layout/product-form-header'; import { ProductFormHeader } from './layout/product-form-header';
import { ProductFormLayout } from './layout/product-form-layout'; import { ProductFormLayout } from './layout/product-form-layout';
import { PricingSection } from './sections/pricing-section';
import { ProductVariationsSection } from './sections/product-variations-section'; import { ProductVariationsSection } from './sections/product-variations-section';
import { validate } from './product-validation'; import { validate } from './product-validation';
import { OptionsSection } from './sections/options-section'; import { OptionsSection } from './sections/options-section';
@ -26,6 +25,7 @@ import {
TAB_GENERAL_ID, TAB_GENERAL_ID,
TAB_SHIPPING_ID, TAB_SHIPPING_ID,
TAB_INVENTORY_ID, TAB_INVENTORY_ID,
TAB_PRICING_ID,
} from './fills/constants'; } from './fills/constants';
export const ProductForm: React.FC< { export const ProductForm: React.FC< {
@ -60,7 +60,9 @@ export const ProductForm: React.FC< {
title="Pricing" title="Pricing"
disabled={ !! product?.variations?.length } disabled={ !! product?.variations?.length }
> >
<PricingSection /> <WooProductSectionItem.Slot
location={ TAB_PRICING_ID }
/>
</ProductFormTab> </ProductFormTab>
<ProductFormTab <ProductFormTab
name="inventory" name="inventory"

View File

@ -19,11 +19,14 @@ import PostsNavigation from './shared/posts-navigation';
import { ProductFormLayout } from './layout/product-form-layout'; import { ProductFormLayout } from './layout/product-form-layout';
import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormFooter } from './layout/product-form-footer';
import { ProductFormTab } from './product-form-tab'; import { ProductFormTab } from './product-form-tab';
import { PricingSection } from './sections/pricing-section';
import { ProductVariationDetailsSection } from './sections/product-variation-details-section'; import { ProductVariationDetailsSection } from './sections/product-variation-details-section';
import { ProductVariationFormHeader } from './layout/product-variation-form-header'; import { ProductVariationFormHeader } from './layout/product-variation-form-header';
import useProductVariationNavigation from './hooks/use-product-variation-navigation'; import useProductVariationNavigation from './hooks/use-product-variation-navigation';
import { TAB_INVENTORY_ID, TAB_SHIPPING_ID } from './fills/constants'; import {
TAB_INVENTORY_ID,
TAB_SHIPPING_ID,
TAB_PRICING_ID,
} from './fills/constants';
import './product-variation-form.scss'; import './product-variation-form.scss';
@ -62,7 +65,9 @@ export const ProductVariationForm: React.FC< {
<ProductVariationDetailsSection /> <ProductVariationDetailsSection />
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="pricing" title="Pricing"> <ProductFormTab name="pricing" title="Pricing">
<PricingSection /> <WooProductSectionItem.Slot
location={ TAB_PRICING_ID }
/>
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="inventory" title="Inventory"> <ProductFormTab name="inventory" title="Inventory">
<WooProductSectionItem.Slot <WooProductSectionItem.Slot

View File

@ -1,480 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
CollapsibleContent,
DateTimePickerControl,
Link,
useFormContext,
__experimentalTooltip as Tooltip,
} from '@woocommerce/components';
import {
Product,
OPTIONS_STORE_NAME,
SETTINGS_STORE_NAME,
EXPERIMENTAL_TAX_CLASSES_STORE_NAME,
TaxClass,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useContext, useEffect, useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { format as formatDate } from '@wordpress/date';
import moment from 'moment';
import interpolateComponents from '@automattic/interpolate-components';
import {
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
BaseControl,
Card,
CardBody,
ToggleControl,
RadioControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import './pricing-section.scss';
import { formatCurrencyDisplayValue } from './utils';
import { ProductSectionLayout } from '../layout/product-section-layout';
import { ADMIN_URL } from '../../utils/admin-settings';
import { CurrencyContext } from '../../lib/currency-context';
import { useProductHelper } from '../use-product-helper';
import { STANDARD_RATE_TAX_CLASS_SLUG } from '../constants';
const PRODUCT_SCHEDULED_SALE_SLUG = 'product-scheduled-sale';
export const PricingSection: React.FC = () => {
const { sanitizePrice } = useProductHelper();
const { getInputProps, setValues, values } = useFormContext< Product >();
const [ showSaleSchedule, setShowSaleSchedule ] = useState( false );
const [ userToggledSaleSchedule, setUserToggledSaleSchedule ] =
useState( false );
const [ autoToggledSaleSchedule, setAutoToggledSaleSchedule ] =
useState( false );
const {
isResolving: isTaxSettingsResolving,
taxSettings,
taxesEnabled,
} = useSelect( ( select ) => {
const { getSettings, hasFinishedResolution } =
select( SETTINGS_STORE_NAME );
return {
isResolving: ! hasFinishedResolution( 'getSettings', [ 'tax' ] ),
taxSettings: getSettings( 'tax' ).tax || {},
taxesEnabled:
getSettings( 'general' )?.general?.woocommerce_calc_taxes ===
'yes',
};
} );
const { isResolving: isTaxClassesResolving, taxClasses } = useSelect(
( select ) => {
const { hasFinishedResolution, getTaxClasses } = select(
EXPERIMENTAL_TAX_CLASSES_STORE_NAME
);
return {
isResolving: ! hasFinishedResolution( 'getTaxClasses' ),
taxClasses: getTaxClasses< TaxClass[] >(),
};
}
);
const pricesIncludeTax =
taxSettings.woocommerce_prices_include_tax === 'yes';
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const taxIncludedInPriceText = __(
'Per your {{link}}store settings{{/link}}, tax is {{strong}}included{{/strong}} in the price.',
'woocommerce'
);
const taxNotIncludedInPriceText = __(
'Per your {{link}}store settings{{/link}}, tax is {{strong}}not included{{/strong}} in the price.',
'woocommerce'
);
const { dateFormat, timeFormat } = useSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
return {
dateFormat: ( getOption( 'date_format' ) as string ) || 'F j, Y',
timeFormat: ( getOption( 'time_format' ) as string ) || 'H:i',
};
} );
const onSaleScheduleToggleChange = ( value: boolean ) => {
recordEvent( 'product_pricing_schedule_sale_toggle_click', {
enabled: value,
} );
setUserToggledSaleSchedule( true );
setShowSaleSchedule( value );
if ( value ) {
setValues( {
date_on_sale_from_gmt: moment().startOf( 'day' ).toISOString(),
date_on_sale_to_gmt: null,
} as Product );
} else {
setValues( {
date_on_sale_from_gmt: null,
date_on_sale_to_gmt: null,
} as Product );
}
};
useEffect( () => {
if ( userToggledSaleSchedule || autoToggledSaleSchedule ) {
return;
}
const hasDateOnSaleFrom =
typeof values.date_on_sale_from_gmt === 'string' &&
values.date_on_sale_from_gmt.length > 0;
const hasDateOnSaleTo =
typeof values.date_on_sale_to_gmt === 'string' &&
values.date_on_sale_to_gmt.length > 0;
const hasSaleSchedule = hasDateOnSaleFrom || hasDateOnSaleTo;
if ( hasSaleSchedule ) {
setAutoToggledSaleSchedule( true );
setShowSaleSchedule( true );
}
}, [ userToggledSaleSchedule, autoToggledSaleSchedule, values ] );
const taxSettingsElement = interpolateComponents( {
mixedString: pricesIncludeTax
? taxIncludedInPriceText
: taxNotIncludedInPriceText,
components: {
link: (
<Link
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=tax` }
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'product_pricing_list_price_help_tax_settings_click'
);
} }
>
<></>
</Link>
),
strong: <strong />,
},
} );
const currencyInputProps = {
prefix: currencyConfig.symbol,
className: 'half-width-field components-currency-control',
sanitize: ( value: Product[ keyof Product ] ) => {
return sanitizePrice( String( value ) );
},
onFocus( event: React.FocusEvent< HTMLInputElement > ) {
// In some browsers like safari .select() function inside
// the onFocus event doesn't work as expected because it
// conflicts with onClick the first time user click the
// input. Using setTimeout defers the text selection and
// avoid the unexpected behaviour.
setTimeout(
function deferSelection( element: HTMLInputElement ) {
element.select();
},
0,
event.currentTarget
);
},
onKeyUp( event: React.KeyboardEvent< HTMLInputElement > ) {
const name = event.currentTarget.name as keyof Pick<
Product,
'regular_price' | 'sale_price'
>;
const amount = Number.parseFloat(
sanitizePrice( values[ name ] || '0' )
);
const step = Number( event.currentTarget.step || '1' );
if ( event.code === 'ArrowUp' ) {
setValues( {
[ name ]: String( amount + step ),
} as unknown as Product );
}
if ( event.code === 'ArrowDown' ) {
setValues( {
[ name ]: String( amount - step ),
} as unknown as Product );
}
},
};
const regularPriceProps = getInputProps(
'regular_price',
currencyInputProps
);
const salePriceProps = getInputProps( 'sale_price', currencyInputProps );
const dateTimePickerProps = {
className: 'woocommerce-product__date-time-picker',
isDateOnlyPicker: true,
dateTimeFormat: dateFormat,
};
const taxStatusProps = getInputProps( 'tax_status' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete taxStatusProps.checked;
delete taxStatusProps.value;
const taxClassProps = getInputProps( 'tax_class' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete taxClassProps.checked;
delete taxClassProps.value;
return (
<ProductSectionLayout
title={ __( 'Pricing', 'woocommerce' ) }
description={
<>
<span>
{ __(
'Set a competitive price, put the product on sale, and manage tax calculations.',
'woocommerce'
) }
</span>
<Link
className="woocommerce-form-section__header-link"
href="https://woocommerce.com/posts/how-to-price-products-strategies-expert-tips/"
target="_blank"
type="external"
onClick={ () => {
recordEvent( 'add_product_pricing_help' );
} }
>
{ __(
'How to price your product: expert tips',
'woocommerce'
) }
</Link>
</>
}
>
<Card>
<CardBody>
<BaseControl
id="product_pricing_regular_price"
help={ regularPriceProps?.help ?? '' }
>
<InputControl
{ ...regularPriceProps }
name="regular_price"
label={ __( 'List price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( regularPriceProps?.value ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
{ ! isTaxSettingsResolving && (
<span className="woocommerce-product-form__secondary-text">
{ taxSettingsElement }
</span>
) }
<BaseControl
id="product_pricing_sale_price"
help={ salePriceProps?.help ?? '' }
>
<InputControl
{ ...salePriceProps }
name="sale_price"
label={ __( 'Sale price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( salePriceProps?.value ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
<ToggleControl
label={
<>
{ __( 'Schedule sale', 'woocommerce' ) }
<Tooltip
text={ interpolateComponents( {
mixedString: __(
'The sale will start at the beginning of the "From" date ({{startTime/}}) and expire at the end of the "To" date ({{endTime/}}). {{moreLink/}}',
'woocommerce'
),
components: {
startTime: (
<span>
{ formatDate(
timeFormat,
moment().startOf(
'day'
)
) }
</span>
),
endTime: (
<span>
{ formatDate(
timeFormat,
moment().endOf( 'day' )
) }
</span>
),
moreLink: (
<Link
href="https://woocommerce.com/document/managing-products/#product-data"
target="_blank"
type="external"
onClick={ () =>
recordEvent(
'add_product_learn_more',
{
category:
PRODUCT_SCHEDULED_SALE_SLUG,
}
)
}
>
{ __(
'Learn more',
'woocommerce'
) }
</Link>
),
},
} ) }
/>
</>
}
checked={ showSaleSchedule }
onChange={ onSaleScheduleToggleChange }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore disabled prop exists
disabled={ ! ( values.sale_price?.length > 0 ) }
/>
{ showSaleSchedule && (
<>
<DateTimePickerControl
label={ __( 'From', 'woocommerce' ) }
placeholder={ __( 'Now', 'woocommerce' ) }
timeForDateOnly={ 'start-of-day' }
currentDate={ values.date_on_sale_from_gmt }
{ ...getInputProps( 'date_on_sale_from_gmt', {
...dateTimePickerProps,
} ) }
/>
<DateTimePickerControl
label={ __( 'To', 'woocommerce' ) }
placeholder={ __(
'No end date',
'woocommerce'
) }
timeForDateOnly={ 'end-of-day' }
currentDate={ values.date_on_sale_to_gmt }
{ ...getInputProps( 'date_on_sale_to_gmt', {
...dateTimePickerProps,
} ) }
/>
</>
) }
</CardBody>
</Card>
{ taxesEnabled && (
<Card>
<CardBody>
<RadioControl
{ ...taxStatusProps }
label={ __( 'Charge sales tax on', 'woocommerce' ) }
options={ [
{
label: __(
'Product and shipping',
'woocommerce'
),
value: 'taxable',
},
{
label: __( 'Only shipping', 'woocommerce' ),
value: 'shipping',
},
{
label: __(
'Dont charge tax',
'woocommerce'
),
value: 'none',
},
] }
/>
<CollapsibleContent
toggleText={ __( 'Advanced', 'woocommerce' ) }
>
{ ! isTaxClassesResolving &&
taxClasses.length > 0 && (
<RadioControl
{ ...taxClassProps }
label={
<>
<span>
{ __(
'Tax class',
'woocommerce'
) }
</span>
<span className="woocommerce-product-form__secondary-text">
{ interpolateComponents( {
mixedString: __(
'Apply a tax rate if this product qualifies for tax reduction or exemption. {{link}}Learn more{{/link}}',
'woocommerce'
),
components: {
link: (
<Link
href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class"
target="_blank"
type="external"
>
<></>
</Link>
),
},
} ) }
</span>
</>
}
options={ taxClasses.map(
( taxClass ) => ( {
label: taxClass.name,
value:
taxClass.slug ===
STANDARD_RATE_TAX_CLASS_SLUG
? ''
: taxClass.slug,
} )
) }
/>
) }
</CollapsibleContent>
</CardBody>
</Card>
) }
</ProductSectionLayout>
);
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Migrating product editor pricing section to slot fills.