PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 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 All 51 releases
fluent-cart / app / Services / Renderer / ProductCardRender.php

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

651 lines 27.5 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\Api\ModuleSettings;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Models\Product;
8 use FluentCart\App\Services\ProductReviewService;
9 use FluentCart\App\Modules\Templating\AssetLoader;
10 use FluentCart\Framework\Support\Arr;
11
12 class ProductCardRender
13 {
14 protected $product;
15
16 protected $viewUrl = '';
17
18 protected $config = [];
19
20 public function __construct(Product $product, $config = [])
21 {
22 $this->product = $product;
23 $this->viewUrl = $product->view_url;
24 $this->config = $config;
25 }
26
27 public function renderWrapperStart()
28 {
29
30 }
31
32 public function renderWrapperEnd()
33 {
34
35 }
36
37 public function render()
38 {
39 AssetLoader::loadSingleProductAssets();
40 // Lets extensions (e.g. Pro's advanced-variation selector) enqueue the
41 // CSS/JS a product purchase UI needs. Backs the card surfaces that go
42 // through this renderer's render() — Shop, Product List, the product
43 // shortcode, and the quick-view modal. Surfaces that render card parts
44 // via the individual render* methods instead of render() (Product
45 // Carousel, Related Products) fire this same hook from their own block
46 // render. Guarded with a static flag so a grid of N cards fires it once
47 // per request, not once per card. No payload — assets are uniform.
48 static $assetsHookFired = false;
49 if (!$assetsHookFired) {
50 $assetsHookFired = true;
51 do_action('fluent_cart/advanced_variation/enqueue_assets');
52 }
53 $cursor = '';
54 if (!empty($this->config['cursor'])) {
55 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
56 }
57
58 $cardWidth = '';
59 $cardWidthValue = Arr::get($this->config, 'card_width', '');
60 if ($cardWidthValue) {
61 $widthValue = $cardWidthValue === '100%' ? '100%' : intval($cardWidthValue) . 'px';
62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
63 }
64
65
66 // Card wrapper classes are filterable so an integration can tag cards
67 // (sale, low-stock, layout variants) without wrapping or re-rendering
68 // the whole card. Sanitised in RenderGate, so listeners may return a
69 // plain array of class names.
70 $cardClasses = RenderGate::cardClasses(['fct-product-card'], [
71 'product' => $this->product,
72 'scope' => RenderGate::SCOPE_CARD,
73 ]);
74
75 do_action('fluent_cart/product/group/before_card', RenderContext::decorate([
76 'product' => $this->product,
77 'scope' => RenderGate::SCOPE_CARD,
78 ]));
79 ?>
80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
81 class="<?php echo esc_attr($cardClasses); ?>"
82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
84 aria-label="<?php echo esc_attr(sprintf(
85 /* translators: %s: product title */
86 __('%s product card', 'fluent-cart'), $this->product->post_title));
87 ?>">
88 <?php $this->renderProductImage(); ?>
89 <?php $this->renderTitle(); ?>
90 <?php $this->renderStarRating(); ?>
91 <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
92 <?php $this->renderPrices(); ?>
93 <?php $this->showBuyButton(); ?>
94 </article>
95 <?php
96 do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
97 'product' => $this->product,
98 'scope' => RenderGate::SCOPE_CARD,
99 ]));
100 }
101
102 /**
103 * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
104 * Kept as a compatibility shim for external callers (themes, extensions).
105 *
106 * @param array $otherInfo Order item's other_info array
107 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
108 */
109 public static function buildPackageInfoFromOtherInfo($otherInfo)
110 {
111 return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
112 }
113
114 /**
115 * @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly.
116 * Kept as a compatibility shim for external callers (themes, extensions) —
117 * package-description rendering now lives in PackageDescriptionRenderer.
118 */
119 public function renderPackageDescription(
120 $wrapper_attributes = '',
121 $showName = true,
122 $showDimensions = true,
123 $showProductWeight = true,
124 $showTotalWeight = true,
125 $variant = null,
126 $defaultVariant = null
127 ) {
128 (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
129 $wrapper_attributes,
130 $showName,
131 $showDimensions,
132 $showProductWeight,
133 $showTotalWeight,
134 $variant,
135 $defaultVariant
136 );
137 }
138
139 public function renderStarRating()
140 {
141 if (!ModuleSettings::isActive('reviews')) {
142 return;
143 }
144
145 // Store-level toggles: Settings → Store Settings → Product Page →
146 // Product Rating. Shop grid/carousels gate on show_rating_in_shop;
147 // relevant/related product sections gate on show_rating_in_relevant
148 // (callers tag those cards with config rating_context = 'relevant').
149 // The explicit product-rating block (renderStarRatingBlock) stays
150 // unaffected — it is user-placed.
151 $ratingContext = Arr::get($this->config, 'rating_context', 'shop');
152
153 $ratingVisibilitySettingKey = $ratingContext === 'relevant'
154 ? 'show_rating_in_relevant'
155 : 'show_rating_in_shop';
156
157 if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
158 return;
159 }
160
161 if (!ProductReviewService::ratingMeetsThresholds($this->product, $this->config)) {
162 return;
163 }
164
165 $otherInfo = $this->product->detail->other_info ?: [];
166 $avgRating = Arr::get($otherInfo, 'average_rating', 0);
167 $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
168
169 if ($reviewCount < 1) {
170 return;
171 }
172
173 $fullStars = (int) floor($avgRating);
174 $halfStar = ($avgRating - $fullStars) >= 0.5;
175 $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
176
177 ?>
178 <div class="fct-product-card-rating" role="img" aria-label="<?php echo esc_attr(sprintf(
179 /* translators: %1$s: average rating, %2$d: review count */
180 __('Rated %1$s out of 5 based on %2$d reviews', 'fluent-cart'), $avgRating, $reviewCount)); ?>">
181 <span class="fct-product-card-stars" aria-hidden="true">
182 <?php
183 for ($i = 0; $i < $fullStars; $i++) {
184 echo '<span class="fct-star fct-star-filled">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
185 }
186 if ($halfStar) {
187 echo '<span class="fct-star fct-star-half"><span class="fct-star-half-empty">' . ReviewThreadMarkup::starSvg() . '</span><span class="fct-star-half-fill">' . ReviewThreadMarkup::starSvg() . '</span></span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
188 }
189 for ($i = 0; $i < $emptyStars; $i++) {
190 echo '<span class="fct-star fct-star-empty">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
191 }
192 ?>
193 </span>
194 <span class="fct-product-card-review-count">(<?php echo esc_html($reviewCount); ?>)</span>
195 </div>
196 <?php
197 }
198
199 public function renderStarRatingBlock($wrapperAttributes = '', array $attributes = [], $block = null)
200 {
201 if (!ProductReviewService::ratingMeetsThresholds($this->product, $attributes, $block)) {
202 return;
203 }
204
205 $otherInfo = $this->product->detail->other_info ?: [];
206 $avgRating = Arr::get($otherInfo, 'average_rating', 0);
207 $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
208
209 $fullStars = (int) floor($avgRating);
210 $halfStar = ($avgRating - $fullStars) >= 0.5;
211 $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
212
213 ?>
214 <div <?php echo $wrapperAttributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> role="img" aria-label="<?php echo esc_attr(sprintf(
215 /* translators: %1$s: average rating, %2$d: review count */
216 __('Rated %1$s out of 5 based on %2$d reviews', 'fluent-cart'), $avgRating, $reviewCount)); ?>">
217 <span class="fct-product-card-stars" aria-hidden="true">
218 <?php
219 for ($i = 0; $i < $fullStars; $i++) {
220 echo '<span class="fct-star fct-star-filled">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
221 }
222 if ($halfStar) {
223 echo '<span class="fct-star fct-star-half"><span class="fct-star-half-empty">' . ReviewThreadMarkup::starSvg() . '</span><span class="fct-star-half-fill">' . ReviewThreadMarkup::starSvg() . '</span></span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
224 }
225 for ($i = 0; $i < $emptyStars; $i++) {
226 echo '<span class="fct-star fct-star-empty">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
227 }
228 ?>
229 </span>
230 <span class="fct-product-card-review-count">(<?php echo esc_html($reviewCount); ?>)</span>
231 </div>
232 <?php
233 }
234
235 public function renderExcerpt($atts = '')
236 {
237 if (empty($this->product->post_excerpt)) {
238 return;
239 }
240
241 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
242
243 if (!RenderGate::shouldRender('excerpt', $gateContext)) {
244 return;
245 }
246
247 do_action('fluent_cart/product/group/before_excerpt_block', $gateContext);
248
249 echo sprintf(
250 '<p %1$s class="fct-product-card-excerpt">
251 %2$s
252 </p>',
253 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
254 wp_kses_post($this->product->post_excerpt),
255 );
256
257 do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
258 }
259
260 public function renderTitle($atts = '', $config = [])
261 {
262 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
263
264 if (!RenderGate::shouldRender('title', $gateContext)) {
265 return;
266 }
267
268 do_action('fluent_cart/product/group/before_title_block', $gateContext);
269
270 $link = Arr::get($config, 'isLink', true);
271 $target = Arr::get($config, 'target', '_self');
272
273 $titleText = esc_html($this->product->post_title);
274
275 if ($link) {
276 // Render as link
277 $targetAttr = $target === '_blank' ? 'target="_blank" rel="noopener noreferrer"' : '';
278 echo sprintf(
279 '<h3 class="fct-product-card-title">
280 <a %1$s data-fluent-cart-product-link
281 data-product-id="%2$s"
282 href="%3$s"
283 aria-label="%4$s"
284 %5$s>%6$s</a>
285 </h3>',
286 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
287 esc_attr($this->product->ID),
288 esc_url($this->product->view_url),
289 esc_attr($this->product->post_title),
290 $targetAttr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
291 esc_html($titleText)
292 );
293 } else {
294 // Render as plain text (no link)
295 echo sprintf(
296 '<h3 class="fct-product-card-title" %1$s>%2$s</h3>',
297 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
298 esc_html($titleText)
299 );
300 }
301
302 do_action('fluent_cart/product/group/after_title_block', $gateContext);
303 }
304
305 public function renderProductImage()
306 {
307 if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
308 return;
309 }
310
311 // Product::$thumbnail already falls back to the placeholder, which
312 // hides whether this is one, so ask for the featured image directly.
313 $detail = $this->product->detail;
314 $image = $detail ? Arr::get((array) $detail->featured_media, 'url') : '';
315 $isPlaceholder = false;
316
317 if (!$image) {
318 $image = Helper::getProductPlaceholderUrl();
319 $isPlaceholder = true;
320 }
321
322 $altText = $isPlaceholder
323 ? sprintf(
324 /* translators: %s: product title */
325 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
326 : $this->product->post_title;
327
328 do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
329 'product' => $this->product,
330 'scope' => 'product_card'
331 ]));
332 ?>
333 <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
334 href="<?php echo esc_url($this->viewUrl); ?>"
335 style="display: block;"
336 aria-label="<?php echo esc_attr(sprintf(
337 /* translators: %s: product title */
338 __('View %s product image', 'fluent-cart'), $this->product->post_title)); ?>">
339 <img class="fct-product-card-image"
340 data-fluent-cart-shop-app-single-product-image
341 src="<?php echo esc_url($image); ?>"
342 alt="<?php echo esc_attr($altText); ?>"
343 loading="lazy"
344 width="300"
345 height="300"/>
346 </a>
347 <?php
348
349 do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
350 'product' => $this->product,
351 'scope' => 'product_card'
352 ]));
353 }
354
355 public function renderPrices($wrapper_attributes = '')
356 {
357 if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
358 return;
359 }
360
361 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
362 $isSimple = $this->product->detail->variation_type === 'simple';
363 $minPrice = $this->product->detail->min_price;
364 $maxPrice = $this->product->detail->max_price;
365 $comparePrice = 0;
366 $firstVariant = null;
367
368 if ($isSimple) {
369 $firstVariant = $this->product->variants->first();
370 if ($firstVariant) {
371 $minPrice = $firstVariant->item_price;
372 $minPrice = apply_filters('fluent_cart/product/display_price', $minPrice, [
373 'product' => $this->product,
374 'variation' => $firstVariant,
375 ]);
376 $minPrice = (int)$minPrice;
377 if ($firstVariant->compare_price > $minPrice) {
378 $comparePrice = $firstVariant->compare_price;
379 }
380 }
381 }
382
383 $formattedMinPrice = Helper::toDecimal($minPrice);
384 $formattedMaxPrice = Helper::toDecimal($maxPrice);
385 $formattedComparePrice = Helper::toDecimal($comparePrice);
386
387 do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
388 'product' => $this->product,
389 'current_price' => $minPrice,
390 'scope' => 'product_card'
391 ]));
392 ?>
393 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
394 class="fct-product-card-prices"
395 role="region"
396 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
397 <?php if ($comparePrice): ?>
398 <span class="fct-compare-price">
399 <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
400 <del><?php echo esc_html($formattedComparePrice); ?></del>
401 </span>
402 <?php endif; ?>
403
404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
405 <!-- Case 2: price range -->
406 <?php if ($priceFormat === 'range'): ?>
407 <span class="fct-item-price">
408 <span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span>
409 <?php echo esc_html($formattedMinPrice); ?>
410 <span aria-hidden="true">-</span>
411 <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
412 <?php echo esc_html($formattedMaxPrice); ?>
413 </span>
414 <?php else: ?>
415 <span class="fct-item-price"><?php
416 /* translators: %s is the minimum price */
417 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
418 ?></span>
419 <?php endif; ?>
420
421 <?php else: ?>
422 <!-- Case 3: Simple or single price -->
423 <span class="fct-item-price">
424 <span class="fct-sr-only"><?php echo esc_html__('Price:', 'fluent-cart'); ?></span>
425 <?php echo esc_html($formattedMinPrice); ?>
426 </span>
427 <?php endif; ?>
428
429 <?php
430 do_action('fluent_cart/product/after_price', RenderContext::decorate([
431 'product' => $this->product,
432 'variant' => $firstVariant,
433 'current_price' => $minPrice,
434 'scope' => 'product_card'
435 ]));
436 RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
437 ?>
438 </div>
439 <?php
440 do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
441 'product' => $this->product,
442 'current_price' => $minPrice,
443 'scope' => 'product_card'
444 ]));
445 }
446
447 public function showBuyButton($atts = '')
448 {
449 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
450
451 // 'actions' gates the whole affordance slot, including the disabled
452 // out-of-stock placeholder — hiding the row should not leave a stray
453 // "Not Available" button behind.
454 if (!RenderGate::shouldRender('actions', $gateContext)) {
455 return;
456 }
457
458 do_action('fluent_cart/product/group/before_actions_block', $gateContext);
459
460 $this->renderBuyButtonMarkup($atts, $gateContext);
461
462 do_action('fluent_cart/product/group/after_actions_block', $gateContext);
463 }
464
465 protected function renderBuyButtonMarkup($atts, array $gateContext)
466 {
467 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
468
469 if ($isOutOfStock) {
470 $soldOutText = __('Not Available', 'fluent-cart');
471 ?>
472 <button type="button" class="fct-product-view-button out-of-stock" disabled aria-disabled="true"
473 aria-label="<?php echo esc_attr($soldOutText); ?>">
474 <span class="fct-button-text">
475 <?php echo esc_html($soldOutText); ?>
476 </span>
477 </button>
478 <?php
479 return;
480 }
481
482 $enableModalCheckout = Helper::isModalCheckoutEnabled();
483 $isSimple = $this->product->detail->variation_type === 'simple';
484 $firstVariant = null;
485 $buttonHref = $this->viewUrl;
486
487 if ($isSimple) {
488 $firstVariant = $this->product->variants->first();
489 if ($firstVariant) {
490 // return '';
491 }
492 }
493
494 $isInstantCheckout = false;
495 $hasSubscription = $this->product->has_subscription;
496 $buttonText = __('View Options', 'fluent-cart');
497 $ariaLabel = sprintf(
498 /* translators: %s: product title */
499 __('View options for %s', 'fluent-cart'), $this->product->post_title);
500
501 if ($isSimple) {
502 if ($hasSubscription) {
503 $buttonText = __('Buy Now', 'fluent-cart');
504 $ariaLabel = sprintf(
505 /* translators: %s: product title */
506 __('Buy %s now', 'fluent-cart'), $this->product->post_title);
507 $buttonHref = $firstVariant->getPurchaseUrl();
508 $isInstantCheckout = true;
509 } else {
510 $buttonText = __('Add To Cart', 'fluent-cart');
511 $ariaLabel = sprintf(
512 /* translators: %s: product title */
513 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
514 }
515 }
516
517 // The card fills this slot with one of three things: an instant-checkout
518 // Buy Now anchor (simple + subscription), an Add to Cart button (simple),
519 // or a "View Options" link through to the product page (variable). The
520 // first two are purchase affordances and answer to their own gates.
521 // "View Options" is navigation, so it stays under 'actions' alone — a
522 // catalog-mode listener hides the buying, not the browsing.
523 if ($isInstantCheckout) {
524 if (!RenderGate::shouldRender('buy_now_button', $gateContext)) {
525 return;
526 }
527 } elseif ($firstVariant) {
528 if (!RenderGate::shouldRender('add_to_cart_button', $gateContext)) {
529 return;
530 }
531 }
532
533 $buttonAttributes = [
534 'class' => 'fct-product-view-button fct-single-product-card-view-button',
535 'data-product-id' => $this->product->ID,
536 'data-fluent-cart-single-product-card-view-button' => '',
537 'aria-label' => $ariaLabel
538 ];
539
540 if ($firstVariant) {
541 $buttonAttributes = [
542 'data-cart-id' => $firstVariant->id,
543 'class' => 'fluent-cart-add-to-cart-button',
544 'data-variation-type' => $this->product->detail->variation_type,
545 'data-fluent-cart-add-to-cart-button' => '',
546 'data-is-custom' => false,
547 'aria-label' => $ariaLabel
548 ];
549 }
550 $customAttributes = $this->parseAttributes($atts);
551 if (!empty($customAttributes)) {
552 if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) {
553 $buttonAttributes['class'] .= ' ' . $customAttributes['class'];
554 unset($customAttributes['class']);
555 }
556 $buttonAttributes = array_merge($buttonAttributes, $customAttributes);
557 }
558
559 $anchorAttributes = [
560 'href' => $buttonHref,
561 'class' => 'fct-product-view-button',
562 'aria-label' => $ariaLabel,
563 ];
564
565 if ($enableModalCheckout) {
566 $anchorAttributes['data-fct-instant-checkout-button'] = '';
567 $anchorAttributes['data-enable-modal-checkout'] = 'yes';
568 }
569
570 if ($isInstantCheckout) {
571 $parsedCustomAttributes = $this->parseAttributes($atts);
572 if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) {
573 $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class'];
574 unset($parsedCustomAttributes['class']);
575 }
576 $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes);
577 }
578 ?>
579 <?php if ($isInstantCheckout): ?>
580 <a <?php $this->renderAttributes($anchorAttributes); ?>>
581 <span aria-hidden="true">
582 <?php echo esc_html($buttonText); ?>
583 </span>
584 </a>
585 <?php else: ?>
586 <button
587 type="button"
588 data-button-url="<?php echo esc_url($buttonHref); ?>"
589 <?php $this->renderAttributes($buttonAttributes); ?>>
590 <span class="fct-button-text">
591 <?php echo esc_html($buttonText); ?>
592 </span>
593 <span
594 class="fluent-cart-loader"
595 role="status"
596 aria-live="polite"
597 aria-label="<?php echo esc_attr__('Loading', 'fluent-cart'); ?>">
598 <svg aria-hidden="true"
599 viewBox="0 0 100 101"
600 fill="none"
601 xmlns="http://www.w3.org/2000/svg"
602 focusable="false">
603 <path
604 d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
605 fill="currentColor"></path>
606 <path
607 d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
608 fill="currentFill"></path>
609 </svg>
610 </span>
611 </button>
612 <?php endif; ?>
613 <?php
614 }
615
616 protected function renderAttributes($atts = [])
617 {
618 foreach ($atts as $attr => $value) {
619 if ($value !== '') {
620 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
621 } else {
622 echo esc_attr($attr) . ' ';
623 }
624 }
625 }
626
627 private function parseAttributes($atts)
628 {
629 if (empty($atts)) {
630 return [];
631 }
632
633 $attributes = [];
634
635 // Match attribute="value" or attribute='value' or attribute=value
636 preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER);
637
638 foreach ($matches as $match) {
639 if (!empty($match[1])) {
640 // Quoted value
641 $attributes[$match[1]] = $match[3];
642 } elseif (!empty($match[4])) {
643 // Unquoted value
644 $attributes[$match[4]] = $match[5];
645 }
646 }
647
648 return $attributes;
649 }
650 }
651