ShopperList.php
1 month ago
ShopperListItem.php
2 weeks ago
ShopperListRenderer.php
1 month ago
ShopperListsController.php
1 month ago
ShopperListRenderer.php
332 lines
| 1 | <?php |
| 2 | |
| 3 | declare( strict_types = 1 ); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Internal\ShopperLists; |
| 6 | |
| 7 | /** |
| 8 | * Shared markup helpers for blocks that render a shopper-list item card |
| 9 | * (Saved for Later, Wishlist, …). Static helpers, not an abstract base — |
| 10 | * the two blocks' lifecycles diverge enough (auto-injected vs merchant- |
| 11 | * placed, different actions, different empty-state gating) that inheritance |
| 12 | * is not a clean fit. Consumers stitch the fragments together with their |
| 13 | * own quantity / action button / heading bits. |
| 14 | * |
| 15 | * Any change here is co-reviewed with every consuming block — drift in the |
| 16 | * shared row shape will break first paint for whoever didn't get the memo. |
| 17 | */ |
| 18 | final class ShopperListRenderer { |
| 19 | |
| 20 | /** |
| 21 | * Shared CSS root class for the row. Each section helper outputs |
| 22 | * BEM-style modifiers off this base (`__image-slot`, `__remove`, …). |
| 23 | */ |
| 24 | public const ROW_CLASS = 'wc-block-shopper-list-item'; |
| 25 | |
| 26 | /** |
| 27 | * Wrap `$inner` in the block's outer `<section><ul>…</ul></section>` |
| 28 | * grid scaffold. `$wrapper_attrs` are merged with the block's wrapper |
| 29 | * attributes via `get_block_wrapper_attributes()`. |
| 30 | * |
| 31 | * Trust contract: callers are responsible for ensuring `$inner` and |
| 32 | * `$before_list` contain only safe, escaped HTML — typically composed |
| 33 | * from the section helpers below, never from raw schema/request input. |
| 34 | * |
| 35 | * @param array<string, mixed> $wrapper_attrs Attributes for the outer `<section>`. |
| 36 | * @param string $list_class Class attribute for the inner `<ul>`. |
| 37 | * @param string $inner Markup placed inside the `<ul>` (template + SSR rows + empty state). |
| 38 | * @param string $before_list Markup placed between `<section>` and `<ul>` (header, notices region). |
| 39 | * @return string |
| 40 | */ |
| 41 | public static function render_grid_wrapper( array $wrapper_attrs, string $list_class, string $inner, string $before_list = '' ): string { |
| 42 | return sprintf( |
| 43 | '<section %1$s>%2$s<ul class="%3$s">%4$s</ul></section>', |
| 44 | get_block_wrapper_attributes( $wrapper_attrs ), |
| 45 | $before_list, |
| 46 | esc_attr( $list_class ), |
| 47 | $inner |
| 48 | ); |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Wrap `$row_inner_markup` in a `<template data-wp-each>` element that |
| 53 | * iAPI uses to render new rows. `$row_inner_markup` is the inner HTML |
| 54 | * for the `<li>` — everything between `<li>` and `</li>`. |
| 55 | * |
| 56 | * Trust contract: caller is responsible for ensuring `$row_inner_markup` |
| 57 | * contains only safe, escaped HTML. |
| 58 | * |
| 59 | * @param string $row_inner_markup Inner markup for the `<li>`. |
| 60 | * @return string |
| 61 | */ |
| 62 | public static function render_each_template( string $row_inner_markup ): string { |
| 63 | return sprintf( |
| 64 | '<template data-wp-each--list-item="state.currentItems" data-wp-each-key="context.listItem.key"><li class="%1$s">%2$s</li></template>', |
| 65 | esc_attr( self::ROW_CLASS ), |
| 66 | $row_inner_markup |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Wrap `$row_inner_markup` in an SSR `<li data-wp-each-child>` element |
| 72 | * seeded with the per-row iAPI context derived from `$item`. iAPI's |
| 73 | * hydration treats this as a no-op diff against the `<template>` if |
| 74 | * the inner markup matches. |
| 75 | * |
| 76 | * Trust contract: caller is responsible for ensuring `$row_inner_markup` |
| 77 | * contains only safe, escaped HTML. |
| 78 | * |
| 79 | * @param array<string, mixed> $item Schema-shape item. |
| 80 | * @param string $row_inner_markup Inner markup for the `<li>`. |
| 81 | * @return string |
| 82 | */ |
| 83 | public static function render_each_child( array $item, string $row_inner_markup ): string { |
| 84 | $context = array( 'listItem' => $item ); |
| 85 | // 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. |
| 86 | return sprintf( |
| 87 | '<li class="%1$s" data-wp-each-child %2$s>%3$s</li>', |
| 88 | esc_attr( self::ROW_CLASS ), |
| 89 | wp_interactivity_data_wp_context( $context ), |
| 90 | $row_inner_markup |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Render the image + title + price triplet for the template-mode row |
| 96 | * (no static attrs; bindings only). Identical between consumer blocks. |
| 97 | * |
| 98 | * @return string |
| 99 | */ |
| 100 | public static function render_template_common_row(): string { |
| 101 | ob_start(); |
| 102 | ?> |
| 103 | <div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto"> |
| 104 | <a data-wp-bind--href="context.listItem.permalink"> |
| 105 | <span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot" data-wp-context='{"htmlField":"image_html"}' data-wp-watch="callbacks.updateInnerHtml"></span> |
| 106 | </a> |
| 107 | <button |
| 108 | type="button" |
| 109 | class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove" |
| 110 | data-wp-on--click="actions.onClickRemove" |
| 111 | data-wp-bind--aria-label="state.currentItemRemoveLabel" |
| 112 | data-wp-bind--disabled="state.isCurrentItemPending" |
| 113 | > |
| 114 | <?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?> |
| 115 | </button> |
| 116 | <span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation" data-wp-bind--hidden="!state.currentItemVariationLabel" data-wp-text="state.currentItemVariationLabel"></span> |
| 117 | </div> |
| 118 | <h2 class="wp-block-post-title has-text-align-center has-medium-font-size"> |
| 119 | <a data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"></a> |
| 120 | </h2> |
| 121 | <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> |
| 122 | <?php |
| 123 | return (string) ob_get_clean(); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Render the image + title + price triplet for the SSR-mode row, with |
| 128 | * values populated from `$item` and `$remove_aria_label_template`. The |
| 129 | * binding directives match the template-mode markup so iAPI's hydration |
| 130 | * is a no-op diff after first paint. |
| 131 | * |
| 132 | * @param array<string, mixed> $item Schema-shape item. |
| 133 | * @param string $remove_aria_label_template Sprintf template for the remove button's aria-label. `%s` is replaced with the product name. |
| 134 | * @return string |
| 135 | */ |
| 136 | public static function render_ssr_common_row( array $item, string $remove_aria_label_template ): string { |
| 137 | $is_live = ! empty( $item['is_live'] ); |
| 138 | $name = (string) ( $item['name'] ?? '' ); |
| 139 | $permalink = (string) ( $item['permalink'] ?? '' ); |
| 140 | $alt = html_entity_decode( $name, ENT_QUOTES, 'UTF-8' ); |
| 141 | $image_html = (string) ( $item['image_html'] ?? '' ); |
| 142 | $price_html = (string) ( $item['price_html'] ?? '' ); |
| 143 | $variation_label = self::get_variation_label( $item ); |
| 144 | $remove_aria = sprintf( $remove_aria_label_template, $alt ); |
| 145 | $is_price_hidden = '' === $price_html; |
| 146 | // Tombstone rows (`is_live=false` or empty permalink) render `<a>` |
| 147 | // without an href — keeps the element shape stable for iAPI |
| 148 | // reconciliation against the live-row template, and the CSS in the |
| 149 | // shared partial drops link affordances when the anchor has no href. |
| 150 | $href_attr = $is_live && '' !== $permalink ? 'href="' . esc_url( $permalink ) . '"' : ''; |
| 151 | |
| 152 | ob_start(); |
| 153 | ?> |
| 154 | <div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto"> |
| 155 | <a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink"> |
| 156 | <span |
| 157 | class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot" |
| 158 | data-wp-context='{"htmlField":"image_html"}' |
| 159 | data-wp-watch="callbacks.updateInnerHtml" |
| 160 | > |
| 161 | <?php echo wp_kses_post( $image_html ); ?> |
| 162 | </span> |
| 163 | </a> |
| 164 | <button |
| 165 | type="button" |
| 166 | class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove" |
| 167 | aria-label="<?php echo esc_attr( $remove_aria ); ?>" |
| 168 | data-wp-on--click="actions.onClickRemove" |
| 169 | data-wp-bind--aria-label="state.currentItemRemoveLabel" |
| 170 | data-wp-bind--disabled="state.isCurrentItemPending" |
| 171 | > |
| 172 | <?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?> |
| 173 | </button> |
| 174 | <span |
| 175 | class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation" |
| 176 | data-wp-bind--hidden="!state.currentItemVariationLabel" |
| 177 | data-wp-text="state.currentItemVariationLabel" |
| 178 | <?php |
| 179 | if ( '' === $variation_label ) { |
| 180 | echo 'hidden'; |
| 181 | } |
| 182 | ?> |
| 183 | ><?php echo esc_html( $variation_label ); ?></span> |
| 184 | </div> |
| 185 | <h2 class="wp-block-post-title has-text-align-center has-medium-font-size"> |
| 186 | <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> |
| 187 | </h2> |
| 188 | <div |
| 189 | class="price wc-block-components-product-price has-text-align-center has-small-font-size" |
| 190 | data-wp-bind--hidden="state.isPriceHidden" |
| 191 | data-wp-context='{"htmlField":"price_html"}' |
| 192 | data-wp-watch="callbacks.updateInnerHtml" |
| 193 | <?php |
| 194 | if ( $is_price_hidden ) { |
| 195 | echo 'hidden'; |
| 196 | } |
| 197 | ?> |
| 198 | > |
| 199 | <?php echo wp_kses_post( $price_html ); ?> |
| 200 | </div> |
| 201 | <?php |
| 202 | return (string) ob_get_clean(); |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Empty-state `<li>` that the block toggles on once `state.isEmpty` |
| 207 | * flips. `$start_hidden = true` makes SSR ship with `hidden` so the |
| 208 | * message doesn't flash for shoppers whose list is being populated |
| 209 | * client-side. `$start_hidden = false` is for blocks (e.g. Wishlist) |
| 210 | * where the message should show on first paint when the list is empty. |
| 211 | * |
| 212 | * @param string $message Visible empty-state message. |
| 213 | * @param string $css_class Class attribute for the `<li>`. |
| 214 | * @param bool $start_hidden Whether the `<li>` should be `hidden` on first paint. |
| 215 | * @return string |
| 216 | */ |
| 217 | public static function render_empty_state( string $message, string $css_class, bool $start_hidden = true ): string { |
| 218 | return sprintf( |
| 219 | '<li class="%1$s" data-wp-bind--hidden="!state.isEmpty"%2$s>%3$s</li>', |
| 220 | esc_attr( $css_class ), |
| 221 | $start_hidden ? ' hidden' : '', |
| 222 | esc_html( $message ) |
| 223 | ); |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Render the iAPI store-notices region used by the row-level error |
| 228 | * banners. Mirrors `AddToCartWithOptions::render_interactivity_notices_region()` |
| 229 | * — keep in sync if the shape changes. |
| 230 | * |
| 231 | * @param string $wrapper_class Class attribute for the outer `<div>`. |
| 232 | * @return string |
| 233 | */ |
| 234 | public static function render_interactivity_notices_region( string $wrapper_class ): string { |
| 235 | ob_start(); |
| 236 | ?> |
| 237 | <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> |
| 238 | <template data-wp-each--notice="context.notices" data-wp-each-key="context.notice.id"> |
| 239 | <div |
| 240 | class="wc-block-components-notice-banner" |
| 241 | data-wp-class--is-error="state.isError" |
| 242 | data-wp-class--is-success="state.isSuccess" |
| 243 | data-wp-class--is-info="state.isInfo" |
| 244 | data-wp-class--is-dismissible="context.notice.dismissible" |
| 245 | data-wp-bind--role="state.role" |
| 246 | data-wp-watch="callbacks.injectIcon" |
| 247 | > |
| 248 | <div class="wc-block-components-notice-banner__content"> |
| 249 | <span data-wp-init="callbacks.renderNoticeContent" aria-live="assertive" aria-atomic="true"></span> |
| 250 | </div> |
| 251 | <button |
| 252 | type="button" |
| 253 | data-wp-bind--hidden="!context.notice.dismissible" |
| 254 | class="wc-block-components-button wp-element-button wc-block-components-notice-banner__dismiss contained" |
| 255 | aria-label="<?php esc_attr_e( 'Dismiss this notice', 'woocommerce' ); ?>" |
| 256 | data-wp-on--click="actions.removeNotice" |
| 257 | > |
| 258 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> |
| 259 | <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" /> |
| 260 | </svg> |
| 261 | </button> |
| 262 | </div> |
| 263 | </template> |
| 264 | </div> |
| 265 | <?php |
| 266 | return (string) ob_get_clean(); |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * Markup for the trash icon used in the remove-item button. Mirrors the |
| 271 | * `trash` icon from `@wordpress/icons` that the cart line item uses for |
| 272 | * `wc-block-cart-item__remove-link`, inlined here so SSR first paint |
| 273 | * matches what JS would render after hydration. `currentColor` lets the |
| 274 | * surrounding badge wrapper drive the fill. |
| 275 | * |
| 276 | * @return string |
| 277 | */ |
| 278 | public static function get_remove_icon_svg(): string { |
| 279 | 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>'; |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Markup for the empty-star icon. Mirrors `starEmpty` from |
| 284 | * `@wordpress/icons`, inlined here so SSR first paint matches what JS |
| 285 | * renders after hydration. `currentColor` lets the surrounding button |
| 286 | * drive the fill. |
| 287 | * |
| 288 | * @return string |
| 289 | */ |
| 290 | public static function get_star_empty_svg(): string { |
| 291 | 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>'; |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Markup for the filled-star icon. Mirrors `starFilled` from |
| 296 | * `@wordpress/icons`, inlined here so SSR first paint matches what JS |
| 297 | * renders after hydration. `currentColor` lets the surrounding button |
| 298 | * drive the fill. |
| 299 | * |
| 300 | * @return string |
| 301 | */ |
| 302 | public static function get_star_filled_svg(): string { |
| 303 | 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>'; |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * Build a comma-separated variation label like "Color: Blue, Size: M". |
| 308 | * |
| 309 | * @param array<string, mixed> $item Schema-shape item. |
| 310 | * @return string |
| 311 | */ |
| 312 | public static function get_variation_label( array $item ): string { |
| 313 | $variation = $item['variation'] ?? array(); |
| 314 | if ( ! is_array( $variation ) || empty( $variation ) ) { |
| 315 | return ''; |
| 316 | } |
| 317 | $parts = array(); |
| 318 | foreach ( $variation as $entry ) { |
| 319 | if ( ! is_array( $entry ) ) { |
| 320 | continue; |
| 321 | } |
| 322 | $attribute = isset( $entry['attribute'] ) ? html_entity_decode( (string) $entry['attribute'], ENT_QUOTES, 'UTF-8' ) : ''; |
| 323 | $value = isset( $entry['value'] ) ? html_entity_decode( (string) $entry['value'], ENT_QUOTES, 'UTF-8' ) : ''; |
| 324 | if ( '' === $attribute && '' === $value ) { |
| 325 | continue; |
| 326 | } |
| 327 | $parts[] = $attribute . ': ' . $value; |
| 328 | } |
| 329 | return implode( ', ', $parts ); |
| 330 | } |
| 331 | } |
| 332 |