| @@ -141,8 +141,119 @@ | ||
| 141 | 141 | ]); |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | 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 | + /** | |
| 145 | 256 | * The store's attribute groups as a lightweight slug => label/type map. |
| 146 | 257 | * |
| 147 | 258 | * Sourced from the request-cached `getStoreProductAttributeSet()` registry, |
| 148 | 259 | * so this is only the current (live) group labels — used to resolve the |
| @@ -243,40 +354,91 @@ | ||
| 243 | 354 | }); |
| 244 | 355 | } |
| 245 | 356 | |
| 246 | 357 | /** |
| 247 | - * Render a line item's attributes as a single inline string. | |
| 358 | + * Render a line item's variation display string. | |
| 248 | 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 | + * | |
| 249 | 366 | * @param array $itemAttributes other_info['item_attributes'] snapshot. |
| 250 | - * @param mixed $item Owning cart/order item (passed to filters). | |
| 367 | + * @param mixed $item Owning cart/order item (model or array). | |
| 251 | 368 | * @param string $scope 'cart' | 'order_item'. |
| 252 | 369 | * @param string $separator Glue between pairs (default ' | '). |
| 253 | - * @return string e.g. "Color: Red | Size: XS" | |
| 370 | + * @return string e.g. "Color: Red | Size: XS", the variation title, or '' | |
| 254 | 371 | */ |
| 255 | 372 | public static function getDisplayAttributesString(array $itemAttributes, $item = null, $scope = 'cart', $separator = ' | ') |
| 256 | 373 | { |
| 257 | 374 | $displayAtts = self::getDisplayAttributes($itemAttributes, $item, $scope); |
| 258 | - if(!$displayAtts || !is_array($displayAtts)) { | |
| 259 | - return ''; | |
| 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 | + } | |
| 260 | 385 | } |
| 261 | 386 | |
| 262 | - $parts = []; | |
| 263 | - foreach ($displayAtts as $attr) { | |
| 264 | - $title = Arr::get($attr, 'display_title', ''); | |
| 265 | - $value = Arr::get($attr, 'display_value', ''); | |
| 266 | - // Skip the "Label: " prefix when the title is missing, otherwise we | |
| 267 | - // would render a stray leading colon (": Red"). | |
| 268 | - $parts[] = $title !== '' ? $title . ': ' . $value : $value; | |
| 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', ''); | |
| 269 | 397 | } |
| 270 | 398 | |
| 271 | - $string = implode($separator, $parts); | |
| 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', ''); | |
| 272 | 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 | + | |
| 273 | 416 | // Let integrators render the combination in their own format — they get |
| 274 | 417 | // the default string plus the resolved rows to rebuild from scratch. |
| 275 | - return apply_filters('fluent_cart/item_display_attr_string', $string, [ | |
| 418 | + return apply_filters('fluent_cart/item_display_attr_string', $displayTitleString, [ | |
| 276 | 419 | 'display_atts' => $displayAtts, |
| 277 | 420 | 'item' => $item, |
| 278 | 421 | 'scope' => $scope, |
| 279 | 422 | 'separator' => $separator, |
| 280 | 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; | |
| 281 | 443 | } |
| 282 | 444 | } |