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

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

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