HEX
Server: LiteSpeed
System: Linux eko108.isimtescil.net 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: uyarreklamcomtr (11202)
PHP: 7.4.33
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/atomic.tar
blocks/component-init.js000064400000005720151547030350011331 0ustar00/**
 * External dependencies
 */
import { registerBlockComponent } from '@woocommerce/blocks-registry';
import { lazy } from '@wordpress/element';
import { WC_BLOCKS_BUILD_URL } from '@woocommerce/block-settings';

// Modify webpack publicPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
__webpack_public_path__ = WC_BLOCKS_BUILD_URL;

registerBlockComponent( {
	blockName: 'woocommerce/product-price',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-price" */ './product-elements/price/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-image',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-image" */ './product-elements/image/frontend'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-title',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-title" */ './product-elements/title/frontend'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-rating',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-rating" */ './product-elements/rating/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-rating-stars',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-rating-stars" */ './product-elements/rating-stars/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-rating-counter',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-rating-counter" */ './product-elements/rating-counter/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-average-rating',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-average-rating" */ './product-elements/average-rating/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-button',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-button" */ './product-elements/button/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-summary',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-summary" */ './product-elements/summary/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-sale-badge',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-sale-badge" */ './product-elements/sale-badge/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-sku',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-sku" */ './product-elements/sku/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-stock-indicator',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-stock-indicator" */ './product-elements/stock-indicator/block'
		)
	),
} );

registerBlockComponent( {
	blockName: 'woocommerce/product-add-to-cart',
	component: lazy( () =>
		import(
			/* webpackChunkName: "product-add-to-cart" */ './product-elements/add-to-cart/frontend'
		)
	),
} );
blocks/index.js000064400000001453151547030350007474 0ustar00/**
 * Internal dependencies
 */
import './product-elements/title';
import './product-elements/price';
import './product-elements/image';
import './product-elements/rating';
import './product-elements/rating-stars';
import './product-elements/rating-counter';
import './product-elements/average-rating';
import './product-elements/button';
import './product-elements/summary';
import './product-elements/sale-badge';
import './product-elements/sku';
import './product-elements/stock-indicator';
import './product-elements/add-to-cart';
import './product-elements/add-to-cart-form';
import './product-elements/product-image-gallery';
import './product-elements/product-details';
import './product-elements/product-reviews';
import './product-elements/related-products';
import './product-elements/product-meta';
blocks/product-elements/add-to-cart/attributes.ts000064400000000265151547030350016156 0ustar00export const blockAttributes = {
	showFormElements: {
		type: 'boolean',
		default: false,
	},
	productId: {
		type: 'number',
		default: 0,
	},
};

export default blockAttributes;
blocks/product-elements/add-to-cart/block.tsx000064400000003633151547030350015254 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import {
	AddToCartFormContextProvider,
	useAddToCartFormContext,
} from '@woocommerce/base-context';
import { useProductDataContext } from '@woocommerce/shared-context';
import { isEmpty } from '@woocommerce/types';
import { withProductDataContext } from '@woocommerce/shared-hocs';

/**
 * Internal dependencies
 */
import './style.scss';
import { AddToCartButton } from './shared';
import {
	SimpleProductForm,
	VariableProductForm,
	ExternalProductForm,
	GroupedProductForm,
} from './product-types';

interface Props {
	/**
	 * CSS Class name for the component.
	 */
	className?: string;
	/**
	 * Whether or not to show form elements.
	 */
	showFormElements?: boolean;
}

/**
 * Renders the add to cart form using useAddToCartFormContext.
 */
const AddToCartForm = () => {
	const { showFormElements, productType } = useAddToCartFormContext();

	if ( showFormElements ) {
		if ( productType === 'variable' ) {
			return <VariableProductForm />;
		}
		if ( productType === 'grouped' ) {
			return <GroupedProductForm />;
		}
		if ( productType === 'external' ) {
			return <ExternalProductForm />;
		}
		if ( productType === 'simple' || productType === 'variation' ) {
			return <SimpleProductForm />;
		}
		return null;
	}

	return <AddToCartButton />;
};

/**
 * Product Add to Form Block Component.
 */
