HEX
Server: LiteSpeed
System: Linux premium212.web-hosting.com 4.18.0-553.124.4.lve.el8.x86_64 #1 SMP Fri May 15 13:02:13 UTC 2026 x86_64
User: vitanhod (1367)
PHP: 8.2.31
Disabled: NONE
Upload Files
File: /home/vitanhod/www/wp-content/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
<?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\ShopperLists;

/**
 * Shared markup helpers for blocks that render a shopper-list item card
 * (Saved for Later, Wishlist, …). Static helpers, not an abstract base —
 * the two blocks' lifecycles diverge enough (auto-injected vs merchant-
 * placed, different actions, different empty-state gating) that inheritance
 * is not a clean fit. Consumers stitch the fragments together with their
 * own quantity / action button / heading bits.
 *
 * Any change here is co-reviewed with every consuming block — drift in the
 * shared row shape will break first paint for whoever didn't get the memo.
 */
final class ShopperListRenderer {

	/**
	 * Shared CSS root class for the row. Each section helper outputs
	 * BEM-style modifiers off this base (`__image-slot`, `__remove`, …).
	 */
	public const ROW_CLASS = 'wc-block-shopper-list-item';

	/**
	 * Wrap `$inner` in the block's outer `<section><ul>…</ul></section>`
	 * grid scaffold. `$wrapper_attrs` are merged with the block's wrapper
	 * attributes via `get_block_wrapper_attributes()`.
	 *
	 * Trust contract: callers are responsible for ensuring `$inner` and
	 * `$before_list` contain only safe, escaped HTML — typically composed
	 * from the section helpers below, never from raw schema/request input.
	 *
	 * @param array<string, mixed> $wrapper_attrs Attributes for the outer `<section>`.
	 * @param string               $list_class    Class attribute for the inner `<ul>`.
	 * @param string               $inner         Markup placed inside the `<ul>` (template + SSR rows + empty state).
	 * @param string               $before_list   Markup placed between `<section>` and `<ul>` (header, notices region).
	 * @return string
	 */
	public static function render_grid_wrapper( array $wrapper_attrs, string $list_class, string $inner, string $before_list = '' ): string {
		return sprintf(
			'<section %1$s>%2$s<ul class="%3$s">%4$s</ul></section>',
			get_block_wrapper_attributes( $wrapper_attrs ),
			$before_list,
			esc_attr( $list_class ),
			$inner
		);
	}

	/**
	 * Wrap `$row_inner_markup` in a `<template data-wp-each>` element that
	 * iAPI uses to render new rows. `$row_inner_markup` is the inner HTML
	 * for the `<li>` — everything between `<li>` and `</li>`.
	 *
	 * Trust contract: caller is responsible for ensuring `$row_inner_markup`
	 * contains only safe, escaped HTML.
	 *
	 * @param string $row_inner_markup Inner markup for the `<li>`.
	 * @return string
	 */
	public static function render_each_template( string $row_inner_markup ): string {
		return sprintf(
			'<template data-wp-each--list-item="state.currentItems" data-wp-each-key="context.listItem.key"><li class="%1$s">%2$s</li></template>',
			esc_attr( self::ROW_CLASS ),
			$row_inner_markup
		);
	}

	/**
	 * Wrap `$row_inner_markup` in an SSR `<li data-wp-each-child>` element
	 * seeded with the per-row iAPI context derived from `$item`. iAPI's
	 * hydration treats this as a no-op diff against the `<template>` if
	 * the inner markup matches.
	 *
	 * Trust contract: caller is responsible for ensuring `$row_inner_markup`
	 * contains only safe, escaped HTML.
	 *
	 * @param array<string, mixed> $item             Schema-shape item.
	 * @param string               $row_inner_markup Inner markup for the `<li>`.
	 * @return string
	 */
	public static function render_each_child( array $item, string $row_inner_markup ): string {
		$context = array( 'listItem' => $item );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_interactivity_data_wp_context() returns a safely-encoded attribute pair; $row_inner_markup is composed of escaped fragments from the section helpers below.
		return sprintf(
			'<li class="%1$s" data-wp-each-child %2$s>%3$s</li>',
			esc_attr( self::ROW_CLASS ),
			wp_interactivity_data_wp_context( $context ),
			$row_inner_markup
		);
	}

