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:
parent
0beb6b7503
commit
4341a53144
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Altering styles to correctly target fields within slot fills on product editor.
|
|
@ -11,9 +11,9 @@
|
|||
&__body {
|
||||
padding: $gap-large;
|
||||
|
||||
> .components-base-control,
|
||||
> .components-dropdown,
|
||||
> .woocommerce-rich-text-editor {
|
||||
.components-base-control,
|
||||
.components-dropdown,
|
||||
.woocommerce-rich-text-editor {
|
||||
&:not(:first-child):not(.components-radio-control) {
|
||||
margin-top: $gap-large - $gap-smaller;
|
||||
margin-bottom: 0;
|
||||
|
|
|
@ -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 INVENTORY_SECTION_ID = 'inventory/inventory';
|
||||
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_GENERAL_ID = 'tab/general';
|
||||
export const TAB_SHIPPING_ID = 'tab/shipping';
|
||||
export const TAB_PRICING_ID = 'tab/pricing';
|
||||
|
||||
export const PLUGIN_ID = 'woocommerce';
|
||||
export const PRODUCT_DETAILS_SLUG = 'product-details';
|
||||
|
|
|
@ -8,3 +8,4 @@ export * from './details-section/details-section-fills';
|
|||
export * from './images-section/images-section-fills';
|
||||
export * from './attributes-section/attributes-section-fills';
|
||||
export * from './inventory-section/inventory-section-fills';
|
||||
export * from './pricing-section/pricing-section-fills';
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
} ) }
|
||||
/>
|
||||
</>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
] }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 />,
|
||||
} );
|
|
@ -16,7 +16,6 @@ import { Ref } from 'react';
|
|||
*/
|
||||
import { ProductFormHeader } from './layout/product-form-header';
|
||||
import { ProductFormLayout } from './layout/product-form-layout';
|
||||
import { PricingSection } from './sections/pricing-section';
|
||||
import { ProductVariationsSection } from './sections/product-variations-section';
|
||||
import { validate } from './product-validation';
|
||||
import { OptionsSection } from './sections/options-section';
|
||||
|
@ -26,6 +25,7 @@ import {
|
|||
TAB_GENERAL_ID,
|
||||
TAB_SHIPPING_ID,
|
||||
TAB_INVENTORY_ID,
|
||||
TAB_PRICING_ID,
|
||||
} from './fills/constants';
|
||||
|
||||
export const ProductForm: React.FC< {
|
||||
|
@ -60,7 +60,9 @@ export const ProductForm: React.FC< {
|
|||
title="Pricing"
|
||||
disabled={ !! product?.variations?.length }
|
||||
>
|
||||
<PricingSection />
|
||||
<WooProductSectionItem.Slot
|
||||
location={ TAB_PRICING_ID }
|
||||
/>
|
||||
</ProductFormTab>
|
||||
<ProductFormTab
|
||||
name="inventory"
|
||||
|
|
|
@ -19,11 +19,14 @@ import PostsNavigation from './shared/posts-navigation';
|
|||
import { ProductFormLayout } from './layout/product-form-layout';
|
||||
import { ProductFormFooter } from './layout/product-form-footer';
|
||||
import { ProductFormTab } from './product-form-tab';
|
||||
import { PricingSection } from './sections/pricing-section';
|
||||
import { ProductVariationDetailsSection } from './sections/product-variation-details-section';
|
||||
import { ProductVariationFormHeader } from './layout/product-variation-form-header';
|
||||
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';
|
||||
|
||||
|
@ -62,7 +65,9 @@ export const ProductVariationForm: React.FC< {
|
|||
<ProductVariationDetailsSection />
|
||||
</ProductFormTab>
|
||||
<ProductFormTab name="pricing" title="Pricing">
|
||||
<PricingSection />
|
||||
<WooProductSectionItem.Slot
|
||||
location={ TAB_PRICING_ID }
|
||||
/>
|
||||
</ProductFormTab>
|
||||
<ProductFormTab name="inventory" title="Inventory">
|
||||
<WooProductSectionItem.Slot
|
||||
|
|
|
@ -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: __(
|
||||
'Don’t 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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Migrating product editor pricing section to slot fills.
|
Loading…
Reference in New Issue