Commit 5d7f4cddd59 for woocommerce

commit 5d7f4cddd5958a71bc0620e9acc9b71f3b5c2c88
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue Apr 21 15:28:40 2026 +0200

    [DataViews - All Products] Add shared field controls (#64268)

    * Add shared controls for products app fields

    * remove not necessary code

    * clean up code

    * Update packages/js/experimental-products-app/tsconfig.json

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

    * Update packages/js/experimental-products-app/src/fields/components/date-picker.tsx

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

    * Fix list-item: import style.scss and default alt to empty string

    Agent-Logs-Url: https://github.com/woocommerce/woocommerce/sessions/f3cf7f22-fc05-4c03-a256-0b68bdb81cd5

    Co-authored-by: gigitux <4463174+gigitux@users.noreply.github.com>

    * fix build

    * fix lint

    ---------

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
    Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
    Co-authored-by: gigitux <4463174+gigitux@users.noreply.github.com>

diff --git a/packages/js/experimental-products-app/changelog/add-products-fields-shared-foundation b/packages/js/experimental-products-app/changelog/add-products-fields-shared-foundation
new file mode 100644
index 00000000000..0ebf062f5b0
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-fields-shared-foundation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add shared products app field controls and utilities
diff --git a/packages/js/experimental-products-app/src/app.tsx b/packages/js/experimental-products-app/src/app.tsx
index 5e989ce2de8..32f9b669e20 100644
--- a/packages/js/experimental-products-app/src/app.tsx
+++ b/packages/js/experimental-products-app/src/app.tsx
@@ -1,7 +1,6 @@
 /**
  * External dependencies
  */
-import { createElement } from '@wordpress/element';
 import { privateApis as routerPrivateApis } from '@wordpress/router';
 import { privateApis as themeProviderPrivateApis } from '@wordpress/theme';
 import { UnsavedChangesWarning } from '@wordpress/editor';
diff --git a/packages/js/experimental-products-app/src/fields/components/currency-input.tsx b/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
new file mode 100644
index 00000000000..dbf2abfb7c0
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/currency-input.tsx
@@ -0,0 +1,79 @@
+/**
+ * External dependencies
+ */
+import {
+	privateApis,
+	__experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
+	__experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
+} from '@wordpress/components';
+
+import type { DataFormControlProps } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+
+import type { ProductEntityRecord } from '../types';
+
+import { getCurrencyObject } from '../utils/currency';
+
+const { ValidatedInputControl } = unlock( privateApis );
+
+const { symbol, symbolPosition, precision } = getCurrencyObject();
+const isCurrencyLeft =
+	symbolPosition === 'left' || symbolPosition === 'left_space';
+// Step matches store decimal precision (e.g. precision=2 → step=0.01).
+const step = Math.pow( 10, -precision );
+
+type PriceField = 'regular_price' | 'sale_price';
+
+/**
+ * Shared Edit component for currency price fields (regular_price, sale_price).
+ * Renders a number input with min=0 and currency prefix/suffix.
+ *
+ * @param root0          Props from DataForm.
+ * @param root0.data     Current product entity record.
+ * @param root0.field    Normalized field definition.
+ * @param root0.onChange Callback to update entity values.
+ * @param root0.validity Per-rule validation state from useFormValidity.
+ */
+export function CurrencyInput( {
+	data,
+	field,
+	onChange,
+	validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+	const fieldId = field.id as PriceField;
+	const value = data[ fieldId ] ?? '';
+
+	return (
+		// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
+		<ValidatedInputControl
+			id={ `currency-input-${ fieldId }` }
+			label={ field.label }
+			value={ value }
+			onChange={ ( newValue: string ) => {
+				onChange( { [ fieldId ]: newValue } );
+			} }
+			type="number"
+			min={ 0 }
+			step={ step }
+			customValidity={ validity?.custom }
+			prefix={
+				isCurrencyLeft ? (
+					<InputControlPrefixWrapper>
+						{ symbol }
+					</InputControlPrefixWrapper>
+				) : undefined
+			}
+			suffix={
+				! isCurrencyLeft ? (
+					<InputControlSuffixWrapper>
+						{ symbol }
+					</InputControlSuffixWrapper>
+				) : undefined
+			}
+		/>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/fields/components/date-picker.tsx b/packages/js/experimental-products-app/src/fields/components/date-picker.tsx
new file mode 100644
index 00000000000..34bbfe5eea7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/date-picker.tsx
@@ -0,0 +1,68 @@
+/**
+ * External dependencies
+ */
+import { InputControl } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+
+import type { ProductEntityRecord } from '../types';
+
+type DateFieldKey = 'date_on_sale_from' | 'date_on_sale_to';
+
+type DatePickerProps = {
+	data: ProductEntityRecord;
+	onChange: ( value: Partial< ProductEntityRecord > ) => void;
+	field: {
+		id?: string;
+		label?: string;
+	};
+	fieldKey: DateFieldKey;
+	min?: Date;
+};
+
+export function parseDateTimeLocal( value: string ) {
+	if ( ! value ) {
+		return null;
+	}
+
+	const parsedDate = new Date( value );
+
+	if ( Number.isNaN( parsedDate.getTime() ) ) {
+		return null;
+	}
+
+	return parsedDate;
+}
+
+export function formatDateTimeLocal( date: Date ) {
+	const year = date.getFullYear();
+	const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
+	const day = String( date.getDate() ).padStart( 2, '0' );
+	const hours = String( date.getHours() ).padStart( 2, '0' );
+	const minutes = String( date.getMinutes() ).padStart( 2, '0' );
+
+	return `${ year }-${ month }-${ day }T${ hours }:${ minutes }`;
+}
+
+export function DatePicker( {
+	data,
+	onChange,
+	field,
+	fieldKey,
+	min,
+}: DatePickerProps ) {
+	return (
+		<InputControl
+			label={ field.label }
+			type="datetime-local"
+			value={ data[ fieldKey ] || '' }
+			min={ min ? formatDateTimeLocal( min ) : undefined }
+			onChange={ ( event ) =>
+				onChange( {
+					[ fieldKey ]: event.target.value || null,
+				} as Partial< ProductEntityRecord > )
+			}
+		/>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/fields/components/dimension.tsx b/packages/js/experimental-products-app/src/fields/components/dimension.tsx
new file mode 100644
index 00000000000..4abcb6249db
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/dimension.tsx
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import { useEntityRecord } from '@wordpress/core-data';
+
+import type { Field } from '@wordpress/dataviews';
+import { InputControl, InputLayout } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, SettingsEntityRecord } from '../types';
+
+export type DimensionKey = 'height' | 'width' | 'length';
+
+export const createDimensionField = (
+	key: DimensionKey
+): Partial< Field< ProductEntityRecord > > => {
+	return {
+		Edit: ( { data, onChange, field } ) => {
+			const {
+				record: storeProductsSettings,
+				isResolving: storeProductsSettingsResolving,
+			} = useEntityRecord< SettingsEntityRecord >(
+				'root',
+				'settings',
+				'products'
+			);
+
+			if ( storeProductsSettingsResolving ) {
+				return null;
+			}
+
+			const dimensionUnit =
+				storeProductsSettings?.values?.woocommerce_dimension_unit;
+
+			return (
+				<InputControl
+					label={ field.label }
+					value={ data.dimensions[ key ] }
+					onChange={ ( event ) => {
+						onChange( {
+							dimensions: {
+								...data.dimensions,
+								[ key ]: event.target.value,
+							},
+						} );
+					} }
+					type="number"
+					min={ 0 }
+					step="any"
+					suffix={
+						<InputLayout.Slot padding="minimal">
+							{ dimensionUnit }
+						</InputLayout.Slot>
+					}
+				/>
+			);
+		},
+	};
+};
diff --git a/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx b/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
new file mode 100644
index 00000000000..4df12e55d94
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/list-item/index.tsx
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { close } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import { isValidElement } from '@wordpress/element';
+import { Stack } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+type Item = {
+	id: number | string;
+	title: string;
+	thumbnail?: string | JSX.Element;
+	meta?: string;
+	altText?: string;
+};
+
+interface ListItemProps {
+	items: Item[];
+	onRemove?: ( item: Item ) => void;
+	showRemoveButton?: boolean;
+}
+
+export const ListItem = ( {
+	items,
+	onRemove,
+	showRemoveButton = true,
+}: ListItemProps ) => {
+	return (
+		<Stack direction="column">
+			{ items.map( ( item ) => (
+				<Stack
+					key={ item.id }
+					direction="row"
+					align="center"
+					justify="space-between"
+					className="woocommerce-list-item"
+				>
+					{ item.thumbnail && (
+						<div className="woocommerce-list-item__thumbnail">
+							{ typeof item.thumbnail === 'string' && (
+								<img
+									src={ item.thumbnail }
+									alt={ item.altText ?? '' }
+									className="woocommerce-list-item__thumbnail-image"
+								/>
+							) }
+							{ isValidElement( item.thumbnail ) &&
+								item.thumbnail }
+						</div>
+					) }
+					<Stack
+						className="woocommerce-list-item__info"
+						direction="column"
+					>
+						<div className="woocommerce-list-item__title">
+							{ item.title }
+						</div>
+						{ item.meta && (
+							<div className="woocommerce-list-item__meta">
+								{ item.meta }
+							</div>
+						) }
+					</Stack>
+					{ showRemoveButton && onRemove && (
+						<Button
+							icon={ close }
+							variant="tertiary"
+							iconSize={ 16 }
+							onClick={ () => onRemove( item ) }
+							aria-label={ __( 'Remove item', 'woocommerce' ) }
+							className="woocommerce-list-item__remove-button"
+						/>
+					) }
+				</Stack>
+			) ) }
+		</Stack>
+	);
+};
diff --git a/packages/js/experimental-products-app/src/fields/components/list-item/style.scss b/packages/js/experimental-products-app/src/fields/components/list-item/style.scss
new file mode 100644
index 00000000000..888194ea7fc
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/list-item/style.scss
@@ -0,0 +1,46 @@
+.woocommerce-list-item {
+	padding: 12px 0;
+	border-bottom: 1px solid #e0e0e0;
+	width: 100%;
+
+	&:last-of-type {
+		border-bottom: none;
+	}
+
+	&__thumbnail {
+		width: 48px;
+		height: 48px;
+		border-radius: 4px;
+		flex-shrink: 0;
+
+		&-image {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+			border-radius: 4px;
+		}
+	}
+
+	&__info {
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__title {
+		font-size: 14px;
+		font-weight: 500;
+		color: #1e1e1e;
+	}
+
+	&__meta {
+		font-size: 12px;
+		color: #757575;
+		font-weight: 500;
+	}
+
+	&__remove-button {
+		color: #757575;
+		padding: 8px;
+		min-width: auto;
+	}
+}
diff --git a/packages/js/experimental-products-app/src/fields/components/product-selector.tsx b/packages/js/experimental-products-app/src/fields/components/product-selector.tsx
new file mode 100644
index 00000000000..64de2dfd6cd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/product-selector.tsx
@@ -0,0 +1,195 @@
+/**
+ * External dependencies
+ */
+import { BaseControl, FormTokenField, Spinner } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { resolveSelect } from '@wordpress/data';
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Product } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductStatus } from '../types';
+
+type ProductSelectorProps = {
+	label?: string;
+	description?: string;
+	selectedProductIds: number[];
+	onSelectedProductIdsChange: ( ids: number[] ) => void;
+	excludeProductIds?: number[];
+	includeProductStatuses?: ProductStatus[];
+};
+
+const SEARCH_DELAY_MS = 250;
+
+function getProductToken( product: Pick< Product, 'id' | 'name' > ) {
+	return `${ product.name || __( '(Untitled product)', 'woocommerce' ) } (#${
+		product.id
+	})`;
+}
+
+export function ProductSelector( {
+	label,
+	description,
+	selectedProductIds,
+	onSelectedProductIdsChange,
+	excludeProductIds = [],
+	includeProductStatuses,
+}: ProductSelectorProps ) {
+	const [ inputValue, setInputValue ] = useState( '' );
+	const [ suggestions, setSuggestions ] = useState< Product[] >( [] );
+	const [ selectedProducts, setSelectedProducts ] = useState< Product[] >(
+		[]
+	);
+	const [ isLoading, setIsLoading ] = useState( false );
+
+	useEffect( () => {
+		let cancelled = false;
+
+		if ( selectedProductIds.length === 0 ) {
+			setSelectedProducts( [] );
+			return;
+		}
+
+		setIsLoading( true );
+
+		void resolveSelect( coreStore )
+			.getEntityRecords( 'postType', 'product', {
+				include: selectedProductIds,
+				per_page: selectedProductIds.length,
+				status: includeProductStatuses,
+			} )
+			.then( ( records: unknown ) => {
+				if ( cancelled || ! Array.isArray( records ) ) {
+					return;
+				}
+
+				const productsById = new Map(
+					records.map( ( product ) => [
+						product.id,
+						product as Product,
+					] )
+				);
+				setSelectedProducts(
+					selectedProductIds
+						.map( ( id ) => productsById.get( id ) )
+						.filter(
+							( product ): product is Product =>
+								product !== undefined
+						)
+				);
+			} )
+			.finally( () => {
+				if ( ! cancelled ) {
+					setIsLoading( false );
+				}
+			} );
+
+		return () => {
+			cancelled = true;
+		};
+	}, [ includeProductStatuses, selectedProductIds ] );
+
+	useEffect( () => {
+		const query = inputValue.trim();
+
+		if ( ! query ) {
+			setSuggestions( [] );
+			return;
+		}
+
+		let cancelled = false;
+		setIsLoading( true );
+
+		const timer = window.setTimeout( () => {
+			void resolveSelect( coreStore )
+				.getEntityRecords( 'postType', 'product', {
+					search: query,
+					per_page: 20,
+					exclude: [ ...excludeProductIds, ...selectedProductIds ],
+					status: includeProductStatuses,
+				} )
+				.then( ( records: unknown ) => {
+					if ( ! cancelled ) {
+						setSuggestions(
+							Array.isArray( records )
+								? ( records as Product[] )
+								: []
+						);
+					}
+				} )
+				.finally( () => {
+					if ( ! cancelled ) {
+						setIsLoading( false );
+					}
+				} );
+		}, SEARCH_DELAY_MS );
+
+		return () => {
+			cancelled = true;
+			window.clearTimeout( timer );
+		};
+	}, [
+		excludeProductIds,
+		includeProductStatuses,
+		inputValue,
+		selectedProductIds,
+	] );
+
+	const productTokenMap = useMemo( () => {
+		const allProducts = [ ...selectedProducts, ...suggestions ];
+		return new Map(
+			allProducts.map( ( product ) => [
+				getProductToken( product ),
+				product.id,
+			] )
+		);
+	}, [ selectedProducts, suggestions ] );
+
+	return (
+		// eslint-disable-next-line @wordpress/no-base-control-with-label-without-id
+		<BaseControl
+			label={ label }
+			help={
+				isLoading ? (
+					<span
+						style={ {
+							display: 'inline-flex',
+							alignItems: 'center',
+							gap: '8px',
+						} }
+					>
+						<Spinner />
+						{ description ||
+							__( 'Loading products…', 'woocommerce' ) }
+					</span>
+				) : (
+					description
+				)
+			}
+		>
+			<FormTokenField
+				value={ selectedProducts.map( getProductToken ) }
+				suggestions={ suggestions.map( getProductToken ) }
+				onInputChange={ setInputValue }
+				onChange={ ( tokens ) => {
+					onSelectedProductIdsChange(
+						tokens
+							.map( ( token ) =>
+								productTokenMap.get(
+									typeof token === 'string'
+										? token
+										: token.value
+								)
+							)
+							.filter(
+								( id ): id is number => typeof id === 'number'
+							)
+					);
+				} }
+			/>
+		</BaseControl>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/fields/components/product-status-badge.tsx b/packages/js/experimental-products-app/src/fields/components/product-status-badge.tsx
new file mode 100644
index 00000000000..6ed56b9077b
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/components/product-status-badge.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge } from '@wordpress/ui';
+import { ProductStatus } from '@woocommerce/data';
+
+type BadgeStatusConfig = {
+	label: string;
+	intent?: React.ComponentProps< typeof Badge >[ 'intent' ];
+};
+
+const statuses = {
+	draft: {
+		label: __( 'Draft', 'woocommerce' ),
+		intent: 'draft',
+	},
+	publish: {
+		label: __( 'Active', 'woocommerce' ),
+		intent: 'stable',
+	},
+	trash: {
+		label: __( 'Trash', 'woocommerce' ),
+		intent: 'none',
+	},
+	'auto-draft': {
+		label: __( 'Draft', 'woocommerce' ),
+		intent: 'draft',
+	},
+	deleted: {
+		label: __( 'Deleted', 'woocommerce' ),
+		intent: 'none',
+	},
+	pending: {
+		label: __( 'Pending', 'woocommerce' ),
+		intent: 'none',
+	},
+	private: {
+		label: __( 'Private', 'woocommerce' ),
+		intent: 'none',
+	},
+	future: {
+		label: __( 'Scheduled', 'woocommerce' ),
+		intent: 'none',
+	},
+	any: {
+		label: __( 'Any', 'woocommerce' ),
+		intent: 'none',
+	},
+} satisfies Record< ProductStatus, BadgeStatusConfig >;
+
+export const ProductStatusBadge = ( { status }: { status: ProductStatus } ) => {
+	const statusData = statuses[ status ];
+
+	if ( ! statusData ) {
+		return <Badge intent="none">{ __( 'Unknown', 'woocommerce' ) }</Badge>;
+	}
+
+	return <Badge intent={ statusData.intent }>{ statusData.label }</Badge>;
+};
diff --git a/packages/js/experimental-products-app/src/fields/price/utils.ts b/packages/js/experimental-products-app/src/fields/price/utils.ts
new file mode 100644
index 00000000000..e7b27b729a6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/price/utils.ts
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+export const getLocalDefaultSaleStart = () => {
+	const tomorrow = new Date();
+	tomorrow.setDate( tomorrow.getDate() + 1 );
+	tomorrow.setHours( 7, 0, 0, 0 );
+	tomorrow.setMinutes( tomorrow.getMinutes() - tomorrow.getTimezoneOffset() );
+	return tomorrow.toISOString().slice( 0, 16 );
+};
+
+export const toNumberOrNaN = ( value: unknown ) => {
+	if ( typeof value === 'number' ) {
+		return value;
+	}
+
+	if ( typeof value === 'string' && value.trim() !== '' ) {
+		return Number.parseFloat( value );
+	}
+
+	return Number.NaN;
+};
+
+/**
+ * Validates that a price value is either empty or a valid non-negative number.
+ * Temporary: will be obsolete when price fields switch to the number field type.
+ *
+ * @param value The price value to validate.
+ * @return Null if valid, or an error message string if invalid.
+ */
+export function validatePrice( value: unknown ): string | null {
+	// Empty values are allowed (not required).
+	if (
+		value === undefined ||
+		value === null ||
+		( typeof value === 'string' && value.trim() === '' )
+	) {
+		return null;
+	}
+
+	const parsed = toNumberOrNaN( value );
+
+	if ( Number.isNaN( parsed ) ) {
+		return __( 'Please enter a valid price.', 'woocommerce' );
+	}
+
+	if ( parsed < 0 ) {
+		return __( 'Price must not be negative.', 'woocommerce' );
+	}
+
+	return null;
+}
diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
new file mode 100644
index 00000000000..7c2b61c1c55
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -0,0 +1,38 @@
+/**
+ * External dependencies
+ */
+import type {
+	Product,
+	ProductStatus as WooProductStatus,
+} from '@woocommerce/data';
+
+export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
+	categories: Array< {
+		id: number;
+		name?: string;
+		image?: {
+			src?: string;
+			alt?: string;
+		};
+	} >;
+	tags: Array< {
+		id: number;
+		name?: string;
+	} >;
+	cross_sell_ids?: number[];
+	upsell_ids?: number[];
+	date_on_sale_from?: string | null;
+	date_on_sale_to?: string | null;
+	seo_title?: string;
+	seo_description?: string;
+	visible_in_pos?: boolean;
+};
+
+export type ProductStatus = WooProductStatus;
+
+export type SettingsEntityRecord = {
+	values?: {
+		woocommerce_dimension_unit?: string;
+		woocommerce_weight_unit?: string;
+	};
+};
diff --git a/packages/js/experimental-products-app/src/fields/utils/currency.ts b/packages/js/experimental-products-app/src/fields/utils/currency.ts
new file mode 100644
index 00000000000..612ad8991b8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/utils/currency.ts
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import {
+	CURRENCY,
+	// @ts-expect-error - The CURRENCY object doesn't have types yet.
+} from '@woocommerce/settings';
+
+type CurrencyObject = {
+	code: string;
+	symbol: string;
+	symbolPosition: string;
+	precision: number;
+	decimalSeparator?: string;
+	thousandSeparator?: string;
+};
+
+export function getCurrencyObject(): CurrencyObject {
+	return {
+		code: CURRENCY.code || 'USD',
+		symbol: CURRENCY.symbol || '$',
+		symbolPosition: CURRENCY.symbolPosition || 'left',
+		precision: Number( CURRENCY.precision ?? 2 ),
+		decimalSeparator: CURRENCY.decimalSeparator,
+		thousandSeparator: CURRENCY.thousandSeparator,
+	};
+}
+
+export function formatCurrency(
+	value: number | string,
+	currencyCode = getCurrencyObject().code
+) {
+	const amount = typeof value === 'number' ? value : Number( value );
+	const locale =
+		typeof document !== 'undefined'
+			? document.documentElement.lang || 'en-US'
+			: 'en-US';
+	const { precision } = getCurrencyObject();
+
+	return new Intl.NumberFormat( locale, {
+		style: 'currency',
+		currency: currencyCode,
+		minimumFractionDigits: precision,
+		maximumFractionDigits: precision,
+	} ).format( amount );
+}
diff --git a/packages/js/experimental-products-app/src/layout.tsx b/packages/js/experimental-products-app/src/layout.tsx
index e76e1c884a6..71af0112f64 100644
--- a/packages/js/experimental-products-app/src/layout.tsx
+++ b/packages/js/experimental-products-app/src/layout.tsx
@@ -1,7 +1,6 @@
 /**
  * External dependencies
  */
-import { createElement, Fragment } from '@wordpress/element';
 import {
 	useViewportMatch,
 	useResizeObserver,
diff --git a/packages/js/experimental-products-app/src/products.tsx b/packages/js/experimental-products-app/src/products.tsx
index 83d07ddc6e9..74a38086004 100644
--- a/packages/js/experimental-products-app/src/products.tsx
+++ b/packages/js/experimental-products-app/src/products.tsx
@@ -1,13 +1,7 @@
 /**
  * External dependencies
  */
-import {
-	StrictMode,
-	Suspense,
-	createElement,
-	createRoot,
-	lazy,
-} from '@wordpress/element';
+import { StrictMode, Suspense, createRoot, lazy } from '@wordpress/element';
 import {
 	Root,
 	// @ts-expect-error missing types.
diff --git a/packages/js/experimental-products-app/src/router.tsx b/packages/js/experimental-products-app/src/router.tsx
index 68155949723..fe995bb0f2e 100644
--- a/packages/js/experimental-products-app/src/router.tsx
+++ b/packages/js/experimental-products-app/src/router.tsx
@@ -1,7 +1,6 @@
 /**
  * External dependencies
  */
-import { createElement } from '@wordpress/element';
 import { privateApis as routerPrivateApis } from '@wordpress/router';

 /**
diff --git a/packages/js/experimental-products-app/src/sidebar/index.tsx b/packages/js/experimental-products-app/src/sidebar/index.tsx
index a0a85f291c5..d1b2b751c72 100644
--- a/packages/js/experimental-products-app/src/sidebar/index.tsx
+++ b/packages/js/experimental-products-app/src/sidebar/index.tsx
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { createElement, useRef } from '@wordpress/element';
+import { useRef } from '@wordpress/element';

 function SidebarContentWrapper( { children }: { children: React.ReactNode } ) {
 	const wrapperRef = useRef< HTMLDivElement | null >( null );
diff --git a/packages/js/experimental-products-app/src/utilites/new-navigation.tsx b/packages/js/experimental-products-app/src/utilites/new-navigation.tsx
index 29b582a587a..2e527015673 100644
--- a/packages/js/experimental-products-app/src/utilites/new-navigation.tsx
+++ b/packages/js/experimental-products-app/src/utilites/new-navigation.tsx
@@ -1,12 +1,7 @@
 /**
  * External dependencies
  */
-import {
-	createElement,
-	createContext,
-	useState,
-	useContext,
-} from '@wordpress/element';
+import { createContext, useState, useContext } from '@wordpress/element';

 type NewNavigationContextType = {
 	showNewNavigation: boolean;
diff --git a/packages/js/experimental-products-app/tsconfig.json b/packages/js/experimental-products-app/tsconfig.json
index 6739b347343..205e7551412 100644
--- a/packages/js/experimental-products-app/tsconfig.json
+++ b/packages/js/experimental-products-app/tsconfig.json
@@ -8,17 +8,11 @@
 		"declarationMap": true,
 		"declarationDir": "./build-types",
 		"resolveJsonModule": true,
-		"typeRoots": [
-			"./typings",
-			"./node_modules/@types"
-		]
+		"typeRoots": [ "./typings", "./node_modules/@types" ],
+		"jsx": "react-jsx",
+		"jsxFactory": null,
+		"jsxFragmentFactory": null
 	},
-	"include": [
-		"typings/**/*",
-		"src/**/*"
-	],
-	"exclude": [
-		"**/test/**",
-		"**/stories/**"
-	]
+	"include": [ "src/**/*" ],
+	"exclude": [ "**/test/**", "**/stories/**" ]
 }