	/**
	 * Render the image + title + price triplet for the template-mode row
	 * (no static attrs; bindings only). Identical between consumer blocks.
	 *
	 * @return string
	 */
	public static function render_template_common_row(): string {
		ob_start();
		?>
		<div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
			<a data-wp-bind--href="context.listItem.permalink">
				<span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot" data-wp-context='{"htmlField":"image_html"}' data-wp-watch="callbacks.updateInnerHtml"></span>
			</a>
			<button
				type="button"
				class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove"
				data-wp-on--click="actions.onClickRemove"
				data-wp-bind--aria-label="state.currentItemRemoveLabel"
				data-wp-bind--disabled="state.isCurrentItemPending"
			>
				<?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
			</button>
			<span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation" data-wp-bind--hidden="!state.currentItemVariationLabel" data-wp-text="state.currentItemVariationLabel"></span>
		</div>
		<h2 class="wp-block-post-title has-text-align-center has-medium-font-size">
			<a data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"></a>
		</h2>
		<div class="price wc-block-components-product-price has-text-align-center has-small-font-size" data-wp-bind--hidden="state.isPriceHidden" data-wp-context='{"htmlField":"price_html"}' data-wp-watch="callbacks.updateInnerHtml"></div>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Render the image + title + price triplet for the SSR-mode row, with
	 * values populated from `$item` and `$remove_aria_label_template`. The
	 * binding directives match the template-mode markup so iAPI's hydration
	 * is a no-op diff after first paint.
	 *
	 * @param array<string, mixed> $item                        Schema-shape item.
	 * @param string               $remove_aria_label_template  Sprintf template for the remove button's aria-label. `%s` is replaced with the product name.
	 * @return string
	 */
	public static function render_ssr_common_row( array $item, string $remove_aria_label_template ): string {
		$is_live         = ! empty( $item['is_live'] );
		$name            = (string) ( $item['name'] ?? '' );
		$permalink       = (string) ( $item['permalink'] ?? '' );
		$alt             = html_entity_decode( $name, ENT_QUOTES, 'UTF-8' );
		$image_html      = (string) ( $item['image_html'] ?? '' );
		$price_html      = (string) ( $item['price_html'] ?? '' );
		$variation_label = self::get_variation_label( $item );
		$remove_aria     = sprintf( $remove_aria_label_template, $alt );
		$is_price_hidden = '' === $price_html;
		// Tombstone rows (`is_live=false` or empty permalink) render `<a>`
		// without an href — keeps the element shape stable for iAPI
		// reconciliation against the live-row template, and the CSS in the
		// shared partial drops link affordances when the anchor has no href.
		$href_attr = $is_live && '' !== $permalink ? 'href="' . esc_url( $permalink ) . '"' : '';

		ob_start();
		?>
		<div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
			<a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink">
				<span
					class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot"
					data-wp-context='{"htmlField":"image_html"}'
					data-wp-watch="callbacks.updateInnerHtml"
				>
					<?php echo wp_kses_post( $image_html ); ?>
				</span>
			</a>
			<button
				type="button"
				class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove"
				aria-label="<?php echo esc_attr( $remove_aria ); ?>"
				data-wp-on--click="actions.onClickRemove"
				data-wp-bind--aria-label="state.currentItemRemoveLabel"
				data-wp-bind--disabled="state.isCurrentItemPending"
			>
				<?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
			</button>
			<span
				class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation"
				data-wp-bind--hidden="!state.currentItemVariationLabel"
				data-wp-text="state.currentItemVariationLabel"
				<?php
				if ( '' === $variation_label ) {
					echo 'hidden';
				}
				?>
			><?php echo esc_html( $variation_label ); ?></span>
		</div>
		<h2 class="wp-block-post-title has-text-align-center has-medium-font-size">
			<a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"><?php echo esc_html( $alt ); ?></a>
		</h2>
		<div
			class="price wc-block-components-product-price has-text-align-center has-small-font-size"
			data-wp-bind--hidden="state.isPriceHidden"
			data-wp-context='{"htmlField":"price_html"}'
			data-wp-watch="callbacks.updateInnerHtml"
			<?php
			if ( $is_price_hidden ) {
				echo 'hidden';
			}
			?>
		>
			<?php echo wp_kses_post( $price_html ); ?>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Empty-state `<li>` that the block toggles on once `state.isEmpty`
	 * flips. `$start_hidden = true` makes SSR ship with `hidden` so the
	 * message doesn't flash for shoppers whose list is being populated
	 * client-side. `$start_hidden = false` is for blocks (e.g. Wishlist)
	 * where the message should show on first paint when the list is empty.
	 *
	 * @param string $message      Visible empty-state message.
	 * @param string $css_class    Class attribute for the `<li>`.
	 * @param bool   $start_hidden Whether the `<li>` should be `hidden` on first paint.
	 * @return string
	 */
	public static function render_empty_state( string $message, string $css_class, bool $start_hidden = true ): string {
		return sprintf(
			'<li class="%1$s" data-wp-bind--hidden="!state.isEmpty"%2$s>%3$s</li>',
			esc_attr( $css_class ),
			$start_hidden ? ' hidden' : '',
			esc_html( $message )
		);
	}

	/**
	 * Render the iAPI store-notices region used by the row-level error
	 * banners. Mirrors `AddToCartWithOptions::render_interactivity_notices_region()`
	 * — keep in sync if the shape changes.
	 *
	 * @param string $wrapper_class Class attribute for the outer `<div>`.
	 * @return string
	 */
	public static function render_interactivity_notices_region( string $wrapper_class ): string {
		ob_start();
		?>
		<div class="<?php echo esc_attr( $wrapper_class ); ?> wc-block-components-notices" data-wp-interactive="woocommerce/store-notices" data-wp-bind--hidden="!context.notices.length" hidden>
			<template data-wp-each--notice="context.notices" data-wp-each-key="context.notice.id">
				<div
					class="wc-block-components-notice-banner"
					data-wp-class--is-error="state.isError"
					data-wp-class--is-success="state.isSuccess"
					data-wp-class--is-info="state.isInfo"
					data-wp-class--is-dismissible="context.notice.dismissible"
					data-wp-bind--role="state.role"
					data-wp-watch="callbacks.injectIcon"
				>
					<div class="wc-block-components-notice-banner__content">
						<span data-wp-init="callbacks.renderNoticeContent" aria-live="assertive" aria-atomic="true"></span>
					</div>
					<button
						type="button"
						data-wp-bind--hidden="!context.notice.dismissible"
						class="wc-block-components-button wp-element-button wc-block-components-notice-banner__dismiss contained"
						aria-label="<?php esc_attr_e( 'Dismiss this notice', 'woocommerce' ); ?>"
						data-wp-on--click="actions.removeNotice"
					>
						<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
							<path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" />
						</svg>
					</button>
				</div>
			</template>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Markup for the trash icon used in the remove-item button. Mirrors the
	 * `trash` icon from `@wordpress/icons` that the cart line item uses for
	 * `wc-block-cart-item__remove-link`, inlined here so SSR first paint
	 * matches what JS would render after hydration. `currentColor` lets the
	 * surrounding badge wrapper drive the fill.
	 *
	 * @return string
	 */
	public static function get_remove_icon_svg(): string {
		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 5.5A2.25 2.25 0 0 0 9.878 7h4.244A2.251 2.251 0 0 0 12 5.5ZM12 4a3.751 3.751 0 0 0-3.675 3H5v1.5h1.27l.818 8.997a2.75 2.75 0 0 0 2.739 2.501h4.347a2.75 2.75 0 0 0 2.738-2.5L17.73 8.5H19V7h-3.325A3.751 3.751 0 0 0 12 4Zm4.224 4.5H7.776l.806 8.861a1.25 1.25 0 0 0 1.245 1.137h4.347a1.25 1.25 0 0 0 1.245-1.137l.805-8.861Z"/></svg>';
	}

	/**
	 * Markup for the empty-star icon. Mirrors `starEmpty` from
	 * `@wordpress/icons`, inlined here so SSR first paint matches what JS
	 * renders after hydration. `currentColor` lets the surrounding button
	 * drive the fill.
	 *
	 * @return string
	 */
	public static function get_star_empty_svg(): string {
		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M9.706 8.646a.25.25 0 01-.188.137l-4.626.672a.25.25 0 00-.139.427l3.348 3.262a.25.25 0 01.072.222l-.79 4.607a.25.25 0 00.362.264l4.138-2.176a.25.25 0 01.233 0l4.137 2.175a.25.25 0 00.363-.263l-.79-4.607a.25.25 0 01.072-.222l3.347-3.262a.25.25 0 00-.139-.427l-4.626-.672a.25.25 0 01-.188-.137l-2.069-4.192a.25.25 0 00-.448 0L9.706 8.646zM12 7.39l-.948 1.921a1.75 1.75 0 01-1.317.957l-2.12.308 1.534 1.495c.412.402.6.982.503 1.55l-.362 2.11 1.896-.997a1.75 1.75 0 011.629 0l1.895.997-.362-2.11a1.75 1.75 0 01.504-1.55l1.533-1.495-2.12-.308a1.75 1.75 0 01-1.317-.957L12 7.39z"/></svg>';
	}

	/**
	 * Markup for the filled-star icon. Mirrors `starFilled` from
	 * `@wordpress/icons`, inlined here so SSR first paint matches what JS
	 * renders after hydration. `currentColor` lets the surrounding button
	 * drive the fill.
	 *
	 * @return string
	 */
	public static function get_star_filled_svg(): string {
		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M11.776 4.454a.25.25 0 01.448 0l2.069 4.192a.25.25 0 00.188.137l4.626.672a.25.25 0 01.139.426l-3.348 3.263a.25.25 0 00-.072.222l.79 4.607a.25.25 0 01-.362.263l-4.138-2.175a.25.25 0 00-.232 0l-4.138 2.175a.25.25 0 01-.363-.263l.79-4.607a.25.25 0 00-.071-.222L4.754 9.881a.25.25 0 01.139-.426l4.626-.672a.25.25 0 00.188-.137l2.069-4.192z"/></svg>';
	}

	/**
	 * Build a comma-separated variation label like "Color: Blue, Size: M".
	 *
	 * @param array<string, mixed> $item Schema-shape item.
	 * @return string
	 */
	public static function get_variation_label( array $item ): string {
		$variation = $item['variation'] ?? array();
		if ( ! is_array( $variation ) || empty( $variation ) ) {
			return '';
		}
		$parts = array();
		foreach ( $variation as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$attribute = isset( $entry['attribute'] ) ? html_entity_decode( (string) $entry['attribute'], ENT_QUOTES, 'UTF-8' ) : '';
			$value     = isset( $entry['value'] ) ? html_entity_decode( (string) $entry['value'], ENT_QUOTES, 'UTF-8' ) : '';
			if ( '' === $attribute && '' === $value ) {
				continue;
			}
			$parts[] = $attribute . ': ' . $value;
		}
		return implode( ', ', $parts );
	}
}