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 / size-chart.php

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

297 lines 8.6 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\Helper;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 /**
12 * Size chart resolution.
13 *
14 * Structurally this mirrors {@see Faq}: an admin-managed library of CPT entries,
15 * each carrying display rules in post meta, resolved per product and cached
16 * under a monotonic version key.
17 *
18 * It differs from FAQ in one deliberate way: FAQ groups *accumulate* (a product
19 * shows every group that matches), while size charts resolve to exactly **one**
20 * winner. Showing a shopper three conflicting measurement tables is worse than
21 * showing one, so matches are scored by specificity and the highest wins.
22 */
23 class SizeChart {
24
25 const BRAND_TAXONOMY = 'storeengine_product_brand';
26
27 /**
28 * Specificity scores. Highest match wins; ties break on menu_order then ID
29 * (get_all_charts() is already ordered that way, and we only replace the
30 * incumbent on a strictly greater score).
31 */
32 const SCORE_PRODUCT = 100;
33 const SCORE_BRAND_CATEGORY = 40;
34 const SCORE_BRAND = 30;
35 const SCORE_CATEGORY = 20;
36 const SCORE_ALL = 10;
37
38 /**
39 * Resolve the single size chart that applies to a product.
40 *
41 * @param int $product_id Product id.
42 *
43 * @return array{id:int,title:string,note:string,tables:array}|array Empty when nothing applies.
44 */
45 public static function get_product_chart( int $product_id ): array {
46 $cache_key = 'storeengine_size_chart_' . self::cache_version() . '_' . $product_id;
47 $cached = wp_cache_get( $cache_key, 'storeengine_size_charts' );
48
49 if ( false !== $cached ) {
50 return $cached;
51 }
52
53 $chart = self::resolve( $product_id );
54
55 /**
56 * Filter a product's resolved size chart.
57 *
58 * @param array $chart Resolved chart payload, or [] when none applies.
59 * @param int $product_id Product id.
60 */
61 $chart = apply_filters( 'storeengine/product_size_chart', $chart, $product_id );
62
63 wp_cache_set( $cache_key, $chart, 'storeengine_size_charts', HOUR_IN_SECONDS );
64
65 return $chart;
66 }
67
68 /**
69 * Score every published chart against the product and return the winner.
70 *
71 * @param int $product_id Product id.
72 *
73 * @return array
74 */
75 protected static function resolve( int $product_id ): array {
76 $category_ids = wp_get_post_terms( $product_id, Helper::PRODUCT_CATEGORY_TAXONOMY, [ 'fields' => 'ids' ] );
77 $category_ids = is_wp_error( $category_ids ) ? [] : array_map( 'absint', $category_ids );
78
79 // The brand taxonomy ships in an addon, so it may not exist.
80 $brand_ids = [];
81 if ( taxonomy_exists( self::BRAND_TAXONOMY ) ) {
82 $brand_ids = wp_get_post_terms( $product_id, self::BRAND_TAXONOMY, [ 'fields' => 'ids' ] );
83 $brand_ids = is_wp_error( $brand_ids ) ? [] : array_map( 'absint', $brand_ids );
84 }
85
86 $winner = null;
87 $best = 0;
88
89 foreach ( self::get_all_charts() as $chart ) {
90 $score = self::score( $chart->ID, $product_id, $category_ids, $brand_ids );
91
92 if ( $score > $best ) {
93 $best = $score;
94 $winner = $chart;
95 }
96 }
97
98 if ( ! $winner ) {
99 return [];
100 }
101
102 $tables = self::clean_tables( get_post_meta( $winner->ID, '_storeengine_size_tables', true ) );
103
104 // A chart with no usable table is not worth a trigger button.
105 if ( empty( $tables ) ) {
106 return [];
107 }
108
109 return [
110 'id' => (int) $winner->ID,
111 'title' => $winner->post_title,
112 'note' => self::render_note( $winner ),
113 'tables' => $tables,
114 ];
115 }
116
117 /**
118 * Render a chart's "how to measure" body (the post content).
119 *
120 * Runs the standard content pipeline so blocks, shortcodes and uploaded
121 * images all resolve. `the_content` is applied against a post that is never
122 * queried on its own, so there is no loop to disturb — but we skip the work
123 * entirely for an empty body, which is the common case.
124 *
125 * @param \WP_Post $chart Chart post.
126 *
127 * @return string
128 */
129 protected static function render_note( \WP_Post $chart ): string {
130 if ( '' === trim( (string) $chart->post_content ) ) {
131 return '';
132 }
133
134 return (string) apply_filters( 'the_content', $chart->post_content );
135 }
136
137 /**
138 * Specificity score for one chart against one product. 0 means no match.
139 *
140 * Rule types combine with AND, values within a rule type with OR — so a
141 * chart set to brand "Nike" + category "Shoes" applies to Nike shoes only,
142 * not to every Nike product. An explicit product pick overrides everything.
143 *
144 * @param int $chart_id Chart post id.
145 * @param int $product_id Product id.
146 * @param int[] $category_ids Product's category term ids.
147 * @param int[] $brand_ids Product's brand term ids.
148 *
149 * @return int
150 */
151 protected static function score( int $chart_id, int $product_id, array $category_ids, array $brand_ids ): int {
152 $chart_products = self::id_meta( $chart_id, '_storeengine_size_product_ids' );
153
154 if ( in_array( $product_id, $chart_products, true ) ) {
155 return self::SCORE_PRODUCT;
156 }
157
158 if ( get_post_meta( $chart_id, '_storeengine_size_apply_all', true ) ) {
159 return self::SCORE_ALL;
160 }
161
162 $chart_categories = self::id_meta( $chart_id, '_storeengine_size_category_ids' );
163 $chart_brands = self::id_meta( $chart_id, '_storeengine_size_brand_ids' );
164
165 $has_category_rule = ! empty( $chart_categories );
166 $has_brand_rule = ! empty( $chart_brands );
167
168 // Not assigned to anything.
169 if ( ! $has_category_rule && ! $has_brand_rule ) {
170 return 0;
171 }
172
173 $category_ok = ! $has_category_rule || array_intersect( $category_ids, $chart_categories );
174 $brand_ok = ! $has_brand_rule || array_intersect( $brand_ids, $chart_brands );
175
176 if ( ! $category_ok || ! $brand_ok ) {
177 return 0;
178 }
179
180 if ( $has_category_rule && $has_brand_rule ) {
181 return self::SCORE_BRAND_CATEGORY;
182 }
183
184 return $has_brand_rule ? self::SCORE_BRAND : self::SCORE_CATEGORY;
185 }
186
187 /**
188 * Read an id-list meta key as a clean array of positive ints.
189 *
190 * get_post_meta() returns '' for an unset key, and (array) '' is [''] —
191 * which absint()s to [0], an array that is *not* empty. Without filtering,
192 * a chart with no category rule would look like it had one, and the
193 * AND-across-rule-types check in score() would then reject every product.
194 *
195 * @param int $chart_id Chart post id.
196 * @param string $key Meta key.
197 *
198 * @return int[]
199 */
200 protected static function id_meta( int $chart_id, string $key ): array {
201 return array_values( array_filter( array_map( 'absint', (array) get_post_meta( $chart_id, $key, true ) ) ) );
202 }
203
204 /**
205 * All published charts, ordered by menu order then title.
206 *
207 * @return \WP_Post[]
208 */
209 protected static function get_all_charts(): array {
210 static $charts = null;
211
212 if ( null === $charts ) {
213 $charts = get_posts( [
214 'post_type' => Helper::SIZE_CHART_POST_TYPE,
215 'post_status' => 'publish',
216 'posts_per_page' => 500,
217 'orderby' => [ 'menu_order' => 'ASC', 'title' => 'ASC' ],
218 'suppress_filters' => false,
219 ] );
220 }
221
222 return $charts;
223 }
224
225 /**
226 * Normalise stored tables — drop empty rows/tables, coerce shape, and pad
227 * every row to the column count so the rendered <table> stays rectangular.
228 *
229 * @param mixed $tables Raw meta value.
230 *
231 * @return array<int,array{title:string,unit:string,columns:string[],rows:array}>
232 */
233 protected static function clean_tables( $tables ): array {
234 if ( ! is_array( $tables ) ) {
235 return [];
236 }
237
238 $clean = [];
239
240 foreach ( $tables as $table ) {
241 if ( ! is_array( $table ) ) {
242 continue;
243 }
244
245 $columns = array_values( array_filter(
246 array_map( 'strval', (array) ( $table['columns'] ?? [] ) ),
247 static fn ( $column ) => '' !== trim( $column )
248 ) );
249
250 if ( empty( $columns ) ) {
251 continue;
252 }
253
254 $rows = [];
255 foreach ( (array) ( $table['rows'] ?? [] ) as $row ) {
256 $row = array_map( 'strval', (array) $row );
257
258 // Skip rows that are entirely blank.
259 if ( ! array_filter( $row, static fn ( $cell ) => '' !== trim( $cell ) ) ) {
260 continue;
261 }
262
263 $rows[] = array_slice( array_pad( $row, count( $columns ), '' ), 0, count( $columns ) );
264 }
265
266 if ( empty( $rows ) ) {
267 continue;
268 }
269
270 $clean[] = [
271 'title' => (string) ( $table['title'] ?? '' ),
272 'unit' => (string) ( $table['unit'] ?? '' ),
273 'columns' => $columns,
274 'rows' => $rows,
275 ];
276 }
277
278 return $clean;
279 }
280
281 /**
282 * Monotonic cache version — folded into every cache key so a single option
283 * bump invalidates all resolved charts at once, even under a persistent
284 * object cache.
285 */
286 protected static function cache_version(): int {
287 return (int) get_option( 'storeengine_size_chart_cache_ver', 1 );
288 }
289
290 /**
291 * Bust the resolved-chart cache. Called on any chart/product save.
292 */
293 public static function flush_cache() {
294 update_option( 'storeengine_size_chart_cache_ver', self::cache_version() + 1, false );
295 }
296 }
297