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 / Helpers / AttributeHelper.php

AttributeHelper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Helpers/AttributeHelper.php

445 lines 17.8 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\Helpers;
4
5 use FluentCart\App\Models\AttributeGroup;
6 use FluentCart\App\Models\AttributeRelation;
7 use FluentCart\Framework\Support\Arr;
8 use FluentCart\Framework\Support\Str;
9
10 /**
11 * Helper for the store attribute library and per-item attribute snapshots.
12 *
13 * @package FluentCart\App\Helpers
14 *
15 * @version 1.0.0
16 */
17 class AttributeHelper
18 {
19 /**
20 * Return the store attribute library, or a single group set by slug.
21 *
22 * Loads every attribute group (with its terms) ONCE per request into a
23 * static cache, then serves all later calls from memory — so resolving
24 * variant attributes for many cart/order items never re-queries the DB.
25 *
26 * Shape — groups keyed by slug; each group carries its meta plus every term
27 * keyed by slug DIRECTLY on the group, so `color.red` resolves the term:
28 * [
29 * 'color' => [
30 * 'title' => 'Color',
31 * 'slug' => 'color',
32 * 'type' => 'color',
33 * 'red' => ['title' => 'Red', 'slug' => 'red', 'settings' => [...]],
34 * 'blue' => ['title' => 'Blue', 'slug' => 'blue', 'settings' => [...]],
35 * ],
36 * ]
37 *
38 * Note: term slugs share the group array with the reserved meta keys
39 * `title`/`slug`/`type`; product attribute terms never use those slugs.
40 *
41 * @param string $attrSlug Group slug (e.g. 'color', 'size'). Empty = all.
42 * @return array Single group set, the whole library keyed by group slug when
43 * $attrSlug is empty, or [] when the slug is not found.
44 */
45 public static function getStoreProductAttributeSet($attrSlug = '')
46 {
47 static $allAttributes = null;
48
49 if ($allAttributes === null) {
50 // Groups keyed by slug; each group merges its meta with every term
51 // keyed by slug (flat), so `color.red` resolves the term directly.
52 $allAttributes = AttributeGroup::query()
53 ->with(['terms' => function ($query) {
54 $query->orderBy('serial', 'ASC');
55 }])
56 ->orderBy('serial', 'ASC')
57 ->get()
58 ->keyBy('slug')
59 ->map(function ($group) {
60 $groupSettings = is_array($group->settings) ? $group->settings : [];
61
62 $terms = $group->terms->keyBy('slug')->map(function ($term) {
63 return [
64 'title' => $term->title,
65 'slug' => $term->slug,
66 'settings' => is_array($term->settings) ? $term->settings : [],
67 ];
68 })->toArray();
69
70 return array_merge([
71 'title' => $group->title,
72 'slug' => $group->slug,
73 'type' => Arr::get($groupSettings, 'type', 'options'),
74 ], $terms);
75 })
76 ->toArray();
77 }
78
79 if ($attrSlug) {
80 return Arr::get($allAttributes, $attrSlug, []);
81 }
82
83 return $allAttributes;
84 }
85
86 /**
87 * Build the `item_attributes` snapshot for a single cart/order line item.
88 *
89 * Stored at `other_info['item_attributes']` on cart_data items and order_items.
90 * FluentCart's own attributes are keyed `pa_{group_slug}`; the value is the
91 * term TITLE and slug is the term SLUG — frozen at the moment it is written so
92 * later renames in the attribute library never rewrite past orders. Third-party
93 * providers append their own entries (WITHOUT the `pa_` prefix) via the
94 * `fluent_cart/item_attributes` filter — they supply their own group slug as
95 * the key plus value/slug.
96 *
97 * Output shape:
98 * [
99 * 'pa_color' => ['value' => 'Red', 'slug' => 'red'],
100 * 'pa_size' => ['value' => 'XS', 'slug' => 'xs'],
101 * 'fluent_booking_start' => ['value' => '2026-06-29 12:12:00', 'slug' => 'fluent_booking'],
102 * ]
103 *
104 * @param int $variationId Product variation id (order/cart item object_id).
105 * @param int $productId Owning product id (passed to the filter for context).
106 * @return array
107 */
108 public static function getProductItemAttributes($variationId, $productId = 0)
109 {
110 $atts = [];
111
112 $variationId = (int) $variationId;
113
114 if ($variationId) {
115 $relations = AttributeRelation::query()
116 ->where('object_id', $variationId)
117 ->with(['group', 'term'])
118 ->get();
119
120 foreach ($relations as $relation) {
121 $group = $relation->group;
122 $term = $relation->term;
123
124 if (!$group || !$term) {
125 continue;
126 }
127
128 // Our own attributes carry the `pa_` prefix on the group slug.
129 $atts['pa_' . $group->slug] = [
130 'value' => $term->title,
131 'slug' => $term->slug,
132 ];
133 }
134 }
135
136 // Third-party attributes are appended without the `pa_` prefix — providers
137 // key by their own group slug and supply value/slug themselves.
138 return apply_filters('fluent_cart/item_attributes', $atts, [
139 'variation_id' => $variationId,
140 'product_id' => (int) $productId,
141 ]);
142 }
143
144 /**
145 * Batched variant of getProductItemAttributes — resolves the item_attributes
146 * snapshot for many variations with a SINGLE whereIn query, so cart writes
147 * carrying several unsnapshotted items don't run one query per item. The
148 * per-item `fluent_cart/item_attributes` filter still fires for each variation.
149 *
150 * @param array $variationIds Variation (object) ids.
151 * @param array $productIds Optional variationId => productId map for filter context.
152 * @return array variationId => item_attributes
153 */
154 public static function getProductItemsAttributes($variationIds, $productIds = [])
155 {
156 $variationIds = array_values(array_unique(array_filter(array_map('intval', (array) $variationIds))));
157
158 if (!$variationIds) {
159 return [];
160 }
161
162 $productIdMap = (array) $productIds;
163
164 $relationsByVariation = AttributeRelation::query()
165 ->whereIn('object_id', $variationIds)
166 ->with(['group', 'term'])
167 ->get()
168 ->groupBy('object_id');
169
170 $attributesByVariation = [];
171 foreach ($variationIds as $variationId) {
172 $atts = [];
173
174 foreach ($relationsByVariation->get($variationId, []) as $relation) {
175 $group = $relation->group;
176 $term = $relation->term;
177
178 if (!$group || !$term) {
179 continue;
180 }
181
182 // Our own attributes carry the `pa_` prefix on the group slug.
183 $atts['pa_' . $group->slug] = [
184 'value' => $term->title,
185 'slug' => $term->slug,
186 ];
187 }
188
189 // Third-party attributes are appended without the `pa_` prefix —
190 // providers key by their own group slug and supply value/slug.
191 $attributesByVariation[$variationId] = apply_filters('fluent_cart/item_attributes', $atts, [
192 'variation_id' => $variationId,
193 'product_id' => (int) Arr::get($productIdMap, $variationId, 0),
194 ]);
195 }
196
197 return $attributesByVariation;
198 }
199
200 /**
201 * Attach a resolved `variation_display_title` ("Color: Red | Size: S") to every
202 * variation of the given products — the picker/list-side mirror of the order
203 * item display.
204 *
205 * Resolves the attribute snapshot for ALL variations in ONE batched query (no
206 * per-variant N+1), and only touches products whose `detail.variants` relation
207 * is eager-loaded — so callers that don't load variants pay nothing. Variations
208 * are mutated in place; the value falls back to the raw variation_title for
209 * simple products. Reusable across any product-list endpoint.
210 *
211 * @param iterable $products Product models with `detail.variants` loaded.
212 * @return void
213 */
214 public static function attachVariationDisplayTitles($products)
215 {
216 // Single pass over the product tree: collect variation ids for the batched
217 // lookup and keep a flat list of variants (with their type) to fill afterwards.
218 $productIdByVariation = [];
219 $pendingVariants = [];
220 foreach ($products as $product) {
221 $variants = ($product->detail && $product->detail->relationLoaded('variants')) ? $product->detail->variants : null;
222 if (!$variants) {
223 continue;
224 }
225 $variationType = $product->detail->variation_type;
226 foreach ($variants as $variant) {
227 $productIdByVariation[$variant->id] = (int) $product->ID;
228 $pendingVariants[] = ['variant' => $variant, 'variation_type' => $variationType];
229 }
230 }
231
232 if (!$pendingVariants) {
233 return;
234 }
235
236 // ONE batched query resolves the attribute snapshot for every variation.
237 $attributesByVariation = self::getProductItemsAttributes(array_keys($productIdByVariation), $productIdByVariation);
238
239 foreach ($pendingVariants as $pendingVariant) {
240 $variant = $pendingVariant['variant'];
241 $variationType = $pendingVariant['variation_type'];
242 $displayTitle = self::getDisplayAttributesString(
243 Arr::get($attributesByVariation, $variant->id, []),
244 [
245 'title' => $variant->variation_title,
246 'variation_type' => $variationType,
247 'other_info' => ['variation_type' => $variationType],
248 ],
249 'order_item'
250 );
251 $variant->variation_display_title = $displayTitle !== '' ? $displayTitle : $variant->variation_title;
252 }
253 }
254
255 /**
256 * The store's attribute groups as a lightweight slug => label/type map.
257 *
258 * Sourced from the request-cached `getStoreProductAttributeSet()` registry,
259 * so this is only the current (live) group labels — used to resolve the
260 * display title of a frozen `pa_{slug}` snapshot entry.
261 *
262 * @return array e.g. ['color' => ['title' => 'Color', 'slug' => 'color', 'type' => 'color']]
263 */
264 public static function getMainAttributes()
265 {
266 $mainAtts = [];
267
268 foreach (self::getStoreProductAttributeSet() as $slug => $group) {
269 $mainAtts[$slug] = [
270 'title' => Arr::get($group, 'title', $slug),
271 'slug' => Arr::get($group, 'slug', $slug),
272 'type' => Arr::get($group, 'type', 'options'),
273 ];
274 }
275
276 return $mainAtts;
277 }
278
279 /**
280 * Resolve a line item's stored `item_attributes` snapshot into display rows.
281 *
282 * FluentCart attributes (keyed `pa_{group_slug}`) are resolved against the
283 * live group library for their display title; the term value/slug come from
284 * the frozen snapshot. A `pa_*` entry whose group no longer exists is
285 * skipped. Third-party (un-prefixed) entries are resolved through the
286 * `fluent_cart/item_display_attr_{key}` filter so the owning plugin can
287 * shape its own label/value.
288 *
289 * @param array $itemAttributes other_info['item_attributes'] snapshot.
290 * @param mixed $item Owning cart/order item (passed to filters).
291 * @param string $scope 'cart' | 'order_item' (passed to filters).
292 * @return array Keyed by attr key: ['display_title','attr_key','slug','display_value','is_system']
293 */
294 public static function getDisplayAttributes(array $itemAttributes, $item = null, $scope = 'cart')
295 {
296 if(!$itemAttributes) {
297 return [];
298 }
299
300 $mainAtts = self::getMainAttributes();
301 $displayAtts = [];
302
303 foreach ($itemAttributes as $key => $value) {
304 if (strpos($key, 'pa_') === 0) {
305 $groupSlug = substr($key, 3);
306
307 // Group was removed from the library — drop the stale entry.
308 if (!isset($mainAtts[$groupSlug])) {
309 $displayAtts[$key] = [
310 'display_title' => Str::of($groupSlug)->title(),
311 'attr_key' => $key,
312 'slug' => $key,
313 'display_value' => Arr::get($value, 'value', ''),
314 'is_system' => true,
315 ];
316 continue;
317 }
318
319 $mainAtt = $mainAtts[$groupSlug];
320
321 $displayAtts[$key] = [
322 'display_title' => $mainAtt['title'],
323 'attr_key' => $key,
324 'slug' => $mainAtt['slug'],
325 'display_value' => Arr::get($value, 'value', ''),
326 'is_system' => true,
327 ];
328
329 continue;
330 }
331
332 // Third-party attribute — let the owning plugin shape the display row.
333 $displayAtts[$key] = apply_filters('fluent_cart/item_display_attr_' . $key, [
334 'display_title' => $key,
335 'attr_key' => $key,
336 'slug' => Arr::get($value, 'slug', $key),
337 'display_value' => Arr::get($value, 'value', ''),
338 'is_system' => false,
339 ], [
340 'attr' => $value,
341 'item' => $item,
342 'scope' => $scope,
343 ]);
344 }
345
346 $displayAtts = apply_filters('fluent_cart/item_display_attr', $displayAtts, [
347 'item' => $item,
348 'scope' => $scope,
349 ]);
350
351 // Drop rows that resolved to an empty value (e.g. a provider opted out).
352 return array_filter($displayAtts, function ($attr) {
353 return !empty($attr['display_value']);
354 });
355 }
356
357 /**
358 * Render a line item's variation display string.
359 *
360 * Returns the labeled attribute combination ("Color: Red | Size: XS") when
361 * the item carries an attribute snapshot. When no attributes resolve it
362 * falls back to the item's variation title — staying empty for simple
363 * products whose title equals their post_title (no real variation). So
364 * callers can use the return value directly without their own fallback.
365 *
366 * @param array $itemAttributes other_info['item_attributes'] snapshot.
367 * @param mixed $item Owning cart/order item (model or array).
368 * @param string $scope 'cart' | 'order_item'.
369 * @param string $separator Glue between pairs (default ' | ').
370 * @return string e.g. "Color: Red | Size: XS", the variation title, or ''
371 */
372 public static function getDisplayAttributesString(array $itemAttributes, $item = null, $scope = 'cart', $separator = ' | ')
373 {
374 $displayAtts = self::getDisplayAttributes($itemAttributes, $item, $scope);
375
376 $parts = [];
377 if (is_array($displayAtts)) {
378 foreach ($displayAtts as $attr) {
379 $title = Arr::get($attr, 'display_title', '');
380 $value = Arr::get($attr, 'display_value', '');
381 // Skip the "Label: " prefix when the title is missing, otherwise
382 // we would render a stray leading colon (": Red").
383 $parts[] = $title !== '' ? $title . ': ' . $value : $value;
384 }
385 }
386
387 $displayTitleString = implode($separator, $parts);
388
389 // variation_type lives in other_info on order items, but cart items carry
390 // it at the root level too — accept either so the advanced-variation check
391 // works the same in cart, checkout and order contexts.
392 $variationType = Arr::get($item, 'other_info.variation_type', '');
393 if ($variationType === '') {
394 $variationType = is_object($item)
395 ? (string) ($item->variation_type ?? '')
396 : (string) Arr::get($item, 'variation_type', '');
397 }
398
399 // Non-advanced items prefix the variation title ("<title> | <attributes>")
400 // since their attributes (third-party injected) don't name the product;
401 // advanced variations skip it as their combination is self-describing.
402 if ($displayTitleString !== '' && $itemAttributes &&
403 $variationType !== Helper::PRODUCT_TYPE_ADVANCE_VARIATION
404 ) {
405 $title = is_object($item) ? (string) ($item->title ?? '') : (string) Arr::get($item, 'title', '');
406
407 if ($title !== '' && strpos($displayTitleString, $title) !== 0) {
408 $displayTitleString = $title . ' | ' . $displayTitleString;
409 }
410 }
411
412 if ($displayTitleString === '') {
413 $displayTitleString = self::variationTitleFallback($item);
414 }
415
416 // Let integrators render the combination in their own format — they get
417 // the default string plus the resolved rows to rebuild from scratch.
418 return apply_filters('fluent_cart/item_display_attr_string', $displayTitleString, [
419 'display_atts' => $displayAtts,
420 'item' => $item,
421 'scope' => $scope,
422 'separator' => $separator,
423 ]);
424 }
425
426 /**
427 * Variation title used when no attributes resolve — empty for simple
428 * products (title === post_title), otherwise the variation title.
429 *
430 * @param mixed $item Cart/order item model or array.
431 * @return string
432 */
433 protected static function variationTitleFallback($item)
434 {
435 if ($item === null) {
436 return '';
437 }
438
439 $postTitle = is_object($item) ? (string) ($item->post_title ?? '') : (string) Arr::get($item, 'post_title', '');
440 $title = is_object($item) ? (string) ($item->title ?? '') : (string) Arr::get($item, 'title', '');
441
442 return $postTitle === $title ? '' : $title;
443 }
444 }
445