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 / ProductCardRender.php

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

551 lines 22.3 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\Modules\Templating\AssetLoader;
9 use FluentCart\App\Vite;
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 if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
91 <?php $this->renderPrices(); ?>
92 <?php $this->showBuyButton(); ?>
93 </article>
94 <?php
95 do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
96 'product' => $this->product,
97 'scope' => RenderGate::SCOPE_CARD,
98 ]));
99 }
100
101 /**
102 * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
103 * Kept as a compatibility shim for external callers (themes, extensions).
104 *
105 * @param array $otherInfo Order item's other_info array
106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
107 */
108 public static function buildPackageInfoFromOtherInfo($otherInfo)
109 {
110 return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
111 }
112
113 /**
114 * @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly.
115 * Kept as a compatibility shim for external callers (themes, extensions) —
116 * package-description rendering now lives in PackageDescriptionRenderer.
117 */
118 public function renderPackageDescription(
119 $wrapper_attributes = '',
120 $showName = true,
121 $showDimensions = true,
122 $showProductWeight = true,
123 $showTotalWeight = true,
124 $variant = null,
125 $defaultVariant = null
126 ) {
127 (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
128 $wrapper_attributes,
129 $showName,
130 $showDimensions,
131 $showProductWeight,
132 $showTotalWeight,
133 $variant,
134 $defaultVariant
135 );
136 }
137
138 public function renderExcerpt($atts = '')
139 {
140 if (empty($this->product->post_excerpt)) {
141 return;
142 }
143
144 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
145
146 if (!RenderGate::shouldRender('excerpt', $gateContext)) {
147 return;
148 }
149
150 do_action('fluent_cart/product/group/before_excerpt_block', $gateContext);
151
152 echo sprintf(
153 '<p %1$s class="fct-product-card-excerpt">
154 %2$s
155 </p>',
156 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
157 wp_kses_post($this->product->post_excerpt),
158 );
159
160 do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
161 }
162
163 public function renderTitle($atts = '', $config = [])
164 {
165 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
166
167 if (!RenderGate::shouldRender('title', $gateContext)) {
168 return;
169 }
170
171 do_action('fluent_cart/product/group/before_title_block', $gateContext);
172
173 $link = Arr::get($config, 'isLink', true);
174 $target = Arr::get($config, 'target', '_self');
175
176 $titleText = esc_html($this->product->post_title);
177
178 if ($link) {
179 // Render as link
180 $targetAttr = $target === '_blank' ? 'target="_blank" rel="noopener noreferrer"' : '';
181 echo sprintf(
182 '<h3 class="fct-product-card-title">
183 <a %1$s data-fluent-cart-product-link
184 data-product-id="%2$s"
185 href="%3$s"
186 aria-label="%4$s"
187 %5$s>%6$s</a>
188 </h3>',
189 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
190 esc_attr($this->product->ID),
191 esc_url($this->product->view_url),
192 esc_attr($this->product->post_title),
193 $targetAttr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
194 esc_html($titleText)
195 );
196 } else {
197 // Render as plain text (no link)
198 echo sprintf(
199 '<h3 class="fct-product-card-title" %1$s>%2$s</h3>',
200 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
201 esc_html($titleText)
202 );
203 }
204
205 do_action('fluent_cart/product/group/after_title_block', $gateContext);
206 }
207
208 public function renderProductImage()
209 {
210 if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
211 return;
212 }
213
214 $image = $this->product->thumbnail;
215 $isPlaceholder = false;
216
217 if (!$image) {
218 $image = Vite::getAssetUrl('images/placeholder.svg');
219 $isPlaceholder = true;
220 }
221
222 $altText = $isPlaceholder
223 ? sprintf(
224 /* translators: %s: product title */
225 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
226 : $this->product->post_title;
227
228 do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
229 'product' => $this->product,
230 'scope' => 'product_card'
231 ]));
232 ?>
233 <a class="fct-product-card-image-wrap"
234 href="<?php echo esc_url($this->viewUrl); ?>"
235 style="display: block;"
236 aria-label="<?php echo esc_attr(sprintf(
237 /* translators: %s: product title */
238 __('View %s product image', 'fluent-cart'), $this->product->post_title)); ?>">
239 <img class="fct-product-card-image"
240 data-fluent-cart-shop-app-single-product-image
241 src="<?php echo esc_url($image); ?>"
242 alt="<?php echo esc_attr($altText); ?>"
243 loading="lazy"
244 width="300"
245 height="300"/>
246 </a>
247 <?php
248
249 do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
250 'product' => $this->product,
251 'scope' => 'product_card'
252 ]));
253 }
254
255 public function renderPrices($wrapper_attributes = '')
256 {
257 if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
258 return;
259 }
260
261 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
262 $isSimple = $this->product->detail->variation_type === 'simple';
263 $minPrice = $this->product->detail->min_price;
264 $maxPrice = $this->product->detail->max_price;
265 $comparePrice = 0;
266 $firstVariant = null;
267
268 if ($isSimple) {
269 $firstVariant = $this->product->variants->first();
270 if ($firstVariant) {
271 $minPrice = $firstVariant->item_price;
272 $minPrice = apply_filters('fluent_cart/product/display_price', $minPrice, [
273 'product' => $this->product,
274 'variation' => $firstVariant,
275 ]);
276 $minPrice = (int)$minPrice;
277 if ($firstVariant->compare_price > $minPrice) {
278 $comparePrice = $firstVariant->compare_price;
279 }
280 }
281 }
282
283 $formattedMinPrice = Helper::toDecimal($minPrice);
284 $formattedMaxPrice = Helper::toDecimal($maxPrice);
285 $formattedComparePrice = Helper::toDecimal($comparePrice);
286
287 do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
288 'product' => $this->product,
289 'current_price' => $minPrice,
290 'scope' => 'product_card'
291 ]));
292 ?>
293 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
294 class="fct-product-card-prices"
295 role="region"
296 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
297 <?php if ($comparePrice): ?>
298 <span class="fct-compare-price">
299 <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
300 <del><?php echo esc_html($formattedComparePrice); ?></del>
301 </span>
302 <?php endif; ?>
303
304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
305 <!-- Case 2: price range -->
306 <?php if ($priceFormat === 'range'): ?>
307 <span class="fct-item-price">
308 <span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span>
309 <?php echo esc_html($formattedMinPrice); ?>
310 <span aria-hidden="true">-</span>
311 <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
312 <?php echo esc_html($formattedMaxPrice); ?>
313 </span>
314 <?php else: ?>
315 <span class="fct-item-price"><?php
316 /* translators: %s is the minimum price */
317 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
318 ?></span>
319 <?php endif; ?>
320
321 <?php else: ?>
322 <!-- Case 3: Simple or single price -->
323 <span class="fct-item-price">
324 <span class="fct-sr-only"><?php echo esc_html__('Price:', 'fluent-cart'); ?></span>
325 <?php echo esc_html($formattedMinPrice); ?>
326 </span>
327 <?php endif; ?>
328
329 <?php
330 do_action('fluent_cart/product/after_price', RenderContext::decorate([
331 'product' => $this->product,
332 'variant' => $firstVariant,
333 'current_price' => $minPrice,
334 'scope' => 'product_card'
335 ]));
336 RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
337 ?>
338 </div>
339 <?php
340 do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
341 'product' => $this->product,
342 'current_price' => $minPrice,
343 'scope' => 'product_card'
344 ]));
345 }
346
347 public function showBuyButton($atts = '')
348 {
349 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
350
351 // 'actions' gates the whole affordance slot, including the disabled
352 // out-of-stock placeholder — hiding the row should not leave a stray
353 // "Not Available" button behind.
354 if (!RenderGate::shouldRender('actions', $gateContext)) {
355 return;
356 }
357
358 do_action('fluent_cart/product/group/before_actions_block', $gateContext);
359
360 $this->renderBuyButtonMarkup($atts, $gateContext);
361
362 do_action('fluent_cart/product/group/after_actions_block', $gateContext);
363 }
364
365 protected function renderBuyButtonMarkup($atts, array $gateContext)
366 {
367 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
368
369 if ($isOutOfStock) {
370 $soldOutText = __('Not Available', 'fluent-cart');
371 ?>
372 <button type="button" class="fct-product-view-button out-of-stock" disabled aria-disabled="true"
373 aria-label="<?php echo esc_attr($soldOutText); ?>">
374 <span class="fct-button-text">
375 <?php echo esc_html($soldOutText); ?>
376 </span>
377 </button>
378 <?php
379 return;
380 }
381
382 $enableModalCheckout = Helper::isModalCheckoutEnabled();
383 $isSimple = $this->product->detail->variation_type === 'simple';
384 $firstVariant = null;
385 $buttonHref = $this->viewUrl;
386
387 if ($isSimple) {
388 $firstVariant = $this->product->variants->first();
389 if ($firstVariant) {
390 // return '';
391 }
392 }
393
394 $isInstantCheckout = false;
395 $hasSubscription = $this->product->has_subscription;
396 $buttonText = __('View Options', 'fluent-cart');
397 $ariaLabel = sprintf(
398 /* translators: %s: product title */
399 __('View options for %s', 'fluent-cart'), $this->product->post_title);
400
401 if ($isSimple) {
402 if ($hasSubscription) {
403 $buttonText = __('Buy Now', 'fluent-cart');
404 $ariaLabel = sprintf(
405 /* translators: %s: product title */
406 __('Buy %s now', 'fluent-cart'), $this->product->post_title);
407 $buttonHref = $firstVariant->getPurchaseUrl();
408 $isInstantCheckout = true;
409 } else {
410 $buttonText = __('Add To Cart', 'fluent-cart');
411 $ariaLabel = sprintf(
412 /* translators: %s: product title */
413 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
414 }
415 }
416
417 // The card fills this slot with one of three things: an instant-checkout
418 // Buy Now anchor (simple + subscription), an Add to Cart button (simple),
419 // or a "View Options" link through to the product page (variable). The
420 // first two are purchase affordances and answer to their own gates.
421 // "View Options" is navigation, so it stays under 'actions' alone — a
422 // catalog-mode listener hides the buying, not the browsing.
423 if ($isInstantCheckout) {
424 if (!RenderGate::shouldRender('buy_now_button', $gateContext)) {
425 return;
426 }
427 } elseif ($firstVariant) {
428 if (!RenderGate::shouldRender('add_to_cart_button', $gateContext)) {
429 return;
430 }
431 }
432
433 $buttonAttributes = [
434 'class' => 'fct-product-view-button fct-single-product-card-view-button',
435 'data-product-id' => $this->product->ID,
436 'data-fluent-cart-single-product-card-view-button' => '',
437 'aria-label' => $ariaLabel
438 ];
439
440 if ($firstVariant) {
441 $buttonAttributes = [
442 'data-cart-id' => $firstVariant->id,
443 'class' => 'fluent-cart-add-to-cart-button',
444 'data-variation-type' => $this->product->detail->variation_type,
445 'data-fluent-cart-add-to-cart-button' => '',
446 'data-is-custom' => false,
447 'aria-label' => $ariaLabel
448 ];
449 }
450 $customAttributes = $this->parseAttributes($atts);
451 if (!empty($customAttributes)) {
452 if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) {
453 $buttonAttributes['class'] .= ' ' . $customAttributes['class'];
454 unset($customAttributes['class']);
455 }
456 $buttonAttributes = array_merge($buttonAttributes, $customAttributes);
457 }
458
459 $anchorAttributes = [
460 'href' => $buttonHref,
461 'class' => 'fct-product-view-button',
462 'aria-label' => $ariaLabel,
463 ];
464
465 if ($enableModalCheckout) {
466 $anchorAttributes['data-fct-instant-checkout-button'] = '';
467 $anchorAttributes['data-enable-modal-checkout'] = 'yes';
468 }
469
470 if ($isInstantCheckout) {
471 $parsedCustomAttributes = $this->parseAttributes($atts);
472 if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) {
473 $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class'];
474 unset($parsedCustomAttributes['class']);
475 }
476 $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes);
477 }
478 ?>
479 <?php if ($isInstantCheckout): ?>
480 <a <?php $this->renderAttributes($anchorAttributes); ?>>
481 <span aria-hidden="true">
482 <?php echo esc_html($buttonText); ?>
483 </span>
484 </a>
485 <?php else: ?>
486 <button
487 type="button"
488 data-button-url="<?php echo esc_url($buttonHref); ?>"
489 <?php $this->renderAttributes($buttonAttributes); ?>>
490 <span class="fct-button-text">
491 <?php echo esc_html($buttonText); ?>
492 </span>
493 <span
494 class="fluent-cart-loader"
495 role="status"
496 aria-live="polite"
497 aria-label="<?php echo esc_attr__('Loading', 'fluent-cart'); ?>">
498 <svg aria-hidden="true"
499 viewBox="0 0 100 101"
500 fill="none"
501 xmlns="http://www.w3.org/2000/svg"
502 focusable="false">
503 <path
504 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"
505 fill="currentColor"></path>
506 <path
507 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"
508 fill="currentFill"></path>
509 </svg>
510 </span>
511 </button>
512 <?php endif; ?>
513 <?php
514 }
515
516 protected function renderAttributes($atts = [])
517 {
518 foreach ($atts as $attr => $value) {
519 if ($value !== '') {
520 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
521 } else {
522 echo esc_attr($attr) . ' ';
523 }
524 }
525 }
526
527 private function parseAttributes($atts)
528 {
529 if (empty($atts)) {
530 return [];
531 }
532
533 $attributes = [];
534
535 // Match attribute="value" or attribute='value' or attribute=value
536 preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER);
537
538 foreach ($matches as $match) {
539 if (!empty($match[1])) {
540 // Quoted value
541 $attributes[$match[1]] = $match[3];
542 } elseif (!empty($match[4])) {
543 // Unquoted value
544 $attributes[$match[4]] = $match[5];
545 }
546 }
547
548 return $attributes;
549 }
550 }
551