PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.19
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.19
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.3.19, at app/Services/Renderer/ProductCardRender.php

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