const Block = ( { className, showFormElements }: Props ) => {
	const { product } = useProductDataContext();
	const componentClass = classnames(
		className,
		'wc-block-components-product-add-to-cart',
		{
			'wc-block-components-product-add-to-cart--placeholder':
				isEmpty( product ),
		}
	);

	return (
		<AddToCartFormContextProvider
			product={ product }
			showFormElements={ showFormElements }
		>
			<div className={ componentClass }>
				<AddToCartForm />
			</div>
		</AddToCartFormContextProvider>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/add-to-cart/constants.tsx000064400000000766151547030350016202 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { cart } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';

export const BLOCK_TITLE = __( 'Add to Cart', 'woo-gutenberg-products-block' );
export const BLOCK_ICON = (
	<Icon icon={ cart } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION = __(
	'Displays an add to cart button. Optionally displays other add to cart form elements.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/add-to-cart/edit.tsx000064400000004642151547030350015110 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import EditProductLink from '@woocommerce/editor-components/edit-product-link';
import { useProductDataContext } from '@woocommerce/shared-context';
import classnames from 'classnames';
import {
	Disabled,
	PanelBody,
	ToggleControl,
	Notice,
} from '@wordpress/components';
import { InspectorControls } from '@wordpress/block-editor';
import { productSupportsAddToCartForm } from '@woocommerce/base-utils';

/**
 * Internal dependencies
 */
import './style.scss';
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import { BLOCK_TITLE, BLOCK_ICON } from './constants';

interface EditProps {
	attributes: {
		className: string;
		showFormElements: boolean;
	};
	setAttributes: ( attributes: { showFormElements: boolean } ) => void;
}

const Edit = ( { attributes, setAttributes }: EditProps ) => {
	const { product } = useProductDataContext();
	const { className, showFormElements } = attributes;

	return (
		<div
			className={ classnames(
				className,
				'wc-block-components-product-add-to-cart'
			) }
		>
			<EditProductLink productId={ product.id } />
			<InspectorControls>
				<PanelBody
					title={ __( 'Layout', 'woo-gutenberg-products-block' ) }
				>
					{ productSupportsAddToCartForm( product ) ? (
						<ToggleControl
							label={ __(
								'Display form elements',
								'woo-gutenberg-products-block'
							) }
							help={ __(
								'Depending on product type, allow customers to select a quantity, variations etc.',
								'woo-gutenberg-products-block'
							) }
							checked={ showFormElements }
							onChange={ () =>
								setAttributes( {
									showFormElements: ! showFormElements,
								} )
							}
						/>
					) : (
						<Notice
							className="wc-block-components-product-add-to-cart-notice"
							isDismissible={ false }
							status="info"
						>
							{ __(
								'This product does not support the block based add to cart form. A link to the product page will be shown instead.',
								'woo-gutenberg-products-block'
							) }
						</Notice>
					) }
				</PanelBody>
			</InspectorControls>
			<Disabled>
				<Block { ...attributes } />
			</Disabled>
		</div>
	);
};

export default withProductSelector( {
	icon: BLOCK_ICON,
	label: BLOCK_TITLE,
	description: __(
		'Choose a product to display its add to cart form.',
		'woo-gutenberg-products-block'
	),
} )( Edit );
blocks/product-elements/add-to-cart/frontend.ts000064400000000411151547030350015600 0ustar00/**
 * External dependencies
 */
import { withFilteredAttributes } from '@woocommerce/shared-hocs';

/**
 * Internal dependencies
 */
import Block from './block';
import attributes from './attributes';

export default withFilteredAttributes( attributes )( Block );
blocks/product-elements/add-to-cart/index.ts000064400000001064151547030350015075 0ustar00/**
 * External dependencies
 */
import { registerExperimentalBlockType } from '@woocommerce/block-settings';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import edit from './edit';
import attributes from './attributes';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';

const blockConfig = {
	title,
	description,
	icon: { src: icon },
	edit,
	attributes,
};

registerExperimentalBlockType( 'woocommerce/product-add-to-cart', {
	...sharedConfig,
	...blockConfig,
} );
blocks/product-elements/add-to-cart/product-types/external.tsx000064400000000336151547030350020623 0ustar00/**
 * Internal dependencies
 */
import AddToCartButton from '../shared/add-to-cart-button';

/**
 * External Product Add To Cart Form
 */
const External = () => {
	return <AddToCartButton />;
};

export default External;
blocks/product-elements/add-to-cart/product-types/grouped.tsx000064400000000245151547030350020445 0ustar00/**
 * Grouped Product Add To Cart Form
 */
const Grouped = () => (
	<p>This is a placeholder for the grouped products form element.</p>
);

export default Grouped;
blocks/product-elements/add-to-cart/product-types/index.ts000064400000000364151547030350017721 0ustar00export { default as SimpleProductForm } from './simple';
export { default as VariableProductForm } from './variable/index';
export { default as ExternalProductForm } from './external';
export { default as GroupedProductForm } from './grouped';
blocks/product-elements/add-to-cart/product-types/simple.tsx000064400000002106151547030350020267 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { useAddToCartFormContext } from '@woocommerce/base-context';

/**
 * Internal dependencies
 */
import { AddToCartButton, QuantityInput, ProductUnavailable } from '../shared';

/**
 * Simple Product Add To Cart Form
 */
const Simple = () => {
	// @todo Add types for `useAddToCartFormContext`
	const {
		product,
		quantity,
		minQuantity,
		maxQuantity,
		multipleOf,
		dispatchActions,
		isDisabled,
	} = useAddToCartFormContext();

	if ( product.id && ! product.is_purchasable ) {
		return <ProductUnavailable />;
	}

	if ( product.id && ! product.is_in_stock ) {
		return (
			<ProductUnavailable
				reason={ __(
					'This product is currently out of stock and cannot be purchased.',
					'woo-gutenberg-products-block'
				) }
			/>
		);
	}

	return (
		<>
			<QuantityInput
				value={ quantity }
				min={ minQuantity }
				max={ maxQuantity }
				step={ multipleOf }
				disabled={ isDisabled }
				onChange={ dispatchActions.setQuantity }
			/>
			<AddToCartButton />
		</>
	);
};

export default Simple;
blocks/product-elements/add-to-cart/product-types/variable/index.tsx000064400000002347151547030350021701 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { useAddToCartFormContext } from '@woocommerce/base-context';

/**
 * Internal dependencies
 */
import {
	AddToCartButton,
	QuantityInput,
	ProductUnavailable,
} from '../../shared';
import VariationAttributes from './variation-attributes';

/**
 * Variable Product Add To Cart Form
 */
const Variable = () => {
	// @todo Add types for `useAddToCartFormContext`
	const {
		product,
		quantity,
		minQuantity,
		maxQuantity,
		multipleOf,
		dispatchActions,
		isDisabled,
	} = useAddToCartFormContext();

	if ( product.id && ! product.is_purchasable ) {
		return <ProductUnavailable />;
	}

	if ( product.id && ! product.is_in_stock ) {
		return (
			<ProductUnavailable
				reason={ __(
					'This product is currently out of stock and cannot be purchased.',
					'woo-gutenberg-products-block'
				) }
			/>
		);
	}

	return (
		<>
			<VariationAttributes
				product={ product }
				dispatchers={ dispatchActions }
			/>
			<QuantityInput
				value={ quantity }
				min={ minQuantity }
				max={ maxQuantity }
				step={ multipleOf }
				disabled={ isDisabled }
				onChange={ dispatchActions.setQuantity }
			/>
			<AddToCartButton />
		</>
	);
};

export default Variable;
blocks/product-elements/add-to-cart/product-types/variable/types.ts000064400000000427151547030350021543 0ustar00/**
 * External dependencies
 */
import { Dictionary } from '@woocommerce/types';

export type AttributesMap = Record<
	string,
	{ id: number; attributes: Dictionary }
>;

export interface VariationParam {
	id: number;
	variation: {
		attribute: string;
		value: string;
	}[];
}
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.tsx000064400000006726151547030350030235 0ustar00/**
 * External dependencies
 */
import { useState, useEffect, useMemo } from '@wordpress/element';
import { useShallowEqual } from '@woocommerce/base-hooks';
import type { SelectControl } from '@wordpress/components';
import { Dictionary, ProductResponseAttributeItem } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import AttributeSelectControl from './attribute-select-control';
import {
	getVariationMatchingSelectedAttributes,
	getActiveSelectControlOptions,
	getDefaultAttributes,
} from './utils';
import { AttributesMap, VariationParam } from '../types';

interface Props {
	attributes: Record< string, ProductResponseAttributeItem >;
	setRequestParams: ( param: VariationParam ) => void;
	variationAttributes: AttributesMap;
}

/**
 * AttributePicker component.
 */
const AttributePicker = ( {
	attributes,
	variationAttributes,
	setRequestParams,
}: Props ) => {
	const currentAttributes = useShallowEqual( attributes );
	const currentVariationAttributes = useShallowEqual( variationAttributes );
	const [ variationId, setVariationId ] = useState( 0 );
	const [ selectedAttributes, setSelectedAttributes ] =
		useState< Dictionary >( {} );
	const [ hasSetDefaults, setHasSetDefaults ] = useState( false );

	// Get options for each attribute picker.
	const filteredAttributeOptions = useMemo( () => {
		return getActiveSelectControlOptions(
			currentAttributes,
			currentVariationAttributes,
			selectedAttributes
		);
	}, [ selectedAttributes, currentAttributes, currentVariationAttributes ] );

	// Set default attributes as selected.
	useEffect( () => {
		if ( ! hasSetDefaults ) {
			const defaultAttributes = getDefaultAttributes( attributes );
			if ( defaultAttributes ) {
				setSelectedAttributes( {
					...defaultAttributes,
				} );
			}
			setHasSetDefaults( true );
		}
	}, [ selectedAttributes, attributes, hasSetDefaults ] );

	// Select variations when selections are change.
	useEffect( () => {
		const hasSelectedAllAttributes =
			Object.values( selectedAttributes ).filter(
				( selected ) => selected !== ''
			).length === Object.keys( currentAttributes ).length;

		if ( hasSelectedAllAttributes ) {
			setVariationId(
				getVariationMatchingSelectedAttributes(
					currentAttributes,
					currentVariationAttributes,
					selectedAttributes
				)
			);
		} else if ( variationId > 0 ) {
			// Unset variation when form is incomplete.
			setVariationId( 0 );
		}
	}, [
		selectedAttributes,
		variationId,
		currentAttributes,
		currentVariationAttributes,
	] );

	// Set requests params as variation ID and data changes.
	useEffect( () => {
		setRequestParams( {
			id: variationId,
			variation: Object.keys( selectedAttributes ).map(
				( attributeName ) => {
					return {
						attribute: attributeName,
						value: selectedAttributes[ attributeName ],
					};
				}
			),
		} );
	}, [ setRequestParams, variationId, selectedAttributes ] );

	return (
		<div className="wc-block-components-product-add-to-cart-attribute-picker">
			{ Object.keys( currentAttributes ).map( ( attributeName ) => (
				<AttributeSelectControl
					key={ attributeName }
					attributeName={ attributeName }
					options={
						filteredAttributeOptions[ attributeName ].filter(
							Boolean
						) as SelectControl.Option[]
					}
					value={ selectedAttributes[ attributeName ] }
					onChange={ ( selected ) => {
						setSelectedAttributes( {
							...selectedAttributes,
							[ attributeName ]: selected,
						} );
					} }
				/>
			) ) }
		</div>
	);
};

export default AttributePicker;
add-to-cart/product-types/variable/variation-attributes/attribute-select-control.tsx000064400000004534151547030350031631 0ustar00blocks/product-elements/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import { SelectControl } from 'wordpress-components';
import type { SelectControl as SelectControlType } from '@wordpress/components';
import { useEffect } from '@wordpress/element';
import classnames from 'classnames';
import { ValidationInputError } from '@woocommerce/blocks-checkout';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { useDispatch, useSelect } from '@wordpress/data';

interface Props extends SelectControlType.Props< string > {
	attributeName: string;
	errorMessage?: string;
}

// Default option for select boxes.
const selectAnOption = {
	value: '',
	label: __( 'Select an option', 'woo-gutenberg-products-block' ),
};

/**
 * VariationAttributeSelect component.
 */
const AttributeSelectControl = ( {
	attributeName,
	options = [],
	value = '',
	onChange = () => void 0,
	errorMessage = __(
		'Please select a value.',
		'woo-gutenberg-products-block'
	),
}: Props ) => {
	const errorId = attributeName;

	const { setValidationErrors, clearValidationError } =
		useDispatch( VALIDATION_STORE_KEY );

	const { error } = useSelect( ( select ) => {
		const store = select( VALIDATION_STORE_KEY );
		return {
			error: store.getValidationError( errorId ) || {},
		};
	} );

	useEffect( () => {
		if ( value ) {
			clearValidationError( errorId );
		} else {
			setValidationErrors( {
				[ errorId ]: {
					message: errorMessage,
					hidden: true,
				},
			} );
		}
	}, [
		value,
		errorId,
		errorMessage,
		clearValidationError,
		setValidationErrors,
	] );

	// Remove validation errors when unmounted.
	useEffect(
		() => () => void clearValidationError( errorId ),
		[ errorId, clearValidationError ]
	);

	return (
		<div className="wc-block-components-product-add-to-cart-attribute-picker__container">
			<SelectControl
				label={ decodeEntities( attributeName ) }
				value={ value || '' }
				options={ [ selectAnOption, ...options ] }
				onChange={ onChange }
				required={ true }
				className={ classnames(
					'wc-block-components-product-add-to-cart-attribute-picker__select',
					{
						'has-error': error?.message && ! error?.hidden,
					}
				) }
			/>
			<ValidationInputError
				propertyName={ errorId }
				elementId={ errorId }
			/>
		</div>
	);
};

export default AttributeSelectControl;
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.tsx000064400000001652151547030350026057 0ustar00/**
 * External dependencies
 */
import { ProductResponseItem } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import './style.scss';
import AttributePicker from './attribute-picker';
import { getAttributes, getVariationAttributes } from './utils';

interface Props {
	dispatchers: { setRequestParams: () => void };
	product: ProductResponseItem;
}

/**
 * VariationAttributes component.
 */
const VariationAttributes = ( { dispatchers, product }: Props ) => {
	const attributes = getAttributes( product.attributes );
	const variationAttributes = getVariationAttributes( product.variations );
	if (
		Object.keys( attributes ).length === 0 ||
		Object.keys( variationAttributes ).length === 0
	) {
		return null;
	}

	return (
		<AttributePicker
			attributes={ attributes }
			variationAttributes={ variationAttributes }
			setRequestParams={ dispatchers.setRequestParams }
		/>
	);
};

export default VariationAttributes;
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss000064400000001067151547030350026245 0ustar00.wc-block-components-product-add-to-cart-attribute-picker {
	margin: 0;
	flex-basis: 100%;

	label {
		display: block;
		@include font-size(regular);
	}

	.wc-block-components-product-add-to-cart-attribute-picker__container {
		position: relative;
	}

	.wc-block-components-product-add-to-cart-attribute-picker__select {
		margin: 0 0 em($gap-small) 0;

		select {
			min-width: 60%;
			min-height: 1.75em;
		}

		&.has-error {
			margin-bottom: $gap-large;

			select {
				border-color: $alert-red;
				&:focus {
					outline-color: $alert-red;
				}
			}
		}
	}
}
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.ts000064400000022660151547030350026650 0ustar00/**
 * External dependencies
 */
import { ProductResponseAttributeItem } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import {
	getAttributes,
	getVariationAttributes,
	getVariationsMatchingSelectedAttributes,
	getVariationMatchingSelectedAttributes,
	getActiveSelectControlOptions,
	getDefaultAttributes,
} from '../utils';

const rawAttributeData: ProductResponseAttributeItem[] = [
	{
		id: 1,
		name: 'Color',
		taxonomy: 'pa_color',
		has_variations: true,
		terms: [
			{
				id: 22,
				name: 'Blue',
				slug: 'blue',
				default: true,
			},
			{
				id: 23,
				name: 'Green',
				slug: 'green',
				default: false,
			},
			{
				id: 24,
				name: 'Red',
				slug: 'red',
				default: false,
			},
		],
	},
	{
		id: 0,
		name: 'Logo',
		taxonomy: 'pa_logo',
		has_variations: true,
		terms: [
			{
				id: 0,
				name: 'Yes',
				slug: 'Yes',
				default: true,
			},
			{
				id: 0,
				name: 'No',
				slug: 'No',
				default: false,
			},
		],
	},
	{
		id: 0,
		name: 'Non-variable attribute',
		taxonomy: 'pa_non-variable-attribute',
		has_variations: false,
		terms: [
			{
				id: 0,
				name: 'Test',
				slug: 'Test',
				default: false,
			},
			{
				id: 0,
				name: 'Test 2',
				slug: 'Test 2',
				default: false,
			},
		],
	},
];

const rawVariations = [
	{
		id: 35,
		attributes: [
			{
				name: 'Color',
				value: 'blue',
			},
			{
				name: 'Logo',
				value: 'Yes',
			},
		],
	},
	{
		id: 28,
		attributes: [
			{
				name: 'Color',
				value: 'red',
			},
			{
				name: 'Logo',
				value: 'No',
			},
		],
	},
	{
		id: 29,
		attributes: [
			{
				name: 'Color',
				value: 'green',
			},
			{
				name: 'Logo',
				value: 'No',
			},
		],
	},
	{
		id: 30,
		attributes: [
			{
				name: 'Color',
				value: 'blue',
			},
			{
				name: 'Logo',
				value: 'No',
			},
		],
	},
];

const formattedAttributes = {
	Color: {
		id: 1,
		name: 'Color',
		taxonomy: 'pa_color',
		has_variations: true,
		terms: [
			{
				id: 22,
				name: 'Blue',
				slug: 'blue',
				default: true,
			},
			{
				id: 23,
				name: 'Green',
				slug: 'green',
				default: false,
			},
			{
				id: 24,
				name: 'Red',
				slug: 'red',
				default: false,
			},
		],
	},
	Size: {
		id: 2,
		name: 'Size',
		taxonomy: 'pa_size',
		has_variations: true,
		terms: [
			{
				id: 25,
				name: 'Large',
				slug: 'large',
				default: false,
			},
			{
				id: 26,
				name: 'Medium',
				slug: 'medium',
				default: true,
			},
			{
				id: 27,
				name: 'Small',
				slug: 'small',
				default: false,
			},
		],
	},
};

describe( 'Testing utils', () => {
	describe( 'Testing getAttributes()', () => {
		it( 'returns empty object if there are no attributes', () => {
			const attributes = getAttributes( null );
			expect( attributes ).toStrictEqual( {} );
		} );
		it( 'returns list of attributes when given valid data', () => {
			const attributes = getAttributes( rawAttributeData );
			expect( attributes ).toStrictEqual( {
				Color: {
					id: 1,
					name: 'Color',
					taxonomy: 'pa_color',
					has_variations: true,
					terms: [
						{
							id: 22,
							name: 'Blue',
							slug: 'blue',
							default: true,
						},
						{
							id: 23,
							name: 'Green',
							slug: 'green',
							default: false,
						},
						{
							id: 24,
							name: 'Red',
							slug: 'red',
							default: false,
						},
					],
				},
				Logo: {
					id: 0,
					name: 'Logo',
					taxonomy: 'pa_logo',
					has_variations: true,
					terms: [
						{
							id: 0,
							name: 'Yes',
							slug: 'Yes',
							default: true,
						},
						{
							id: 0,
							name: 'No',
							slug: 'No',
							default: false,
						},
					],
				},
			} );
		} );
	} );
	describe( 'Testing getVariationAttributes()', () => {
		it( 'returns empty object if there are no variations', () => {
			const variationAttributes = getVariationAttributes( null );
			expect( variationAttributes ).toStrictEqual( {} );
		} );
		it( 'returns list of attribute names and value pairs when given valid data', () => {
			const variationAttributes = getVariationAttributes( rawVariations );
			expect( variationAttributes ).toStrictEqual( {
				'id:35': {
					id: 35,
					attributes: {
						Color: 'blue',
						Logo: 'Yes',
					},
				},
				'id:28': {
					id: 28,
					attributes: {
						Color: 'red',
						Logo: 'No',
					},
				},
				'id:29': {
					id: 29,
					attributes: {
						Color: 'green',
						Logo: 'No',
					},
				},
				'id:30': {
					id: 30,
					attributes: {
						Color: 'blue',
						Logo: 'No',
					},
				},
			} );
		} );
	} );
	describe( 'Testing getVariationsMatchingSelectedAttributes()', () => {
		const attributes = getAttributes( rawAttributeData );
		const variationAttributes = getVariationAttributes( rawVariations );

		it( 'returns all variations, in the correct order, if no selections have been made yet', () => {
			const selectedAttributes = {};
			const matches = getVariationsMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( [ 35, 28, 29, 30 ] );
		} );

		it( 'returns correct subset of variations after a selection', () => {
			const selectedAttributes = {
				Color: 'blue',
			};
			const matches = getVariationsMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( [ 35, 30 ] );
		} );

		it( 'returns correct subset of variations after all selections', () => {
			const selectedAttributes = {
				Color: 'blue',
				Logo: 'No',
			};
			const matches = getVariationsMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( [ 30 ] );
		} );

		it( 'returns no results if selection does not match or is invalid', () => {
			const selectedAttributes = {
				Color: 'brown',
			};
			const matches = getVariationsMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( [] );
		} );
	} );
	describe( 'Testing getVariationMatchingSelectedAttributes()', () => {
		const attributes = getAttributes( rawAttributeData );
		const variationAttributes = getVariationAttributes( rawVariations );

		it( 'returns first match if no selections have been made yet', () => {
			const selectedAttributes = {};
			const matches = getVariationMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( 35 );
		} );

		it( 'returns first match after single selection', () => {
			const selectedAttributes = {
				Color: 'blue',
			};
			const matches = getVariationMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( 35 );
		} );

		it( 'returns correct match after all selections', () => {
			const selectedAttributes = {
				Color: 'blue',
				Logo: 'No',
			};
			const matches = getVariationMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( 30 );
		} );

		it( 'returns no match if invalid', () => {
			const selectedAttributes = {
				Color: 'brown',
			};
			const matches = getVariationMatchingSelectedAttributes(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( matches ).toStrictEqual( 0 );
		} );
	} );
	describe( 'Testing getActiveSelectControlOptions()', () => {
		const attributes = getAttributes( rawAttributeData );
		const variationAttributes = getVariationAttributes( rawVariations );

		it( 'returns all possible options if no selections have been made yet', () => {
			const selectedAttributes = {};
			const controlOptions = getActiveSelectControlOptions(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( controlOptions ).toStrictEqual( {
				Color: [
					{
						value: 'blue',
						label: 'Blue',
					},
					{
						value: 'green',
						label: 'Green',
					},
					{
						value: 'red',
						label: 'Red',
					},
				],
				Logo: [
					{
						value: 'Yes',
						label: 'Yes',
					},
					{
						value: 'No',
						label: 'No',
					},
				],
			} );
		} );

		it( 'returns only valid options if color is selected', () => {
			const selectedAttributes = {
				Color: 'green',
			};
			const controlOptions = getActiveSelectControlOptions(
				attributes,
				variationAttributes,
				selectedAttributes
			);
			expect( controlOptions ).toStrictEqual( {
				Color: [
					{
						value: 'blue',
						label: 'Blue',
					},
					{
						value: 'green',
						label: 'Green',
					},
					{
						value: 'red',
						label: 'Red',
					},
				],
				Logo: [
					{
						value: 'No',
						label: 'No',
					},
				],
			} );
		} );
	} );
	describe( 'Testing getDefaultAttributes()', () => {
		const defaultAttributes = getDefaultAttributes( formattedAttributes );

		it( 'should return default attributes in the format that is ready for setting state', () => {
			expect( defaultAttributes ).toStrictEqual( {
				Color: 'blue',
				Size: 'medium',
			} );
		} );

		it( 'should return an empty object if given unexpected values', () => {
			// @ts-expect-error Expected TS Error as we are checking how the function does with *unexpected values*.
			expect( getDefaultAttributes( [] ) ).toStrictEqual( {} );
			// @ts-expect-error Ditto above.
			expect( getDefaultAttributes( null ) ).toStrictEqual( {} );
			// @ts-expect-error Ditto above.
			expect( getDefaultAttributes( undefined ) ).toStrictEqual( {} );
		} );
	} );
} );
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.ts000064400000017213151547030350025720 0ustar00/**
 * External dependencies
 */
import { decodeEntities } from '@wordpress/html-entities';
import {
	Dictionary,
	isObject,
	ProductResponseAttributeItem,
	ProductResponseTermItem,
	ProductResponseVariationsItem,
} from '@woocommerce/types';
import { keyBy } from '@woocommerce/base-utils';

/**
 * Internal dependencies
 */
import { AttributesMap } from '../types';

/**
 * Key an array of attributes by name,
 */
export const getAttributes = (
	attributes?: ProductResponseAttributeItem[] | null
) => {
	return attributes
		? keyBy(
				Object.values( attributes ).filter(
					( { has_variations: hasVariations } ) => hasVariations
				),
				'name'
		  )
		: {};
};

/**
 * Format variations from the API into a map of just the attribute names and values.
 *
 * Note, each item is keyed by the variation ID with an id: prefix. This is to prevent the object
 * being reordered when iterated.
 */
export const getVariationAttributes = (
	/**
	 * List of Variation objects and attributes keyed by variation ID.
	 */
	variations?: ProductResponseVariationsItem[] | null
) => {
	if ( ! variations ) {
		return {};
	}

	const attributesMap: AttributesMap = {};

	variations.forEach( ( { id, attributes } ) => {
		attributesMap[ `id:${ id }` ] = {
			id,
			attributes: attributes.reduce( ( acc, { name, value } ) => {
				acc[ name ] = value;
				return acc;
			}, {} as Dictionary ),
		};
	} );

	return attributesMap;
};

/**
 * Given a list of variations and a list of attribute values, return variations which match.
 *
 * Allows an attribute to be excluded by name. This is used to filter displayed options for
 * individual attribute selects.
 *
 * @return List of matching variation IDs.
 */
export const getVariationsMatchingSelectedAttributes = (
	/**
	 * List of attribute names and terms.
	 *
	 * As returned from {@link getAttributes()}.
	 */
	attributes: Record< string, ProductResponseAttributeItem >,
	/**
	 * Attributes for each variation keyed by variation ID.
	 *
	 * As returned from {@link getVariationAttributes()}.
	 */
	variationAttributes: AttributesMap,
	/**
	 * Attribute Name Value pairs of current selections by the user.
	 */
	selectedAttributes: Record< string, string | null >
) => {
	const variationIds = Object.values( variationAttributes ).map(
		( { id } ) => id
	);

	// If nothing is selected yet, just return all variations.
	if (
		Object.values( selectedAttributes ).every( ( value ) => value === '' )
	) {
		return variationIds;
	}

	const attributeNames = Object.keys( attributes );

	return variationIds.filter( ( variationId ) =>
		attributeNames.every( ( attributeName ) => {
			const selectedAttribute = selectedAttributes[ attributeName ] || '';
			const variationAttribute =
				variationAttributes[ 'id:' + variationId ].attributes[
					attributeName
				];

			// If there is no selected attribute, consider this a match.
			if ( selectedAttribute === '' ) {
				return true;
			}
			// If the variation attributes for this attribute are set to null, it matches all values.
			if ( variationAttribute === null ) {
				return true;
			}
			// Otherwise, only match if the selected values are the same.
			return variationAttribute === selectedAttribute;
		} )
	);
};

/**
 * Given a list of variations and a list of attribute values, returns the first matched variation ID.
 *
 * @return Variation ID.
 */
export const getVariationMatchingSelectedAttributes = (
	/**
	 * List of attribute names and terms.
	 *
	 * As returned from {@link getAttributes()}.
	 */
	attributes: Record< string, ProductResponseAttributeItem >,
	/**
	 * Attributes for each variation keyed by variation ID.
	 *
	 * As returned from {@link getVariationAttributes()}.
	 */
	variationAttributes: AttributesMap,
	/**
	 * Attribute Name Value pairs of current selections by the user.
	 */
	selectedAttributes: Dictionary
) => {
	const matchingVariationIds = getVariationsMatchingSelectedAttributes(
		attributes,
		variationAttributes,
		selectedAttributes
	);
	return matchingVariationIds[ 0 ] || 0;
};

/**
 * Given a list of terms, filter them and return valid options for the select boxes.
 *
 * @see getActiveSelectControlOptions
 *
 * @return Value/Label pairs of select box options.
 */
const getValidSelectControlOptions = (
	/**
	 * List of attribute term objects.
	 */
	attributeTerms: ProductResponseTermItem[],
	/**
	 * Valid values if selections have been made already.
	 */
	validAttributeTerms: Array< string | null > | null = null
) => {
	return Object.values( attributeTerms )
		.map( ( { name, slug } ) => {
			if (
				validAttributeTerms === null ||
				validAttributeTerms.includes( null ) ||
				validAttributeTerms.includes( slug )
			) {
				return {
					value: slug,
					label: decodeEntities( name ),
				};
			}
			return null;
		} )
		.filter( Boolean );
};

/**
 * Given a list of terms, filter them and return active options for the select boxes. This factors in
 * which options should be hidden due to current selections.
 *
 * @return Select box options.
 */
export const getActiveSelectControlOptions = (
	/**
	 * List of attribute names and terms.
	 *
	 * As returned from {@link getAttributes()}.
	 */
	attributes: Record< string, ProductResponseAttributeItem >,
	/**
	 * Attributes for each variation keyed by variation ID.
	 *
	 * As returned from {@link getVariationAttributes()}.
	 */
	variationAttributes: AttributesMap,
	/**
	 * Attribute Name Value pairs of current selections by the user.
	 */
	selectedAttributes: Dictionary
) => {
	const options: Record<
		string,
		Array< { label: string; value: string } | null >
	> = {};
	const attributeNames = Object.keys( attributes );
	const hasSelectedAttributes =
		Object.values( selectedAttributes ).filter( Boolean ).length > 0;

	attributeNames.forEach( ( attributeName ) => {
		const currentAttribute = attributes[ attributeName ];
		const selectedAttributesExcludingCurrentAttribute = {
			...selectedAttributes,
			[ attributeName ]: null,
		};
		// This finds matching variations for selected attributes apart from this one. This will be
		// used to get valid attribute terms of the current attribute narrowed down by those matching
		// variation IDs. For example, if I had Large Blue Shirts and Medium Red Shirts, I want to only
		// show Red shirts if Medium is selected.
		const matchingVariationIds = hasSelectedAttributes
			? getVariationsMatchingSelectedAttributes(
					attributes,
					variationAttributes,
					selectedAttributesExcludingCurrentAttribute
			  )
			: null;
		// Uses the above matching variation IDs to get the attributes from just those variations.
		const validAttributeTerms =
			matchingVariationIds !== null
				? matchingVariationIds.map(
						( varId ) =>
							variationAttributes[ 'id:' + varId ].attributes[
								attributeName
							]
				  )
				: null;
		// Intersects attributes with valid attributes.
		options[ attributeName ] = getValidSelectControlOptions(
			currentAttribute.terms,
			validAttributeTerms
		);
	} );

	return options;
};

/**
 * Return the default values of the given attributes in a format ready to be set in state.
 *
 * @return Default attributes.
 */
export const getDefaultAttributes = (
	/**
	 * List of attribute names and terms.
	 *
	 * As returned from {@link getAttributes()}.
	 */
	attributes: Record< string, ProductResponseAttributeItem >
) => {
	if ( ! isObject( attributes ) ) {
		return {};
	}

	const attributeNames = Object.keys( attributes );

	if ( attributeNames.length === 0 ) {
		return {};
	}

	const attributesEntries = Object.values( attributes );

	return attributesEntries.reduce( ( acc, curr ) => {
		const defaultValues = curr.terms.filter( ( term ) => term.default );

		if ( defaultValues.length > 0 ) {
			acc[ curr.name ] = defaultValues[ 0 ]?.slug;
		}

		return acc;
	}, {} as Dictionary );
};
blocks/product-elements/add-to-cart/shared/add-to-cart-button.tsx000064400000010204151547030350021030 0ustar00/**
 * External dependencies
 */
import { __, _n, sprintf } from '@wordpress/i18n';
import Button, { ButtonProps } from '@woocommerce/base-components/button';
import { Icon, check } from '@wordpress/icons';
import { useState, useEffect } from '@wordpress/element';
import { useAddToCartFormContext } from '@woocommerce/base-context';
import {
	useStoreEvents,
	useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import { useInnerBlockLayoutContext } from '@woocommerce/shared-context';

type LinkProps = Pick< ButtonProps, 'className' | 'href' | 'onClick' | 'text' >;

interface ButtonComponentProps
	extends Pick< ButtonProps, 'className' | 'onClick' > {
	/**
	 * Whether the button is disabled or not.
	 */
	isDisabled: boolean;
	/**
	 * Whether processing is done.
	 */
	isDone: boolean;
	/**
	 * Whether processing action is occurring.
	 */
	isProcessing: ButtonProps[ 'showSpinner' ];
	/**
	 * Quantity of said item currently in the cart.
	 */
	quantityInCart: number;
}

/**
 * Button component for non-purchasable products.
 */
const LinkComponent = ( { className, href, text, onClick }: LinkProps ) => {
	return (
		<Button
			className={ className }
			href={ href }
			onClick={ onClick }
			rel="nofollow"
		>
			{ text }
		</Button>
	);
};

/**
 * Button for purchasable products.
 */
const ButtonComponent = ( {
	className,
	quantityInCart,
	isProcessing,
	isDisabled,
	isDone,
	onClick,
}: ButtonComponentProps ) => {
	return (
		<Button
			className={ className }
			disabled={ isDisabled }
			showSpinner={ isProcessing }
			onClick={ onClick }
		>
			{ isDone && quantityInCart > 0
				? sprintf(
						/* translators: %s number of products in cart. */
						_n(
							'%d in cart',
							'%d in cart',
							quantityInCart,
							'woo-gutenberg-products-block'
						),
						quantityInCart
				  )
				: __( 'Add to cart', 'woo-gutenberg-products-block' ) }
			{ !! isDone && <Icon icon={ check } /> }
		</Button>
	);
};

/**
 * Add to Cart Form Button Component.
 */
const AddToCartButton = () => {
	// @todo Add types for `useAddToCartFormContext`
	const {
		showFormElements,
		productIsPurchasable,
		productHasOptions,
		product,
		productType,
		isDisabled,
		isProcessing,
		eventRegistration,
		hasError,
		dispatchActions,
	} = useAddToCartFormContext();
	const { parentName } = useInnerBlockLayoutContext();
	const { dispatchStoreEvent } = useStoreEvents();
	const { cartQuantity } = useStoreAddToCart( product.id || 0 );
	const [ addedToCart, setAddedToCart ] = useState( false );
	const addToCartButtonData = product.add_to_cart || {
		url: '',
		text: '',
	};

	// Subscribe to emitter for after processing.
	useEffect( () => {
		const onSuccess = () => {
			if ( ! hasError ) {
				setAddedToCart( true );
			}
			return true;
		};
		const unsubscribeProcessing =
			eventRegistration.onAddToCartAfterProcessingWithSuccess(
				onSuccess,
				0
			);
		return () => {
			unsubscribeProcessing();
		};
	}, [ eventRegistration, hasError ] );

	/**
	 * We can show a real button if we are:
	 *
	 *  	a) Showing a full add to cart form.
	 * 		b) The product doesn't have options and can therefore be added directly to the cart.
	 * 		c) The product is purchasable.
	 *
	 * Otherwise we show a link instead.
	 */
	const showButton =
		( showFormElements ||
			( ! productHasOptions && productType === 'simple' ) ) &&
		productIsPurchasable;

	return showButton ? (
		<ButtonComponent
			className="wc-block-components-product-add-to-cart-button"
			quantityInCart={ cartQuantity }
			isDisabled={ isDisabled }
			isProcessing={ isProcessing }
			isDone={ addedToCart }
			onClick={ () => {
				dispatchActions.submitForm(
					`woocommerce/single-product/${ product?.id || 0 }`
				);
				dispatchStoreEvent( 'cart-add-item', {
					product,
					listName: parentName,
				} );
			} }
		/>
	) : (
		<LinkComponent
			className="wc-block-components-product-add-to-cart-button"
			href={ addToCartButtonData.url }
			text={
				addToCartButtonData.text ||
				__( 'View Product', 'woo-gutenberg-products-block' )
			}
			onClick={ () => {
				dispatchStoreEvent( 'product-view-link', {
					product,
					listName: parentName,
				} );
			} }
		/>
	);
};

export default AddToCartButton;
blocks/product-elements/add-to-cart/shared/index.ts000064400000000307151547030350016342 0ustar00export { default as AddToCartButton } from './add-to-cart-button';
export { default as QuantityInput } from './quantity-input';
export { default as ProductUnavailable } from './product-unavailable';
blocks/product-elements/add-to-cart/shared/product-unavailable.tsx000064400000000545151547030350021370 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';

const ProductUnavailable = ( {
	reason = __(
		'Sorry, this product cannot be purchased.',
		'woo-gutenberg-products-block'
	),
} ) => {
	return (
		<div className="wc-block-components-product-add-to-cart-unavailable">
			{ reason }
		</div>
	);
};

export default ProductUnavailable;
blocks/product-elements/add-to-cart/shared/quantity-input.tsx000064400000004075151547030350020444 0ustar00/**
 * External dependencies
 */
import { useDebouncedCallback } from 'use-debounce';

type JSXInputProps = JSX.IntrinsicElements[ 'input' ];

interface QuantityInputProps extends Omit< JSXInputProps, 'onChange' > {
	max: number;
	min: number;
	onChange: ( val: number | string ) => void;
	step: number;
}

/**
 * Quantity Input Component.
 */
const QuantityInput = ( {
	disabled,
	min,
	max,
	step = 1,
	value,
	onChange,
}: QuantityInputProps ) => {
	const hasMaximum = typeof max !== 'undefined';

	/**
	 * The goal of this function is to normalize what was inserted,
	 * but after the customer has stopped typing.
	 *
	 * It's important to wait before normalizing or we end up with
	 * a frustrating experience, for example, if the minimum is 2 and
	 * the customer is trying to type "10", premature normalizing would
	 * always kick in at "1" and turn that into 2.
	 *
	 * Copied from <QuantitySelector>
	 */
	const normalizeQuantity = useDebouncedCallback< ( val: number ) => void >(
		( initialValue ) => {
			// We copy the starting value.
			let newValue = initialValue;

			// We check if we have a maximum value, and select the lowest between what was inserted and the maximum.
			if ( hasMaximum ) {
				newValue = Math.min(
					newValue,
					// the maximum possible value in step increments.
					Math.floor( max / step ) * step
				);
			}

			// Select the biggest between what's inserted, the the minimum value in steps.
			newValue = Math.max( newValue, Math.ceil( min / step ) * step );

			// We round off the value to our steps.
			newValue = Math.floor( newValue / step ) * step;

			// Only commit if the value has changed
			if ( newValue !== initialValue ) {
				onChange?.( newValue );
			}
		},
		300
	);

	return (
		<input
			className="wc-block-components-product-add-to-cart-quantity"
			type="number"
			value={ value }
			min={ min }
			max={ max }
			step={ step }
			hidden={ max === 1 }
			disabled={ disabled }
			onChange={ ( e ) => {
				onChange?.( e.target.value );
				normalizeQuantity( Number( e.target.value ) );
			} }
		/>
	);
};

export default QuantityInput;
blocks/product-elements/add-to-cart/style.scss000064400000002055151547030350015454 0ustar00.wc-block-components-product-add-to-cart {
	margin: 0;
	display: flex;
	flex-wrap: wrap;

	.wc-block-components-product-add-to-cart-button {
		margin: 0 0 em($gap-small) 0;

		.wc-block-components-button__text {
			display: block;

			> svg {
				fill: currentColor;
				vertical-align: top;
				width: 1.5em;
				height: 1.5em;
				margin: -0.25em 0 -0.25em 0.5em;
			}
		}
	}

	.wc-block-components-product-add-to-cart-quantity {
		margin: 0 1em em($gap-small) 0;
		flex-basis: 5em;
		padding: 0.618em;
		background: $white;
		border: 1px solid #ccc;
		border-radius: 2px;
		color: #43454b;
		box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.125);
		text-align: center;
	}
}

.is-loading .wc-block-components-product-add-to-cart,
.wc-block-components-product-add-to-cart--placeholder {
	.wc-block-components-product-add-to-cart-quantity,
	.wc-block-components-product-add-to-cart-button {
		@include placeholder();
	}
}

.wc-block-grid .wc-block-components-product-add-to-cart {
	justify-content: center;
}

.wc-block-components-product-add-to-cart-notice {
	margin: 0;
}
blocks/product-elements/add-to-cart-form/block.json000064400000001072151547030350016343 0ustar00{
	"name": "woocommerce/add-to-cart-form",
	"version": "1.0.0",
	"title": "Add to Cart with Options",
	"description": "Display a button so the customer can add a product to their cart. Options will also be displayed depending on product type. e.g. quantity, variation.",
	"attributes": {
		"isDescendentOfSingleProductBlock": {
			"type": "boolean",
			"default": false
		}
	},
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"usesContext": ["postId"],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/add-to-cart-form/edit.tsx000064400000003455151547030350016052 0ustar00/**
 * External dependencies
 */
import { useEffect } from '@wordpress/element';
import { useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { Button, Disabled, Tooltip } from '@wordpress/components';
import { Skeleton } from '@woocommerce/base-components/skeleton';
import { BlockEditProps } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
export interface Attributes {
	className?: string;
	isDescendentOfSingleProductBlock: boolean;
}

const Edit = ( props: BlockEditProps< Attributes > ) => {
	const { setAttributes } = props;
	const blockProps = useBlockProps( {
		className: 'wc-block-add-to-cart-form',
	} );
	const { isDescendentOfSingleProductBlock } =
		useIsDescendentOfSingleProductBlock( {
			blockClientId: blockProps?.id,
		} );

	useEffect( () => {
		setAttributes( {
			isDescendentOfSingleProductBlock,
		} );
	}, [ setAttributes, isDescendentOfSingleProductBlock ] );

	return (
		<div { ...blockProps }>
			<Tooltip
				text="Customer will see product add-to-cart options in this space, dependend on the product type. "
				position="bottom right"
			>
				<div className="wc-block-editor-container">
					<Skeleton numberOfLines={ 3 } />
					<Disabled>
						<input
							type={ 'number' }
							value={ '1' }
							className={
								'wc-block-editor-add-to-cart-form__quantity'
							}
							readOnly
						/>
						<Button
							variant={ 'primary' }
							className={
								'wc-block-editor-add-to-cart-form__button'
							}
						>
							{ __(
								'Add to cart',
								'woo-gutenberg-products-block'
							) }
						</Button>
					</Disabled>
				</div>
			</Tooltip>
		</div>
	);
};

export default Edit;
blocks/product-elements/add-to-cart-form/editor.scss000064400000001131151547030350016535 0ustar00.wc-block-editor-add-to-cart-form {
	display: flex;
	flex-direction: column;
	row-gap: $default-block-margin;
}

input.wc-block-editor-add-to-cart-form__quantity[type="number"] {
	max-width: 50px;
	min-height: 23px;
	float: left;
	padding: 6px 6px 6px 12px;
	margin-right: 10px;
	font-size: 13px;
	height: inherit;
}

input[type="number"]::-webkit-inner-spin-button {
	opacity: 1;
}

button.components-button.wc-block-add-to-cart-form__button {
	float: left;
	padding: 20px 30px;
	border-radius: 0;
}

.wc-block-editor-container {
	cursor: help;
	gap: 10px;
	display: flex;
	flex-direction: column;
}
blocks/product-elements/add-to-cart-form/index.tsx000064400000001245151547030350016227 0ustar00/**
 * External dependencies
 */
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon, button } from '@wordpress/icons';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import edit from './edit';
import './style.scss';
import './editor.scss';

const blockSettings = {
	edit,
	icon: {
		src: (
			<Icon
				icon={ button }
				className="wc-block-editor-components-block-icon"
			/>
		),
	},
	ancestor: [ 'woocommerce/single-product' ],
	save() {
		return null;
	},
};

registerBlockSingleProductTemplate( {
	blockName: metadata.name,
	blockMetadata: metadata,
	blockSettings,
	isAvailableOnPostEditor: true,
} );
blocks/product-elements/add-to-cart-form/style.scss000064400000000667151547030350016424 0ustar00.wp-block-add-to-cart-form {
	width: unset;
	/**
	* This is a base style for the input text element in WooCommerce that prevents inputs from appearing too small.
	*
	* @link https://github.com/woocommerce/woocommerce/blob/95ca53675f2817753d484583c96ca9ab9f725172/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss#L203-L206
	*/
	.input-text {
		font-size: var(--wp--preset--font-size--small);
		padding: 0.9rem 1.1rem;
	}
}
blocks/product-elements/average-rating/block.json000064400000000675151547030350016207 0ustar00{
	"name": "woocommerce/product-average-rating",
	"version": "1.0.0",
	"title": "Product Average Rating (Beta)",
	"description": "Display the average rating of a product",
	"attributes": {
		"textAlign": {
			"type": "string"
		}
	},
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"ancestor": [ "woocommerce/single-product" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/average-rating/block.tsx000064400000001763151547030350016053 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import { useProductDataContext } from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { __ } from '@wordpress/i18n';
import { withProductDataContext } from '@woocommerce/shared-hocs';

type ProductAverageRatingProps = {
	className?: string;
	textAlign?: string;
};

export const Block = ( props: ProductAverageRatingProps ): JSX.Element => {
	const { textAlign } = props;
	const styleProps = useStyleProps( props );
	const { product } = useProductDataContext();

	const className = classnames(
		styleProps.className,
		'wc-block-components-product-average-rating',
		{
			[ `has-text-align-${ textAlign }` ]: textAlign,
		}
	);

	return (
		<div className={ className } style={ styleProps.style }>
			{ Number( product.average_rating ) > 0
				? product.average_rating
				: __( 'No ratings', 'woo-gutenberg-products-block' ) }
		</div>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/average-rating/edit.tsx000064400000001524151547030350015701 0ustar00/**
 * External dependencies
 */
import {
	AlignmentToolbar,
	BlockControls,
	useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import Block from './block';

export interface BlockAttributes {
	textAlign: string;
}

const Edit = ( props: BlockEditProps< BlockAttributes > ): JSX.Element => {
	const { attributes, setAttributes } = props;
	const blockProps = useBlockProps( {
		className: 'wp-block-woocommerce-product-average-rating',
	} );

	return (
		<>
			<BlockControls>
				<AlignmentToolbar
					value={ attributes.textAlign }
					onChange={ ( newAlign ) => {
						setAttributes( { textAlign: newAlign || '' } );
					} }
				/>
			</BlockControls>
			<div { ...blockProps }>
				<Block { ...attributes } />
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/average-rating/index.tsx000064400000000700151547030360016057 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starHalf } from '@wordpress/icons';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';

registerBlockType( metadata, {
	icon: {
		src: (
			<Icon
				icon={ starHalf }
				className="wc-block-editor-components-block-icon"
			/>
		),
	},
	supports,
	edit,
} );
blocks/product-elements/average-rating/support.ts000064400000001130151547030360016272 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';

export const supports = {
	...( isFeaturePluginBuild() && {
		color: {
			text: true,
			background: true,
			__experimentalSkipSerialization: true,
		},
		spacing: {
			margin: true,
			padding: true,
			__experimentalSkipSerialization: true,
		},
		typography: {
			fontSize: true,
			__experimentalFontWeight: true,
			__experimentalSkipSerialization: true,
		},
		__experimentalSelector: '.wc-block-components-product-average-rating',
	} ),
};
blocks/product-elements/button/block.json000064400000002463151547030360014624 0ustar00{
	"name": "woocommerce/product-button",
	"version": "1.0.0",
	"title": "Add to Cart Button",
	"description": "Display a call to action button which either adds the product to the cart, or links to the product page.",
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"usesContext": [ "query", "queryId", "postId" ],
	"textdomain": "woocommerce",
	"attributes": {
		"productId": {
			"type": "number",
			"default": 0
		},
		"textAlign": {
			"type": "string",
			"default": ""
		},
		"width": {
			"type": "number"
		},
		"isDescendentOfSingleProductBlock": {
			"type": "boolean",
			"default": false
		},
		"isDescendentOfQueryLoop": {
			"type": "boolean",
			"default": false
		}
	},
	"supports": {
		"align": [ "wide", "full" ],
		"color": {
			"background": false,
			"link": true
		},
		"interactivity": true,
		"html": false,
		"typography": {
			"fontSize": true,
			"lineHeight": true
		}
	},
	"ancestor": [
		"woocommerce/all-products",
		"woocommerce/single-product",
		"core/post-template",
		"woocommerce/product-template"
	],
	"styles": [
		{
			"name": "fill",
			"label": "Fill",
			"isDefault": true
		},
		{
			"name": "outline",
			"label": "Outline"
		}
	],
	"viewScript": [
		"wc-product-button-interactivity-frontend"
	],
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/button/block.tsx000064400000010154151547030360014465 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import { __, _n, sprintf } from '@wordpress/i18n';
import {
	useStoreEvents,
	useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { CART_URL } from '@woocommerce/block-settings';
import { getSetting } from '@woocommerce/settings';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';

/**
 * Internal dependencies
 */
import './style.scss';
import type {
	BlockAttributes,
	AddToCartButtonAttributes,
	AddToCartButtonPlaceholderAttributes,
} from './types';

const AddToCartButton = ( {
	product,
	className,
	style,
}: AddToCartButtonAttributes ): JSX.Element => {
	const {
		id,
		permalink,
		add_to_cart: productCartDetails,
		has_options: hasOptions,
		is_purchasable: isPurchasable,
		is_in_stock: isInStock,
	} = product;
	const { dispatchStoreEvent } = useStoreEvents();
	const { cartQuantity, addingToCart, addToCart } = useStoreAddToCart( id );

	const addedToCart = Number.isFinite( cartQuantity ) && cartQuantity > 0;
	const allowAddToCart = ! hasOptions && isPurchasable && isInStock;
	const buttonAriaLabel = decodeEntities(
		productCartDetails?.description || ''
	);
	const buttonText = addedToCart
		? sprintf(
				/* translators: %s number of products in cart. */
				_n(
					'%d in cart',
					'%d in cart',
					cartQuantity,
					'woo-gutenberg-products-block'
				),
				cartQuantity
		  )
		: decodeEntities(
				productCartDetails?.text ||
					__( 'Add to cart', 'woo-gutenberg-products-block' )
		  );

	const ButtonTag = allowAddToCart ? 'button' : 'a';
	const buttonProps = {} as HTMLAnchorElement & { onClick: () => void };

	if ( ! allowAddToCart ) {
		buttonProps.href = permalink;
		buttonProps.rel = 'nofollow';
		buttonProps.onClick = () => {
			dispatchStoreEvent( 'product-view-link', {
				product,
			} );
		};
	} else {
		buttonProps.onClick = async () => {
			await addToCart();
			dispatchStoreEvent( 'cart-add-item', {
				product,
			} );
			// redirect to cart if the setting to redirect to the cart page
			// on cart add item is enabled
			const { cartRedirectAfterAdd }: { cartRedirectAfterAdd: boolean } =
				getSetting( 'productsSettings' );
			if ( cartRedirectAfterAdd ) {
				window.location.href = CART_URL;
			}
		};
	}

	return (
		<ButtonTag
			{ ...buttonProps }
			aria-label={ buttonAriaLabel }
			disabled={ addingToCart }
			className={ classnames(
				className,
				'wp-block-button__link',
				'wp-element-button',
				'add_to_cart_button',
				'wc-block-components-product-button__button',
				{
					loading: addingToCart,
					added: addedToCart,
				}
			) }
			style={ style }
		>
			{ buttonText }
		</ButtonTag>
	);
};

const AddToCartButtonPlaceholder = ( {
	className,
	style,
}: AddToCartButtonPlaceholderAttributes ): JSX.Element => {
	return (
		<button
			className={ classnames(
				'wp-block-button__link',
				'wp-element-button',
				'add_to_cart_button',
				'wc-block-components-product-button__button',
				'wc-block-components-product-button__button--placeholder',
				className
			) }
			style={ style }
			disabled={ true }
		/>
	);
};

export const Block = ( props: BlockAttributes ): JSX.Element => {
	const { className, textAlign } = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();

	return (
		<div
			className={ classnames(
				className,
				'wp-block-button',
				'wc-block-components-product-button',
				{
					[ `${ parentClassName }__product-add-to-cart` ]:
						parentClassName,
					[ `align-${ textAlign }` ]: textAlign,
				}
			) }
		>
			{ product.id ? (
				<AddToCartButton
					product={ product }
					style={ styleProps.style }
					className={ styleProps.className }
				/>
			) : (
				<AddToCartButtonPlaceholder
					style={ styleProps.style }
					className={ styleProps.className }
				/>
			) }
		</div>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/button/edit.tsx000064400000005177151547030360014331 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import {
	Disabled,
	Button,
	ButtonGroup,
	PanelBody,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import {
	AlignmentToolbar,
	BlockControls,
	useBlockProps,
	InspectorControls,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';

/**
 * Internal dependencies
 */
import Block from './block';
import { BlockAttributes } from './types';

function WidthPanel( {
	selectedWidth,
	setAttributes,
}: {
	selectedWidth: number | undefined;
	setAttributes: ( attributes: BlockAttributes ) => void;
} ) {
	function handleChange( newWidth: number ) {
		// Check if we are toggling the width off
		const width = selectedWidth === newWidth ? undefined : newWidth;

		// Update attributes.
		setAttributes( { width } );
	}

	return (
		<PanelBody
			title={ __( 'Width settings', 'woo-gutenberg-products-block' ) }
		>
			<ButtonGroup
				aria-label={ __(
					'Button width',
					'woo-gutenberg-products-block'
				) }
			>
				{ [ 25, 50, 75, 100 ].map( ( widthValue ) => {
					return (
						<Button
							key={ widthValue }
							isSmall
							variant={
								widthValue === selectedWidth
									? 'primary'
									: undefined
							}
							onClick={ () => handleChange( widthValue ) }
						>
							{ widthValue }%
						</Button>
					);
				} ) }
			</ButtonGroup>
		</PanelBody>
	);
}

const Edit = ( {
	attributes,
	setAttributes,
	context,
}: BlockEditProps< BlockAttributes > & {
	context?: Context | undefined;
} ): JSX.Element => {
	const blockProps = useBlockProps();
	const isDescendentOfQueryLoop = Number.isFinite( context?.queryId );
	const { width } = attributes;

	useEffect(
		() => setAttributes( { isDescendentOfQueryLoop } ),
		[ setAttributes, isDescendentOfQueryLoop ]
	);
	return (
		<>
			<BlockControls>
				{ isDescendentOfQueryLoop && (
					<AlignmentToolbar
						value={ attributes.textAlign }
						onChange={ ( newAlign ) => {
							setAttributes( { textAlign: newAlign || '' } );
						} }
					/>
				) }
			</BlockControls>
			<InspectorControls>
				<WidthPanel
					selectedWidth={ width }
					setAttributes={ setAttributes }
				/>
			</InspectorControls>
			<div { ...blockProps }>
				<Disabled>
					<Block
						{ ...{ ...attributes, ...context } }
						className={ classnames( attributes.className, {
							[ `has-custom-width wp-block-button__width-${ width }` ]:
								width,
						} ) }
					/>
				</Disabled>
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/button/frontend.tsx000064400000021265151547030360015217 0ustar00/* eslint-disable @typescript-eslint/no-explicit-any */
/**
 * External dependencies
 */
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { store as interactivityStore } from '@woocommerce/interactivity';
import { dispatch, select, subscribe } from '@wordpress/data';
import { Cart } from '@woocommerce/type-defs/cart';
import { createRoot } from '@wordpress/element';
import NoticeBanner from '@woocommerce/base-components/notice-banner';

type Context = {
	woocommerce: {
		isLoading: boolean;
		addToCartText: string;
		productId: number;
		displayViewCart: boolean;
		quantityToAdd: number;
		temporaryNumberOfItems: number;
		animationStatus: AnimationStatus;
	};
};

enum AnimationStatus {
	IDLE = 'IDLE',
	SLIDE_OUT = 'SLIDE-OUT',
	SLIDE_IN = 'SLIDE-IN',
}

type State = {
	woocommerce: {
		cart: Cart | undefined;
		inTheCartText: string;
	};
};

type Store = {
	state: State;
	context: Context;
	selectors: any;
	ref: HTMLElement;
};

const storeNoticeClass = '.wc-block-store-notices';

const createNoticeContainer = () => {
	const noticeContainer = document.createElement( 'div' );
	noticeContainer.classList.add( storeNoticeClass.replace( '.', '' ) );
	return noticeContainer;
};

const injectNotice = ( domNode: Element, errorMessage: string ) => {
	const root = createRoot( domNode );

	root.render(
		<NoticeBanner status="error" onRemove={ () => root.unmount() }>
			{ errorMessage }
		</NoticeBanner>
	);

	domNode?.scrollIntoView( {
		behavior: 'smooth',
		inline: 'nearest',
	} );
};

const getProductById = ( cartState: Cart | undefined, productId: number ) => {
	return cartState?.items.find( ( item ) => item.id === productId );
};

const getTextButton = ( {
	addToCartText,
	inTheCartText,
	numberOfItems,
}: {
	addToCartText: string;
	inTheCartText: string;
	numberOfItems: number;
} ) => {
	if ( numberOfItems === 0 ) {
		return addToCartText;
	}
	return inTheCartText.replace( '###', numberOfItems.toString() );
};

const productButtonSelectors = {
	woocommerce: {
		addToCartText: ( store: Store ) => {
			const { context, state, selectors } = store;

			// We use the temporary number of items when there's no animation, or the
			// second part of the animation hasn't started.
			if (
				context.woocommerce.animationStatus === AnimationStatus.IDLE ||
				context.woocommerce.animationStatus ===
					AnimationStatus.SLIDE_OUT
			) {
				return getTextButton( {
					addToCartText: context.woocommerce.addToCartText,
					inTheCartText: state.woocommerce.inTheCartText,
					numberOfItems: context.woocommerce.temporaryNumberOfItems,
				} );
			}

			return getTextButton( {
				addToCartText: context.woocommerce.addToCartText,
				inTheCartText: state.woocommerce.inTheCartText,
				numberOfItems:
					selectors.woocommerce.numberOfItemsInTheCart( store ),
			} );
		},
		displayViewCart: ( store: Store ) => {
			const { context, selectors } = store;
			if ( ! context.woocommerce.displayViewCart ) return false;
			if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
				return context.woocommerce.temporaryNumberOfItems > 0;
			}
			return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0;
		},
		hasCartLoaded: ( { state }: { state: State } ) => {
			return state.woocommerce.cart !== undefined;
		},
		numberOfItemsInTheCart: ( { state, context }: Store ) => {
			const product = getProductById(
				state.woocommerce.cart,
				context.woocommerce.productId
			);
			return product?.quantity || 0;
		},
		slideOutAnimation: ( { context }: Store ) =>
			context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT,
		slideInAnimation: ( { context }: Store ) =>
			context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN,
	},
};

interactivityStore(
	// @ts-expect-error: Store function isn't typed.
	{
		selectors: productButtonSelectors,
		actions: {
			woocommerce: {
				addToCart: async ( store: Store ) => {
					const { context, selectors, ref } = store;

					if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) {
						return;
					}

					context.woocommerce.isLoading = true;

					// Allow 3rd parties to validate and quit early.
					// https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77
					const event = new CustomEvent(
						'should_send_ajax_request.adding_to_cart',
						{ detail: [ ref ], cancelable: true }
					);
					const shouldSendRequest =
						document.body.dispatchEvent( event );

					if ( shouldSendRequest === false ) {
						const ajaxNotSentEvent = new CustomEvent(
							'ajax_request_not_sent.adding_to_cart',
							{ detail: [ false, false, ref ] }
						);
						document.body.dispatchEvent( ajaxNotSentEvent );
						return true;
					}

					try {
						await dispatch( storeKey ).addItemToCart(
							context.woocommerce.productId,
							context.woocommerce.quantityToAdd
						);

						// After the cart has been updated, sync the temporary number of
						// items again.
						context.woocommerce.temporaryNumberOfItems =
							selectors.woocommerce.numberOfItemsInTheCart(
								store
							);
					} catch ( error ) {
						const storeNoticeBlock =
							document.querySelector( storeNoticeClass );

						if ( ! storeNoticeBlock ) {
							document
								.querySelector( '.entry-content' )
								?.prepend( createNoticeContainer() );
						}

						const domNode =
							storeNoticeBlock ??
							document.querySelector( storeNoticeClass );

						if ( domNode ) {
							injectNotice( domNode, error.message );
						}

						// We don't care about errors blocking execution, but will
						// console.error for troubleshooting.
						// eslint-disable-next-line no-console
						console.error( error );
					} finally {
						context.woocommerce.displayViewCart = true;
						context.woocommerce.isLoading = false;
					}
				},
				handleAnimationEnd: (
					store: Store & { event: AnimationEvent }
				) => {
					const { event, context, selectors } = store;
					if ( event.animationName === 'slideOut' ) {
						// When the first part of the animation (slide-out) ends, we move
						// to the second part (slide-in).
						context.woocommerce.animationStatus =
							AnimationStatus.SLIDE_IN;
					} else if ( event.animationName === 'slideIn' ) {
						// When the second part of the animation ends, we update the
						// temporary number of items to sync it with the cart and reset the
						// animation status so it can be triggered again.
						context.woocommerce.temporaryNumberOfItems =
							selectors.woocommerce.numberOfItemsInTheCart(
								store
							);
						context.woocommerce.animationStatus =
							AnimationStatus.IDLE;
					}
				},
			},
		},
		init: {
			woocommerce: {
				syncTemporaryNumberOfItemsOnLoad: ( store: Store ) => {
					const { selectors, context } = store;
					// If the cart has loaded when we instantiate this element, we sync
					// the temporary number of items with the number of items in the cart
					// to avoid triggering the animation. We do this only once, but we
					// use useLayoutEffect to avoid the useEffect flickering.
					if ( selectors.woocommerce.hasCartLoaded( store ) ) {
						context.woocommerce.temporaryNumberOfItems =
							selectors.woocommerce.numberOfItemsInTheCart(
								store
							);
					}
				},
			},
		},
		effects: {
			woocommerce: {
				startAnimation: ( store: Store ) => {
					const { context, selectors } = store;
					// We start the animation if the cart has loaded, the temporary number
					// of items is out of sync with the number of items in the cart, the
					// button is not loading (because that means the user started the
					// interaction) and the animation hasn't started yet.
					if (
						selectors.woocommerce.hasCartLoaded( store ) &&
						context.woocommerce.temporaryNumberOfItems !==
							selectors.woocommerce.numberOfItemsInTheCart(
								store
							) &&
						! context.woocommerce.isLoading &&
						context.woocommerce.animationStatus ===
							AnimationStatus.IDLE
					) {
						context.woocommerce.animationStatus =
							AnimationStatus.SLIDE_OUT;
					}
				},
			},
		},
	},
	{
		afterLoad: ( store: Store ) => {
			const { state, selectors } = store;
			// Subscribe to changes in Cart data.
			subscribe( () => {
				const cartData = select( storeKey ).getCartData();
				const isResolutionFinished =
					select( storeKey ).hasFinishedResolution( 'getCartData' );
				if ( isResolutionFinished ) {
					state.woocommerce.cart = cartData;
				}
			}, storeKey );

			// This selector triggers a fetch of the Cart data. It is done in a
			// `requestIdleCallback` to avoid potential performance issues.
			requestIdleCallback( () => {
				if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
					select( storeKey ).getCartData();
				}
			} );
		},
	}
);
blocks/product-elements/button/index.tsx000064400000003503151547030360014502 0ustar00/**
 * External dependencies
 */
import { Icon, button } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
 * Internal dependencies
 */
import edit from './edit';
import save from './save';
import metadata from './block.json';

const featurePluginSupport = {
	...metadata.supports,
	...( isFeaturePluginBuild() && {
		color: {
			text: true,
			background: true,
			link: false,
			__experimentalSkipSerialization: true,
		},
		__experimentalBorder: {
			radius: true,
			__experimentalSkipSerialization: true,
		},
		...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
			spacing: {
				margin: true,
				padding: true,
				__experimentalSkipSerialization: true,
			},
		} ),
		typography: {
			fontSize: true,
			lineHeight: true,
			__experimentalFontWeight: true,
			__experimentalFontFamily: true,
			__experimentalFontStyle: true,
			__experimentalTextTransform: true,
			__experimentalTextDecoration: true,
			__experimentalLetterSpacing: true,
			__experimentalDefaultControls: {
				fontSize: true,
			},
		},
		__experimentalSelector:
			'.wp-block-button.wc-block-components-product-button .wc-block-components-product-button__button',
	} ),
	...( typeof __experimentalGetSpacingClassesAndStyles === 'function' &&
		! isFeaturePluginBuild() && {
			spacing: {
				margin: true,
			},
		} ),
};
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
registerBlockType( metadata, {
	icon: {
		src: (
			<Icon
				icon={ button }
				className="wc-block-editor-components-block-icon"
			/>
		),
	},
	attributes: {
		...metadata.attributes,
	},
	supports: {
		...featurePluginSupport,
	},
	edit,
	save,
} );
blocks/product-elements/button/save.tsx000064400000001270151547030360014330 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';

/**
 * Internal dependencies
 */
import { BlockAttributes } from './types';

type Props = {
	attributes: BlockAttributes;
};

const Save = ( { attributes }: Props ): JSX.Element | null => {
	if (
		attributes.isDescendentOfQueryLoop ||
		attributes.isDescendentOfSingleProductBlock
	) {
		return null;
	}

	return (
		<div
			{ ...useBlockProps.save( {
				className: classnames( 'is-loading', attributes.className, {
					[ `has-custom-width wp-block-button__width-${ attributes.width }` ]:
						attributes.width,
				} ),
			} ) }
		/>
	);
};

export default Save;
blocks/product-elements/button/style.scss000064400000005215151547030360014672 0ustar00.wp-block-button.wc-block-components-product-button {
	word-break: break-word;
	white-space: normal;
	display: flex;
	justify-content: center;
	flex-direction: column;
	align-items: center;
	gap: $gap-small;

	.wp-block-button__link {
		word-break: break-word;
		white-space: normal;
		display: inline-flex;
		justify-content: center;
		text-align: center;
		// Set button font size and padding so it inherits from parent.
		padding: 0.5em 1em;
		font-size: 1em;

		&.loading {
			opacity: 0.25;
		}

		&.loading::after {
			font-family: WooCommerce; /* stylelint-disable-line */
			content: "\e031";
			animation: spin 2s linear infinite;
			margin-left: 0.5em;
			display: inline-block;
			width: auto;
			height: auto;
		}
	}

	a[hidden] {
		display: none;
	}

	@keyframes slideOut {
		from {
			transform: translateY(0);
		}
		to {
			transform: translateY(-100%);
		}
	}

	@keyframes slideIn {
		from {
			transform: translateY(90%);
			opacity: 0;
		}
		to {
			transform: translate(0);
			opacity: 1;
		}
	}

	&.align-left {
		align-items: flex-start;
	}

	&.align-right {
		align-items: flex-end;
	}

	.wc-block-components-product-button__button {
		border-style: none;
		display: inline-flex;
		justify-content: center;
		white-space: normal;
		word-break: break-word;
		width: 150px;
		overflow: hidden;

		span {

			&.wc-block-slide-out {
				animation: slideOut 0.1s linear 1 normal forwards;
			}
			&.wc-block-slide-in {
				animation: slideIn 0.1s linear 1 normal;
			}
		}
	}

	.wc-block-components-product-button__button--placeholder {
		@include placeholder();
		min-width: 8em;
		min-height: 3em;
	}

	.wc-block-all-products & {
		margin-bottom: $gap-small;
	}
}

.is-loading .wc-block-components-product-button > .wc-block-components-product-button__button {
	@include placeholder();
	min-width: 8em;
	min-height: 3em;
}

.theme-twentytwentyone {
	// Prevent buttons appearing disabled in the editor.
	.editor-styles-wrapper .wc-block-components-product-button .wp-block-button__link {
		background-color: var(--button--color-background);
		color: var(--button--color-text);
		border-color: var(--button--color-background);
	}
}

// Style: Fill & Outline
.wp-block-button.is-style-outline {
	.wp-block-button__link {
		border: 2px solid currentColor;

		&:not(.has-text-color) {
			color: currentColor;
		}

		&:not(.has-background) {
			background-color: transparent;
			background-image: none;
		}
	}
}

// Width setting
.wp-block-button {
	&.has-custom-width {
		.wp-block-button__link {
			box-sizing: border-box;
		}
	}

	@for $i from 1 through 4 {
		&.wp-block-button__width-#{$i * 25} {
			.wp-block-button__link {
				width: $i * 25%; // 25%, 50%, 75%, 100%
			}
		}
	}
}
blocks/product-elements/button/types.ts000064400000001441151547030360014346 0ustar00interface WithClass {
	className: string;
}

interface WithStyle {
	style: Record< string, unknown >;
}

export interface BlockAttributes {
	className?: string | undefined;
	textAlign?: string | undefined;
	isDescendentOfQueryLoop?: boolean | undefined;
	isDescendentOfSingleProductBlock?: boolean | undefined;
	width?: number | undefined;
}

export interface AddToCartButtonPlaceholderAttributes {
	className: string;
	style: React.CSSProperties;
}

export interface AddToCartButtonAttributes
	extends AddToCartButtonPlaceholderAttributes {
	product: {
		id: number;
		permalink: string;
		add_to_cart: {
			url: string;
			description: string;
			text: string;
		};
		has_options: boolean;
		is_purchasable: boolean;
		is_in_stock: boolean;
	};
	textAlign?: ( WithClass & WithStyle ) | undefined;
}
blocks/product-elements/image/attributes.ts000064400000001455151547030360015144 0ustar00/**
 * External dependencies
 */
import type { BlockAttributes } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import { ImageSizing } from './types';

export const blockAttributes: BlockAttributes = {
	showProductLink: {
		type: 'boolean',
		default: true,
	},
	showSaleBadge: {
		type: 'boolean',
		default: true,
	},
	saleBadgeAlign: {
		type: 'string',
		default: 'right',
	},
	imageSizing: {
		type: 'string',
		default: ImageSizing.SINGLE,
	},
	productId: {
		type: 'number',
		default: 0,
	},
	isDescendentOfQueryLoop: {
		type: 'boolean',
		default: false,
	},
	isDescendentOfSingleProductBlock: {
		type: 'boolean',
		default: false,
	},
	width: {
		type: 'string',
	},
	height: {
		type: 'string',
	},
	scale: {
		type: 'string',
		default: 'cover',
	},
};

export default blockAttributes;
blocks/product-elements/image/block.tsx000064400000010040151547030360014226 0ustar00/**
 * External dependencies
 */
import { Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import type { HTMLAttributes } from 'react';

/**
 * Internal dependencies
 */
import ProductSaleBadge from '../sale-badge/block';
import './style.scss';
import { BlockAttributes, ImageSizing } from './types';

const ImagePlaceholder = ( props ): JSX.Element => {
	return (
		<img
			{ ...props }
			src={ PLACEHOLDER_IMG_SRC }
			alt=""
			width={ undefined }
			height={ undefined }
		/>
	);
};

interface ImageProps {
	image?: null | {
		alt?: string | undefined;
		id: number;
		name: string;
		sizes?: string | undefined;
		src?: string | undefined;
		srcset?: string | undefined;
		thumbnail?: string | undefined;
	};
	loaded: boolean;
	showFullSize: boolean;
	fallbackAlt: string;
	scale: string;
	width?: string | undefined;
	height?: string | undefined;
}

const Image = ( {
	image,
	loaded,
	showFullSize,
	fallbackAlt,
	width,
	scale,
	height,
}: ImageProps ): JSX.Element => {
	const { thumbnail, src, srcset, sizes, alt } = image || {};
	const imageProps = {
		alt: alt || fallbackAlt,
		hidden: ! loaded,
		src: thumbnail,
		...( showFullSize && { src, srcSet: srcset, sizes } ),
	};

	const imageStyles: Record< string, string | undefined > = {
		height,
		width,
		objectFit: scale,
	};

	return (
		<>
			{ imageProps.src && (
				/* eslint-disable-next-line jsx-a11y/alt-text */
				<img
					style={ imageStyles }
					data-testid="product-image"
					{ ...imageProps }
				/>
			) }
			{ ! image && <ImagePlaceholder style={ imageStyles } /> }
		</>
	);
};

type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;

export const Block = ( props: Props ): JSX.Element | null => {
	const {
		className,
		imageSizing = ImageSizing.SINGLE,
		showProductLink = true,
		showSaleBadge,
		saleBadgeAlign = 'right',
		height,
		width,
		scale,
		...restProps
	} = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product, isLoading } = useProductDataContext();
	const { dispatchStoreEvent } = useStoreEvents();

	if ( ! product.id ) {
		return (
			<div
				className={ classnames(
					className,
					'wc-block-components-product-image',
					{
						[ `${ parentClassName }__product-image` ]:
							parentClassName,
					},
					styleProps.className
				) }
				style={ styleProps.style }
			>
				<ImagePlaceholder />
			</div>
		);
	}
	const hasProductImages = !! product.images.length;
	const image = hasProductImages ? product.images[ 0 ] : null;
	const ParentComponent = showProductLink ? 'a' : Fragment;
	const anchorLabel = sprintf(
		/* translators: %s is referring to the product name */
		__( 'Link to %s', 'woo-gutenberg-products-block' ),
		product.name
	);
	const anchorProps = {
		href: product.permalink,
		...( ! hasProductImages && { 'aria-label': anchorLabel } ),
		onClick: () => {
			dispatchStoreEvent( 'product-view-link', {
				product,
			} );
		},
	};

	return (
		<div
			className={ classnames(
				className,
				'wc-block-components-product-image',
				{
					[ `${ parentClassName }__product-image` ]: parentClassName,
				},
				styleProps.className
			) }
			style={ styleProps.style }
		>
			<ParentComponent { ...( showProductLink && anchorProps ) }>
				{ !! showSaleBadge && (
					<ProductSaleBadge
						align={ saleBadgeAlign }
						{ ...restProps }
					/>
				) }
				<Image
					fallbackAlt={ product.name }
					image={ image }
					loaded={ ! isLoading }
					showFullSize={ imageSizing !== ImageSizing.THUMBNAIL }
					width={ width }
					height={ height }
					scale={ scale }
				/>
			</ParentComponent>
		</div>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/image/constants.tsx000064400000000677151547030360015167 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { image, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product Image',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon icon={ image } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
	'Display the main product image.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/image/edit.tsx000064400000012554151547030360014075 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { createInterpolateElement, useEffect } from '@wordpress/element';
import { getAdminLink, getSettingWithCoercion } from '@woocommerce/settings';
import { isBoolean } from '@woocommerce/types';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import {
	Disabled,
	PanelBody,
	ToggleControl,
	// eslint-disable-next-line @typescript-eslint/ban-ts-comment
	// @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalToggleGroupControl as ToggleGroupControl,
	// eslint-disable-next-line @typescript-eslint/ban-ts-comment
	// @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';

/**
 * Internal dependencies
 */
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import {
	BLOCK_TITLE as label,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';
import { BlockAttributes, ImageSizing } from './types';
import { ImageSizeSettings } from './image-size-settings';

type SaleBadgeAlignProps = 'left' | 'center' | 'right';

const Edit = ( {
	attributes,
	setAttributes,
	context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
	const {
		showProductLink,
		imageSizing,
		showSaleBadge,
		saleBadgeAlign,
		width,
		height,
		scale,
	} = attributes;
	const blockProps = useBlockProps( { style: { width, height } } );
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
	const isBlockThemeEnabled = getSettingWithCoercion(
		'isBlockThemeEnabled',
		false,
		isBoolean
	);

	useEffect(
		() => setAttributes( { isDescendentOfQueryLoop } ),
		[ setAttributes, isDescendentOfQueryLoop ]
	);

	return (
		<div { ...blockProps }>
			<InspectorControls>
				<ImageSizeSettings
					scale={ scale }
					width={ width }
					height={ height }
					setAttributes={ setAttributes }
				/>
				<PanelBody
					title={ __( 'Content', 'woo-gutenberg-products-block' ) }
				>
					<ToggleControl
						label={ __(
							'Link to Product Page',
							'woo-gutenberg-products-block'
						) }
						help={ __(
							'Links the image to the single product listing.',
							'woo-gutenberg-products-block'
						) }
						checked={ showProductLink }
						onChange={ () =>
							setAttributes( {
								showProductLink: ! showProductLink,
							} )
						}
					/>
					<ToggleControl
						label={ __(
							'Show On-Sale Badge',
							'woo-gutenberg-products-block'
						) }
						help={ __(
							'Display a “sale” badge if the product is on-sale.',
							'woo-gutenberg-products-block'
						) }
						checked={ showSaleBadge }
						onChange={ () =>
							setAttributes( {
								showSaleBadge: ! showSaleBadge,
							} )
						}
					/>
					{ showSaleBadge && (
						<ToggleGroupControl
							label={ __(
								'Sale Badge Alignment',
								'woo-gutenberg-products-block'
							) }
							value={ saleBadgeAlign }
							onChange={ ( value: SaleBadgeAlignProps ) =>
								setAttributes( { saleBadgeAlign: value } )
							}
						>
							<ToggleGroupControlOption
								value="left"
								label={ __(
									'Left',
									'woo-gutenberg-products-block'
								) }
							/>
							<ToggleGroupControlOption
								value="center"
								label={ __(
									'Center',
									'woo-gutenberg-products-block'
								) }
							/>
							<ToggleGroupControlOption
								value="right"
								label={ __(
									'Right',
									'woo-gutenberg-products-block'
								) }
							/>
						</ToggleGroupControl>
					) }
					{ ! isBlockThemeEnabled && (
						<ToggleGroupControl
							label={ __(
								'Image Sizing',
								'woo-gutenberg-products-block'
							) }
							help={ createInterpolateElement(
								__(
									'Product image cropping can be modified in the <a>Customizer</a>.',
									'woo-gutenberg-products-block'
								),
								{
									a: (
										// eslint-disable-next-line jsx-a11y/anchor-has-content
										<a
											href={ `${ getAdminLink(
												'customize.php'
											) }?autofocus[panel]=woocommerce&autofocus[section]=woocommerce_product_images` }
											target="_blank"
											rel="noopener noreferrer"
										/>
									),
								}
							) }
							value={ imageSizing }
							onChange={ ( value: ImageSizing ) =>
								setAttributes( { imageSizing: value } )
							}
						>
							<ToggleGroupControlOption
								value={ ImageSizing.SINGLE }
								label={ __(
									'Full Size',
									'woo-gutenberg-products-block'
								) }
							/>
							<ToggleGroupControlOption
								value={ ImageSizing.THUMBNAIL }
								label={ __(
									'Cropped',
									'woo-gutenberg-products-block'
								) }
							/>
						</ToggleGroupControl>
					) }
				</PanelBody>
			</InspectorControls>
			<Disabled>
				<Block { ...{ ...attributes, ...context } } />
			</Disabled>
		</div>
	);
};

export default withProductSelector( { icon, label, description } )( Edit );
blocks/product-elements/image/frontend.ts000064400000000411151547030360014564 0ustar00/**
 * External dependencies
 */
import { withFilteredAttributes } from '@woocommerce/shared-hocs';

/**
 * Internal dependencies
 */
import Block from './block';
import attributes from './attributes';

export default withFilteredAttributes( attributes )( Block );
blocks/product-elements/image/image-size-settings.tsx000064400000006337151547030360017042 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { BlockAttributes } from '@wordpress/blocks';
import {
	// @ts-expect-error Using experimental features
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalToggleGroupControl as ToggleGroupControl,
	// @ts-expect-error Using experimental features
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalToggleGroupControlOption as ToggleGroupControlOption,
	// @ts-expect-error Using experimental features
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalToolsPanel as ToolsPanel,
	// @ts-expect-error Using experimental features
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalToolsPanelItem as ToolsPanelItem,
	// @ts-expect-error Using experimental features
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalUnitControl as UnitControl,
} from '@wordpress/components';

interface ImageSizeSettingProps {
	scale: string;
	width: string | undefined;
	height: string | undefined;
	setAttributes: ( attrs: BlockAttributes ) => void;
}

const scaleHelp: Record< string, string > = {
	cover: __(
		'Image is scaled and cropped to fill the entire space without being distorted.',
		'woo-gutenberg-products-block'
	),
	contain: __(
		'Image is scaled to fill the space without clipping nor distorting.',
		'woo-gutenberg-products-block'
	),
	fill: __(
		'Image will be stretched and distorted to completely fill the space.',
		'woo-gutenberg-products-block'
	),
};

export const ImageSizeSettings = ( {
	scale,
	width,
	height,
	setAttributes,
}: ImageSizeSettingProps ) => {
	return (
		<ToolsPanel
			className="wc-block-product-image__tools-panel"
			label={ __( 'Image size', 'woo-gutenberg-products-block' ) }
		>
			<UnitControl
				label={ __( 'Height', 'woo-gutenberg-products-block' ) }
				onChange={ ( value: string ) => {
					setAttributes( { height: value } );
				} }
				value={ height }
				units={ [
					{
						value: 'px',
						label: 'px',
					},
				] }
			/>
			<UnitControl
				label={ __( 'Width', 'woo-gutenberg-products-block' ) }
				onChange={ ( value: string ) => {
					setAttributes( { width: value } );
				} }
				value={ width }
				units={ [
					{
						value: 'px',
						label: 'px',
					},
				] }
			/>
			{ height && (
				<ToolsPanelItem
					hasValue={ () => true }
					label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
				>
					<ToggleGroupControl
						label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
						value={ scale }
						help={ scaleHelp[ scale ] }
						onChange={ ( value: string ) =>
							setAttributes( {
								scale: value,
							} )
						}
						isBlock
					>
						<>
							<ToggleGroupControlOption
								value="cover"
								label={ __(
									'Cover',
									'woo-gutenberg-products-block'
								) }
							/>
							<ToggleGroupControlOption
								value="contain"
								label={ __(
									'Contain',
									'woo-gutenberg-products-block'
								) }
							/>
							<ToggleGroupControlOption
								value="fill"
								label={ __(
									'Fill',
									'woo-gutenberg-products-block'
								) }
							/>
						</>
					</ToggleGroupControl>
				</ToolsPanelItem>
			) }
		</ToolsPanel>
	);
};
blocks/product-elements/image/index.ts000064400000001706151547030360014064 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import edit from './edit';

import { supports } from './supports';
import attributes from './attributes';
import sharedConfig from '../shared/config';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';

const blockConfig: BlockConfiguration = {
	...sharedConfig,
	apiVersion: 2,
	name: 'woocommerce/product-image',
	title,
	icon: { src: icon },
	keywords: [ 'WooCommerce' ],
	description,
	usesContext: [ 'query', 'queryId', 'postId' ],
	ancestor: [
		'woocommerce/all-products',
		'woocommerce/single-product',
		'core/post-template',
		'woocommerce/product-template',
	],
	textdomain: 'woo-gutenberg-products-block',
	attributes,
	supports,
	edit,
};

registerBlockType( 'woocommerce/product-image', { ...blockConfig } );
blocks/product-elements/image/style.scss000064400000002350151547030360014436 0ustar00.editor-styles-wrapper .wc-block-grid__products .wc-block-grid__product .wc-block-components-product-image,
.wc-block-components-product-image {
	text-decoration: none;
	display: block;
	position: relative;

	a {
		border-radius: inherit;
		text-decoration: none;
		border: 0;
		outline: 0;
		box-shadow: none;
	}

	img {
		border-radius: inherit;
		vertical-align: middle;
		width: 100%;
		height: auto;

		&[hidden] {
			display: none;
		}
	}

	img[alt=""] {
		border: 1px solid $image-placeholder-border-color;
	}

	.wc-block-components-product-sale-badge {
		&--align-left {
			position: absolute;
			left: $gap-smaller * 0.5;
			top: $gap-smaller * 0.5;
			right: auto;
			margin: 0;
		}
		&--align-center {
			position: absolute;
			top: $gap-smaller * 0.5;
			left: 50%;
			right: auto;
			transform: translateX(-50%);
			margin: 0;
		}
		&--align-right {
			position: absolute;
			right: $gap-smaller * 0.5;
			top: $gap-smaller * 0.5;
			left: auto;
			margin: 0;
		}
	}
}

.is-loading .wc-block-components-product-image {
	@include placeholder($include-border-radius: false);
	width: auto;
}

.wc-block-components-product-image {
	margin: 0 0 $gap-small;
}

.wc-block-product-image__tools-panel .components-input-control {
	margin-bottom: 8px;
}
blocks/product-elements/image/supports.ts000064400000001350151547030360014647 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles } from '@wordpress/block-editor';

/**
 * Internal dependencies
 */

export const supports = {
	html: false,
	...( isFeaturePluginBuild() && {
		__experimentalBorder: {
			radius: true,
			__experimentalSkipSerialization: true,
		},
		typography: {
			fontSize: true,
			__experimentalSkipSerialization: true,
		},
		...( typeof getSpacingClassesAndStyles === 'function' && {
			spacing: {
				margin: true,
				padding: true,
			},
		} ),
		__experimentalSelector: '.wc-block-components-product-image',
	} ),
};
blocks/product-elements/image/test/block.test.tsx000064400000016430151547030360016174 0ustar00/**
 * External dependencies
 */
import { render, fireEvent } from '@testing-library/react';
import { ProductDataContextProvider } from '@woocommerce/shared-context';
import { ProductResponseItem } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import { Block } from '../block';
import { ImageSizing } from '../types';

jest.mock( '@woocommerce/base-hooks', () => ( {
	__esModule: true,
	useStyleProps: jest.fn( () => ( {
		className: '',
		style: {},
	} ) ),
} ) );

const productWithoutImages: ProductResponseItem = {
	name: 'Test product',
	id: 1,
	permalink: 'http://test.com/product/test-product/',
	images: [],
	parent: 0,
	type: '',
	variation: '',
	sku: '',
	short_description: '',
	description: '',
	on_sale: false,
	prices: {
		currency_code: 'USD',
		currency_symbol: '',
		currency_minor_unit: 0,
		currency_decimal_separator: '',
		currency_thousand_separator: '',
		currency_prefix: '',
		currency_suffix: '',
		price: '',
		regular_price: '',
		sale_price: '',
		price_range: null,
	},
	price_html: '',
	average_rating: '',
	review_count: 0,
	categories: [],
	tags: [],
	attributes: [],
	variations: [],
	has_options: false,
	is_purchasable: false,
	is_in_stock: false,
	is_on_backorder: false,
	low_stock_remaining: null,
	sold_individually: false,
	add_to_cart: {
		text: '',
		description: '',
		url: '',
		minimum: 0,
		maximum: 0,
		multiple_of: 0,
	},
};

const productWithImages: ProductResponseItem = {
	name: 'Test product',
	id: 1,
	permalink: 'http://test.com/product/test-product/',
	images: [
		{
			id: 56,
			src: 'logo-1.jpg',
			thumbnail: 'logo-1-324x324.jpg',
			srcset: 'logo-1.jpg 800w, logo-1-300x300.jpg 300w, logo-1-150x150.jpg 150w, logo-1-768x767.jpg 768w, logo-1-324x324.jpg 324w, logo-1-416x415.jpg 416w, logo-1-100x100.jpg 100w',
			sizes: '(max-width: 800px) 100vw, 800px',
			name: 'logo-1.jpg',
			alt: '',
		},
		{
			id: 55,
			src: 'beanie-with-logo-1.jpg',
			thumbnail: 'beanie-with-logo-1-324x324.jpg',
			srcset: 'beanie-with-logo-1.jpg 800w, beanie-with-logo-1-300x300.jpg 300w, beanie-with-logo-1-150x150.jpg 150w, beanie-with-logo-1-768x768.jpg 768w, beanie-with-logo-1-324x324.jpg 324w, beanie-with-logo-1-416x416.jpg 416w, beanie-with-logo-1-100x100.jpg 100w',
			sizes: '(max-width: 800px) 100vw, 800px',
			name: 'beanie-with-logo-1.jpg',
			alt: '',
		},
	],
	parent: 0,
	type: '',
	variation: '',
	sku: '',
	short_description: '',
	description: '',
	on_sale: false,
	prices: {
		currency_code: 'USD',
		currency_symbol: '',
		currency_minor_unit: 0,
		currency_decimal_separator: '',
		currency_thousand_separator: '',
		currency_prefix: '',
		currency_suffix: '',
		price: '',
		regular_price: '',
		sale_price: '',
		price_range: null,
	},
	price_html: '',
	average_rating: '',
	review_count: 0,
	categories: [],
	tags: [],
	attributes: [],
	variations: [],
	has_options: false,
	is_purchasable: false,
	is_in_stock: false,
	is_on_backorder: false,
	low_stock_remaining: null,
	sold_individually: false,
	add_to_cart: {
		text: '',
		description: '',
		url: '',
		minimum: 0,
		maximum: 0,
		multiple_of: 0,
	},
};

describe( 'Product Image Block', () => {
	describe( 'with product link', () => {
		test( 'should render an anchor with the product image', () => {
			const component = render(
				<ProductDataContextProvider
					product={ productWithImages }
					isLoading={ false }
				>
					<Block
						showProductLink={ true }
						productId={ productWithImages.id }
						showSaleBadge={ false }
						saleBadgeAlign={ 'left' }
						imageSizing={ ImageSizing.SINGLE }
						isDescendentOfQueryLoop={ false }
					/>
				</ProductDataContextProvider>
			);

			// use testId as alt is added after image is loaded
			const image = component.getByTestId( 'product-image' );
			fireEvent.load( image );

			const productImage = component.getByAltText(
				productWithImages.name
			);
			expect( productImage.getAttribute( 'src' ) ).toBe(
				productWithImages.images[ 0 ].src
			);

			const anchor = productImage.closest( 'a' );
			expect( anchor?.getAttribute( 'href' ) ).toBe(
				productWithImages.permalink
			);
		} );

		test( 'should render an anchor with the placeholder image', () => {
			const component = render(
				<ProductDataContextProvider
					product={ productWithoutImages }
					isLoading={ false }
				>
					<Block
						showProductLink={ true }
						productId={ productWithoutImages.id }
						showSaleBadge={ false }
						saleBadgeAlign={ 'left' }
						imageSizing={ ImageSizing.SINGLE }
						isDescendentOfQueryLoop={ false }
					/>
				</ProductDataContextProvider>
			);

			const placeholderImage = component.getByAltText( '' );
			expect( placeholderImage.getAttribute( 'src' ) ).toBe(
				'placeholder.jpg'
			);

			const anchor = placeholderImage.closest( 'a' );
			expect( anchor?.getAttribute( 'href' ) ).toBe(
				productWithoutImages.permalink
			);
			expect( anchor?.getAttribute( 'aria-label' ) ).toBe(
				`Link to ${ productWithoutImages.name }`
			);
		} );
	} );

	describe( 'without product link', () => {
		test( 'should render the product image without an anchor wrapper', () => {
			const component = render(
				<ProductDataContextProvider
					product={ productWithImages }
					isLoading={ false }
				>
					<Block
						showProductLink={ false }
						productId={ productWithImages.id }
						showSaleBadge={ false }
						saleBadgeAlign={ 'left' }
						imageSizing={ ImageSizing.SINGLE }
						isDescendentOfQueryLoop={ false }
					/>
				</ProductDataContextProvider>
			);
			const image = component.getByTestId( 'product-image' );
			fireEvent.load( image );

			const productImage = component.getByAltText(
				productWithImages.name
			);
			expect( productImage.getAttribute( 'src' ) ).toBe(
				productWithImages.images[ 0 ].src
			);

			const anchor = productImage.closest( 'a' );
			expect( anchor ).toBe( null );
		} );

		test( 'should render the placeholder image without an anchor wrapper', () => {
			const component = render(
				<ProductDataContextProvider
					product={ productWithoutImages }
					isLoading={ false }
				>
					<Block
						showProductLink={ false }
						productId={ productWithoutImages.id }
						showSaleBadge={ false }
						saleBadgeAlign={ 'left' }
						imageSizing={ ImageSizing.SINGLE }
						isDescendentOfQueryLoop={ false }
					/>
				</ProductDataContextProvider>
			);

			const placeholderImage = component.getByAltText( '' );
			expect( placeholderImage.getAttribute( 'src' ) ).toBe(
				'placeholder.jpg'
			);

			const anchor = placeholderImage.closest( 'a' );
			expect( anchor ).toBe( null );
		} );
	} );

	describe( 'without image', () => {
		test( 'should render the placeholder with no inline width or height attributes', () => {
			const component = render(
				<ProductDataContextProvider
					product={ productWithoutImages }
					isLoading={ false }
				>
					<Block
						showProductLink={ true }
						productId={ productWithoutImages.id }
						showSaleBadge={ false }
						saleBadgeAlign={ 'left' }
						imageSizing={ ImageSizing.SINGLE }
						isDescendentOfQueryLoop={ false }
					/>
				</ProductDataContextProvider>
			);

			const placeholderImage = component.getByAltText( '' );
			expect( placeholderImage.getAttribute( 'src' ) ).toBe(
				'placeholder.jpg'
			);
			expect( placeholderImage.getAttribute( 'width' ) ).toBe( null );
			expect( placeholderImage.getAttribute( 'height' ) ).toBe( null );
		} );
	} );
} );
blocks/product-elements/image/types.ts000064400000001405151547030360014115 0ustar00export enum ImageSizing {
	SINGLE = 'single',
	THUMBNAIL = 'thumbnail',
}

export interface BlockAttributes {
	// The product ID.
	productId: number;
	// CSS Class name for the component.
	className?: string | undefined;
	// Whether or not to display a link to the product page.
	showProductLink: boolean;
	// Whether or not to display the on sale badge.
	showSaleBadge: boolean;
	// How should the sale badge be aligned if displayed.
	saleBadgeAlign: 'left' | 'center' | 'right';
	// Size of image to use.
	imageSizing: ImageSizing;
	// Whether or not be a children of Query Loop Block.
	isDescendentOfQueryLoop: boolean;
	// Height of the image.
	height?: string;
	// Width of the image.
	width?: string;
	// Image scaling method.
	scale: 'cover' | 'contain' | 'fill';
}
blocks/product-elements/price/attributes.ts000064400000000766151547030360015170 0ustar00/**
 * External dependencies
 */
import { BlockAttributes } from '@wordpress/blocks';

export const blockAttributes: BlockAttributes = {
	productId: {
		type: 'number',
		default: 0,
	},
	isDescendentOfQueryLoop: {
		type: 'boolean',
		default: false,
	},
	textAlign: {
		type: 'string',
		default: '',
	},
	isDescendentOfSingleProductTemplate: {
		type: 'boolean',
		default: false,
	},
	isDescendentOfSingleProductBlock: {
		type: 'boolean',
		default: false,
	},
};

export default blockAttributes;
blocks/product-elements/price/block.tsx000064400000007747151547030360014272 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import ProductPrice from '@woocommerce/base-components/product-price';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { CurrencyCode } from '@woocommerce/type-defs/currency';
import type { HTMLAttributes } from 'react';

/**
 * Internal dependencies
 */
import type { BlockAttributes } from './types';

type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;

interface PriceProps {
	currency_code: CurrencyCode;
	currency_symbol: string;
	currency_minor_unit: number;
	currency_decimal_separator: string;
	currency_thousand_separator: string;
	currency_prefix: string;
	currency_suffix: string;
	price: string;
	regular_price: string;
	sale_price: string;
	price_range: null | { min_amount: string; max_amount: string };
}

export const Block = ( props: Props ): JSX.Element | null => {
	const { className, textAlign, isDescendentOfSingleProductTemplate } = props;
	const styleProps = useStyleProps( props );
	const { parentName, parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();

	const isDescendentOfAllProductsBlock =
		parentName === 'woocommerce/all-products';

	const wrapperClassName = classnames(
		'wc-block-components-product-price',
		className,
		styleProps.className,
		{
			[ `${ parentClassName }__product-price` ]: parentClassName,
		}
	);

	if ( ! product.id && ! isDescendentOfSingleProductTemplate ) {
		const productPriceComponent = (
			<ProductPrice align={ textAlign } className={ wrapperClassName } />
		);
		if ( isDescendentOfAllProductsBlock ) {
			return (
				<div className="wp-block-woocommerce-product-price">
					{ productPriceComponent }
				</div>
			);
		}
		return productPriceComponent;
	}

	const prices: PriceProps = product.prices;
	const currency = isDescendentOfSingleProductTemplate
		? getCurrencyFromPriceResponse()
		: getCurrencyFromPriceResponse( prices );

	const pricePreview = '5000';
	const isOnSale = prices.price !== prices.regular_price;
	const priceClassName = classnames( {
		[ `${ parentClassName }__product-price__value` ]: parentClassName,
		[ `${ parentClassName }__product-price__value--on-sale` ]: isOnSale,
	} );

	const productPriceComponent = (
		<ProductPrice
			align={ textAlign }
			className={ wrapperClassName }
			style={ styleProps.style }
			regularPriceStyle={ styleProps.style }
			priceStyle={ styleProps.style }
			priceClassName={ priceClassName }
			currency={ currency }
			price={
				isDescendentOfSingleProductTemplate
					? pricePreview
					: prices.price
			}
			// Range price props
			minPrice={ prices?.price_range?.min_amount }
			maxPrice={ prices?.price_range?.max_amount }
			// This is the regular or original price when the `price` value is a sale price.
			regularPrice={
				isDescendentOfSingleProductTemplate
					? pricePreview
					: prices.regular_price
			}
			regularPriceClassName={ classnames( {
				[ `${ parentClassName }__product-price__regular` ]:
					parentClassName,
			} ) }
		/>
	);
	if ( isDescendentOfAllProductsBlock ) {
		return (
			<div className="wp-block-woocommerce-product-price">
				{ productPriceComponent }
			</div>
		);
	}
	return productPriceComponent;
};

export default ( props: Props ) => {
	// It is necessary because this block has to support serveral contexts:
	// - Inside `All Products Block` -> `withProductDataContext` HOC
	// - Inside `Products Block` -> Gutenberg Context
	// - Inside `Single Product Template` -> Gutenberg Context
	// - Without any parent -> `WithSelector` and `withProductDataContext` HOCs
	// For more details, check https://github.com/woocommerce/woocommerce-blocks/pull/8609
	if ( props.isDescendentOfSingleProductTemplate ) {
		return <Block { ...props } />;
	}
	return withProductDataContext( Block )( props );
};
blocks/product-elements/price/constants.tsx000064400000000726151547030360015202 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { currencyDollar, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product Price',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon
		icon={ currencyDollar }
		className="wc-block-editor-components-block-icon"
	/>
);
export const BLOCK_DESCRIPTION: string = __(
	'Display the price of a product.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/price/edit.tsx000064400000003665151547030360014120 0ustar00/**
 * External dependencies
 */
import {
	AlignmentToolbar,
	BlockControls,
	useBlockProps,
} from '@wordpress/block-editor';
import { useEffect } from '@wordpress/element';
import type { BlockAlignment } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import Block from './block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

type UnsupportedAligments = 'wide' | 'full';
type AllowedAlignments = Exclude< BlockAlignment, UnsupportedAligments >;

interface BlockAttributes {
	textAlign?: AllowedAlignments;
}

interface Attributes {
	textAlign: 'left' | 'center' | 'right';
	isDescendentOfSingleProduct: boolean;
	isDescendentOfSingleProductBlock: boolean;
	productId: number;
}

interface Context {
	queryId: number;
}

interface Props {
	attributes: Attributes;
	setAttributes: (
		attributes: Partial< BlockAttributes > & Record< string, unknown >
	) => void;
	context: Context;
}

const PriceEdit = ( {
	attributes,
	setAttributes,
	context,
}: Props ): JSX.Element => {
	const blockProps = useBlockProps();
	const blockAttrs = {
		...attributes,
		...context,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );

	let { isDescendentOfSingleProductTemplate } =
		useIsDescendentOfSingleProductTemplate( { isDescendentOfQueryLoop } );

	if ( isDescendentOfQueryLoop ) {
		isDescendentOfSingleProductTemplate = false;
	}

	useEffect(
		() =>
			setAttributes( {
				isDescendentOfQueryLoop,
				isDescendentOfSingleProductTemplate,
			} ),
		[
			isDescendentOfQueryLoop,
			isDescendentOfSingleProductTemplate,
			setAttributes,
		]
	);

	return (
		<>
			<BlockControls>
				<AlignmentToolbar
					value={ attributes.textAlign }
					onChange={ ( textAlign: AllowedAlignments ) => {
						setAttributes( { textAlign } );
					} }
				/>
			</BlockControls>
			<div { ...blockProps }>
				<Block { ...blockAttrs } />
			</div>
		</>
	);
};

export default PriceEdit;
blocks/product-elements/price/index.tsx000064400000001254151547030360014272 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import edit from './edit';
import attributes from './attributes';
import { supports } from './supports';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';

const { ancestor, ...configuration } = sharedConfig;

const blockConfig = {
	...configuration,
	apiVersion: 2,
	title,
	description,
	usesContext: [ 'query', 'queryId', 'postId' ],
	icon: { src: icon },
	attributes,
	supports,
	edit,
};

registerBlockType( 'woocommerce/product-price', blockConfig );
blocks/product-elements/price/supports.ts000064400000001670151547030360014674 0ustar00/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';

export const supports = {
	...sharedConfig.supports,
	...( isFeaturePluginBuild() && {
		color: {
			text: true,
			background: true,
			link: false,
			__experimentalSkipSerialization: true,
		},
		typography: {
			fontSize: true,
			lineHeight: true,
			__experimentalFontFamily: true,
			__experimentalFontWeight: true,
			__experimentalFontStyle: true,
			__experimentalSkipSerialization: true,
			__experimentalLetterSpacing: true,
		},
		__experimentalSelector:
			'.wp-block-woocommerce-product-price .wc-block-components-product-price',
	} ),
	...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
		spacing: {
			margin: true,
			padding: true,
		},
	} ),
};
blocks/product-elements/price/types.ts000064400000000315151547030360014134 0ustar00export interface BlockAttributes {
	productId?: number;
	className?: string;
	textAlign?: 'left' | 'center' | 'right';
	isDescendentOfQueryLoop?: boolean;
	isDescendentOfSingleProductTemplate?: boolean;
}
blocks/product-elements/product-details/block.json000064400000000647151547030370016417 0ustar00{
	"name": "woocommerce/product-details",
	"version": "1.0.0",
	"icon": "info",
	"title": "Product Details",
	"description": "Display a product's description, attributes, and reviews.",
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"supports": {
		"align": true,
		"spacing": {
			"margin": true
		}
	},
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-details/block.tsx000064400000003441151547030370016257 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';

interface SingleProductTab {
	id: string;
	title: string;
	active: boolean;
	content: string | undefined;
}

const ProductTabTitle = ( {
	id,
	title,
	active,
}: Pick< SingleProductTab, 'id' | 'title' | 'active' > ) => {
	return (
		<li
			className={ classnames( `${ id }_tab`, {
				active,
			} ) }
			id={ `tab-title-${ id }` }
			role="tab"
			aria-controls={ `tab-${ id }` }
		>
			<a href={ `#tab-${ id }` }>{ title }</a>
		</li>
	);
};

const ProductTabContent = ( {
	id,
	content,
}: Pick< SingleProductTab, 'id' | 'content' > ) => {
	return (
		<div
			className={ `${ id }_tab` }
			id={ `tab-title-${ id }` }
			role="tab"
			aria-controls={ `tab-${ id }` }
		>
			{ content }
		</div>
	);
};

export const SingleProductDetails = () => {
	const blockProps = useBlockProps();
	const productTabs = [
		{
			id: 'description',
			title: 'Description',
			active: true,
			content: __(
				'This block lists description, attributes and reviews for a single product.',
				'woo-gutenberg-products-block'
			),
		},
		{
			id: 'additional_information',
			title: 'Additional Information',
			active: false,
		},
		{ id: 'reviews', title: 'Reviews', active: false },
	];
	const tabsTitle = productTabs.map( ( { id, title, active } ) => (
		<ProductTabTitle
			key={ id }
			id={ id }
			title={ title }
			active={ active }
		/>
	) );
	const tabsContent = productTabs.map( ( { id, content } ) => (
		<ProductTabContent key={ id } id={ id } content={ content } />
	) );

	return (
		<div { ...blockProps }>
			<ul className="wc-tabs tabs" role="tablist">
				{ tabsTitle }
			</ul>
			{ tabsContent }
		</div>
	);
};

export default SingleProductDetails;
blocks/product-elements/product-details/edit.tsx000064400000001117151547030370016110 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import { Disabled } from '@wordpress/components';
import type { BlockEditProps } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import Block from './block';
import { Attributes } from './types';

const Edit = ( { attributes }: BlockEditProps< Attributes > ) => {
	const { className } = attributes;
	const blockProps = useBlockProps( {
		className,
	} );

	return (
		<>
			<div { ...blockProps }>
				<Disabled>
					<Block />
				</Disabled>
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/product-details/index.tsx000064400000001311151547030370016266 0ustar00/**
 * External dependencies
 */
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productDetails } from '@woocommerce/icons';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import edit from './edit';
import './style.scss';

registerBlockSingleProductTemplate( {
	blockName: metadata.name,
	// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
	blockMetadata: metadata,
	blockSettings: {
		icon: {
			src: (
				<Icon
					icon={ productDetails }
					className="wc-block-editor-components-block-icon"
				/>
			),
		},
		edit,
	},
	isAvailableOnPostEditor: false,
} );
blocks/product-elements/product-details/style.scss000064400000001124151547030370016456 0ustar00.wp-block-woocommerce-product-details {
	ul.wc-tabs {
		list-style: none;
		padding: 0 0 0 1em;
		margin: 0 0 1.618em;
		overflow: hidden;
		position: relative;
		border-bottom: 1px solid $gray-200;

		li {
			border: 1px solid $gray-200;
			display: inline-block;
			position: relative;
			z-index: 0;
			border-radius: 4px 4px 0 0;
			margin: 0;
			padding: 0.5em 1em;

			a {
				display: inline-block;
				font-weight: 700;
				text-decoration: none;

				&:hover {
					text-decoration: none;
				}
			}

			&.active {
				z-index: 2;

				a {
					text-shadow: inherit;
				}
			}
		}
	}
}
blocks/product-elements/product-details/types.ts000064400000000065151547030370016140 0ustar00export interface Attributes {
	className?: string;
}
blocks/product-elements/product-image-gallery/block.json000064400000000674151547030370017511 0ustar00{
	"name": "woocommerce/product-image-gallery",
	"version": "1.0.0",
	"title": "Product Image Gallery",
	"icon": "gallery",
	"description": "Display a product's images.",
	"category": "woocommerce",
	"supports": {
		"align": true,
		"multiple": false
	},
	"keywords": [ "WooCommerce" ],
	"usesContext": [ "postId", "postType", "queryId" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-image-gallery/edit.tsx000064400000002666151547030370017214 0ustar00/**
 * External dependencies
 */
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
import { isEmptyObject } from '@woocommerce/types';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockAttributes } from '@wordpress/blocks';
import { Disabled } from '@wordpress/components';
import type { BlockEditProps } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import './editor.scss';

const Placeholder = () => {
	return (
		<div className="wc-block-editor-product-gallery">
			<img
				src={ `${ WC_BLOCKS_IMAGE_URL }block-placeholders/product-image-gallery.svg` }
				alt="Placeholder"
			/>
			<div className="wc-block-editor-product-gallery__other-images">
				{ [ ...Array( 4 ).keys() ].map( ( index ) => {
					return (
						<img
							key={ index }
							src={ `${ WC_BLOCKS_IMAGE_URL }block-placeholders/product-image-gallery.svg` }
							alt="Placeholder"
						/>
					);
				} ) }
			</div>
		</div>
	);
};

type Context = {
	postId: string;
	postType: string;
	queryId: string;
};

interface Props extends BlockEditProps< BlockAttributes > {
	context: Context;
}

const Edit = ( { context }: Props ) => {
	const blockProps = useBlockProps();

	if ( isEmptyObject( context ) ) {
		return (
			<div { ...blockProps }>
				<Disabled>
					<Placeholder />
				</Disabled>
			</div>
		);
	}
	// We have work on this case when we will work on the Single Product block.
	return <></>;
};

export default Edit;
blocks/product-elements/product-image-gallery/editor.scss000064400000000326151547030370017701 0ustar00.wc-block-editor-product-gallery {
	img {
		max-width: 500px;
		width: 100%;
		height: auto;
	}
	.wc-block-editor-product-gallery__other-images {
		img {
			width: 100px;
			height: 100px;
			margin: 5px;
		}
	}
}
blocks/product-elements/product-image-gallery/index.ts000064400000001154151547030370017175 0ustar00/**
 * External dependencies
 */
import { gallery as icon } from '@wordpress/icons';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';

/**
 * Internal dependencies
 */
import edit from './edit';
import metadata from './block.json';
import './style.scss';

registerBlockSingleProductTemplate( {
	blockName: metadata.name,
	// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
	blockMetadata: metadata,
	blockSettings: {
		icon,
		// @ts-expect-error `edit` can be extended to include other attributes
		edit,
	},
	isAvailableOnPostEditor: false,
} );
blocks/product-elements/product-image-gallery/style.scss000064400000001445151547030370017556 0ustar00.woocommerce .wp-block-woocommerce-product-image-gallery {
	position: relative;
	// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
	clear: both;
	max-width: 512px;

	span.onsale {
		right: unset;
		z-index: 1;
		left: -1rem;
	}
}

// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
.woocommerce .wp-block-woocommerce-product-image-gallery::after {
	clear: both;
	content: "";
	display: table;
}


.woocommerce .wp-block-woocommerce-product-image-gallery .woocommerce-product-gallery.images {
	width: 100%;
}
blocks/product-elements/product-meta/block.json000064400000000705151547030370015713 0ustar00{
	"name": "woocommerce/product-meta",
	"version": "1.0.0",
	"title": "Product Meta",
	"icon": "product",
	"description": "Display a product’s SKU, categories, tags, and more.",
	"category": "woocommerce",
	"supports": {
		"align": true,
		"reusable": false
	},
	"keywords": [ "WooCommerce" ],
	"usesContext": [ "postId", "postType", "queryId" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-meta/edit.tsx000064400000002024151547030370015407 0ustar00/**
 * External dependencies
 */
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import { InnerBlockTemplate } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import './editor.scss';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

const Edit = () => {
	const isDescendentOfSingleProductTemplate =
		useIsDescendentOfSingleProductTemplate();

	const TEMPLATE: InnerBlockTemplate[] = [
		[
			'core/group',
			{ layout: { type: 'flex', flexWrap: 'nowrap' } },
			[
				[
					'woocommerce/product-sku',
					{
						isDescendentOfSingleProductTemplate,
					},
				],
				[
					'core/post-terms',
					{
						prefix: 'Category: ',
						term: 'product_cat',
					},
				],
				[
					'core/post-terms',
					{
						prefix: 'Tags: ',
						term: 'product_tag',
					},
				],
			],
		],
	];
	const blockProps = useBlockProps();

	return (
		<div { ...blockProps }>
			<InnerBlocks template={ TEMPLATE } />
		</div>
	);
};

export default Edit;
blocks/product-elements/product-meta/editor.scss000064400000000133151547030370016104 0ustar00.wc-block-editor-related-products__notice {
	margin: 10px auto;
	max-width: max-content;
}
blocks/product-elements/product-meta/index.tsx000064400000001374151547030370015600 0ustar00/**
 * External dependencies
 */
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productMeta } from '@woocommerce/icons';

/**
 * Internal dependencies
 */
import edit from './edit';
import save from './save';
import metadata from './block.json';

registerBlockSingleProductTemplate( {
	blockName: metadata.name,
	// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
	blockMetadata: metadata,
	blockSettings: {
		edit,
		save,
		icon: {
			src: (
				<Icon
					icon={ productMeta }
					className="wc-block-editor-components-block-icon"
				/>
			),
		},
		ancestor: [ 'woocommerce/single-product' ],
	},
	isAvailableOnPostEditor: true,
} );
blocks/product-elements/product-meta/save.tsx000064400000000560151547030370015423 0ustar00/**
 * External dependencies
 */
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';

const Save = () => {
	const blockProps = useBlockProps.save();

	return (
		<div { ...blockProps }>
			{ /* @ts-expect-error: `InnerBlocks.Content` is a component that is typed in WordPress core*/ }
			<InnerBlocks.Content />
		</div>
	);
};

export default Save;
blocks/product-elements/product-reviews/block.json000064400000000636151547030370016454 0ustar00{
	"name": "woocommerce/product-reviews",
	"version": "1.0.0",
	"icon": "admin-comments",
	"title": "Product Reviews",
	"description": "A block that shows the reviews for a product.",
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"supports": {},
	"attributes": {},
	"usesContext": [ "postId" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-reviews/block.tsx000064400000003673151547030370016325 0ustar00// We are using anchors as mere placeholders to replicate the front-end look.
/* eslint-disable jsx-a11y/anchor-is-valid */

/**
 * External dependencies
 */
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Notice } from '@wordpress/components';

export const ProductReviews = () => {
	const blockProps = useBlockProps();

	return (
		<div { ...blockProps }>
			<Notice
				className={ 'wc-block-editor-related-products__notice' }
				status={ 'info' }
				isDismissible={ false }
			>
				<p>
					{ __(
						'The products reviews and the form to add a new review will be displayed here according to your theme. The look you see here is not representative of what is going to look like, this is just a placeholder.',
						'woo-gutenberg-products-block'
					) }
				</p>
			</Notice>
			<h2>
				{ __(
					'3 reviews for this product',
					'woo-gutenberg-products-block'
				) }
			</h2>
			<img
				src={ `${ WC_BLOCKS_IMAGE_URL }block-placeholders/product-reviews.svg` }
				alt="Placeholder"
			/>
			<h3>{ __( 'Add a review', 'woo-gutenberg-products-block' ) }</h3>
			<div className="wp-block-woocommerce-product-reviews__editor__form-container">
				<div className="wp-block-woocommerce-product-reviews__editor__row">
					<span>
						{ __(
							'Your rating *',
							'woo-gutenberg-products-block'
						) }
					</span>
					<p className="wp-block-woocommerce-product-reviews__editor__stars"></p>
				</div>
				<div className="wp-block-woocommerce-product-reviews__editor__row">
					<span>
						{ __(
							'Your review *',
							'woo-gutenberg-products-block'
						) }
					</span>
					<textarea />
				</div>
				<input
					type="submit"
					className="submit wp-block-button__link wp-element-button"
					value={ __( 'Submit', 'woo-gutenberg-products-block' ) }
				/>
			</div>
		</div>
	);
};

export default ProductReviews;
blocks/product-elements/product-reviews/edit.tsx000064400000001117151547030370016147 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import { Disabled } from '@wordpress/components';
import type { BlockEditProps } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import Block from './block';
import { Attributes } from './types';

const Edit = ( { attributes }: BlockEditProps< Attributes > ) => {
	const { className } = attributes;
	const blockProps = useBlockProps( {
		className,
	} );

	return (
		<>
			<div { ...blockProps }>
				<Disabled>
					<Block />
				</Disabled>
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/product-reviews/index.tsx000064400000000747151547030370016341 0ustar00/**
 * External dependencies
 */
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import edit from './edit';
import './style.scss';

registerBlockSingleProductTemplate( {
	blockName: metadata.name,
	// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
	blockMetadata: metadata,
	blockSettings: {
		edit,
	},
	isAvailableOnPostEditor: false,
} );
blocks/product-elements/product-reviews/style.scss000064400000001521151547030370016516 0ustar00.wp-block-woocommerce-product-reviews {
	img {
		max-width: 600px;
	}

	.submit {
		margin-top: 2rem;
	}
}

.wp-block-woocommerce-product-reviews__editor__row {
	align-items: center;
	display: flex;
	gap: 2rem;

	> span {
		flex-basis: 20%;
	}

	textarea,
	.wp-block-woocommerce-product-reviews__editor__stars {
		flex-grow: 1;
		margin-right: 1rem;
	}

	textarea {
		flex-grow: 1;
		height: 8rem;
	}
}

.wp-block-woocommerce-product-reviews__editor__stars {
	display: inline-block;
	overflow: hidden;
	position: relative;
	width: 5.3em;
	height: 1.618em;
	line-height: 1.618;
	font-size: 1em;
	/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
	font-family: star;
	font-weight: 400;

	&::before {
		color: inherit;
		content: "SSSSS";
		position: absolute;
		left: 0;
		right: 0;
		top: 0;
		white-space: nowrap;
	}
}
blocks/product-elements/product-reviews/types.ts000064400000000065151547030370016177 0ustar00export interface Attributes {
	className?: string;
}
blocks/product-elements/rating/block.json000064400000001452151547030370014573 0ustar00{
	"name": "woocommerce/product-rating",
	"version": "1.0.0",
	"icon": "info",
	"title": "Product Rating",
	"description": "Display the average rating of a product.",
	"attributes": {
		"productId": {
			"type": "number",
			"default": 0
		},
		"isDescendentOfQueryLoop": {
			"type": "boolean",
			"default": false
		},
		"textAlign": {
			"type": "string",
			"default": ""
		},
		"isDescendentOfSingleProductBlock": {
			"type": "boolean",
			"default": false
		},
		"isDescendentOfSingleProductTemplate": {
			"type": "boolean",
			"default": false
		}
	},
	"usesContext": [ "query", "queryId", "postId" ],
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"supports": {
		"align": true
	},
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/rating/block.tsx000064400000011272151547030370014441 0ustar00/**
 * External dependencies
 */
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import './style.scss';

type RatingProps = {
	reviews: number;
	rating: number;
	parentClassName?: string;
};

const getAverageRating = (
	product: Omit< ProductResponseItem, 'average_rating' > & {
		average_rating: string;
	}
) => {
	const rating = parseFloat( product.average_rating );

	return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};

const getRatingCount = ( product: ProductResponseItem ) => {
	const count = isNumber( product.review_count )
		? product.review_count
		: parseInt( product.review_count, 10 );

	return Number.isFinite( count ) && count > 0 ? count : 0;
};

const getStarStyle = ( rating: number ) => ( {
	width: ( rating / 5 ) * 100 + '%',
} );

const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
	const starStyle = getStarStyle( 0 );

	return (
		<div
			className={ classnames(
				'wc-block-components-product-rating__norating-container',
				`${ parentClassName }-product-rating__norating-container`
			) }
		>
			<div
				className={ 'wc-block-components-product-rating__norating' }
				role="img"
			>
				<span style={ starStyle } />
			</div>
			<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
		</div>
	);
};

const Rating = ( props: RatingProps ): JSX.Element => {
	const { rating, reviews, parentClassName } = props;

	const starStyle = getStarStyle( rating );

	const ratingText = sprintf(
		/* translators: %f is referring to the average rating value */
		__( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
		rating
	);

	const ratingHTML = {
		__html: sprintf(
			/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
			_n(
				'Rated %1$s out of 5 based on %2$s customer rating',
				'Rated %1$s out of 5 based on %2$s customer ratings',
				reviews,
				'woo-gutenberg-products-block'
			),
			sprintf( '<strong class="rating">%f</strong>', rating ),
			sprintf( '<span class="rating">%d</span>', reviews )
		),
	};
	return (
		<div
			className={ classnames(
				'wc-block-components-product-rating__stars',
				`${ parentClassName }__product-rating__stars`
			) }
			role="img"
			aria-label={ ratingText }
		>
			<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
		</div>
	);
};

const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
	const { reviews } = props;

	const reviewsCount = sprintf(
		/* translators: %s is referring to the total of reviews for a product */
		_n(
			'(%s customer review)',
			'(%s customer reviews)',
			reviews,
			'woo-gutenberg-products-block'
		),
		reviews
	);

	return (
		<span className="wc-block-components-product-rating__reviews_count">
			{ reviewsCount }
		</span>
	);
};

type ProductRatingProps = {
	className?: string;
	textAlign?: string;
	isDescendentOfSingleProductBlock: boolean;
	isDescendentOfQueryLoop: boolean;
	postId: number;
	productId: number;
	shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};

export const Block = ( props: ProductRatingProps ): JSX.Element | undefined => {
	const {
		textAlign,
		isDescendentOfSingleProductBlock,
		shouldDisplayMockedReviewsWhenProductHasNoReviews,
	} = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();
	const rating = getAverageRating( product );
	const reviews = getRatingCount( product );

	const className = classnames(
		styleProps.className,
		'wc-block-components-product-rating',
		{
			[ `${ parentClassName }__product-rating` ]: parentClassName,
			[ `has-text-align-${ textAlign }` ]: textAlign,
		}
	);
	const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
		<NoRating parentClassName={ parentClassName } />
	) : null;

	const content = reviews ? (
		<Rating
			rating={ rating }
			reviews={ reviews }
			parentClassName={ parentClassName }
		/>
	) : (
		mockedRatings
	);

	if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
		return (
			<div className={ className } style={ styleProps.style }>
				<div className="wc-block-components-product-rating__container">
					{ content }
					{ reviews && isDescendentOfSingleProductBlock ? (
						<ReviewsCount reviews={ reviews } />
					) : null }
				</div>
			</div>
		);
	}
};

export default withProductDataContext( Block );
blocks/product-elements/rating/constants.tsx000064400000000726151547030370015365 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { starEmpty, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product Rating',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon
		icon={ starEmpty }
		className="wc-block-editor-components-block-icon"
	/>
);
export const BLOCK_DESCRIPTION: string = __(
	'Display the average rating of a product.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/rating/edit.tsx000064400000004004151547030370014267 0ustar00/**
 * External dependencies
 */
import {
	AlignmentToolbar,
	BlockControls,
	useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';

/**
 * Internal dependencies
 */
import Block from './block';
import { BlockAttributes } from './types';
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

const Edit = (
	props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
	const { attributes, setAttributes, context } = props;
	const blockProps = useBlockProps( {
		className: 'wp-block-woocommerce-product-rating',
	} );
	const blockAttrs = {
		...attributes,
		...context,
		shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
	const { isDescendentOfSingleProductBlock } =
		useIsDescendentOfSingleProductBlock( {
			blockClientId: blockProps?.id,
		} );
	let { isDescendentOfSingleProductTemplate } =
		useIsDescendentOfSingleProductTemplate();

	if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
		isDescendentOfSingleProductTemplate = false;
	}

	useEffect( () => {
		setAttributes( {
			isDescendentOfQueryLoop,
			isDescendentOfSingleProductBlock,
			isDescendentOfSingleProductTemplate,
		} );
	}, [
		setAttributes,
		isDescendentOfQueryLoop,
		isDescendentOfSingleProductBlock,
		isDescendentOfSingleProductTemplate,
	] );

	return (
		<>
			<BlockControls>
				<AlignmentToolbar
					value={ attributes.textAlign }
					onChange={ ( newAlign ) => {
						setAttributes( { textAlign: newAlign || '' } );
					} }
				/>
			</BlockControls>
			<div { ...blockProps }>
				<Block { ...blockAttrs } />
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/rating/editor.scss000064400000000105151547030370014763 0ustar00.wc-block-components-product-rating__link {
	pointer-events: none;
}
blocks/product-elements/rating/index.ts000064400000001463151547030370014267 0ustar00/**
 * External dependencies
 */
import type { BlockConfiguration } from '@wordpress/blocks';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import edit from './edit';
import { BLOCK_ICON as icon } from './constants';
import metadata from './block.json';
import { supports } from './support';

const blockConfig: BlockConfiguration = {
	...sharedConfig,
	ancestor: [
		'woocommerce/all-products',
		'woocommerce/single-product',
		'core/post-template',
		'woocommerce/product-template',
	],
	icon: { src: icon },
	supports,
	edit,
};

registerBlockSingleProductTemplate( {
	blockName: 'woocommerce/product-rating',
	blockMetadata: metadata,
	blockSettings: blockConfig,
	isAvailableOnPostEditor: true,
} );
blocks/product-elements/rating/style.scss000064400000000334151547030370014641 0ustar00.wc-block-components-product-rating {
	.wc-block-components-product-rating__container {
		> * {
			vertical-align: middle;
		}
	}

	.wc-block-components-product-rating__stars {
		display: inline-block;
		margin: 0;
	}
}
blocks/product-elements/rating/support.ts000064400000001375151547030370014676 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';

export const supports = {
	...( isFeaturePluginBuild() && {
		color: {
			text: true,
			background: false,
			link: false,
			__experimentalSkipSerialization: true,
		},
		spacing: {
			margin: true,
			padding: true,
		},
		typography: {
			fontSize: true,
			__experimentalSkipSerialization: true,
		},
		__experimentalSelector: '.wc-block-components-product-rating',
	} ),
	...( ! isFeaturePluginBuild() &&
		typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
			spacing: {
				margin: true,
			},
		} ),
};
blocks/product-elements/rating/types.ts000064400000000313151547030370014315 0ustar00export interface BlockAttributes {
	productId: number;
	isDescendentOfQueryLoop: boolean;
	isDescendentOfSingleProductBlock: boolean;
	isDescendentOfSingleProductTemplate: boolean;
	textAlign: string;
}
blocks/product-elements/rating-counter/block.json000064400000001525151547030370016251 0ustar00{
	"name": "woocommerce/product-rating-counter",
	"version": "1.0.0",
	"title": "Product Rating Counter",
	"description": "Display the review count of a product",
	"attributes": {
		"productId": {
			"type": "number",
			"default": 0
		},
		"isDescendentOfQueryLoop": {
			"type": "boolean",
			"default": false
		},
		"textAlign": {
			"type": "string",
			"default": ""
		},
		"isDescendentOfSingleProductBlock": {
			"type": "boolean",
			"default": false
		},
		"isDescendentOfSingleProductTemplate": {
			"type": "boolean",
			"default": false
		}
	},
	"usesContext": [ "query", "queryId", "postId" ],
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"supports": {
		"align": true
	},
	"ancestor": [ "woocommerce/single-product" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/rating-counter/block.tsx000064400000004705151547030370016121 0ustar00/**
 * External dependencies
 */
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
import { Disabled } from '@wordpress/components';

const getRatingCount = ( product: ProductResponseItem ) => {
	const count = isNumber( product.review_count )
		? product.review_count
		: parseInt( product.review_count, 10 );

	return Number.isFinite( count ) && count > 0 ? count : 0;
};

const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
	const { reviews } = props;

	const reviewsCount = reviews
		? sprintf(
				/* translators: %s is referring to the total of reviews for a product */
				_n(
					'(%s customer review)',
					'(%s customer reviews)',
					reviews,
					'woo-gutenberg-products-block'
				),
				reviews
		  )
		: __( '(X customer reviews)', 'woo-gutenberg-products-block' );

	return (
		<span className="wc-block-components-product-rating-counter__reviews_count">
			<Disabled>
				<a href="/">{ reviewsCount }</a>
			</Disabled>
		</span>
	);
};

type ProductRatingCounterProps = {
	className?: string;
	textAlign?: string;
	isDescendentOfSingleProductBlock: boolean;
	isDescendentOfQueryLoop: boolean;
	postId: number;
	productId: number;
	shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};

export const Block = (
	props: ProductRatingCounterProps
): JSX.Element | undefined => {
	const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
		props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();
	const reviews = getRatingCount( product );

	const className = classnames(
		styleProps.className,
		'wc-block-components-product-rating-counter',
		{
			[ `${ parentClassName }__product-rating` ]: parentClassName,
			[ `has-text-align-${ textAlign }` ]: textAlign,
		}
	);

	if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
		return (
			<div className={ className } style={ styleProps.style }>
				<div className="wc-block-components-product-rating-counter__container">
					<ReviewsCount reviews={ reviews } />
				</div>
			</div>
		);
	}
};

export default withProductDataContext( Block );
blocks/product-elements/rating-counter/edit.tsx000064400000003764151547030400015752 0ustar00/**
 * External dependencies
 */
import {
	AlignmentToolbar,
	BlockControls,
	useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';

/**
 * Internal dependencies
 */
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

const Edit = (
	props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
	const { attributes, setAttributes, context } = props;
	const blockProps = useBlockProps( {
		className: 'wp-block-woocommerce-product-rating-counter',
	} );
	const blockAttrs = {
		...attributes,
		...context,
		shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
	const { isDescendentOfSingleProductBlock } =
		useIsDescendentOfSingleProductBlock( {
			blockClientId: blockProps?.id,
		} );
	let { isDescendentOfSingleProductTemplate } =
		useIsDescendentOfSingleProductTemplate();

	if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
		isDescendentOfSingleProductTemplate = false;
	}

	useEffect( () => {
		setAttributes( {
			isDescendentOfQueryLoop,
			isDescendentOfSingleProductBlock,
			isDescendentOfSingleProductTemplate,
		} );
	}, [
		setAttributes,
		isDescendentOfQueryLoop,
		isDescendentOfSingleProductBlock,
		isDescendentOfSingleProductTemplate,
	] );

	return (
		<>
			<BlockControls>
				<AlignmentToolbar
					value={ attributes.textAlign }
					onChange={ ( newAlign ) => {
						setAttributes( { textAlign: newAlign || '' } );
					} }
				/>
			</BlockControls>
			<div { ...blockProps }>
				<Block { ...blockAttrs } />
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/rating-counter/index.tsx000064400000000704151547030400016123 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';

registerBlockType( metadata, {
	icon: {
		src: (
			<Icon
				icon={ starFilled }
				className="wc-block-editor-components-block-icon"
			/>
		),
	},
	supports,
	edit,
} );
blocks/product-elements/rating-counter/support.ts000064400000000762151547030400016344 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';

export const supports = {
	...( isFeaturePluginBuild() && {
		color: {
			text: false,
			background: false,
			link: true,
		},
		spacing: {
			margin: true,
			padding: true,
		},
		typography: {
			fontSize: true,
			__experimentalSkipSerialization: true,
		},
		__experimentalSelector: '.wc-block-components-product-rating-counter',
	} ),
};
blocks/product-elements/rating-counter/types.ts000064400000000313151547030400015764 0ustar00export interface BlockAttributes {
	productId: number;
	isDescendentOfQueryLoop: boolean;
	isDescendentOfSingleProductBlock: boolean;
	isDescendentOfSingleProductTemplate: boolean;
	textAlign: string;
}
blocks/product-elements/rating-stars/block.json000064400000001536151547030400015722 0ustar00{
	"name": "woocommerce/product-rating-stars",
	"version": "1.0.0",
	"title": "Product Rating Stars",
	"description": "Display the average rating of a product with stars",
	"attributes": {
		"productId": {
			"type": "number",
			"default": 0
		},
		"isDescendentOfQueryLoop": {
			"type": "boolean",
			"default": false
		},
		"textAlign": {
			"type": "string",
			"default": ""
		},
		"isDescendentOfSingleProductBlock": {
			"type": "boolean",
			"default": false
		},
		"isDescendentOfSingleProductTemplate": {
			"type": "boolean",
			"default": false
		}
	},
	"usesContext": [ "query", "queryId", "postId" ],
	"category": "woocommerce",
	"keywords": [ "WooCommerce" ],
	"supports": {
		"align": true
	},
	"ancestor": [ "woocommerce/single-product" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/rating-stars/block.tsx000064400000010106151547030400015560 0ustar00/**
 * External dependencies
 */
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import './style.scss';

type RatingProps = {
	reviews: number;
	rating: number;
	parentClassName?: string;
};

const getAverageRating = (
	product: Omit< ProductResponseItem, 'average_rating' > & {
		average_rating: string;
	}
) => {
	const rating = parseFloat( product.average_rating );

	return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};

const getRatingCount = ( product: ProductResponseItem ) => {
	const count = isNumber( product.review_count )
		? product.review_count
		: parseInt( product.review_count, 10 );

	return Number.isFinite( count ) && count > 0 ? count : 0;
};

const getStarStyle = ( rating: number ) => ( {
	width: ( rating / 5 ) * 100 + '%',
} );

const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
	const starStyle = getStarStyle( 0 );

	return (
		<div
			className={ classnames(
				'wc-block-components-product-rating-stars__norating-container',
				`${ parentClassName }-product-rating-stars__norating-container`
			) }
		>
			<div
				className={
					'wc-block-components-product-rating-stars__norating'
				}
				role="img"
			>
				<span style={ starStyle } />
			</div>
			<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
		</div>
	);
};

const Rating = ( props: RatingProps ): JSX.Element => {
	const { rating, reviews, parentClassName } = props;

	const starStyle = getStarStyle( rating );

	const ratingText = sprintf(
		/* translators: %f is referring to the average rating value */
		__( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
		rating
	);

	const ratingHTML = {
		__html: sprintf(
			/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
			_n(
				'Rated %1$s out of 5 based on %2$s customer rating',
				'Rated %1$s out of 5 based on %2$s customer ratings',
				reviews,
				'woo-gutenberg-products-block'
			),
			sprintf( '<strong class="rating">%f</strong>', rating ),
			sprintf( '<span class="rating">%d</span>', reviews )
		),
	};
	return (
		<div
			className={ classnames(
				'wc-block-components-product-rating-stars__stars',
				`${ parentClassName }__product-rating-stars__stars`
			) }
			role="img"
			aria-label={ ratingText }
		>
			<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
		</div>
	);
};

interface ProductRatingStarsProps {
	className?: string;
	textAlign?: string;
	isDescendentOfSingleProductBlock: boolean;
	isDescendentOfQueryLoop: boolean;
	postId: number;
	productId: number;
	shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
}

export const Block = ( props: ProductRatingStarsProps ): JSX.Element | null => {
	const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
		props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();
	const rating = getAverageRating( product );
	const reviews = getRatingCount( product );

	const className = classnames(
		styleProps.className,
		'wc-block-components-product-rating-stars',
		{
			[ `${ parentClassName }__product-rating` ]: parentClassName,
			[ `has-text-align-${ textAlign }` ]: textAlign,
		}
	);
	const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
		<NoRating parentClassName={ parentClassName } />
	) : null;

	const content = reviews ? (
		<Rating
			rating={ rating }
			reviews={ reviews }
			parentClassName={ parentClassName }
		/>
	) : (
		mockedRatings
	);

	return (
		<div className={ className } style={ styleProps.style }>
			<div className="wc-block-components-product-rating-stars__container">
				{ content }
			</div>
		</div>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/rating-stars/edit.tsx000064400000003754151547030400015426 0ustar00/**
 * External dependencies
 */
import {
	AlignmentToolbar,
	BlockControls,
	useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';

/**
 * Internal dependencies
 */
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

const Edit = (
	props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
	const { attributes, setAttributes, context } = props;
	const blockProps = useBlockProps( {
		className: 'wp-block-woocommerce-product-rating',
	} );
	const blockAttrs = {
		...attributes,
		...context,
		shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
	const { isDescendentOfSingleProductBlock } =
		useIsDescendentOfSingleProductBlock( {
			blockClientId: blockProps?.id,
		} );
	let { isDescendentOfSingleProductTemplate } =
		useIsDescendentOfSingleProductTemplate();

	if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
		isDescendentOfSingleProductTemplate = false;
	}

	useEffect( () => {
		setAttributes( {
			isDescendentOfQueryLoop,
			isDescendentOfSingleProductBlock,
			isDescendentOfSingleProductTemplate,
		} );
	}, [
		setAttributes,
		isDescendentOfQueryLoop,
		isDescendentOfSingleProductBlock,
		isDescendentOfSingleProductTemplate,
	] );

	return (
		<>
			<BlockControls>
				<AlignmentToolbar
					value={ attributes.textAlign }
					onChange={ ( newAlign ) => {
						setAttributes( { textAlign: newAlign || '' } );
					} }
				/>
			</BlockControls>
			<div { ...blockProps }>
				<Block { ...blockAttrs } />
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/rating-stars/index.tsx000064400000000704151547030400015600 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';

registerBlockType( metadata, {
	icon: {
		src: (
			<Icon
				icon={ starFilled }
				className="wc-block-editor-components-block-icon"
			/>
		),
	},
	supports,
	edit,
} );
blocks/product-elements/rating-stars/style.scss000064400000003614151547030400015771 0ustar00.wc-block-components-product-rating-stars {
	display: block;
	line-height: 1;

	&__stars {
		display: inline-block;
		overflow: hidden;
		position: relative;
		width: 5.3em;
		height: 1.618em;
		line-height: 1.618;
		font-size: 1em;
		/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
		font-family: star;
		font-weight: 400;
		text-align: left;

		&::before {
			content: "\53\53\53\53\53";
			top: 0;
			left: 0;
			right: 0;
			position: absolute;
			opacity: 0.5;
			color: inherit;
			white-space: nowrap;
		}
		span {
			overflow: hidden;
			top: 0;
			left: 0;
			right: 0;
			position: absolute;
			color: inherit;
			padding-top: 1.5em;
		}
		span::before {
			content: "\53\53\53\53\53";
			top: 0;
			left: 0;
			right: 0;
			position: absolute;
			color: inherit;
			white-space: nowrap;
		}
	}

	&__link {
		display: inline-block;
		height: 1.618em;
		width: 100%;
		text-align: inherit;
		@include font-size(small);
	}

	.wc-block-all-products & {
		margin-top: 0;
		margin-bottom: $gap-small;
	}

	&__norating-container {
		display: inline-flex;
		flex-direction: row;
		align-items: center;
		gap: $gap-smaller;
	}

	&__norating {
		display: inline-block;
		overflow: hidden;
		position: relative;
		width: 1.5em;
		height: 1.618em;
		line-height: 1.618;
		font-size: 1em;
		/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
		font-family: star;
		font-weight: 400;
		-webkit-text-stroke: 2px var(--wp--preset--color--black, #000);
		&::before {
			content: "\53";
			top: 0;
			left: 0;
			right: 0;
			position: absolute;
			color: transparent;
			white-space: nowrap;
			text-align: center;
		}
	}
}

.wp-block-woocommerce-single-product {
	.wc-block-components-product-rating__stars {
		margin: 0;
	}
}

.wc-block-all-products,
.wp-block-query {
	.is-loading {
		.wc-block-components-product-rating {
			@include placeholder();
			width: 7em;
		}
	}
}
blocks/product-elements/rating-stars/support.ts000064400000001024151547030400016011 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';

export const supports = {
	...( isFeaturePluginBuild() && {
		color: {
			text: true,
			background: false,
			link: false,
			__experimentalSkipSerialization: true,
		},
		spacing: {
			margin: true,
			padding: true,
		},
		typography: {
			fontSize: true,
			__experimentalSkipSerialization: true,
		},
		__experimentalSelector: '.wc-block-components-product-rating',
	} ),
};
blocks/product-elements/rating-stars/types.ts000064400000000313151547030400015441 0ustar00export interface BlockAttributes {
	productId: number;
	isDescendentOfQueryLoop: boolean;
	isDescendentOfSingleProductBlock: boolean;
	isDescendentOfSingleProductTemplate: boolean;
	textAlign: string;
}
blocks/product-elements/related-products/block.json000064400000000660151547030400016562 0ustar00{
	"name": "woocommerce/related-products",
	"version": "1.0.0",
	"title": "Related Products",
	"icon": "product",
	"description": "Display related products.",
	"category": "woocommerce",
	"supports": {
		"align": true,
		"reusable": false
	},
	"keywords": [ "WooCommerce" ],
	"usesContext": [ "postId", "postType", "queryId" ],
	"textdomain": "woocommerce",
	"apiVersion": 2,
	"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/related-products/edit.tsx000064400000002053151547030400016260 0ustar00/**
 * External dependencies
 */
import {
	BLOCK_ATTRIBUTES,
	INNER_BLOCKS_TEMPLATE,
} from '@woocommerce/blocks/product-query/variations';
import {
	InnerBlocks,
	InspectorControls,
	useBlockProps,
} from '@wordpress/block-editor';
import { InnerBlockTemplate } from '@wordpress/blocks';
import { Notice } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

/**
 * Internal dependencies
 */
import './editor.scss';

const Edit = () => {
	const TEMPLATE: InnerBlockTemplate[] = [
		[ 'core/query', BLOCK_ATTRIBUTES, INNER_BLOCKS_TEMPLATE ],
	];
	const blockProps = useBlockProps();

	return (
		<div { ...blockProps }>
			<InspectorControls>
				<Notice
					className={ 'wc-block-editor-related-products__notice' }
					status={ 'warning' }
					isDismissible={ false }
				>
					<p>
						{ __(
							'These products will vary depending on the main product in the page',
							'woo-gutenberg-products-block'
						) }
					</p>
				</Notice>
			</InspectorControls>
			<InnerBlocks template={ TEMPLATE } />
		</div>
	);
};

export default Edit;
blocks/product-elements/related-products/editor.scss000064400000000140151547030400016751 0ustar00.wc-block-editor-related-products__notice {
	margin: auto $gap $gap;
	max-width: max-content;
}
blocks/product-elements/related-products/index.tsx000064400000001053151547030400016441 0ustar00/**
 * External dependencies
 */
import { box as icon } from '@wordpress/icons';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';

/**
 * Internal dependencies
 */
import edit from './edit';
import save from './save';
import metadata from './block.json';

registerBlockSingleProductTemplate( {
	blockName: metadata.name,
	// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
	blockMetadata: metadata,
	blockSettings: {
		icon,
		edit,
		save,
	},
	isAvailableOnPostEditor: false,
} );
blocks/product-elements/related-products/save.tsx000064400000000560151547030400016272 0ustar00/**
 * External dependencies
 */
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';

const Save = () => {
	const blockProps = useBlockProps.save();

	return (
		<div { ...blockProps }>
			{ /* @ts-expect-error: `InnerBlocks.Content` is a component that is typed in WordPress core*/ }
			<InnerBlocks.Content />
		</div>
	);
};

export default Save;
blocks/product-elements/sale-badge/attributes.ts000064400000000572151547030400016040 0ustar00/**
 * External dependencies
 */
import type { BlockAttributes } from '@wordpress/blocks';

export const blockAttributes: BlockAttributes = {
	productId: {
		type: 'number',
		default: 0,
	},
	isDescendentOfQueryLoop: {
		type: 'boolean',
		default: false,
	},
	isDescendentOfSingleProductTemplate: {
		type: 'boolean',
		default: false,
	},
};

export default blockAttributes;
blocks/product-elements/sale-badge/block.tsx000064400000003076151547030400015136 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import Label from '@woocommerce/base-components/label';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';

/**
 * Internal dependencies
 */
import './style.scss';
import type { BlockAttributes } from './types';

type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;

export const Block = ( props: Props ): JSX.Element | null => {
	const { className, align } = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();

	if (
		( ! product.id || ! product.on_sale ) &&
		! props.isDescendentOfSingleProductTemplate
	) {
		return null;
	}

	const alignClass =
		typeof align === 'string'
			? `wc-block-components-product-sale-badge--align-${ align }`
			: '';

	return (
		<div
			className={ classnames(
				'wc-block-components-product-sale-badge',
				className,
				alignClass,
				{
					[ `${ parentClassName }__product-onsale` ]: parentClassName,
				},
				styleProps.className
			) }
			style={ styleProps.style }
		>
			<Label
				label={ __( 'Sale', 'woo-gutenberg-products-block' ) }
				screenReaderLabel={ __(
					'Product on sale',
					'woo-gutenberg-products-block'
				) }
			/>
		</div>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/sale-badge/constants.tsx000064400000000730151547030400016052 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { percent, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'On-Sale Badge',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon icon={ percent } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
	'Displays an on-sale badge if the product is on-sale.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/sale-badge/edit.tsx000064400000002313151547030400014762 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { useEffect } from '@wordpress/element';

/**
 * Internal dependencies
 */
import Block from './block';
import type { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

const Edit = ( {
	attributes,
	setAttributes,
	context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
	const blockProps = useBlockProps();

	const blockAttrs = {
		...attributes,
		...context,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );

	const { isDescendentOfSingleProductTemplate } =
		useIsDescendentOfSingleProductTemplate();

	useEffect(
		() =>
			setAttributes( {
				isDescendentOfQueryLoop,
				isDescendentOfSingleProductTemplate,
			} ),
		[
			setAttributes,
			isDescendentOfQueryLoop,
			isDescendentOfSingleProductTemplate,
		]
	);

	return (
		<div { ...blockProps }>
			<Block { ...blockAttrs } />
		</div>
	);
};

export default Edit;
blocks/product-elements/sale-badge/index.ts000064400000001573151547030400014763 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';
import { supports } from './support';

const blockConfig: BlockConfiguration = {
	...sharedConfig,
	title,
	description,
	icon: { src: icon },
	apiVersion: 2,
	supports,
	attributes,
	edit,
	usesContext: [ 'query', 'queryId', 'postId' ],
	ancestor: [
		'woocommerce/all-products',
		'woocommerce/single-product',
		'core/post-template',
		'woocommerce/product-template',
		'woocommerce/product-gallery',
	],
};

registerBlockType( 'woocommerce/product-sale-badge', { ...blockConfig } );
blocks/product-elements/sale-badge/style.scss000064400000001210151547030400015325 0ustar00.wp-block-woocommerce-product-sale-badge {
	display: flex;
	flex-direction: column;
}

.wc-block-components-product-sale-badge {
	@include font-size(small);
	padding: em($gap-smallest) em($gap-small);
	display: inline-block;
	width: fit-content;
	border: 1px solid #43454b;
	border-radius: 3px;
	box-sizing: border-box;
	color: #43454b;
	background: #fff;
	text-align: center;
	text-transform: uppercase;
	font-weight: 600;
	z-index: 9;
	position: static;

	&--align-left {
		align-self: auto;
	}
	&--align-center {
		align-self: center;
	}
	&--align-right {
		align-self: flex-end;
	}

	span {
		color: inherit;
		background-color: inherit;
	}

}
blocks/product-elements/sale-badge/support.ts000064400000002564151547030400015371 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';

export const supports = {
	html: false,
	align: true,
	...( isFeaturePluginBuild() && {
		color: {
			gradients: true,
			background: true,
			link: false,
			__experimentalSkipSerialization: true,
		},
		typography: {
			fontSize: true,
			lineHeight: true,
			__experimentalFontFamily: true,
			__experimentalFontWeight: true,
			__experimentalFontStyle: true,
			__experimentalSkipSerialization: true,
			__experimentalLetterSpacing: true,
			__experimentalTextTransform: true,
			__experimentalTextDecoration: true,
		},
		__experimentalBorder: {
			color: true,
			radius: true,
			width: true,
			__experimentalSkipSerialization: true,
		},
		// @todo: Improve styles support when WordPress 6.4 is released. https://make.wordpress.org/core/2023/07/17/introducing-the-block-selectors-api/
		...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
			spacing: {
				margin: true,
				padding: true,
			},
		} ),
		__experimentalSelector: '.wc-block-components-product-sale-badge',
	} ),
	...( typeof __experimentalGetSpacingClassesAndStyles === 'function' &&
		! isFeaturePluginBuild() && {
			spacing: {
				margin: true,
			},
		} ),
};
blocks/product-elements/sale-badge/types.ts000064400000000276151547030400015017 0ustar00export interface BlockAttributes {
	productId: number;
	align: 'left' | 'center' | 'right';
	isDescendentOfQueryLoop?: boolean | undefined;
	isDescendentOfSingleProductTemplate?: boolean;
}
blocks/product-elements/save.js000064400000000604151547030400012606 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';

const save = ( { attributes } ) => {
	if (
		attributes.isDescendentOfQueryLoop ||
		attributes.isDescendentOfSingleProductBlock ||
		attributes.isDescendentOfSingleProductTemplate
	) {
		return null;
	}

	return (
		<div className={ classnames( 'is-loading', attributes.className ) } />
	);
};

export default save;
blocks/product-elements/shared/config.tsx000064400000001640151547030400014566 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { Icon, grid } from '@wordpress/icons';
import type { BlockConfiguration } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import save from '../save';

/**
 * Holds default config for this collection of blocks.
 * attributes and title are omitted here as these are added on an individual block level.
 */
const sharedConfig: Omit< BlockConfiguration, 'attributes' | 'title' > = {
	category: 'woocommerce-product-elements',
	keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
	icon: {
		src: (
			<Icon
				icon={ grid }
				className="wc-block-editor-components-block-icon"
			/>
		),
	},
	supports: {
		html: false,
	},
	ancestor: [ 'woocommerce/all-products', 'woocommerce/single-product' ],
	save,
	deprecated: [
		{
			attributes: {},
			save(): null {
				return null;
			},
		},
	],
};

export default sharedConfig;
blocks/product-elements/shared/editor.scss000064400000000330151547030400014737 0ustar00.wc-atomic-blocks-product__selection {
	width: 100%;
}
.wc-atomic-blocks-product__edit-card {
	padding: 16px;
	border-top: 1px solid $gray-200;

	.wc-atomic-blocks-product__edit-card-title {
		margin: 0 0 $gap;
	}
}
blocks/product-elements/shared/product-selector.tsx000064400000003460151547030410016622 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import ProductControl from '@woocommerce/editor-components/product-control';
import { Placeholder, Button, ToolbarGroup } from '@wordpress/components';
import { BlockControls } from '@wordpress/block-editor';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
import { useState } from '@wordpress/element';

export const ProductSelector = ( {
	productId,
	icon,
	label,
	description,
	setAttributes,
	children,
}: {
	productId: string;
	icon: string;
	label: string;
	description: string;
	setAttributes: ( obj: Record< string, string > ) => void;
	children: React.ReactNode;
} ) => {
	const [ isEditing, setIsEditing ] = useState( ! productId );

	return (
		<>
			{ isEditing ? (
				<Placeholder
					icon={ icon || '' }
					label={ label || '' }
					className="wc-atomic-blocks-product"
				>
					{ !! description && <div>{ description }</div> }
					<div className="wc-atomic-blocks-product__selection">
						<ProductControl
							selected={ productId || 0 }
							showVariations
							onChange={ ( value = [] ) => {
								setAttributes( {
									productId: value[ 0 ] ? value[ 0 ].id : 0,
								} );
							} }
						/>
						<Button
							isSecondary
							disabled={ ! productId }
							onClick={ () => {
								setIsEditing( false );
							} }
						>
							{ __( 'Done', 'woo-gutenberg-products-block' ) }
						</Button>
					</div>
				</Placeholder>
			) : (
				<>
					<BlockControls>
						<ToolbarGroup>
							<TextToolbarButton
								onClick={ () => setIsEditing( true ) }
							>
								{ __(
									'Switch product…',
									'woo-gutenberg-products-block'
								) }
							</TextToolbarButton>
						</ToolbarGroup>
					</BlockControls>
					{ children }
				</>
			) }
		</>
	);
};
blocks/product-elements/shared/use-is-descendent-of-single-product-block.tsx000064400000001361151547030410023270 0ustar00/**
 * External dependencies
 */
import { useSelect } from '@wordpress/data';

interface UseIsDescendentOfSingleProductBlockProps {
	blockClientId: string;
}

export const useIsDescendentOfSingleProductBlock = ( {
	blockClientId,
}: UseIsDescendentOfSingleProductBlockProps ) => {
	const { isDescendentOfSingleProductBlock } = useSelect(
		( select ) => {
			const { getBlockParentsByBlockName } =
				select( 'core/block-editor' );
			const blockParentBlocksIds = getBlockParentsByBlockName(
				blockClientId?.replace( 'block-', '' ),
				[ 'woocommerce/single-product' ]
			);
			return {
				isDescendentOfSingleProductBlock:
					blockParentBlocksIds.length > 0,
			};
		},
		[ blockClientId ]
	);

	return { isDescendentOfSingleProductBlock };
};
blocks/product-elements/shared/use-is-descendent-of-single-product-template.tsx000064400000000676151547030410024021 0ustar00/**
 * External dependencies
 */
import { useSelect } from '@wordpress/data';

export const useIsDescendentOfSingleProductTemplate = () => {
	const isDescendentOfSingleProductTemplate = useSelect( ( select ) => {
		const store = select( 'core/edit-site' );
		const postId = store?.getEditedPostId< string | undefined >();

		return Boolean( postId?.includes( '//single-product' ) );
	}, [] );

	return { isDescendentOfSingleProductTemplate };
};
blocks/product-elements/shared/with-product-selector.js000064400000004541151547030410017372 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import ProductControl from '@woocommerce/editor-components/product-control';
import { Placeholder, Button, ToolbarGroup } from '@wordpress/components';
import { BlockControls } from '@wordpress/block-editor';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
import { useProductDataContext } from '@woocommerce/shared-context';

/**
 * Internal dependencies
 */
import './editor.scss';

/**
 * This HOC shows a product selection interface if context is not present in the editor.
 *
 * @param {Object} selectorArgs Options for the selector.
 *
 */
const withProductSelector = ( selectorArgs ) => ( OriginalComponent ) => {
	return ( props ) => {
		const productDataContext = useProductDataContext();
		const { attributes, setAttributes } = props;
		const { productId } = attributes;
		const [ isEditing, setIsEditing ] = useState( ! productId );

		if (
			productDataContext.hasContext ||
			Number.isFinite( props.context?.queryId )
		) {
			return <OriginalComponent { ...props } />;
		}

		return (
			<>
				{ isEditing ? (
					<Placeholder
						icon={ selectorArgs.icon || '' }
						label={ selectorArgs.label || '' }
						className="wc-atomic-blocks-product"
					>
						{ !! selectorArgs.description && (
							<div>{ selectorArgs.description }</div>
						) }
						<div className="wc-atomic-blocks-product__selection">
							<ProductControl
								selected={ productId || 0 }
								showVariations
								onChange={ ( value = [] ) => {
									setAttributes( {
										productId: value[ 0 ]
											? value[ 0 ].id
											: 0,
									} );
								} }
							/>
							<Button
								isSecondary
								disabled={ ! productId }
								onClick={ () => {
									setIsEditing( false );
								} }
							>
								{ __( 'Done', 'woocommerce' ) }
							</Button>
						</div>
					</Placeholder>
				) : (
					<>
						<BlockControls>
							<ToolbarGroup>
								<TextToolbarButton
									onClick={ () => setIsEditing( true ) }
								>
									{ __(
										'Switch product…',
										'woocommerce'
									) }
								</TextToolbarButton>
							</ToolbarGroup>
						</BlockControls>
						<OriginalComponent { ...props } />
					</>
				) }
			</>
		);
	};
};

export default withProductSelector;
blocks/product-elements/sku/attributes.ts000064400000001002151547030410014644 0ustar00/**
 * External dependencies
 */
import type { BlockAttributes } from '@wordpress/blocks';

export const blockAttributes: BlockAttributes = {
	productId: {
		type: 'number',
		default: 0,
	},
	isDescendentOfQueryLoop: {
		type: 'boolean',
		default: false,
	},
	isDescendentOfSingleProductTemplate: {
		type: 'boolean',
		default: false,
	},
	isDescendantOfAllProducts: {
		type: 'boolean',
		default: false,
	},
	showProductSelector: {
		type: 'boolean',
		default: false,
	},
};

export default blockAttributes;
blocks/product-elements/sku/block.tsx000064400000003503151547030410013750 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
import { useStyleProps } from '@woocommerce/base-hooks';

/**
 * Internal dependencies
 */
import './style.scss';
import type { Attributes } from './types';

type Props = Attributes & HTMLAttributes< HTMLDivElement >;

const Preview = ( {
	parentClassName,
	sku,
	className,
	style,
}: {
	parentClassName: string;
	sku: string;
	className?: string | undefined;
	style?: React.CSSProperties | undefined;
} ) => (
	<div
		className={ classnames( className, {
			[ `${ parentClassName }__product-sku` ]: parentClassName,
		} ) }
		style={ style }
	>
		{ __( 'SKU:', 'woo-gutenberg-products-block' ) }{ ' ' }
		<strong>{ sku }</strong>
	</div>
);

const Block = ( props: Props ): JSX.Element | null => {
	const { className } = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();
	const sku = product.sku;

	if ( props.isDescendentOfSingleProductTemplate ) {
		return (
			<Preview
				parentClassName={ parentClassName }
				className={ className }
				sku={ 'Product SKU' }
			/>
		);
	}

	if ( ! sku ) {
		return null;
	}

	return (
		<Preview
			className={ className }
			parentClassName={ parentClassName }
			sku={ sku }
			{ ...( props.isDescendantOfAllProducts && {
				className: classnames(
					className,
					'wc-block-components-product-sku wp-block-woocommerce-product-sku',
					styleProps.className
				),
				style: {
					...styleProps.style,
				},
			} ) }
		/>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/sku/constants.tsx000064400000000744151547030410014676 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { barcode } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product SKU',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon icon={ barcode } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
	'Display the SKU of a product.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/sku/edit.tsx000064400000004115151547030410013603 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import EditProductLink from '@woocommerce/editor-components/edit-product-link';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { useEffect } from '@wordpress/element';

/**
 * Internal dependencies
 */
import Block from './block';
import type { Attributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';

const Edit = ( {
	attributes,
	setAttributes,
	context,
}: BlockEditProps< Attributes > & { context: Context } ): JSX.Element => {
	const { style, ...blockProps } = useBlockProps( {
		className:
			'wc-block-components-product-sku wp-block-woocommerce-product-sku',
	} );
	const blockAttrs = {
		...attributes,
		...context,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
	const { isDescendentOfSingleProductBlock } =
		useIsDescendentOfSingleProductBlock( { blockClientId: blockProps.id } );

	let { isDescendentOfSingleProductTemplate } =
		useIsDescendentOfSingleProductTemplate();

	if ( isDescendentOfQueryLoop ) {
		isDescendentOfSingleProductTemplate = false;
	}

	useEffect(
		() =>
			setAttributes( {
				isDescendentOfQueryLoop,
				isDescendentOfSingleProductTemplate,
				isDescendentOfSingleProductBlock,
			} ),
		[
			setAttributes,
			isDescendentOfQueryLoop,
			isDescendentOfSingleProductTemplate,
			isDescendentOfSingleProductBlock,
		]
	);

	return (
		<>
			<EditProductLink />
			<div
				{ ...blockProps }
				/**
				 * If block is decendant of the All Products block, we don't want to
				 * apply style here because it will be applied inside Block using
				 * useColors, useTypography, and useSpacing hooks.
				 */
				style={
					attributes.isDescendantOfAllProducts ? undefined : style
				}
			>
				<Block { ...blockAttrs } />
			</div>
		</>
	);
};

export default Edit;
blocks/product-elements/sku/index.tsx000064400000001651151547030410013767 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import { supports } from './supports';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';

const { ancestor, ...configuration } = sharedConfig;

const blockConfig: BlockConfiguration = {
	...configuration,
	apiVersion: 2,
	title,
	description,
	icon: { src: icon },
	usesContext: [ 'query', 'queryId', 'postId' ],
	attributes,
	ancestor: [
		'woocommerce/all-products',
		'woocommerce/single-product',
		'core/post-template',
		'woocommerce/product-template',
		'woocommerce/product-meta',
	],
	edit,
	supports,
};

registerBlockType( 'woocommerce/product-sku', { ...blockConfig } );
blocks/product-elements/sku/style.scss000064400000000212151547030410014145 0ustar00.wc-block-components-product-sku {
	display: block;
	text-transform: uppercase;
	@include font-size(small);
	overflow-wrap: break-word;
}
blocks/product-elements/sku/supports.ts000064400000001656151547030410014374 0ustar00/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import {
	// @ts-expect-error We check if this exists before using it.
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalGetSpacingClassesAndStyles,
} from '@wordpress/block-editor';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';

export const supports = {
	...sharedConfig.supports,
	color: {
		text: true,
		background: true,
	},
	typography: {
		fontSize: true,
		lineHeight: true,
		...( isFeaturePluginBuild() && {
			__experimentalFontWeight: true,
			__experimentalFontFamily: true,
			__experimentalFontStyle: true,
			__experimentalTextTransform: true,
			__experimentalTextDecoration: true,
			__experimentalLetterSpacing: true,
		} ),
	},
	...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
		spacing: {
			margin: true,
			padding: true,
		},
	} ),
};
blocks/product-elements/sku/types.ts000064400000000366151547030410013636 0ustar00export interface Attributes {
	productId: number;
	isDescendentOfQueryLoop: boolean;
	isDescendentOfSingleProductTemplate: boolean;
	isDescendentOfSingleProductBlock: boolean;
	showProductSelector: boolean;
	isDescendantOfAllProducts: boolean;
}
blocks/product-elements/stock-indicator/attributes.ts000064400000000560151547030410017147 0ustar00/**
 * External dependencies
 */
import type { BlockAttributes } from '@wordpress/blocks';

export const blockAttributes: BlockAttributes = {
	productId: {
		type: 'number',
		default: 0,
	},
	isDescendentOfQueryLoop: {
		type: 'boolean',
		default: false,
	},
	isDescendantOfAllProducts: {
		type: 'boolean',
		default: false,
	},
};

export default blockAttributes;
blocks/product-elements/stock-indicator/block.tsx000064400000006333151547030410016247 0ustar00/**
 * External dependencies
 */
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';

/**
 * Internal dependencies
 */
import './style.scss';
import type { BlockAttributes } from './types';

/**
 * Get stock text based on stock. For example:
 * - In stock
 * - Out of stock
 * - Available on backorder
 * - 2 left in stock
 *
 * @param  stockInfo                Object containing stock information.
 * @param  stockInfo.isInStock      Whether product is in stock.
 * @param  stockInfo.isLowStock     Whether product is low in stock.
 * @param  stockInfo.lowStockAmount Number of items left in stock.
 * @param  stockInfo.isOnBackorder  Whether product is on backorder.
 * @return string Stock text.
 */
const getTextBasedOnStock = ( {
	isInStock = false,
	isLowStock = false,
	lowStockAmount = null,
	isOnBackorder = false,
}: {
	isInStock?: boolean;
	isLowStock?: boolean;
	lowStockAmount?: number | null;
	isOnBackorder?: boolean;
} ): string => {
	if ( isLowStock && lowStockAmount !== null ) {
		return sprintf(
			/* translators: %d stock amount (number of items in stock for product) */
			__( '%d left in stock', 'woo-gutenberg-products-block' ),
			lowStockAmount
		);
	} else if ( isOnBackorder ) {
		return __( 'Available on backorder', 'woo-gutenberg-products-block' );
	} else if ( isInStock ) {
		return __( 'In stock', 'woo-gutenberg-products-block' );
	}
	return __( 'Out of stock', 'woo-gutenberg-products-block' );
};

type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;

export const Block = ( props: Props ): JSX.Element | null => {
	const { className } = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();

	if ( ! product.id ) {
		return null;
	}

	const inStock = !! product.is_in_stock;
	const lowStock = product.low_stock_remaining;
	const isBackordered = product.is_on_backorder;

	return (
		<div
			className={ classnames( className, {
				[ `${ parentClassName }__stock-indicator` ]: parentClassName,
				'wc-block-components-product-stock-indicator--in-stock':
					inStock,
				'wc-block-components-product-stock-indicator--out-of-stock':
					! inStock,
				'wc-block-components-product-stock-indicator--low-stock':
					!! lowStock,
				'wc-block-components-product-stock-indicator--available-on-backorder':
					!! isBackordered,
				// When inside All products block
				...( props.isDescendantOfAllProducts && {
					[ styleProps.className ]: styleProps.className,
					'wc-block-components-product-stock-indicator wp-block-woocommerce-product-stock-indicator':
						true,
				} ),
			} ) }
			// When inside All products block
			{ ...( props.isDescendantOfAllProducts && {
				style: styleProps.style,
			} ) }
		>
			{ getTextBasedOnStock( {
				isInStock: inStock,
				isLowStock: !! lowStock,
				lowStockAmount: lowStock,
				isOnBackorder: isBackordered,
			} ) }
		</div>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/stock-indicator/constants.tsx000064400000000703151547030410017164 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { box, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product Stock Indicator',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon icon={ box } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
	'Display product stock status.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/stock-indicator/edit.tsx000064400000003123151547030410016074 0ustar00/**
 * External dependencies
 */
import EditProductLink from '@woocommerce/editor-components/edit-product-link';
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { useEffect } from '@wordpress/element';

/**
 * Internal dependencies
 */
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import {
	BLOCK_TITLE as label,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';
import type { BlockAttributes } from './types';

const Edit = ( {
	attributes,
	setAttributes,
	context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
	const { style, ...blockProps } = useBlockProps( {
		className: 'wc-block-components-product-stock-indicator',
	} );

	const blockAttrs = {
		...attributes,
		...context,
	};
	const isDescendentOfQueryLoop = Number.isFinite( context.queryId );

	useEffect(
		() => setAttributes( { isDescendentOfQueryLoop } ),
		[ setAttributes, isDescendentOfQueryLoop ]
	);

	return (
		<div
			{ ...blockProps }
			/**
			 * If block is decendant of the All Products block, we don't want to
			 * apply style here because it will be applied inside Block using
			 * useColors, useTypography, and useSpacing hooks.
			 */
			style={ attributes.isDescendantOfAllProducts ? undefined : style }
		>
			<EditProductLink />
			<Block { ...blockAttrs } />
		</div>
	);
};

export default withProductSelector( { icon, label, description } )( Edit );
blocks/product-elements/stock-indicator/index.ts000064400000001543151547030410016072 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import { supports } from './supports';

import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';

const blockConfig: BlockConfiguration = {
	...sharedConfig,
	apiVersion: 2,
	title,
	description,
	icon: { src: icon },
	attributes,
	supports,
	edit,
	usesContext: [ 'query', 'queryId', 'postId' ],
	ancestor: [
		'woocommerce/all-products',
		'woocommerce/single-product',
		'core/post-template',
		'woocommerce/product-template',
	],
};

registerBlockType( 'woocommerce/product-stock-indicator', {
	...blockConfig,
} );
blocks/product-elements/stock-indicator/style.scss000064400000000216151547030410016444 0ustar00.wc-block-components-product-stock-indicator {
	margin-top: 0;
	margin-bottom: em($gap-small);
	display: block;
	@include font-size(small);
}
blocks/product-elements/stock-indicator/supports.ts000064400000001656151547030410016667 0ustar00/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import {
	// @ts-expect-error We check if this exists before using it.
	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
	__experimentalGetSpacingClassesAndStyles,
} from '@wordpress/block-editor';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';

export const supports = {
	...sharedConfig.supports,
	color: {
		text: true,
		background: true,
	},
	typography: {
		fontSize: true,
		lineHeight: true,
		...( isFeaturePluginBuild() && {
			__experimentalFontWeight: true,
			__experimentalFontFamily: true,
			__experimentalFontStyle: true,
			__experimentalTextTransform: true,
			__experimentalTextDecoration: true,
			__experimentalLetterSpacing: true,
		} ),
	},
	...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
		spacing: {
			margin: true,
			padding: true,
		},
	} ),
};
blocks/product-elements/stock-indicator/types.ts000064400000000201151547030410016115 0ustar00export interface BlockAttributes {
	productId: number;
	isDescendentOfQueryLoop: boolean;
	isDescendantOfAllProducts: boolean;
}
blocks/product-elements/summary/attributes.ts000064400000000344151547030410015547 0ustar00/**
 * External dependencies
 */
import type { BlockAttributes } from '@wordpress/blocks';

export const blockAttributes: BlockAttributes = {
	productId: {
		type: 'number',
		default: 0,
	},
};

export default blockAttributes;
blocks/product-elements/summary/block.tsx000064400000003167151547030410014651 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import Summary from '@woocommerce/base-components/summary';
import { blocksConfig } from '@woocommerce/block-settings';

import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';

/**
 * Internal dependencies
 */
import './style.scss';
import type { BlockAttributes } from './types';

type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;

const Block = ( props: Props ): JSX.Element | null => {
	const { className } = props;
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();
	const styleProps = useStyleProps( props );

	if ( ! product ) {
		return (
			<div
				className={ classnames(
					className,
					`wc-block-components-product-summary`,
					{
						[ `${ parentClassName }__product-summary` ]:
							parentClassName,
					}
				) }
			/>
		);
	}

	const source = product.short_description
		? product.short_description
		: product.description;

	if ( ! source ) {
		return null;
	}

	return (
		<Summary
			className={ classnames(
				className,
				styleProps.className,
				`wc-block-components-product-summary`,
				{
					[ `${ parentClassName }__product-summary` ]:
						parentClassName,
				}
			) }
			source={ source }
			maxLength={ 150 }
			countType={ blocksConfig.wordCountType || 'words' }
			style={ styleProps.style }
		/>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/summary/constants.tsx000064400000000714151547030410015566 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { page, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product Summary',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon icon={ page } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
	'Display a short description about a product.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/summary/edit.tsx000064400000002110151547030410014467 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';

/**
 * Internal dependencies
 */
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import {
	BLOCK_TITLE as label,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';
import './editor.scss';
import type { BlockAttributes } from './types';

interface Props {
	attributes: BlockAttributes;
}

const Edit = ( { attributes }: Props ): JSX.Element => {
	const blockProps = useBlockProps();
	return (
		<div { ...blockProps }>
			<Block { ...attributes } />
		</div>
	);
};

// @todo: Refactor this to remove the HOC 'withProductSelector()' component as users will not see this block in the inserter. Therefore, we can export the Edit component by default. The HOC 'withProductSelector()' component should also be removed from other `product-elements` components. See also https://github.com/woocommerce/woocommerce-blocks/pull/7566#pullrequestreview-1168635469.
export default withProductSelector( { icon, label, description } )( Edit );
blocks/product-elements/summary/editor.scss000064400000000107151547030410015171 0ustar00.wc-block-components-product-summary {
	p {
		font-size: inherit;
	}
}
blocks/product-elements/summary/index.ts000064400000001304151547030410014465 0ustar00/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import { supports } from './supports';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';
import { Save } from './save';

const blockConfig: BlockConfiguration = {
	...sharedConfig,
	apiVersion: 2,
	title,
	description,
	icon: { src: icon },
	attributes,
	supports,
	edit,
	save: Save,
};

registerBlockType( 'woocommerce/product-summary', blockConfig );
blocks/product-elements/summary/save.tsx000064400000000635151547030410014512 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';

type Props = {
	attributes: Record< string, unknown > & {
		className?: string;
	};
};

export const Save = ( { attributes }: Props ): JSX.Element => {
	return (
		<div
			{ ...useBlockProps.save( {
				className: classnames( 'is-loading', attributes.className ),
			} ) }
		/>
	);
};
blocks/product-elements/summary/style.scss000064400000000347151547030410015051 0ustar00.wc-block-components-product-summary {
	margin-top: 0;
	margin-bottom: $gap-small;
}
.is-loading .wc-block-components-product-summary::before {
	@include placeholder();
	content: ".";
	display: block;
	width: 100%;
	height: 6em;
}
blocks/product-elements/summary/supports.ts000064400000000473151547030410015263 0ustar00/**
 * External dependencies
 */
import { isFeaturePluginBuild } from '@woocommerce/block-settings';

export const supports = {
	...( isFeaturePluginBuild() && {
		color: {
			background: false,
		},
		typography: {
			fontSize: true,
		},
		__experimentalSelector: '.wc-block-components-product-summary',
	} ),
};
blocks/product-elements/summary/types.ts000064400000000071151547030410014522 0ustar00export interface BlockAttributes {
	productId: number;
}
blocks/product-elements/title/attributes.ts000064400000001051151547030410015167 0ustar00/**
 * External dependencies
 */
import type { BlockAttributes } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';

let blockAttributes: BlockAttributes = {
	headingLevel: {
		type: 'number',
		default: 2,
	},
	showProductLink: {
		type: 'boolean',
		default: true,
	},
	linkTarget: {
		type: 'string',
	},
	productId: {
		type: 'number',
		default: 0,
	},
};

if ( isFeaturePluginBuild() ) {
	blockAttributes = {
		...blockAttributes,
		align: {
			type: 'string',
		},
	};
}
export default blockAttributes;
blocks/product-elements/title/block.tsx000064400000006260151547030410014272 0ustar00/**
 * External dependencies
 */
import classnames from 'classnames';
import {
	useInnerBlockLayoutContext,
	useProductDataContext,
} from '@woocommerce/shared-context';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import ProductName from '@woocommerce/base-components/product-name';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import type { HTMLAttributes } from 'react';

/**
 * Internal dependencies
 */
import './style.scss';
import { Attributes } from './types';

type Props = Attributes & HTMLAttributes< HTMLDivElement >;

interface TagNameProps extends HTMLAttributes< HTMLOrSVGElement > {
	headingLevel: number;
	elementType?: keyof JSX.IntrinsicElements;
}

const TagName = ( {
	children,
	headingLevel,
	elementType:
		ElementType = `h${ headingLevel }` as keyof JSX.IntrinsicElements,
	...props
}: TagNameProps ): JSX.Element => {
	return <ElementType { ...props }>{ children }</ElementType>;
};

/**
 * Product Title Block Component.
 *
 * @param {Object}  props                   Incoming props.
 * @param {string}  [props.className]       CSS Class name for the component.
 * @param {number}  [props.headingLevel]    Heading level (h1, h2, etc.)
 * @param {boolean} [props.showProductLink] Whether or not to display a link to the product page.
 * @param {string}  [props.linkTarget]      Specifies where to open the linked URL.
 * @param {string}  [props.align]           Title alignment.
 *                                          will be used if this is not provided.
 * @return {*} The component.
 */
export const Block = ( props: Props ): JSX.Element => {
	const {
		className,
		headingLevel = 2,
		showProductLink = true,
		linkTarget,
		align,
	} = props;
	const styleProps = useStyleProps( props );
	const { parentClassName } = useInnerBlockLayoutContext();
	const { product } = useProductDataContext();
	const { dispatchStoreEvent } = useStoreEvents();

	if ( ! product.id ) {
		return (
			<TagName
				headingLevel={ headingLevel }
				className={ classnames(
					className,
					styleProps.className,
					'wc-block-components-product-title',
					{
						[ `${ parentClassName }__product-title` ]:
							parentClassName,
						[ `wc-block-components-product-title--align-${ align }` ]:
							align && isFeaturePluginBuild(),
					}
				) }
				style={ isFeaturePluginBuild() ? styleProps.style : {} }
			/>
		);
	}

	return (
		<TagName
			headingLevel={ headingLevel }
			className={ classnames(
				className,
				styleProps.className,
				'wc-block-components-product-title',
				{
					[ `${ parentClassName }__product-title` ]: parentClassName,
					[ `wc-block-components-product-title--align-${ align }` ]:
						align && isFeaturePluginBuild(),
				}
			) }
			style={ isFeaturePluginBuild() ? styleProps.style : {} }
		>
			<ProductName
				disabled={ ! showProductLink }
				name={ product.name }
				permalink={ product.permalink }
				target={ linkTarget }
				onClick={ () => {
					dispatchStoreEvent( 'product-view-link', {
						product,
					} );
				} }
			/>
		</TagName>
	);
};

export default withProductDataContext( Block );
blocks/product-elements/title/constants.tsx000064400000000703151547030410015210 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { heading, Icon } from '@wordpress/icons';

export const BLOCK_TITLE: string = __(
	'Product Title',
	'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
	<Icon icon={ heading } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
	'Display the title of a product.',
	'woo-gutenberg-products-block'
);
blocks/product-elements/title/edit.tsx000064400000005150151547030410014122 0ustar00/**
 * External dependencies
 */
import { __ } from '@wordpress/i18n';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import {
	InspectorControls,
	BlockControls,
	AlignmentToolbar,
	useBlockProps,
} from '@wordpress/block-editor';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import HeadingToolbar from '@woocommerce/editor-components/heading-toolbar';

/**
 * Internal dependencies
 */
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import { BLOCK_TITLE, BLOCK_ICON } from './constants';
import { Attributes } from './types';
import './editor.scss';

interface Props {
	attributes: Attributes;
	setAttributes: ( attributes: Record< string, unknown > ) => void;
}

const TitleEdit = ( { attributes, setAttributes }: Props ): JSX.Element => {
	const blockProps = useBlockProps();
	const { headingLevel, showProductLink, align, linkTarget } = attributes;
	return (
		<div { ...blockProps }>
			<BlockControls>
				<HeadingToolbar
					isCollapsed={ true }
					minLevel={ 1 }
					maxLevel={ 7 }
					selectedLevel={ headingLevel }
					onChange={ ( newLevel: number ) =>
						setAttributes( { headingLevel: newLevel } )
					}
				/>
				{ isFeaturePluginBuild() && (
					<AlignmentToolbar
						value={ align }
						onChange={ ( newAlign ) => {
							setAttributes( { align: newAlign } );
						} }
					/>
				) }
			</BlockControls>
			<InspectorControls>
				<PanelBody
					title={ __(
						'Link settings',
						'woo-gutenberg-products-block'
					) }
				>
					<ToggleControl
						label={ __(
							'Make title a link',
							'woo-gutenberg-products-block'
						) }
						checked={ showProductLink }
						onChange={ () =>
							setAttributes( {
								showProductLink: ! showProductLink,
							} )
						}
					/>
					{ showProductLink && (
						<>
							<ToggleControl
								label={ __(
									'Open in new tab',
									'woo-gutenberg-products-block'
								) }
								onChange={ ( value ) =>
									setAttributes( {
										linkTarget: value ? '_blank' : '_self',
									} )
								}
								checked={ linkTarget === '_blank' }
							/>
						</>
					) }
				</PanelBody>
			</InspectorControls>
			<Disabled>
				<Block { ...attributes } />
			</Disabled>
		</div>
	);
};

const Title = isFeaturePluginBuild()
	? compose( [
			withProductSelector( {
				icon: BLOCK_ICON,
				label: BLOCK_TITLE,
				description: __(
					'Choose a product to display its title.',
					'woo-gutenberg-products-block'
				),
			} ),
	  ] )( TitleEdit )
	: TitleEdit;

export default Title;
blocks/product-elements/title/editor.scss000064400000000466151547030410014625 0ustar00.editor-styles-wrapper .wc-block-components-product-title a.wc-block-components-product-name {
	color: inherit;
}

.editor-styles-wrapper .wc-block-components-product-title {
	margin-top: 0;
	margin-bottom: $gap-small;
	line-height: 1.5;
	font-weight: 700;
	padding: 0;
	display: block;
	font-size: inherit;
}
blocks/product-elements/title/frontend.ts000064400000000411151547030410014617 0ustar00/**
 * External dependencies
 */
import { withFilteredAttributes } from '@woocommerce/shared-hocs';

/**
 * Internal dependencies
 */
import Block from './block';
import attributes from './attributes';

export default withFilteredAttributes( attributes )( Block );
blocks/product-elements/title/index.ts000064400000002730151547030410014115 0ustar00/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
 * External dependencies
 */
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';

/**
 * Internal dependencies
 */
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import {
	BLOCK_TITLE as title,
	BLOCK_ICON as icon,
	BLOCK_DESCRIPTION as description,
} from './constants';
import { Save } from './save';

const blockConfig: BlockConfiguration = {
	...sharedConfig,
	apiVersion: 2,
	title,
	description,
	icon: { src: icon },
	attributes,
	edit,
	save: Save,
	supports: {
		...sharedConfig.supports,
		...( isFeaturePluginBuild() && {
			typography: {
				fontSize: true,
				lineHeight: true,
				__experimentalFontWeight: true,
				__experimentalTextTransform: true,
				__experimentalFontFamily: true,
			},
			color: {
				text: true,
				background: true,
				link: false,
				gradients: true,
				__experimentalSkipSerialization: true,
			},
			...( typeof __experimentalGetSpacingClassesAndStyles ===
				'function' && {
				spacing: {
					margin: true,
					__experimentalSkipSerialization: true,
				},
			} ),
			__experimentalSelector: '.wc-block-components-product-title',
		} ),
	},
};

registerBlockType( 'woocommerce/product-title', blockConfig );
blocks/product-elements/title/save.tsx000064400000000635151547030410014136 0ustar00/**
 * External dependencies
 */
import { useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';

type Props = {
	attributes: Record< string, unknown > & {
		className?: string;
	};
};

export const Save = ( { attributes }: Props ): JSX.Element => {
	return (
		<div
			{ ...useBlockProps.save( {
				className: classnames( 'is-loading', attributes.className ),
			} ) }
		/>
	);
};
blocks/product-elements/title/style.scss000064400000001301151547030410014464 0ustar00.wc-block-components-product-title {
	margin-top: 0;
	margin-bottom: $gap-small;
	line-height: 1.5;
	font-weight: 700;
	padding: 0;
	display: block;
	font-size: inherit;

	a {
		color: inherit;
		font-size: inherit;
	}

}

.is-loading {
	.wc-block-components-product-title::before {
		@include placeholder();
		content: ".";
		display: inline-block;
		width: 7em;
	}
	.wc-block-grid .wc-block-components-product-title::before {
		width: 10em;
	}
}

/*rtl:begin:ignore*/
.wc-block-components-product-title--align-left {
	text-align: left;
}
.wc-block-components-product-title--align-center {
	text-align: center;
}
.wc-block-components-product-title--align-right {
	text-align: right;
}
/*rtl:end:ignore*/
blocks/product-elements/title/test/block.test.js000064400000003467151547030410016033 0ustar00/**
 * External dependencies
 */
import { render } from '@testing-library/react';
import { ProductDataContextProvider } from '@woocommerce/shared-context';

/**
 * Internal dependencies
 */
import { Block } from '../block';

const product = {
	id: 1,
	name: 'Test product',
	permalink: 'https://test.com/product/test-product/',
};

describe( 'Product Title Block', () => {
	describe( 'without product link', () => {
		test( 'should render the product title without an anchor wrapper', () => {
			const component = render(
				<ProductDataContextProvider product={ product }>
					<Block showProductLink={ false } />
				</ProductDataContextProvider>
			);

			const productName = component.getByText( product.name );
			const anchor = productName.closest( 'a' );

			expect( anchor ).toBe( null );
		} );
	} );

	describe( 'with product link', () => {
		test( 'should render an anchor with the product title', () => {
			const component = render(
				<ProductDataContextProvider product={ product }>
					<Block showProductLink={ true } />
				</ProductDataContextProvider>
			);

			const productName = component.getByText( product.name );
			const anchor = productName.closest( 'a' );

			expect( anchor.getAttribute( 'href' ) ).toBe( product.permalink );
			expect( anchor.getAttribute( 'target' ) ).toBeNull();
		} );

		test( 'should render an anchor with the product title and target blank', () => {
			const component = render(
				<ProductDataContextProvider product={ product }>
					<Block showProductLink={ true } linkTarget="_blank" />
				</ProductDataContextProvider>
			);

			const productName = component.getByText( product.name );
			const anchor = productName.closest( 'a' );

			expect( anchor.getAttribute( 'href' ) ).toBe( product.permalink );
			expect( anchor.getAttribute( 'target' ) ).toBe( '_blank' );
		} );
	} );
} );
blocks/product-elements/title/types.ts000064400000000366151547030410014155 0ustar00export interface Attributes {
	headingLevel: number;
	showProductLink: boolean;
	linkTarget?: string;
	align: string;
}

export interface Props {
	align: string;
	color: string;
	customColor: string;
	fontSize: string;
	customFontSize: string;
}
utils/create-blocks-from-template.js000064400000000720151547030410013531 0ustar00/**
 * External dependencies
 */
import { createBlock } from '@wordpress/blocks';

/**
 * Creates blocks for a given inner blocks Template.
 *
 * @param {Array} template Inner Blocks Template.
 */
export const createBlocksFromTemplate = ( template ) => {
	return template.map( ( [ name, atts = {}, innerBlocks = [] ] ) => {
		const children = innerBlocks
			? createBlocksFromTemplate( innerBlocks )
			: [];
		return createBlock( name, atts, children );
	} );
};
utils/get-block-map.ts000064400000001022151547030410010671 0ustar00/**
 * External dependencies
 */
import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry';
import type { RegisteredBlockComponent } from '@woocommerce/types';

/**
 * Internal dependencies
 */
import '../blocks/component-init';

/**
 * Map named Blocks to defined React Components to render on the frontend.
 *
 * @param {string} blockName Name of the parent block.
 */
export const getBlockMap = (
	blockName: string
): Record< string, RegisteredBlockComponent > =>
	getRegisteredBlockComponents( blockName );
utils/index.js000064400000000335151547030410007352 0ustar00export * from './get-block-map';
export * from './create-blocks-from-template';
export * from './render-parent-block';
export * from './render-standalone-blocks';
export * from './register-block-single-product-template';
utils/register-block-single-product-template.ts000064400000007632151547030410015746 0ustar00/**
 * External dependencies
 */
import { isNumber } from '@woocommerce/types';
import {
	BlockAttributes,
	BlockConfiguration,
	BlockVariation,
	getBlockType,
	registerBlockType,
	registerBlockVariation,
	unregisterBlockType,
	unregisterBlockVariation,
} from '@wordpress/blocks';
import { subscribe, select } from '@wordpress/data';

// Creating a local cache to prevent multiple registration tries.
const blocksRegistered = new Set();

function parseTemplateId( templateId: string | number | undefined ) {
	// With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230
	const parsedTemplateId = isNumber( templateId ) ? undefined : templateId;
	return parsedTemplateId?.split( '//' )[ 1 ];
}

export const registerBlockSingleProductTemplate = ( {
	blockName,
	blockMetadata,
	blockSettings,
	isVariationBlock = false,
	variationName,
	isAvailableOnPostEditor,
}: {
	blockName: string;
	blockMetadata: Partial< BlockConfiguration >;
	blockSettings: Partial< BlockConfiguration >;
	isAvailableOnPostEditor: boolean;
	isVariationBlock?: boolean;
	variationName?: string;
} ) => {
	let currentTemplateId: string | undefined = '';

	subscribe( () => {
		const previousTemplateId = currentTemplateId;
		const store = select( 'core/edit-site' );

		// With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230
		currentTemplateId = parseTemplateId(
			store?.getEditedPostId() as string | number | undefined
		);
		const hasChangedTemplate = previousTemplateId !== currentTemplateId;
		const hasTemplateId = Boolean( currentTemplateId );

		if ( ! hasChangedTemplate || ! hasTemplateId || ! blockName ) {
			return;
		}

		let isBlockRegistered = Boolean( getBlockType( blockName ) );

		/**
		 * We need to unregister the block each time the user visits or leaves the Single Product template.
		 *
		 * The Single Product template is the only template where the `ancestor` property is not needed because it provides the context
		 * for the product blocks. We need to unregister and re-register the block to remove or add the `ancestor` property depending on which
		 * location (template, post, page, etc.) the user is in.
		 *
		 */
		if (
			isBlockRegistered &&
			( currentTemplateId?.includes( 'single-product' ) ||
				previousTemplateId?.includes( 'single-product' ) )
		) {
			if ( isVariationBlock && variationName ) {
				unregisterBlockVariation( blockName, variationName );
			} else {
				unregisterBlockType( blockName );
			}
			isBlockRegistered = false;
		}

		if ( ! isBlockRegistered ) {
			if ( isVariationBlock ) {
				registerBlockVariation( blockName, {
					...blockSettings,
					// @ts-expect-error: `ancestor` key is typed in WordPress core
					ancestor: ! currentTemplateId?.includes( 'single-product' )
						? blockSettings?.ancestor
						: undefined,
				} );
			} else {
				// @ts-expect-error: `registerBlockType` is typed in WordPress core
				registerBlockType( blockMetadata, {
					...blockSettings,
					ancestor: ! currentTemplateId?.includes( 'single-product' )
						? blockSettings?.ancestor
						: undefined,
				} );
			}
		}
	}, 'core/edit-site' );

	subscribe( () => {
		const isBlockRegistered = Boolean( variationName )
			? blocksRegistered.has( variationName )
			: blocksRegistered.has( blockName );
		// This subscribe callback could be invoked with the core/blocks store
		// which would cause infinite registration loops because of the `registerBlockType` call.
		// This local cache helps prevent that.
		if ( ! isBlockRegistered && isAvailableOnPostEditor ) {
			if ( isVariationBlock ) {
				blocksRegistered.add( variationName );
				registerBlockVariation(
					blockName,
					blockSettings as BlockVariation< BlockAttributes >
				);
			} else {
				blocksRegistered.add( blockName );
				// @ts-expect-error: `registerBlockType` is typed in WordPress core
				registerBlockType( blockMetadata, blockSettings );
			}
		}
	}, 'core/edit-post' );
};
utils/render-parent-block.tsx000064400000023057151547030410012311 0ustar00/**
 * External dependencies
 */
import { renderFrontend } from '@woocommerce/base-utils';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import {
	Fragment,
	Suspense,
	isValidElement,
	cloneElement,
} from '@wordpress/element';
import parse from 'html-react-parser';
import {
	getRegisteredBlocks,
	hasInnerBlocks,
} from '@woocommerce/blocks-checkout';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';

/**
 * This file contains logic used on the frontend to convert DOM elements (saved by the block editor) to React
 * Components. These components are registered using registerBlockComponent() and registerCheckoutBlock() and map 1:1
 * to a block by name.
 *
 * Blocks using this system will have their blockName stored as a data attribute, for example:
 * 		<div data-block-name="woocommerce/product-title"></div>
 *
 * This block name is then read, and using the map, dynamically converted to a real React Component.
 *
 * @see registerBlockComponent
 * @see registerCheckoutBlock
 */

/**
 * Gets a component from the block map for a given block name, or returns null if a component is not registered.
 */
const getBlockComponentFromMap = (
	block: string,
	blockMap: Record< string, React.ReactNode >
): React.ElementType | null => {
	return block && blockMap[ block ]
		? ( blockMap[ block ] as React.ElementType )
		: null;
};

/**
 * Render forced blocks which are missing from the template.
 *
 * Forced blocks are registered in registerCheckoutBlock. If a block is forced, it will be inserted in the editor
 * automatically, however, until that happens they may be missing from the frontend. To fix this, we look up what blocks
 * are registered as forced, and then append them here if they are missing.
 *
 * @see registerCheckoutBlock
 */
const renderForcedBlocks = (
	block: string,
	blockMap: Record< string, React.ReactNode >,
	// Current children from the parent (siblings of the forced block)
	blockChildren: NodeListOf< ChildNode > | null,
	// Wrapper for inner components.
	blockWrapper?: React.ElementType
) => {
	if ( ! hasInnerBlocks( block ) ) {
		return null;
	}

	const currentBlocks = blockChildren
		? ( Array.from( blockChildren )
				.map( ( node: Node ) =>
					node instanceof HTMLElement
						? node?.dataset.blockName || null
						: null
				)
				.filter( Boolean ) as string[] )
		: [];

	const forcedBlocks = getRegisteredBlocks( block ).filter(
		( { blockName, force } ) =>
			force === true && ! currentBlocks.includes( blockName )
	);

	// This will wrap inner blocks with the provided wrapper. If no wrapper is provided, we default to Fragment.
	const InnerBlockComponentWrapper = blockWrapper ? blockWrapper : Fragment;

	return (
		<>
			{ forcedBlocks.map(
				(
					{ blockName, component },
					index: number
				): JSX.Element | null => {
					const ForcedComponent = component
						? component
						: getBlockComponentFromMap( blockName, blockMap );
					return ForcedComponent ? (
						<BlockErrorBoundary
							key={ `${ blockName }_blockerror` }
							text={ `Unexpected error in: ${ blockName }` }
							showErrorBlock={ CURRENT_USER_IS_ADMIN as boolean }
						>
							<InnerBlockComponentWrapper>
								<ForcedComponent
									key={ `${ blockName }_forced_${ index }` }
								/>
							</InnerBlockComponentWrapper>
						</BlockErrorBoundary>
					) : null;
				}
			) }
		</>
	);
};

interface renderInnerBlocksProps {
	// Block (parent) being rendered. Used for inner block component mapping.
	block: string;
	// Map of block names to block components for children.
	blockMap: Record< string, React.ReactNode >;
	// Wrapper for inner components.
	blockWrapper?: React.ElementType | undefined;
	// Elements from the DOM being converted to components.
	children: HTMLCollection | NodeList;
	// Depth within the DOM hierarchy.
	depth?: number;
}

/**
 * Recursively replace block markup in the DOM with React Components.
 */
const renderInnerBlocks = ( {
	// This is the parent block we're working within (see renderParentBlock)
	block,
	// This is the map of blockNames->components
	blockMap,
	// Component which inner blocks are wrapped with.
	blockWrapper,
	// The children from the DOM we're currently iterating over.
	children,
	// Current depth of the children. Used to ensure keys are unique.
	depth = 1,
}: renderInnerBlocksProps ): ( string | JSX.Element | null )[] | null => {
	if ( ! children || children.length === 0 ) {
		return null;
	}
	return Array.from( children ).map( ( node: Node, index: number ) => {
		/**
		 * This will grab the blockName from the data- attributes stored in block markup. Without a blockName, we cannot
		 * convert the HTMLElement to a React component.
		 */
		const { blockName = '', ...componentProps } = {
			...( node instanceof HTMLElement ? node.dataset : {} ),
			className: node instanceof Element ? node?.className : '',
		};
		const componentKey = `${ block }_${ depth }_${ index }`;
		const InnerBlockComponent = getBlockComponentFromMap(
			blockName,
			blockMap
		);

		/**
		 * If the component cannot be found, or blockName is missing, return the original element. This also ensures
		 * that children within the element are processed also, since it may be an element containing block markup.
		 *
		 * Note we use childNodes rather than children so that text nodes are also rendered.
		 */
		if ( ! InnerBlockComponent ) {
			const parsedElement = parse(
				( node instanceof Element && node?.outerHTML ) ||
					node?.textContent ||
					''
			);

			// Returns text nodes without manipulation.
			if ( typeof parsedElement === 'string' && !! parsedElement ) {
				return parsedElement;
			}

			// Do not render invalid elements.
			if ( ! isValidElement( parsedElement ) ) {
				return null;
			}

			const renderedChildren = node.childNodes.length
				? renderInnerBlocks( {
						block,
						blockMap,
						children: node.childNodes,
						depth: depth + 1,
						blockWrapper,
				  } )
				: undefined;

			// We pass props here rather than componentProps to avoid the data attributes being renamed.
			return renderedChildren
				? cloneElement(
						parsedElement,
						{
							key: componentKey,
							...( parsedElement?.props || {} ),
						},
						renderedChildren
				  )
				: cloneElement( parsedElement, {
						key: componentKey,
						...( parsedElement?.props || {} ),
				  } );
		}

		// This will wrap inner blocks with the provided wrapper. If no wrapper is provided, we default to Fragment.
		const InnerBlockComponentWrapper = blockWrapper
			? blockWrapper
			: Fragment;

		return (
			<Suspense
				key={ `${ block }_${ depth }_${ index }_suspense` }
				fallback={ <div className="wc-block-placeholder" /> }
			>
				{ /* Prevent third party components from breaking the entire checkout */ }
				<BlockErrorBoundary
					text={ `Unexpected error in: ${ blockName }` }
					showErrorBlock={ CURRENT_USER_IS_ADMIN as boolean }
				>
					<InnerBlockComponentWrapper>
						<InnerBlockComponent
							key={ componentKey }
							{ ...componentProps }
						>
							{
								/**
								 * Within this Inner Block Component we also need to recursively render it's children. This
								 * is done here with a depth+1. The same block map and parent is used, but we pass new
								 * children from this element.
								 */
								renderInnerBlocks( {
									block,
									blockMap,
									children: node.childNodes,
									depth: depth + 1,
									blockWrapper,
								} )
							}
							{
								/**
								 * In addition to the inner blocks, we may also need to render FORCED blocks which have not
								 * yet been added to the inner block template. We do this by comparing the current children
								 * to the list of registered forced blocks.
								 *
								 * @see registerCheckoutBlock
								 */
								renderForcedBlocks(
									blockName,
									blockMap,
									node.childNodes,
									blockWrapper
								)
							}
						</InnerBlockComponent>
					</InnerBlockComponentWrapper>
				</BlockErrorBoundary>
			</Suspense>
		);
	} );
};

/**
 * Render a parent block on the frontend.
 *
 * This is the main entry point used on the frontend to convert Block Markup (with inner blocks) in the DOM to React
 * Components.
 *
 * This uses renderFrontend(). The difference is, renderFrontend renders a single block, but renderParentBlock() also
 * handles inner blocks by recursively running over children from the DOM.
 *
 * @see renderInnerBlocks
 * @see renderFrontend
 */
export const renderParentBlock = ( {
	Block,
	selector,
	blockName,
	getProps = () => ( {} ),
	blockMap,
	blockWrapper,
}: {
	// Parent Block Name. Used for inner block component mapping.
	blockName: string;
	// Map of block names to block components for children.
	blockMap: Record< string, React.ReactNode >;
	// Wrapper for inner components.
	blockWrapper?: React.ElementType;
	// React component to use as a replacement.
	Block: React.FunctionComponent;
	// CSS selector to match the elements to replace.
	selector: string;
	// Function to generate the props object for the block.
	getProps: ( el: Element, i: number ) => Record< string, unknown >;
} ): void => {
	/**
	 * In addition to getProps, we need to render and return the children. This adds children to props.
	 */
	const getPropsWithChildren = ( element: Element, i: number ) => {
		const children = renderInnerBlocks( {
			block: blockName,
			blockMap,
			children: element.children || [],
			blockWrapper,
		} );
		return { ...getProps( element, i ), children };
	};
	/**
	 * The only difference between using renderParentBlock and renderFrontend is that here we provide children.
	 */
	renderFrontend( {
		Block,
		selector,
		getProps: getPropsWithChildren,
	} );
};
utils/render-standalone-blocks.js000064400000001022151547030410013115 0ustar00/**
 * External dependencies
 */
import { renderFrontend } from '@woocommerce/base-utils';

/**
 * Internal dependencies
 */
import { getBlockMap } from './get-block-map';

export const renderStandaloneBlocks = () => {
	const blockMap = getBlockMap( '' );

	Object.keys( blockMap ).forEach( ( blockName ) => {
		const selector = '.wp-block-' + blockName.replace( '/', '-' );

		const getProps = ( el ) => {
			return el.dataset;
		};

		renderFrontend( {
			Block: blockMap[ blockName ],
			selector,
			getProps,
		} );
	} );
};