PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.3
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 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Services / Renderer / ProductCardRender.php

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

555 lines 22.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\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" aria-label="<?php echo esc_attr(sprintf(
299 /* translators: %s: product price */
300 __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
301 <del aria-hidden="true"><?php echo esc_html($formattedComparePrice); ?></del>
302 </span>
303 <?php endif; ?>
304
305 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
306 <!-- Case 2: price range -->
307 <?php if ($priceFormat === 'range'): ?>
308 <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
309 /* translators: %1$s: min price, %2$s: max price */
310 __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
311 <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?> - <?php echo esc_html($formattedMaxPrice); ?></span>
312 </span>
313 <?php else: ?>
314 <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
315 /* translators: %s: min price */
316 __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
317 <span aria-hidden="true"><?php
318 /* translators: %s is the minimum price */
319 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
320 ?></span>
321 </span>
322 <?php endif; ?>
323
324 <?php else: ?>
325 <!-- Case 3: Simple or single price -->
326 <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
327 /* translators: %s: product price */
328 __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
329 <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?></span>
330 </span>
331 <?php endif; ?>
332
333 <?php
334 do_action('fluent_cart/product/after_price', RenderContext::decorate([
335 'product' => $this->product,
336 'variant' => $firstVariant,
337 'current_price' => $minPrice,
338 'scope' => 'product_card'
339 ]));
340 RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
341 ?>
342 </div>
343 <?php
344 do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
345 'product' => $this->product,
346 'current_price' => $minPrice,
347 'scope' => 'product_card'
348 ]));
349 }
350
351 public function showBuyButton($atts = '')
352 {
353 $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
354
355 // 'actions' gates the whole affordance slot, including the disabled
356 // out-of-stock placeholder — hiding the row should not leave a stray
357 // "Not Available" button behind.
358 if (!RenderGate::shouldRender('actions', $gateContext)) {
359 return;
360 }
361
362 do_action('fluent_cart/product/group/before_actions_block', $gateContext);
363
364 $this->renderBuyButtonMarkup($atts, $gateContext);
365
366 do_action('fluent_cart/product/group/after_actions_block', $gateContext);
367 }
368
369 protected function renderBuyButtonMarkup($atts, array $gateContext)
370 {
371 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
372
373 if ($isOutOfStock) {
374 $soldOutText = __('Not Available', 'fluent-cart');
375 ?>
376 <button type="button" class="fct-product-view-button out-of-stock" disabled aria-disabled="true"
377 aria-label="<?php echo esc_attr($soldOutText); ?>">
378 <span class="fct-button-text">
379 <?php echo esc_html($soldOutText); ?>
380 </span>
381 </button>
382 <?php
383 return;
384 }
385
386 $enableModalCheckout = Helper::isModalCheckoutEnabled();
387 $isSimple = $this->product->detail->variation_type === 'simple';
388 $firstVariant = null;
389 $buttonHref = $this->viewUrl;
390
391 if ($isSimple) {
392 $firstVariant = $this->product->variants->first();
393 if ($firstVariant) {
394 // return '';
395 }
396 }
397
398 $isInstantCheckout = false;
399 $hasSubscription = $this->product->has_subscription;
400 $buttonText = __('View Options', 'fluent-cart');
401 $ariaLabel = sprintf(
402 /* translators: %s: product title */
403 __('View options for %s', 'fluent-cart'), $this->product->post_title);
404
405 if ($isSimple) {
406 if ($hasSubscription) {
407 $buttonText = __('Buy Now', 'fluent-cart');
408 $ariaLabel = sprintf(
409 /* translators: %s: product title */
410 __('Buy %s now', 'fluent-cart'), $this->product->post_title);
411 $buttonHref = $firstVariant->getPurchaseUrl();
412 $isInstantCheckout = true;
413 } else {
414 $buttonText = __('Add To Cart', 'fluent-cart');
415 $ariaLabel = sprintf(
416 /* translators: %s: product title */
417 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
418 }
419 }
420
421 // The card fills this slot with one of three things: an instant-checkout
422 // Buy Now anchor (simple + subscription), an Add to Cart button (simple),
423 // or a "View Options" link through to the product page (variable). The
424 // first two are purchase affordances and answer to their own gates.
425 // "View Options" is navigation, so it stays under 'actions' alone — a
426 // catalog-mode listener hides the buying, not the browsing.
427 if ($isInstantCheckout) {
428 if (!RenderGate::shouldRender('buy_now_button', $gateContext)) {
429 return;
430 }
431 } elseif ($firstVariant) {
432 if (!RenderGate::shouldRender('add_to_cart_button', $gateContext)) {
433 return;
434 }
435 }
436
437 $buttonAttributes = [
438 'class' => 'fct-product-view-button fct-single-product-card-view-button',
439 'data-product-id' => $this->product->ID,
440 'data-fluent-cart-single-product-card-view-button' => '',
441 'aria-label' => $ariaLabel
442 ];
443
444 if ($firstVariant) {
445 $buttonAttributes = [
446 'data-cart-id' => $firstVariant->id,
447 'class' => 'fluent-cart-add-to-cart-button',
448 'data-variation-type' => $this->product->detail->variation_type,
449 'data-fluent-cart-add-to-cart-button' => '',
450 'data-is-custom' => false,
451 'aria-label' => $ariaLabel
452 ];
453 }
454 $customAttributes = $this->parseAttributes($atts);
455 if (!empty($customAttributes)) {
456 if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) {
457 $buttonAttributes['class'] .= ' ' . $customAttributes['class'];
458 unset($customAttributes['class']);
459 }
460 $buttonAttributes = array_merge($buttonAttributes, $customAttributes);
461 }
462
463 $anchorAttributes = [
464 'href' => $buttonHref,
465 'class' => 'fct-product-view-button',
466 'aria-label' => $ariaLabel,
467 ];
468
469 if ($enableModalCheckout) {
470 $anchorAttributes['data-fct-instant-checkout-button'] = '';
471 $anchorAttributes['data-enable-modal-checkout'] = 'yes';
472 }
473
474 if ($isInstantCheckout) {
475 $parsedCustomAttributes = $this->parseAttributes($atts);
476 if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) {
477 $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class'];
478 unset($parsedCustomAttributes['class']);
479 }
480 $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes);
481 }
482 ?>
483 <?php if ($isInstantCheckout): ?>
484 <a <?php $this->renderAttributes($anchorAttributes); ?>>
485 <span aria-hidden="true">
486 <?php echo esc_html($buttonText); ?>
487 </span>
488 </a>
489 <?php else: ?>
490 <button
491 type="button"
492 data-button-url="<?php echo esc_url($buttonHref); ?>"
493 <?php $this->renderAttributes($buttonAttributes); ?>>
494 <span class="fct-button-text">
495 <?php echo esc_html($buttonText); ?>
496 </span>
497 <span
498 class="fluent-cart-loader"
499 role="status"
500 aria-live="polite"
501 aria-label="<?php echo esc_attr__('Loading', 'fluent-cart'); ?>">
502 <svg aria-hidden="true"
503 viewBox="0 0 100 101"
504 fill="none"
505 xmlns="http://www.w3.org/2000/svg"
506 focusable="false">
507 <path
508 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"
509 fill="currentColor"></path>
510 <path
511 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"
512 fill="currentFill"></path>
513 </svg>
514 </span>
515 </button>
516 <?php endif; ?>
517 <?php
518 }
519
520 protected function renderAttributes($atts = [])
521 {
522 foreach ($atts as $attr => $value) {
523 if ($value !== '') {
524 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
525 } else {
526 echo esc_attr($attr) . ' ';
527 }
528 }
529 }
530
531 private function parseAttributes($atts)
532 {
533 if (empty($atts)) {
534 return [];
535 }
536
537 $attributes = [];
538
539 // Match attribute="value" or attribute='value' or attribute=value
540 preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER);
541
542 foreach ($matches as $match) {
543 if (!empty($match[1])) {
544 // Quoted value
545 $attributes[$match[1]] = $match[3];
546 } elseif (!empty($match[4])) {
547 // Unquoted value
548 $attributes[$match[4]] = $match[5];
549 }
550 }
551
552 return $attributes;
553 }
554 }
555