product = $product; } /** * Whitelist a term's stored color before it lands in an inline * `style="background-color: ..."` attribute. esc_attr escapes HTML * attribute chars but NOT CSS syntax — a stored value saved through * sanitize_text_field (which never validated color format) like * "red;position:fixed;inset:0;z-index:9999" would otherwise inject * extra CSS declarations and deface the product page. Accept only * hex (#rgb/#rgba/#rrggbb/#rrggbbaa), rgb()/rgba() with numeric * content, or a bare CSS named color; anything else returns '' and * the caller drops the style attribute entirely. */ protected function safeCssColor($color): string { $color = trim((string) $color); if ($color === '') { return ''; } if (preg_match('/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color)) { return $color; } if (preg_match('/^rgba?\(\s*[0-9.,%\s]+\)$/i', $color)) { return $color; } if (preg_match('/^[a-zA-Z]{1,40}$/', $color)) { return $color; } return ''; } /** * Emits the storefront selector markup. Returns true only when markup * was actually echoed — false on every early bail. The caller writes * this into the filter's 'rendered' flag so free core can fall through * to its simple-variation rendering when this renderer no-ops (e.g. a * product switched to advanced_variations before attributes are built). */ public function render($selectorStyle = 'auto'): bool { $variants = $this->product->variants; if (!$variants || $variants->isEmpty()) { return false; } $variants->load(['attrMap', 'media']); $otherInfo = (array)Arr::get($this->product->detail, 'other_info'); $attributeConfig = Arr::get($otherInfo, 'attribute_config', []); if (empty($attributeConfig)) { return false; } $allTermIds = []; foreach ($attributeConfig as $attrGroup) { // (array) cast — other_info is an independent read path; if a // corrupt or non-syncVariantOption writer ever stored a scalar // under 'variants', a bare array_merge would TypeError and fatal // the whole product page. $termIds = (array) Arr::get($attrGroup, 'variants', []); $allTermIds = array_merge($allTermIds, $termIds); } $allTermIds = array_unique(array_map('intval', $allTermIds)); $terms = AttributeTerm::query()->whereIn('id', $allTermIds)->get()->keyBy('id'); $groupIds = array_column($attributeConfig, 'group_id'); $groups = AttributeGroup::query()->whereIn('id', array_map('intval', $groupIds))->get()->keyBy('id'); $variationMap = []; $variantData = []; foreach ($variants as $variant) { if ($variant->item_status !== 'active') { continue; } $termIds = []; foreach ($variant->attrMap as $rel) { $termIds[] = (int)$rel->term_id; } sort($termIds, SORT_NUMERIC); $identifier = implode('_', $termIds); $variationMap[$identifier] = (int)$variant->id; $variantData[$variant->id] = [ 'stock' => ($variant->manage_stock ? ($variant->stock_status ?: 'in-stock') : 'in-stock'), // Raw — esc_attr on the JSON below escapes the HTML-attribute // context, and the JS consumer must render via textContent // (NOT innerHTML). Pre-escaping here would double-encode // titles like "S&L" into "S&L" by the time JS reads // them, showing literal "&" to the shopper. 'title' => $variant->variation_title, // Authoritative payment_type column so the selector can hide // Add to Cart for subscription combinations (subscriptions are // Buy Now only) — mirrors the simple-variation flow. 'payment_type' => $variant->payment_type, // serial_index so the selector can fall back to the first // combination by order (not first in-stock) when the product has // no default_variation_id — matching the server-side default rule. // Keep NULL as null (don't cast to 0) so the selector's // `?? Infinity` guard sorts an unordered legacy row LAST, matching // the server fallback — casting null->0 would make it sort first. 'serial_index' => is_null($variant->serial_index) ? null : (int) $variant->serial_index, ]; } // If every variant was filtered out (all inactive), there's nothing // for the selector to map to — bail before emitting markup that // would just be dead controls. if (empty($variationMap)) { return false; } // Authoritative group→terms ownership map — used by JS to avoid heuristic narrowing $attrConfigForJs = []; foreach ($attributeConfig as $attrGroup) { $groupId = absint(Arr::get($attrGroup, 'group_id')); $termIds = array_values(array_map('absint', Arr::get($attrGroup, 'variants', []))); $attrConfigForJs[$groupId] = $termIds; } // Auto-detect the primary visual group (color or image type) — its terms // drive the main gallery image swap on the storefront. First match wins. $primaryGroupId = 0; foreach ($attributeConfig as $attrGroup) { $gid = absint(Arr::get($attrGroup, 'group_id')); $group = $groups->get($gid); $type = $group ? Arr::get((array) $group->settings, 'type', '') : ''; if (in_array($type, ['color', 'image'], true)) { $primaryGroupId = $gid; break; } } // Build term→image map for the primary group: first non-empty thumbnail // found among variants that belong to each term wins. $termImageMap = []; if ($primaryGroupId) { foreach ($variants as $variant) { $thumb = $variant->thumbnail ?: ''; if (!$thumb) { continue; } foreach ($variant->attrMap as $rel) { $tid = (int) $rel->term_id; if ((int) $rel->group_id === $primaryGroupId && !isset($termImageMap[$tid])) { $termImageMap[$tid] = $thumb; } } } } // wp_json_encode returns false on encoding failure (e.g. invalid UTF-8 // in a term title); fall back to a parseable empty object so the JS // doesn't crash on JSON.parse(""). Default to {} (not []) because // the JS consumers expect object lookups by string key. $variationMapJson = wp_json_encode($variationMap) ?: '{}'; $variantDataJson = wp_json_encode($variantData) ?: '{}'; $attrConfigJson = wp_json_encode($attrConfigForJs) ?: '{}'; $termImageMapJson = wp_json_encode($termImageMap) ?: '{}'; // Surface the merchant's saved default variant id so the JS // selector can pre-select that combination on first paint. When // the column is empty (legacy products, or the merchant cleared // it), the JS falls back to the first combination by serial_index. Read // straight off the model — (array) cast on a WPFluent model // returns its public protected/$attributes shape inconsistently // depending on lazy-load state, so `default_variation_id` came // back null on first paint even when the column was set. $defaultVariationId = $this->product->detail ? (int) $this->product->detail->default_variation_id : 0; ?>
settings) ? $group->settings : []; $groupType = Arr::get($groupSettings, 'type', 'options'); $groupStyling = Arr::get($groupSettings, 'styling', ''); do_action('fluent_cart/product/single/before_variant_item', [ 'product' => $this->product, 'group' => $group, 'scope' => 'attribute_selector_group', ]); if ($groupType === 'color' && $groupStyling === 'dropdown') { $this->renderColorDropdown($group, $groupTerms); } elseif ($groupType === 'image' && $groupStyling === 'dropdown') { $this->renderImageDropdown($group, $groupTerms); } elseif ($groupStyling === 'dropdown') { $this->renderAttributeDropdown($group, $groupTerms); } else { $this->renderAttributeSwatches($group, $groupTerms); } do_action('fluent_cart/product/single/after_variant_item', [ 'product' => $this->product, 'group' => $group, 'scope' => 'attribute_selector_group', ]); } ?>
id; ?>
id; ?>
id; ?>
settings) ? $group->settings : []; // Image groups render thumbnail-only pills (no caption under each // image), so the currently selected term's title is surfaced next to // the group label instead — e.g. "Image: Design 2". $isImageGroup = Arr::get($groupSettings, 'type') === 'image'; ?>
settings) ? $term->settings : []; $color = $this->safeCssColor(Arr::get($settings, 'color', '')); $image = Arr::get($settings, 'image', ''); ?>