PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / product-compare.php

product-compare.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/product-compare.php

358 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use StoreEngine\Utils\Formatting;
6 use StoreEngine\Utils\Helper;
7 use StoreEngine\Utils\Template;
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Side-by-side product comparison table.
15 *
16 * Storage and the toggle buttons follow the wishlist pattern (see the compare
17 * section in includes/frontend/functions.php); this class owns only the table
18 * itself — the rows to show, and how each cell renders.
19 *
20 * Rows are built from what the compared products actually have: the fixed rows
21 * (image, title, price, availability) always render, taxonomies and attributes
22 * only when at least one product in the set carries them. That keeps a
23 * two-simple-product comparison from being mostly empty cells.
24 */
25 class ProductCompare {
26
27 const BRAND_TAXONOMY = 'storeengine_product_brand';
28
29 /** Default number of products a shopper can line up at once. */
30 const MAX_DEFAULT = 4;
31
32 /**
33 * How many products may be compared at once.
34 *
35 * Kept small on purpose: the table scrolls horizontally, and past four
36 * columns the cells are too narrow to read on a laptop.
37 */
38 public static function max(): int {
39 /**
40 * Filter the maximum number of products in a comparison.
41 *
42 * @param int $max Maximum count.
43 */
44 $max = (int) apply_filters( 'storeengine/compare/max_products', self::MAX_DEFAULT );
45
46 return max( 2, $max );
47 }
48
49 /**
50 * Render the comparison table for a list of product ids.
51 *
52 * @param int[] $ids Product ids, in the shopper's chosen order.
53 *
54 * @return string Empty when nothing comparable was found.
55 */
56 public static function get_table_html( array $ids ): string {
57 $products = self::collect( $ids );
58
59 if ( count( $products ) < 1 ) {
60 return '';
61 }
62
63 $rows = array_merge(
64 [
65 [
66 'label' => __( 'Product', 'storeengine' ),
67 'render' => [ __CLASS__, 'cell_media' ],
68 'class' => 'is-media',
69 ],
70 [
71 'label' => __( 'Price', 'storeengine' ),
72 'render' => [ __CLASS__, 'cell_price' ],
73 ],
74 [
75 'label' => __( 'Availability', 'storeengine' ),
76 'render' => [ __CLASS__, 'cell_stock' ],
77 ],
78 ],
79 self::taxonomy_rows( $products ),
80 self::attribute_rows( $products ),
81 [
82 [
83 'label' => __( 'Add to cart', 'storeengine' ),
84 'render' => [ __CLASS__, 'cell_add_to_cart' ],
85 'class' => 'is-action',
86 ],
87 ]
88 );
89
90 ob_start();
91 ?>
92 <div class="storeengine-compare-table__scroll">
93 <table class="storeengine-compare-table">
94 <tbody>
95 <?php foreach ( $rows as $row ) : ?>
96 <tr class="<?php echo esc_attr( $row['class'] ?? '' ); ?>">
97 <th scope="row"><?php echo esc_html( $row['label'] ); ?></th>
98 <?php foreach ( $products as $product ) : ?>
99 <td>
100 <?php
101 echo call_user_func( $row['render'], $product, $row ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each cell_* escapes its own output.
102 ?>
103 </td>
104 <?php endforeach; ?>
105 </tr>
106 <?php endforeach; ?>
107 </tbody>
108 </table>
109 </div>
110 <?php
111
112 return (string) ob_get_clean();
113 }
114
115 /**
116 * Load the comparable products, preserving the requested order and dropping
117 * anything unpublished or missing.
118 *
119 * @param int[] $ids Product ids.
120 *
121 * @return AbstractProduct[]
122 */
123 protected static function collect( array $ids ): array {
124 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
125
126 // Keep the NEWEST picks, matching storeengine_set_user_compare() and the
127 // frontend cap — all three trim from the front so the table and the tray
128 // can never disagree about which products are in the comparison.
129 if ( count( $ids ) > self::max() ) {
130 $ids = array_slice( $ids, -self::max() );
131 }
132
133 $products = [];
134
135 foreach ( $ids as $id ) {
136 if ( 'publish' !== get_post_status( $id ) ) {
137 continue;
138 }
139
140 $product = Helper::get_product( $id );
141
142 if ( $product ) {
143 $products[] = $product;
144 }
145 }
146
147 return $products;
148 }
149
150 /**
151 * Category / tag / brand rows, included only when something in the set has
152 * terms in that taxonomy.
153 *
154 * @param AbstractProduct[] $products Compared products.
155 *
156 * @return array<int,array>
157 */
158 protected static function taxonomy_rows( array $products ): array {
159 $candidates = [
160 Helper::PRODUCT_CATEGORY_TAXONOMY => __( 'Category', 'storeengine' ),
161 self::BRAND_TAXONOMY => __( 'Brand', 'storeengine' ),
162 Helper::PRODUCT_TAG_TAXONOMY => __( 'Tags', 'storeengine' ),
163 ];
164
165 $rows = [];
166
167 foreach ( $candidates as $taxonomy => $label ) {
168 if ( ! taxonomy_exists( $taxonomy ) ) {
169 continue;
170 }
171
172 $used = false;
173 foreach ( $products as $product ) {
174 $terms = get_the_terms( $product->get_id(), $taxonomy );
175 if ( $terms && ! is_wp_error( $terms ) ) {
176 $used = true;
177 break;
178 }
179 }
180
181 if ( $used ) {
182 $rows[] = [
183 'label' => $label,
184 'render' => [ __CLASS__, 'cell_terms' ],
185 'taxonomy' => $taxonomy,
186 ];
187 }
188 }
189
190 return $rows;
191 }
192
193 /**
194 * One row per product attribute taxonomy present across the set, in the
195 * order the first product that uses it declares.
196 *
197 * @param AbstractProduct[] $products Compared products.
198 *
199 * @return array<int,array>
200 */
201 protected static function attribute_rows( array $products ): array {
202 $taxonomies = [];
203
204 foreach ( $products as $product ) {
205 foreach ( array_keys( $product->get_attributes() ) as $taxonomy ) {
206 if ( ! in_array( $taxonomy, $taxonomies, true ) ) {
207 $taxonomies[] = $taxonomy;
208 }
209 }
210 }
211
212 $rows = [];
213
214 foreach ( $taxonomies as $taxonomy ) {
215 $object = get_taxonomy( $taxonomy );
216 $rows[] = [
217 'label' => $object ? $object->labels->singular_name : $taxonomy,
218 'render' => [ __CLASS__, 'cell_attribute' ],
219 'taxonomy' => $taxonomy,
220 ];
221 }
222
223 return $rows;
224 }
225
226 /* ---------------------------------------------------------------------- *
227 * Cell renderers. Each escapes its own output.
228 * ---------------------------------------------------------------------- */
229
230 /** Thumbnail + linked title + a control to drop the column. */
231 protected static function cell_media( $product ): string {
232 $id = $product->get_id();
233 $link = get_permalink( $id );
234
235 return sprintf(
236 '<button type="button" class="storeengine-compare-table__remove" data-storeengine-compare-remove="%1$d" aria-label="%2$s" title="%2$s">&times;</button>'
237 . '<a class="storeengine-compare-table__media" href="%3$s">%4$s</a>'
238 . '<a class="storeengine-compare-table__title" href="%3$s">%5$s</a>',
239 (int) $id,
240 esc_attr__( 'Remove from comparison', 'storeengine' ),
241 esc_url( $link ),
242 storeengine_get_product_image( 'storeengine_thumbnail', $id ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_get_attachment_image output.
243 esc_html( $product->get_name() )
244 );
245 }
246
247 /** Single price, or a range when the product has several. */
248 protected static function cell_price( $product ): string {
249 $prices = array_map(
250 static fn( $price ) => (float) $price->get_price(),
251 $product->get_prices()
252 );
253
254 if ( ! $prices ) {
255 return '<span class="storeengine-compare-table__empty">&mdash;</span>';
256 }
257
258 $html = ( min( $prices ) !== max( $prices ) )
259 ? Formatting::format_price_range( max( $prices ), min( $prices ) )
260 : Formatting::price( reset( $prices ) );
261
262 return wp_kses_post( $html );
263 }
264
265 protected static function cell_stock( $product ): string {
266 $in_stock = ! method_exists( $product, 'is_in_stock' ) || $product->is_in_stock();
267
268 return sprintf(
269 '<span class="storeengine-compare-table__stock %1$s">%2$s</span>',
270 $in_stock ? 'is-in' : 'is-out',
271 $in_stock ? esc_html__( 'In stock', 'storeengine' ) : esc_html__( 'Out of stock', 'storeengine' )
272 );
273 }
274
275 /**
276 * Linked term list for a taxonomy row.
277 *
278 * @param AbstractProduct $product Product.
279 * @param array $row Row definition (carries the taxonomy).
280 */
281 protected static function cell_terms( $product, array $row ): string {
282 $terms = get_the_terms( $product->get_id(), $row['taxonomy'] );
283
284 if ( ! $terms || is_wp_error( $terms ) ) {
285 return '<span class="storeengine-compare-table__empty">&mdash;</span>';
286 }
287
288 $links = [];
289 foreach ( $terms as $term ) {
290 $url = get_term_link( $term );
291 $links[] = is_wp_error( $url )
292 ? esc_html( $term->name )
293 : sprintf( '<a href="%s">%s</a>', esc_url( $url ), esc_html( $term->name ) );
294 }
295
296 return implode( ', ', $links );
297 }
298
299 /**
300 * Attribute values for one attribute taxonomy.
301 *
302 * @param AbstractProduct $product Product.
303 * @param array $row Row definition (carries the taxonomy).
304 */
305 protected static function cell_attribute( $product, array $row ): string {
306 $attributes = $product->get_attributes();
307 $values = $attributes[ $row['taxonomy'] ] ?? [];
308
309 if ( ! $values ) {
310 return '<span class="storeengine-compare-table__empty">&mdash;</span>';
311 }
312
313 // AttributeData exposes plain public properties, not getters.
314 $names = array_map(
315 static fn( $attribute ) => esc_html( $attribute->name ?? '' ),
316 $values
317 );
318
319 return implode( ', ', array_filter( $names ) );
320 }
321
322 /**
323 * Real add-to-cart controls, by rendering the same loop template the shop
324 * cards use — so variable products get their swatches and simple products a
325 * working button, rather than a link that drops the shopper somewhere else.
326 */
327 protected static function cell_add_to_cart( $product ): string {
328 $id = $product->get_id();
329 $post = get_post( $id );
330
331 if ( ! $post ) {
332 return '';
333 }
334
335 $prev_post = $GLOBALS['post'] ?? null;
336 $prev_product = $GLOBALS['product'] ?? null;
337
338 $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
339 $GLOBALS['product'] = $product; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
340 setup_postdata( $post );
341
342 ob_start();
343 Template::get_template( 'loop/add-to-cart.php' );
344 $html = (string) ob_get_clean();
345
346 $GLOBALS['post'] = $prev_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
347 $GLOBALS['product'] = $prev_product; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
348
349 if ( $prev_post instanceof \WP_Post ) {
350 setup_postdata( $prev_post );
351 } else {
352 wp_reset_postdata();
353 }
354
355 return $html;
356 }
357 }
358