PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / Renderer / AdvancedVariationRenderer.php

AdvancedVariationRenderer.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Services/Renderer/AdvancedVariationRenderer.php

486 lines 23.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer;
4
5 use FluentCart\App\Models\AttributeGroup;
6 use FluentCart\App\Models\AttributeTerm;
7 use FluentCart\Framework\Support\Arr;
8
9 class AdvancedVariationRenderer
10 {
11 protected $product;
12
13 public function __construct($product)
14 {
15 $this->product = $product;
16 }
17
18 /**
19 * Whitelist a term's stored color before it lands in an inline
20 * `style="background-color: ..."` attribute. esc_attr escapes HTML
21 * attribute chars but NOT CSS syntax — a stored value saved through
22 * sanitize_text_field (which never validated color format) like
23 * "red;position:fixed;inset:0;z-index:9999" would otherwise inject
24 * extra CSS declarations and deface the product page. Accept only
25 * hex (#rgb/#rgba/#rrggbb/#rrggbbaa), rgb()/rgba() with numeric
26 * content, or a bare CSS named color; anything else returns '' and
27 * the caller drops the style attribute entirely.
28 */
29 protected function safeCssColor($color): string
30 {
31 $color = trim((string) $color);
32 if ($color === '') {
33 return '';
34 }
35 if (preg_match('/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color)) {
36 return $color;
37 }
38 if (preg_match('/^rgba?\(\s*[0-9.,%\s]+\)$/i', $color)) {
39 return $color;
40 }
41 if (preg_match('/^[a-zA-Z]{1,40}$/', $color)) {
42 return $color;
43 }
44 return '';
45 }
46
47 /**
48 * Emits the storefront selector markup. Returns true only when markup
49 * was actually echoed — false on every early bail. The caller writes
50 * this into the filter's 'rendered' flag so free core can fall through
51 * to its simple-variation rendering when this renderer no-ops (e.g. a
52 * product switched to advanced_variations before attributes are built).
53 */
54 public function render($selectorStyle = 'auto'): bool
55 {
56 $variants = $this->product->variants;
57 if (!$variants || $variants->isEmpty()) {
58 return false;
59 }
60
61 $variants->load(['attrMap', 'media']);
62
63 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
64 $attributeConfig = Arr::get($otherInfo, 'attribute_config', []);
65
66 if (empty($attributeConfig)) {
67 return false;
68 }
69
70 $allTermIds = [];
71 foreach ($attributeConfig as $attrGroup) {
72 // (array) cast — other_info is an independent read path; if a
73 // corrupt or non-syncVariantOption writer ever stored a scalar
74 // under 'variants', a bare array_merge would TypeError and fatal
75 // the whole product page.
76 $termIds = (array) Arr::get($attrGroup, 'variants', []);
77 $allTermIds = array_merge($allTermIds, $termIds);
78 }
79 $allTermIds = array_unique(array_map('intval', $allTermIds));
80
81 $terms = AttributeTerm::query()->whereIn('id', $allTermIds)->get()->keyBy('id');
82
83 $groupIds = array_column($attributeConfig, 'group_id');
84 $groups = AttributeGroup::query()->whereIn('id', array_map('intval', $groupIds))->get()->keyBy('id');
85
86 $variationMap = [];
87 $variantData = [];
88
89 foreach ($variants as $variant) {
90 if ($variant->item_status !== 'active') {
91 continue;
92 }
93
94 $termIds = [];
95 foreach ($variant->attrMap as $rel) {
96 $termIds[] = (int)$rel->term_id;
97 }
98 sort($termIds, SORT_NUMERIC);
99 $identifier = implode('_', $termIds);
100
101 $variationMap[$identifier] = (int)$variant->id;
102
103 $variantData[$variant->id] = [
104 'stock' => ($variant->manage_stock ? ($variant->stock_status ?: 'in-stock') : 'in-stock'),
105 // Raw — esc_attr on the JSON below escapes the HTML-attribute
106 // context, and the JS consumer must render via textContent
107 // (NOT innerHTML). Pre-escaping here would double-encode
108 // titles like "S&L" into "S&amp;L" by the time JS reads
109 // them, showing literal "&amp;" to the shopper.
110 'title' => $variant->variation_title,
111 // Authoritative payment_type column so the selector can hide
112 // Add to Cart for subscription combinations (subscriptions are
113 // Buy Now only) — mirrors the simple-variation flow.
114 'payment_type' => $variant->payment_type,
115 // serial_index so the selector can fall back to the first
116 // combination by order (not first in-stock) when the product has
117 // no default_variation_id — matching the server-side default rule.
118 // Keep NULL as null (don't cast to 0) so the selector's
119 // `?? Infinity` guard sorts an unordered legacy row LAST, matching
120 // the server fallback — casting null->0 would make it sort first.
121 'serial_index' => is_null($variant->serial_index) ? null : (int) $variant->serial_index,
122 ];
123 }
124
125 // If every variant was filtered out (all inactive), there's nothing
126 // for the selector to map to — bail before emitting markup that
127 // would just be dead controls.
128 if (empty($variationMap)) {
129 return false;
130 }
131
132 // Authoritative group→terms ownership map — used by JS to avoid heuristic narrowing
133 $attrConfigForJs = [];
134 foreach ($attributeConfig as $attrGroup) {
135 $groupId = absint(Arr::get($attrGroup, 'group_id'));
136 $termIds = array_values(array_map('absint', Arr::get($attrGroup, 'variants', [])));
137 $attrConfigForJs[$groupId] = $termIds;
138 }
139
140 // Auto-detect the primary visual group (color or image type) — its terms
141 // drive the main gallery image swap on the storefront. First match wins.
142 $primaryGroupId = 0;
143 foreach ($attributeConfig as $attrGroup) {
144 $gid = absint(Arr::get($attrGroup, 'group_id'));
145 $group = $groups->get($gid);
146 $type = $group ? Arr::get((array) $group->settings, 'type', '') : '';
147 if (in_array($type, ['color', 'image'], true)) {
148 $primaryGroupId = $gid;
149 break;
150 }
151 }
152
153 // Build term→image map for the primary group: first non-empty thumbnail
154 // found among variants that belong to each term wins.
155 $termImageMap = [];
156 if ($primaryGroupId) {
157 foreach ($variants as $variant) {
158 $thumb = $variant->thumbnail ?: '';
159 if (!$thumb) {
160 continue;
161 }
162 foreach ($variant->attrMap as $rel) {
163 $tid = (int) $rel->term_id;
164 if ((int) $rel->group_id === $primaryGroupId && !isset($termImageMap[$tid])) {
165 $termImageMap[$tid] = $thumb;
166 }
167 }
168 }
169 }
170
171 // wp_json_encode returns false on encoding failure (e.g. invalid UTF-8
172 // in a term title); fall back to a parseable empty object so the JS
173 // doesn't crash on JSON.parse(""). Default to {} (not []) because
174 // the JS consumers expect object lookups by string key.
175 $variationMapJson = wp_json_encode($variationMap) ?: '{}';
176 $variantDataJson = wp_json_encode($variantData) ?: '{}';
177 $attrConfigJson = wp_json_encode($attrConfigForJs) ?: '{}';
178 $termImageMapJson = wp_json_encode($termImageMap) ?: '{}';
179
180 // Surface the merchant's saved default variant id so the JS
181 // selector can pre-select that combination on first paint. When
182 // the column is empty (legacy products, or the merchant cleared
183 // it), the JS falls back to the first combination by serial_index. Read
184 // straight off the model — (array) cast on a WPFluent model
185 // returns its public protected/$attributes shape inconsistently
186 // depending on lazy-load state, so `default_variation_id` came
187 // back null on first paint even when the column was set.
188 $defaultVariationId = $this->product->detail
189 ? (int) $this->product->detail->default_variation_id
190 : 0;
191
192 ?>
193 <div class="fct-advanced-variation-wrap"
194 data-variation-map="<?php echo esc_attr($variationMapJson); ?>"
195 data-variant-data="<?php echo esc_attr($variantDataJson); ?>"
196 data-attribute-config="<?php echo esc_attr($attrConfigJson); ?>"
197 data-primary-group-id="<?php echo esc_attr($primaryGroupId); ?>"
198 data-term-image-map="<?php echo esc_attr($termImageMapJson); ?>"
199 data-product-id="<?php echo esc_attr($this->product->ID); ?>"
200 data-default-variation-id="<?php echo esc_attr($defaultVariationId); ?>"
201 data-selector-style="<?php echo esc_attr($selectorStyle); ?>">
202 <?php
203 foreach ($attributeConfig as $attrConfig) {
204 $groupId = absint(Arr::get($attrConfig, 'group_id'));
205 $group = isset($groups[$groupId]) ? $groups[$groupId] : null;
206 $termIds = Arr::get($attrConfig, 'variants', []);
207
208 if (!$group || empty($termIds)) {
209 continue;
210 }
211
212 $groupTerms = [];
213 foreach ($termIds as $termId) {
214 $termId = absint($termId);
215 if (isset($terms[$termId])) {
216 $groupTerms[] = $terms[$termId];
217 }
218 }
219
220 // After term resolution, every claimed term ID could be a
221 // term that no longer exists (deleted after the editor save).
222 // Rendering an empty dropdown / swatch row would show a
223 // ghost control with no options — skip the whole group.
224 if (empty($groupTerms)) {
225 continue;
226 }
227
228 $groupSettings = is_array($group->settings) ? $group->settings : [];
229 $groupType = Arr::get($groupSettings, 'type', 'options');
230 $groupStyling = Arr::get($groupSettings, 'styling', '');
231
232 do_action('fluent_cart/product/single/before_variant_item', [
233 'product' => $this->product,
234 'group' => $group,
235 'scope' => 'attribute_selector_group',
236 ]);
237
238 if ($groupType === 'color' && $groupStyling === 'dropdown') {
239 $this->renderColorDropdown($group, $groupTerms);
240 } elseif ($groupType === 'image' && $groupStyling === 'dropdown') {
241 $this->renderImageDropdown($group, $groupTerms);
242 } elseif ($groupStyling === 'dropdown') {
243 $this->renderAttributeDropdown($group, $groupTerms);
244 } else {
245 $this->renderAttributeSwatches($group, $groupTerms);
246 }
247
248 do_action('fluent_cart/product/single/after_variant_item', [
249 'product' => $this->product,
250 'group' => $group,
251 'scope' => 'attribute_selector_group',
252 ]);
253 }
254 ?>
255
256 </div>
257 <?php
258
259 return true;
260 }
261
262 protected function renderAttributeDropdown($group, $terms)
263 {
264 ?>
265 <?php $listboxId = 'fct-listbox-' . (int) $group->id; ?>
266 <div class="fct-attribute-selector fct-custom-dropdown fct-options-dropdown"
267 data-group-id="<?php echo esc_attr($group->id); ?>"
268 data-attribute-group="<?php echo esc_attr($group->id); ?>">
269 <label class="fct-attribute-label">
270 <?php echo esc_html($group->title); ?>
271 </label>
272
273 <div class="fct-custom-select" data-fct-custom-select tabindex="0"
274 role="combobox"
275 aria-haspopup="listbox"
276 aria-expanded="false"
277 aria-controls="<?php echo esc_attr($listboxId); ?>"
278 aria-label="<?php echo esc_attr($group->title); ?>">
279 <div class="fct-custom-select__trigger" data-fct-select-trigger>
280 <span class="fct-custom-select__value" data-fct-select-value>
281 <?php
282 /* translators: %1$s is the attribute group title */
283 printf(esc_html__('Choose %1$s', 'fluent-cart'), esc_html($group->title));
284 ?>
285 </span>
286 <span class="fct-custom-select__arrow" aria-hidden="true"></span>
287 </div>
288 <div class="fct-custom-select__options" role="listbox" id="<?php echo esc_attr($listboxId); ?>">
289 <?php foreach ($terms as $term) : ?>
290 <div class="fct-custom-select__option"
291 data-fct-select-option
292 role="option"
293 aria-selected="false"
294 data-value="<?php echo esc_attr($term->id); ?>"
295 data-term-slug="<?php echo esc_attr($term->slug); ?>">
296 <span class="fct-option-label">
297 <?php echo esc_html($term->title); ?>
298 </span>
299 </div>
300 <?php endforeach; ?>
301 </div>
302 </div>
303 </div>
304 <?php
305 }
306
307 protected function renderColorDropdown($group, $terms)
308 {
309 ?>
310 <?php $listboxId = 'fct-listbox-' . (int) $group->id; ?>
311 <div class="fct-attribute-selector fct-custom-dropdown fct-color-dropdown"
312 data-group-id="<?php echo esc_attr($group->id); ?>"
313 data-attribute-group="<?php echo esc_attr($group->id); ?>">
314 <label class="fct-attribute-label">
315 <?php echo esc_html($group->title); ?>
316 </label>
317
318 <div class="fct-custom-select" data-fct-custom-select tabindex="0"
319 role="combobox"
320 aria-haspopup="listbox"
321 aria-expanded="false"
322 aria-controls="<?php echo esc_attr($listboxId); ?>"
323 aria-label="<?php echo esc_attr($group->title); ?>">
324 <div class="fct-custom-select__trigger" data-fct-select-trigger>
325 <span class="fct-custom-select__value" data-fct-select-value>
326 <?php
327 /* translators: %1$s is the attribute group title */
328 printf(esc_html__('Choose %1$s', 'fluent-cart'), esc_html($group->title));
329 ?>
330 </span>
331 <span class="fct-custom-select__arrow" aria-hidden="true"></span>
332 </div>
333 <div class="fct-custom-select__options" role="listbox" id="<?php echo esc_attr($listboxId); ?>">
334 <?php foreach ($terms as $term) :
335 $settings = is_array($term->settings) ? $term->settings : [];
336 $color = $this->safeCssColor(Arr::get($settings, 'color', ''));
337 ?>
338 <div class="fct-custom-select__option"
339 data-fct-select-option
340 role="option"
341 aria-selected="false"
342 data-value="<?php echo esc_attr($term->id); ?>"
343 data-term-slug="<?php echo esc_attr($term->slug); ?>">
344 <div class="fct-color-option">
345 <?php if ($color) : ?>
346 <span class="fct-color-swatch" style="background-color: <?php echo esc_attr($color); ?>"></span>
347 <?php endif; ?>
348 <span class="fct-color-label">
349 <?php echo esc_html($term->title); ?>
350 </span>
351 </div>
352 </div>
353 <?php endforeach; ?>
354 </div>
355 </div>
356 </div>
357 <?php
358 }
359
360 protected function renderImageDropdown($group, $terms)
361 {
362 ?>
363 <?php $listboxId = 'fct-listbox-' . (int) $group->id; ?>
364 <div class="fct-attribute-selector fct-custom-dropdown fct-image-dropdown"
365 data-group-id="<?php echo esc_attr($group->id); ?>"
366 data-attribute-group="<?php echo esc_attr($group->id); ?>">
367 <label class="fct-attribute-label">
368 <?php echo esc_html($group->title); ?>
369 </label>
370
371 <div class="fct-custom-select" data-fct-custom-select tabindex="0"
372 role="combobox"
373 aria-haspopup="listbox"
374 aria-expanded="false"
375 aria-controls="<?php echo esc_attr($listboxId); ?>"
376 aria-label="<?php echo esc_attr($group->title); ?>">
377 <div class="fct-custom-select__trigger" data-fct-select-trigger>
378 <span class="fct-custom-select__value" data-fct-select-value>
379 <?php
380 /* translators: %1$s is the attribute group title */
381 printf(esc_html__('Choose %1$s', 'fluent-cart'), esc_html($group->title));
382 ?>
383 </span>
384 <span class="fct-custom-select__arrow" aria-hidden="true"></span>
385 </div>
386 <div class="fct-custom-select__options" role="listbox" id="<?php echo esc_attr($listboxId); ?>">
387 <?php foreach ($terms as $term) :
388 $settings = is_array($term->settings) ? $term->settings : [];
389 $image = Arr::get($settings, 'image', '');
390 ?>
391 <div class="fct-custom-select__option"
392 data-fct-select-option
393 role="option"
394 aria-selected="false"
395 data-value="<?php echo esc_attr($term->id); ?>"
396 data-term-slug="<?php echo esc_attr($term->slug); ?>">
397 <div class="fct-image-option">
398 <?php if ($image) : ?>
399 <img
400 class="fct-image-src"
401 src="<?php echo esc_url($image); ?>"
402 alt="<?php echo esc_attr($term->title); ?>"
403 />
404 <?php endif; ?>
405 <span class="fct-image-label">
406 <?php echo esc_html($term->title); ?>
407 </span>
408 </div>
409 </div>
410 <?php endforeach; ?>
411 </div>
412 </div>
413 </div>
414 <?php
415 }
416
417 protected function renderAttributeSwatches($group, $terms)
418 {
419 $groupSettings = is_array($group->settings) ? $group->settings : [];
420 // Image groups render thumbnail-only pills (no caption under each
421 // image), so the currently selected term's title is surfaced next to
422 // the group label instead — e.g. "Image: Design 2".
423 $isImageGroup = Arr::get($groupSettings, 'type') === 'image';
424 ?>
425 <div class="fct-attribute-selector fct-swatch-selector<?php echo $isImageGroup ? ' fct-image-group' : ''; ?>" data-group-id="<?php echo esc_attr($group->id); ?>">
426 <label class="fct-attribute-label">
427 <?php echo esc_html($group->title); ?>
428 <?php if ($isImageGroup) : ?>
429 <span class="fct-selected-term-title" aria-live="polite"></span>
430 <?php endif; ?>
431 </label>
432 <div class="fct-swatch-options" role="radiogroup" aria-label="<?php echo esc_attr($group->title); ?>">
433 <?php foreach ($terms as $term) :
434 $settings = is_array($term->settings) ? $term->settings : [];
435 $color = $this->safeCssColor(Arr::get($settings, 'color', ''));
436 $image = Arr::get($settings, 'image', '');
437 ?>
438 <?php if (!empty($color)) : ?>
439 <button class="fct-swatch-item fct-swatch-color"
440 data-term-id="<?php echo esc_attr($term->id); ?>"
441 data-group-id="<?php echo esc_attr($group->id); ?>"
442 data-term-title="<?php echo esc_attr($term->title); ?>"
443 title="<?php echo esc_attr($term->title); ?>"
444 role="radio"
445 aria-checked="false"
446 tabindex="0"
447 type="button">
448 <span class="fct-swatch-dot" style="background-color: <?php echo esc_attr($color); ?>"></span>
449 <span class="fct-swatch-text"><?php echo esc_html($term->title); ?></span>
450 </button>
451 <?php elseif (!empty($image)) : ?>
452 <button class="fct-swatch-item fct-swatch-image<?php echo $isImageGroup ? ' fct-swatch-image-only' : ''; ?>"
453 data-term-id="<?php echo esc_attr($term->id); ?>"
454 data-group-id="<?php echo esc_attr($group->id); ?>"
455 data-term-title="<?php echo esc_attr($term->title); ?>"
456 title="<?php echo esc_attr($term->title); ?>"
457 role="radio"
458 aria-checked="false"
459 tabindex="0"
460 type="button">
461 <span class="fct-swatch-thumb">
462 <img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($term->title); ?>" />
463 </span>
464 <?php if (!$isImageGroup) : ?>
465 <span class="fct-swatch-text"><?php echo esc_html($term->title); ?></span>
466 <?php endif; ?>
467 </button>
468 <?php else : ?>
469 <button class="fct-swatch-item fct-swatch-label"
470 data-term-id="<?php echo esc_attr($term->id); ?>"
471 data-group-id="<?php echo esc_attr($group->id); ?>"
472 data-term-title="<?php echo esc_attr($term->title); ?>"
473 role="radio"
474 aria-checked="false"
475 tabindex="0"
476 type="button">
477 <?php echo esc_html($term->title); ?>
478 </button>
479 <?php endif; ?>
480 <?php endforeach; ?>
481 </div>
482 </div>
483 <?php
484 }
485 }
486