| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Services\Renderer; |
| 4 |
|
| 5 |
use FluentCart\App\App; |
| 6 |
use FluentCart\App\Services\FrontendView; |
| 7 |
use FluentCart\App\Vite; |
| 8 |
use FluentCart\Api\StoreSettings; |
| 9 |
use FluentCart\Api\ModuleSettings; |
| 10 |
use FluentCart\App\Helpers\Helper; |
| 11 |
use FluentCart\App\Models\Product; |
| 12 |
use FluentCart\Framework\Support\Arr; |
| 13 |
use FluentCart\App\Http\Routes\WebRoutes; |
| 14 |
use FluentCart\App\Models\ProductVariation; |
| 15 |
use FluentCart\Framework\Support\Collection; |
| 16 |
use FluentCart\App\Modules\Templating\AssetLoader; |
| 17 |
use FluentCart\App\Modules\FluentPlayer\ProductVideoRenderer; |
| 18 |
|
| 19 |
class ProductRenderer |
| 20 |
{ |
| 21 |
protected $product; |
| 22 |
|
| 23 |
protected $variants; |
| 24 |
|
| 25 |
protected $storeSettings; |
| 26 |
|
| 27 |
protected $defaultVariant = null; |
| 28 |
|
| 29 |
protected $hasOnetime = false; |
| 30 |
|
| 31 |
protected $hasSubscription = false; |
| 32 |
|
| 33 |
protected $viewType = ''; |
| 34 |
|
| 35 |
protected $columnType = ''; |
| 36 |
|
| 37 |
protected $defaultVariationId = ''; |
| 38 |
|
| 39 |
protected $defaultGalleryImageId = 0; |
| 40 |
|
| 41 |
protected $galleryActiveSet = false; |
| 42 |
|
| 43 |
protected $productVideoRenderer = null; |
| 44 |
|
| 45 |
protected $paymentTypes = []; |
| 46 |
|
| 47 |
protected $variantsByPaymentTypes = []; |
| 48 |
|
| 49 |
protected $activeTab = 'onetime'; |
| 50 |
|
| 51 |
protected $images = []; |
| 52 |
|
| 53 |
protected $variantTermMap = []; |
| 54 |
|
| 55 |
protected $defaultImageUrl = null; |
| 56 |
|
| 57 |
protected $defaultImageAlt = null; |
| 58 |
|
| 59 |
public function __construct(Product $product, $config = []) |
| 60 |
{ |
| 61 |
|
| 62 |
$this->product = $product; |
| 63 |
$this->variants = $product->variants; |
| 64 |
|
| 65 |
$this->storeSettings = new StoreSettings(); |
| 66 |
$this->viewType = $this->storeSettings->get('variation_view', 'both'); |
| 67 |
$this->columnType = $this->storeSettings->get('variation_columns', 'masonry'); |
| 68 |
|
| 69 |
$defaultVariationId = $config['default_variation_id'] ?? ''; |
| 70 |
|
| 71 |
// 'image', 'text','both' |
| 72 |
$this->viewType = apply_filters('fluent_cart/single_product/variation_view_type', $this->viewType, [ |
| 73 |
'product' => $product, |
| 74 |
'variants' => $this->variants, |
| 75 |
'defaultVariationId' => $defaultVariationId, |
| 76 |
]); |
| 77 |
|
| 78 |
// 'one', 'two','three', 'four', 'masonry' |
| 79 |
$this->columnType = apply_filters('fluent_cart/single_product/variation_column_type', $this->columnType, [ |
| 80 |
'product' => $product, |
| 81 |
'variants' => $this->variants, |
| 82 |
'defaultVariationId' => $defaultVariationId, |
| 83 |
]); |
| 84 |
|
| 85 |
|
| 86 |
$hasExplicitDefault = true; |
| 87 |
|
| 88 |
if (!$defaultVariationId) { |
| 89 |
$variationIds = $product->variants->pluck('id')->toArray(); |
| 90 |
$defaultVariationId = $product->detail->default_variation_id; |
| 91 |
|
| 92 |
// For advanced variations the storefront selector only exposes ACTIVE |
| 93 |
// variants (AdvancedVariationRenderer skips item_status != active), so |
| 94 |
// resolve the default against the same set. default_variation_id is |
| 95 |
// maintained stock/status-agnostically, so it can legitimately point |
| 96 |
// at an inactive variant — accepting it here would render the inactive |
| 97 |
// default's price/stock/button while the selector omits that id and |
| 98 |
// falls back to a different active variant. Scoped to advanced so |
| 99 |
// simple / simple_variations keep their existing default handling. |
| 100 |
$isAdvanced = $product->detail |
| 101 |
&& $product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION; |
| 102 |
$eligibleVariants = $isAdvanced |
| 103 |
? $product->variants->where('item_status', 'active') |
| 104 |
: $product->variants; |
| 105 |
$eligibleIds = $eligibleVariants->pluck('id')->toArray(); |
| 106 |
|
| 107 |
if (!$defaultVariationId || !in_array($defaultVariationId, $eligibleIds)) { |
| 108 |
// No valid stored default — fall back to the FIRST eligible |
| 109 |
// combination by serial_index (active-only for advanced), not the |
| 110 |
// first by DB/id order, so the server-rendered price/stock/button |
| 111 |
// match what the storefront selector highlights. Stock is ignored |
| 112 |
// — first by order wins. A NULL serial_index (legacy/malformed row |
| 113 |
// the merchant never ordered) is pushed AFTER numbered variants so |
| 114 |
// it can't become the default ahead of an explicitly ordered one — |
| 115 |
// PHP's default null-first sort would otherwise promote it, and the |
| 116 |
// frontend mirrors this by treating null serial as last too. |
| 117 |
$firstBySerial = $eligibleVariants |
| 118 |
->sortBy(function ($variant) { |
| 119 |
return is_null($variant->serial_index) |
| 120 |
? PHP_INT_MAX |
| 121 |
: (int) $variant->serial_index; |
| 122 |
}) |
| 123 |
->first(); |
| 124 |
$defaultVariationId = $firstBySerial ? (int) $firstBySerial->id : Arr::get($variationIds, '0'); |
| 125 |
$hasExplicitDefault = false; |
| 126 |
} |
| 127 |
} |
| 128 |
|
| 129 |
// Always set resolved default variation id |
| 130 |
$this->defaultVariationId = $defaultVariationId; |
| 131 |
|
| 132 |
// Gallery defaults to featured image (key 0) when no explicit default variation is set |
| 133 |
$this->defaultGalleryImageId = $hasExplicitDefault ? $defaultVariationId : 0; |
| 134 |
|
| 135 |
|
| 136 |
$this->product->variants->load('bundleChildren.product'); |
| 137 |
|
| 138 |
|
| 139 |
|
| 140 |
foreach ($this->product->variants as $variant) { |
| 141 |
if ($variant->id == $this->defaultVariationId) { |
| 142 |
$this->defaultVariant = $variant; |
| 143 |
} |
| 144 |
// Read the authoritative payment_type column, not other_info. The |
| 145 |
// ProductVariation accessor only injects payment_type into other_info |
| 146 |
// for subscriptions, so one-time variants (notably advanced-variation |
| 147 |
// combinations generated by Pro) leave other_info['payment_type'] |
| 148 |
// unset. Anything that is not a subscription is a one-time path. |
| 149 |
if ($variant->payment_type === 'subscription') { |
| 150 |
$this->hasSubscription = true; |
| 151 |
} else { |
| 152 |
$this->hasOnetime = true; |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
$this->buildProductGroups(); |
| 157 |
} |
| 158 |
|
| 159 |
public function buildProductGroups() |
| 160 |
{ |
| 161 |
$groupKey = 'repeat_interval'; |
| 162 |
$otherInfo = (array)Arr::get($this->product->detail, 'other_info'); |
| 163 |
$groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none |
| 164 |
|
| 165 |
|
| 166 |
if ($groupBy !== 'none') { |
| 167 |
if ($groupBy === 'payment_type') { |
| 168 |
$groupKey = 'payment_type'; |
| 169 |
} |
| 170 |
|
| 171 |
$paymentTypes = []; |
| 172 |
|
| 173 |
if ($groupBy === 'repeat_interval') { |
| 174 |
foreach ($this->variants as $key => $variant) { |
| 175 |
$paymentType = 'onetime'; |
| 176 |
$type = Arr::get($variant, 'payment_type'); |
| 177 |
if ($type === 'subscription') { |
| 178 |
$isInstallment = Arr::get($variant, 'other_info.installment', 'no'); |
| 179 |
if ($isInstallment === 'yes' && App::isProActive()) { |
| 180 |
$paymentType = 'installment'; |
| 181 |
} else { |
| 182 |
$paymentType = Arr::get($variant, 'other_info.repeat_interval', 'onetime');; |
| 183 |
} |
| 184 |
} |
| 185 |
|
| 186 |
$paymentTypes[] = $paymentType; |
| 187 |
|
| 188 |
if (!isset($this->variantsByPaymentTypes[$paymentType])) { |
| 189 |
$this->variantsByPaymentTypes[$paymentType] = []; |
| 190 |
} |
| 191 |
|
| 192 |
$this->variantsByPaymentTypes[$paymentType][] = $variant; |
| 193 |
|
| 194 |
if ($this->defaultVariationId == $variant['id']) { |
| 195 |
$this->activeTab = $paymentType; |
| 196 |
} |
| 197 |
|
| 198 |
} |
| 199 |
} else { |
| 200 |
foreach ($this->variants as $key => $variant) { |
| 201 |
$paymentType = 'onetime'; |
| 202 |
$type = Arr::get($variant, 'payment_type'); |
| 203 |
if ($type === 'subscription') { |
| 204 |
$isInstallment = Arr::get($variant, 'other_info.installment'); |
| 205 |
if ($isInstallment === 'yes' && App::isProActive()) { |
| 206 |
$paymentType = 'installment'; |
| 207 |
} else { |
| 208 |
$paymentType = 'subscription'; |
| 209 |
} |
| 210 |
} |
| 211 |
$paymentTypes[] = $paymentType; |
| 212 |
|
| 213 |
if (!isset($this->variantsByPaymentTypes[$paymentType])) { |
| 214 |
$this->variantsByPaymentTypes[$paymentType] = []; |
| 215 |
} |
| 216 |
|
| 217 |
$this->variantsByPaymentTypes[$paymentType][] = $variant; |
| 218 |
|
| 219 |
if ($this->defaultVariationId == $variant['id']) { |
| 220 |
$this->activeTab = $paymentType; |
| 221 |
} |
| 222 |
|
| 223 |
} |
| 224 |
} |
| 225 |
|
| 226 |
$paymentTypes = array_unique($paymentTypes); |
| 227 |
|
| 228 |
|
| 229 |
$intervalOptions = Helper::getAvailableSubscriptionIntervalOptions(); |
| 230 |
|
| 231 |
$groupLanguageMap = [ |
| 232 |
'onetime' => __('One Time', 'fluent-cart'), |
| 233 |
'subscription' => __('Subscription', 'fluent-cart'), |
| 234 |
'installment' => __('Installment', 'fluent-cart'), |
| 235 |
]; |
| 236 |
|
| 237 |
foreach ($intervalOptions as $interval) { |
| 238 |
$groupLanguageMap[$interval['value']] = $interval['label']; |
| 239 |
} |
| 240 |
|
| 241 |
foreach ($paymentTypes as $paymentType) { |
| 242 |
$this->paymentTypes[$paymentType ?: 'onetime'] = Arr::get($groupLanguageMap, $paymentType ?: 'onetime'); |
| 243 |
} |
| 244 |
} |
| 245 |
} |
| 246 |
|
| 247 |
public function render() |
| 248 |
{ |
| 249 |
?> |
| 250 |
<div class="fct-single-product-page" data-fluent-cart-single-product-page data-product-id="<?php echo esc_attr($this->product->ID); ?>"> |
| 251 |
<div class="fct-single-product-page-row"> |
| 252 |
<?php $this->renderGallery(); ?> |
| 253 |
<div class="fct-product-summary"> |
| 254 |
<?php |
| 255 |
$this->renderTitle(); |
| 256 |
$this->renderProductMeta(); |
| 257 |
$this->renderExcerpt(); |
| 258 |
$this->renderPrices(); |
| 259 |
|
| 260 |
if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) { |
| 261 |
foreach ($this->product->variants as $variant) { |
| 262 |
$this->renderVariationsBundleProduct($variant); |
| 263 |
} |
| 264 |
} |
| 265 |
|
| 266 |
$this->renderPackageDescription(); |
| 267 |
$this->renderBuySection(); |
| 268 |
?> |
| 269 |
</div> |
| 270 |
</div> |
| 271 |
</div> |
| 272 |
<?php |
| 273 |
} |
| 274 |
|
| 275 |
public function renderProductMeta() { |
| 276 |
?> |
| 277 |
<div class="fct-product-meta"> |
| 278 |
<?php $this->renderStockAvailability(); ?> |
| 279 |
<?php $this->renderSku(); ?> |
| 280 |
</div> |
| 281 |
|
| 282 |
<?php |
| 283 |
} |
| 284 |
|
| 285 |
public function renderBuySectionWrapperStart() |
| 286 |
{ |
| 287 |
?> |
| 288 |
<div aria-labelledby="fct-product-summary-title" data-fluent-cart-product-pricing-section data-product-id="<?php echo esc_attr($this->product->ID); ?>" class="fct_buy_section"> |
| 289 |
<?php |
| 290 |
} |
| 291 |
|
| 292 |
public function renderBuySectionWrapperEnd() |
| 293 |
{ |
| 294 |
?> |
| 295 |
</div> |
| 296 |
<?php |
| 297 |
} |
| 298 |
|
| 299 |
public function renderBuySection($atts = []) |
| 300 |
{ |
| 301 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 302 |
|
| 303 |
// The buy section is the root the variation-selector JS binds to |
| 304 |
// (data-fluent-cart-product-pricing-section). Gating it removes the |
| 305 |
// variation picker along with the buttons, which is what a full catalog |
| 306 |
// mode wants; to keep shoppers able to browse options while hiding only |
| 307 |
// the purchase affordances, gate 'actions' instead. |
| 308 |
if (!RenderGate::shouldRender('buy_section', $gateContext)) { |
| 309 |
return; |
| 310 |
} |
| 311 |
|
| 312 |
// Render no buy section when there is nothing purchasable — avoids a |
| 313 |
// broken quantity + "Not Available" block. Two cases: |
| 314 |
// - no variants at all (any product type), or |
| 315 |
// - an advanced-variation product with no attribute_config yet (e.g. |
| 316 |
// switched on before options were configured). Its variants are kept |
| 317 |
// until generation, so we key on the config, not the variant count, |
| 318 |
// to hide it until real combinations exist. |
| 319 |
$isUnconfiguredAdvanced = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION |
| 320 |
&& empty(Arr::get((array) $this->product->detail->other_info, 'attribute_config')); |
| 321 |
|
| 322 |
if ($this->product->variants->isEmpty() || $isUnconfiguredAdvanced) { |
| 323 |
return; |
| 324 |
} |
| 325 |
|
| 326 |
$otherInfo = (array)Arr::get($this->product->detail, 'other_info'); |
| 327 |
$groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none |
| 328 |
|
| 329 |
$this->renderBuySectionWrapperStart(); |
| 330 |
|
| 331 |
$this->renderVariationDisplay($atts); |
| 332 |
|
| 333 |
$this->renderItemPrice(); |
| 334 |
|
| 335 |
$this->renderQuantity(); |
| 336 |
?> |
| 337 |
<div class="fct-product-buttons-wrap"> |
| 338 |
<?php $this->renderPurchaseButtons(Arr::get($atts, 'button_atts', [])); ?> |
| 339 |
</div> |
| 340 |
<?php |
| 341 |
$this->renderBuySectionWrapperEnd(); |
| 342 |
} |
| 343 |
|
| 344 |
public function renderVariationDisplay($atts = []) |
| 345 |
{ |
| 346 |
// Hand off rendering to the advanced-variation selector when the |
| 347 |
// product is configured for advanced variations. The handler returns |
| 348 |
// the filtered array with rendered=true after emitting its markup; if |
| 349 |
// no listener handles it (e.g. an unconfigured advanced product), the |
| 350 |
// filter is a no-op and we fall through to the simple-variation |
| 351 |
// rendering below. |
| 352 |
if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) { |
| 353 |
$result = apply_filters('fluent_cart/product/render_advanced_variation', [ |
| 354 |
'product' => $this->product, |
| 355 |
'selector_style' => Arr::get($atts, 'selector_style', 'auto'), |
| 356 |
'rendered' => false, |
| 357 |
]); |
| 358 |
if (!empty($result['rendered'])) { |
| 359 |
return; |
| 360 |
} |
| 361 |
} |
| 362 |
|
| 363 |
$otherInfo = (array)Arr::get($this->product->detail, 'other_info'); |
| 364 |
$groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none |
| 365 |
|
| 366 |
if (count($this->paymentTypes) === 1 || $groupBy === 'none') { |
| 367 |
$this->renderVariants(Arr::get($atts, 'variation_atts', [])); |
| 368 |
} else { |
| 369 |
$this->renderTab(Arr::get($atts, 'variation_atts', [])); |
| 370 |
} |
| 371 |
} |
| 372 |
|
| 373 |
public function renderGalleryThumb() |
| 374 |
{ |
| 375 |
$thumbnails = []; |
| 376 |
|
| 377 |
$featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg'); |
| 378 |
|
| 379 |
// thumbnail can be an empty string (not null), so ?? above doesn't catch |
| 380 |
// it — fall back to the placeholder so $featuredMedia is always a usable |
| 381 |
// image URL for both the main <img> and data-default-image-url. |
| 382 |
if (!$featuredMedia || !\is_string($featuredMedia)) { |
| 383 |
$featuredMedia = Vite::getAssetUrl('images/placeholder.svg'); |
| 384 |
} |
| 385 |
|
| 386 |
$galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true); |
| 387 |
|
| 388 |
if (!empty($galleryImage)) { |
| 389 |
$thumbnails[0] = [ |
| 390 |
'media' => $galleryImage, |
| 391 |
]; |
| 392 |
} |
| 393 |
|
| 394 |
foreach ($this->variants as $variant) { |
| 395 |
if (!empty($variant['media']['meta_value'])) { |
| 396 |
$thumbnails[$variant['id']] = [ |
| 397 |
'media' => $variant['media']['meta_value'], |
| 398 |
]; |
| 399 |
} else { |
| 400 |
$this->defaultImageUrl = $featuredMedia; |
| 401 |
$this->defaultImageAlt = Arr::get($variant, 'variation_title', ''); |
| 402 |
} |
| 403 |
} |
| 404 |
|
| 405 |
$images = empty($thumbnails) ? [] : $thumbnails; |
| 406 |
|
| 407 |
|
| 408 |
|
| 409 |
$this->images = $images; |
| 410 |
|
| 411 |
if (!empty($images)) { |
| 412 |
$imageId = $this->defaultGalleryImageId; |
| 413 |
|
| 414 |
if (isset($images[$imageId])) { |
| 415 |
$imageMetaValue = $images[$imageId]; |
| 416 |
$this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', ''); |
| 417 |
$this->defaultImageAlt = Arr::get($imageMetaValue, 'media.0.title', ''); |
| 418 |
} else { |
| 419 |
// Fallback to the first available thumbnail. Advanced-variation |
| 420 |
// products often have per-variant images but no explicit |
| 421 |
// default_variation_id — the thumbnails are keyed by real |
| 422 |
// variant IDs while defaultGalleryImageId is 0, so the lookup |
| 423 |
// above misses and the main image area renders blank with a |
| 424 |
// broken-image icon. |
| 425 |
$firstImage = reset($images); |
| 426 |
$fallbackUrl = Arr::get($firstImage, 'media.0.url', ''); |
| 427 |
$this->defaultImageUrl = $fallbackUrl ?: ($featuredMedia ?: ''); |
| 428 |
$this->defaultImageAlt = $this->defaultImageAlt ?: Arr::get($firstImage, 'media.0.title', ''); |
| 429 |
} |
| 430 |
} |
| 431 |
|
| 432 |
// Nothing set a main image — e.g. a product with no variants, or no |
| 433 |
// variant/gallery media. Fall back to the featured/placeholder image so |
| 434 |
// the main area shows the placeholder instead of a broken <img src="">. |
| 435 |
if (empty($this->defaultImageUrl)) { |
| 436 |
$this->defaultImageUrl = $featuredMedia; |
| 437 |
} |
| 438 |
|
| 439 |
$videoRenderer = $this->getProductVideoRenderer(); |
| 440 |
$videoRenderer->showFirstByDefault(!$this->hasGalleryImages()); |
| 441 |
$defaultVideoId = $videoRenderer->getDefaultMediaId(); |
| 442 |
|
| 443 |
?> |
| 444 |
<div class="fct-product-gallery-thumb<?php echo $defaultVideoId ? ' is-video-active' : ''; ?>" role="region" |
| 445 |
aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>" |
| 446 |
<?php echo $defaultVideoId ? 'data-fct-video-default="' . esc_attr((string) $defaultVideoId) . '"' : ''; ?>> |
| 447 |
<img |
| 448 |
src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>" |
| 449 |
alt="<?php echo esc_attr($this->defaultImageAlt) ?>" |
| 450 |
data-fluent-cart-single-product-page-product-thumbnail |
| 451 |
data-default-image-url="<?php echo esc_url($featuredMedia) ?>" |
| 452 |
/> |
| 453 |
<?php $this->getProductVideoRenderer()->renderInlinePlayers(); ?> |
| 454 |
</div> |
| 455 |
<?php |
| 456 |
} |
| 457 |
|
| 458 |
/** |
| 459 |
* Whether any gallery / variant image exists to show in the main area. |
| 460 |
* Only meaningful after renderGalleryThumb() has built $this->images. |
| 461 |
*/ |
| 462 |
protected function hasGalleryImages(): bool |
| 463 |
{ |
| 464 |
foreach ($this->images as $image) { |
| 465 |
foreach ((array) Arr::get($image, 'media', []) as $item) { |
| 466 |
if (!empty(Arr::get($item, 'url'))) { |
| 467 |
return true; |
| 468 |
} |
| 469 |
} |
| 470 |
} |
| 471 |
|
| 472 |
return false; |
| 473 |
} |
| 474 |
|
| 475 |
public function getProductVideoRenderer(): ProductVideoRenderer |
| 476 |
{ |
| 477 |
if ($this->productVideoRenderer === null) { |
| 478 |
$this->productVideoRenderer = new ProductVideoRenderer($this->product); |
| 479 |
} |
| 480 |
|
| 481 |
return $this->productVideoRenderer; |
| 482 |
} |
| 483 |
|
| 484 |
public function renderGalleryThumbControls($maxThumbnails = null) |
| 485 |
{ |
| 486 |
$totalThumbImages = Arr::pluck($this->images, 'media.*.url'); |
| 487 |
|
| 488 |
// A lone image needs no strip, but a video thumb still has to be reachable. |
| 489 |
if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1 && !$this->getProductVideoRenderer()->isAvailable()){ |
| 490 |
|
| 491 |
return ''; |
| 492 |
} |
| 493 |
|
| 494 |
// A lone video without images is already showing in the main area. |
| 495 |
if (!$this->hasGalleryImages() && count($this->getProductVideoRenderer()->getVideos()) === 1) { |
| 496 |
return ''; |
| 497 |
} |
| 498 |
|
| 499 |
// Build variantTermMap and variantFirstMediaMap. |
| 500 |
// For advanced variations, Pro builds both maps via the gallery_variation_data |
| 501 |
// filter — it has access to AttributeGroup types (color/image) that free cannot |
| 502 |
// query directly. For all other product types, free builds variantFirstMediaMap |
| 503 |
// from the already-loaded $this->images; variantTermMap stays empty. |
| 504 |
$this->variantTermMap = []; |
| 505 |
$variantFirstMediaMap = []; |
| 506 |
|
| 507 |
if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) { |
| 508 |
$galleryVariationData = apply_filters('fluent_cart/product/gallery_variation_data', [ |
| 509 |
'variant_term_map' => [], |
| 510 |
'variant_first_media_map' => [], |
| 511 |
], $this->product); |
| 512 |
$this->variantTermMap = (array) Arr::get($galleryVariationData, 'variant_term_map', []); |
| 513 |
$variantFirstMediaMap = (array) Arr::get($galleryVariationData, 'variant_first_media_map', []); |
| 514 |
} else { |
| 515 |
foreach ($this->images as $imageId => $image) { |
| 516 |
if (empty($image['media']) || !is_array($image['media'])) { |
| 517 |
continue; |
| 518 |
} |
| 519 |
$firstItem = $image['media'][0] ?? null; |
| 520 |
if (!$firstItem) { |
| 521 |
continue; |
| 522 |
} |
| 523 |
$firstUrl = Arr::get($firstItem, 'url', ''); |
| 524 |
$firstMediaId = (int) Arr::get($firstItem, 'id', 0); |
| 525 |
if ($firstUrl) { |
| 526 |
$variantFirstMediaMap[(int) $imageId] = [ |
| 527 |
'id' => $firstMediaId, |
| 528 |
'url' => $firstUrl, |
| 529 |
]; |
| 530 |
} |
| 531 |
} |
| 532 |
} |
| 533 |
|
| 534 |
// Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs). |
| 535 |
// For advanced variations, deduplicate by media ID (when > 0) or by URL (for imported images). |
| 536 |
$allGalleryImages = []; |
| 537 |
$addedMediaKeys = []; |
| 538 |
$isAdvVariation = !empty($this->variantTermMap); |
| 539 |
foreach ($this->images as $imageId => $image) { |
| 540 |
if (empty($image['media']) || !is_array($image['media'])) { |
| 541 |
continue; |
| 542 |
} |
| 543 |
foreach ($image['media'] as $item) { |
| 544 |
$url = Arr::get($item, 'url', ''); |
| 545 |
$mediaId = (int) Arr::get($item, 'id', 0); |
| 546 |
if (empty($url)) { |
| 547 |
continue; |
| 548 |
} |
| 549 |
if ($isAdvVariation) { |
| 550 |
$dedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url; |
| 551 |
if (isset($addedMediaKeys[$dedupeKey])) { |
| 552 |
continue; // skip — already added this image |
| 553 |
} |
| 554 |
$addedMediaKeys[$dedupeKey] = true; |
| 555 |
} |
| 556 |
$allGalleryImages[] = [ |
| 557 |
'url' => $url, |
| 558 |
'title' => Arr::get($item, 'title', ''), |
| 559 |
'variation_id' => (string) $imageId, |
| 560 |
'term_id' => (int) ($this->variantTermMap[(int) $imageId] ?? 0), |
| 561 |
'media_id' => $mediaId, |
| 562 |
]; |
| 563 |
} |
| 564 |
} |
| 565 |
|
| 566 |
?> |
| 567 |
|
| 568 |
<div class="fct-gallery-thumb-controls" |
| 569 |
role="toolbar" |
| 570 |
aria-label="<?php echo esc_attr__('Product image thumbnails', 'fluent-cart'); ?>" |
| 571 |
data-fluent-cart-single-product-page-product-thumbnail-controls |
| 572 |
data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>" |
| 573 |
data-variant-first-media-map="<?php echo esc_attr(wp_json_encode($variantFirstMediaMap) ?: '{}'); ?>"> |
| 574 |
|
| 575 |
<?php $this->renderGalleryThumbControl($maxThumbnails); ?> |
| 576 |
|
| 577 |
</div> |
| 578 |
|
| 579 |
<?php |
| 580 |
|
| 581 |
} |
| 582 |
|
| 583 |
public function renderGalleryThumbControl($maxThumbnails = null) |
| 584 |
{ |
| 585 |
if ($maxThumbnails !== null && $maxThumbnails <= 0) { |
| 586 |
$maxThumbnails = null; // treat invalid value as "no limit" |
| 587 |
} |
| 588 |
|
| 589 |
$isAdvVariation = !empty($this->variantTermMap); |
| 590 |
$countedMediaKeys = []; |
| 591 |
$count = 0; |
| 592 |
$totalImages = 0; |
| 593 |
$videoRenderer = $this->getProductVideoRenderer(); |
| 594 |
|
| 595 |
// The gallery opens on a video the admin put in front of every image, |
| 596 |
// so no image thumb takes the selected slot in that case. |
| 597 |
if ($videoRenderer->getDefaultMediaId()) { |
| 598 |
$this->galleryActiveSet = true; |
| 599 |
} |
| 600 |
|
| 601 |
// Count unique images to render. For advanced variations, deduplicate by WP media ID |
| 602 |
// when available, or by URL for externally imported images (media ID = 0). |
| 603 |
foreach ($this->images as $imageId => $image) { |
| 604 |
if (empty($image['media']) || !is_array($image['media'])) { |
| 605 |
continue; |
| 606 |
} |
| 607 |
foreach ($image['media'] as $item) { |
| 608 |
$url = Arr::get($item, 'url', ''); |
| 609 |
$mediaId = (int) Arr::get($item, 'id', 0); |
| 610 |
if (empty($url)) { |
| 611 |
continue; |
| 612 |
} |
| 613 |
if ($isAdvVariation) { |
| 614 |
$mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url; |
| 615 |
if (isset($countedMediaKeys[$mediaDedupeKey])) { |
| 616 |
continue; |
| 617 |
} |
| 618 |
$countedMediaKeys[$mediaDedupeKey] = true; |
| 619 |
} |
| 620 |
$totalImages++; |
| 621 |
} |
| 622 |
} |
| 623 |
|
| 624 |
$renderedMediaKeys = []; |
| 625 |
// Render up to max; skip already-rendered media for advanced variation products. |
| 626 |
foreach ($this->images as $imageId => $image) { |
| 627 |
if (empty($image['media']) || !is_array($image['media'])) { |
| 628 |
continue; |
| 629 |
} |
| 630 |
foreach ($image['media'] as $item) { |
| 631 |
$url = Arr::get($item, 'url', ''); |
| 632 |
$mediaId = (int) Arr::get($item, 'id', 0); |
| 633 |
if (empty($url)) { |
| 634 |
continue; |
| 635 |
} |
| 636 |
if ($isAdvVariation) { |
| 637 |
$mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url; |
| 638 |
if (isset($renderedMediaKeys[$mediaDedupeKey])) { |
| 639 |
continue; |
| 640 |
} |
| 641 |
$renderedMediaKeys[$mediaDedupeKey] = true; |
| 642 |
} |
| 643 |
if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) { |
| 644 |
$videoRenderer->renderThumbControls(!$this->galleryActiveSet); |
| 645 |
$this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails); |
| 646 |
return; |
| 647 |
} |
| 648 |
// Videos the admin dragged in front of this image come first. |
| 649 |
$videoRenderer->renderThumbControlsBefore($count, !$this->galleryActiveSet); |
| 650 |
$termId = (int) ($this->variantTermMap[(int) $imageId] ?? 0); |
| 651 |
$this->renderGalleryThumbControlButton($item, $imageId, $termId, $mediaId); |
| 652 |
$count++; |
| 653 |
} |
| 654 |
} |
| 655 |
|
| 656 |
$videoRenderer->renderThumbControls(!$this->galleryActiveSet); |
| 657 |
} |
| 658 |
|
| 659 |
public function renderGallerySeeMoreButton($remainingCount) |
| 660 |
{ |
| 661 |
?> |
| 662 |
<button |
| 663 |
type="button" |
| 664 |
class="fct-gallery-see-more-button" |
| 665 |
data-fluent-cart-gallery-see-more |
| 666 |
aria-label="<?php echo esc_attr( |
| 667 |
sprintf( |
| 668 |
/* translators: %d number of remaining images */ |
| 669 |
__('View all %d more images', 'fluent-cart'), |
| 670 |
$remainingCount |
| 671 |
) |
| 672 |
); ?>" |
| 673 |
> |
| 674 |
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 675 |
<path d="M2.58078 19.0103L2.56078 19.0303C2.29078 18.4403 2.12078 17.7703 2.05078 17.0303C2.12078 17.7603 2.31078 18.4203 2.58078 19.0103Z" fill="#9D9FAC"/> |
| 676 |
<path d="M8.99914 10.3801C10.3136 10.3801 11.3791 9.31456 11.3791 8.00012C11.3791 6.68568 10.3136 5.62012 8.99914 5.62012C7.6847 5.62012 6.61914 6.68568 6.61914 8.00012C6.61914 9.31456 7.6847 10.3801 8.99914 10.3801Z" fill="#9D9FAC"/> |
| 677 |
<path d="M16.19 2H7.81C4.17 2 2 4.17 2 7.81V16.19C2 17.28 2.19 18.23 2.56 19.03C3.42 20.93 5.26 22 7.81 22H16.19C19.83 22 22 19.83 22 16.19V13.9V7.81C22 4.17 19.83 2 16.19 2ZM20.37 12.5C19.59 11.83 18.33 11.83 17.55 12.5L13.39 16.07C12.61 16.74 11.35 16.74 10.57 16.07L10.23 15.79C9.52 15.17 8.39 15.11 7.59 15.65L3.85 18.16C3.63 17.6 3.5 16.95 3.5 16.19V7.81C3.5 4.99 4.99 3.5 7.81 3.5H16.19C19.01 3.5 20.5 4.99 20.5 7.81V12.61L20.37 12.5Z" fill="#9D9FAC"/> |
| 678 |
<script xmlns=""/></svg> |
| 679 |
|
| 680 |
<span class="fct-see-more-text"> |
| 681 |
<?php echo esc_html__('See', 'fluent-cart'); ?> |
| 682 |
<span class="fct-see-more-count"><?php echo esc_html($remainingCount); ?></span> |
| 683 |
<?php echo esc_html__('More', 'fluent-cart'); ?> |
| 684 |
</span> |
| 685 |
</button> |
| 686 |
<?php |
| 687 |
} |
| 688 |
|
| 689 |
public function renderGalleryThumbControlButton($item, $imageId, $termId = 0, $mediaId = 0) |
| 690 |
{ |
| 691 |
|
| 692 |
$isHidden = ''; //$imageId != $this->defaultVariationId ? 'is-hidden' : ''; |
| 693 |
$itemUrl = Arr::get($item, 'url', ''); |
| 694 |
$itemTitle = Arr::get($item, 'title', ''); |
| 695 |
$isSelected = !$this->galleryActiveSet && $imageId == $this->defaultGalleryImageId; |
| 696 |
if ($isSelected) { |
| 697 |
$this->galleryActiveSet = true; |
| 698 |
} |
| 699 |
?> |
| 700 |
|
| 701 |
<button |
| 702 |
type="button" |
| 703 |
class="fct-gallery-thumb-control-button <?php echo $isSelected ? 'active' : ''; ?> <?php echo esc_attr($isHidden); ?>" |
| 704 |
data-fluent-cart-thumb-control-button |
| 705 |
data-url="<?php echo esc_url($itemUrl); ?>" |
| 706 |
data-variation-id="<?php echo esc_attr($imageId); ?>" |
| 707 |
data-term-id="<?php echo esc_attr((string) $termId); ?>" |
| 708 |
data-media-id="<?php echo esc_attr((string) $mediaId); ?>" |
| 709 |
aria-label="<?php echo |
| 710 |
/* translators: %1$s: image title */ |
| 711 |
esc_attr(sprintf(__('View %1$s image', 'fluent-cart'), $itemTitle)); |
| 712 |
?>" |
| 713 |
aria-pressed="<?php echo $isSelected ? 'true' : 'false'; ?>" |
| 714 |
tabindex="<?php echo $isSelected ? '0' : '-1'; ?>" |
| 715 |
> |
| 716 |
<img |
| 717 |
class="fct-gallery-control-thumb" |
| 718 |
data-fluent-cart-single-product-page-product-thumbnail-controls-thumb |
| 719 |
src="<?php echo esc_url($itemUrl); ?>" |
| 720 |
alt="<?php echo esc_attr($itemTitle); ?>" |
| 721 |
/> |
| 722 |
</button> |
| 723 |
|
| 724 |
<?php |
| 725 |
|
| 726 |
|
| 727 |
} |
| 728 |
|
| 729 |
public function renderGallery($args = []) |
| 730 |
{ |
| 731 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 732 |
|
| 733 |
if (!RenderGate::shouldRender('image', $gateContext)) { |
| 734 |
return; |
| 735 |
} |
| 736 |
|
| 737 |
$defaults = [ |
| 738 |
'thumbnail_mode' => 'all', // horizontal, vertical |
| 739 |
'thumb_position' => 'bottom', // bottom, left, right, top |
| 740 |
'scrollable_thumbs' => 'no', // yes / no |
| 741 |
'max_thumbnails' => null, // null = no limit, integer = max visible |
| 742 |
]; |
| 743 |
|
| 744 |
$atts = wp_parse_args($args, $defaults); |
| 745 |
|
| 746 |
$thumbnailMode = $atts['thumbnail_mode']; |
| 747 |
|
| 748 |
$wrapperAtts = [ |
| 749 |
'class' => 'fct-product-gallery-wrapper ' . 'thumb-pos-' . $atts['thumb_position'] . ' thumb-mode-' . $thumbnailMode, |
| 750 |
'data-fct-product-gallery' => '', |
| 751 |
'data-fluent-cart-product-gallery-wrapper' => '', |
| 752 |
'data-thumbnail-mode' => $thumbnailMode, |
| 753 |
'data-product-id' => $this->product->ID, |
| 754 |
'data-scrollable-thumbs' => $atts['scrollable_thumbs'], |
| 755 |
]; |
| 756 |
|
| 757 |
?> |
| 758 |
|
| 759 |
<div <?php RenderHelper::renderAtts($wrapperAtts); ?>> |
| 760 |
|
| 761 |
<?php |
| 762 |
$this->renderGalleryThumb(); |
| 763 |
$this->renderGalleryThumbControls($atts['max_thumbnails']); |
| 764 |
?> |
| 765 |
</div> |
| 766 |
|
| 767 |
<?php |
| 768 |
} |
| 769 |
|
| 770 |
public function renderTitle() |
| 771 |
{ |
| 772 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 773 |
|
| 774 |
if (!RenderGate::shouldRender('title', $gateContext)) { |
| 775 |
return; |
| 776 |
} |
| 777 |
|
| 778 |
do_action('fluent_cart/product/single/before_title_block', $gateContext); |
| 779 |
?> |
| 780 |
<div class="fct-product-title"> |
| 781 |
<h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1> |
| 782 |
</div> |
| 783 |
<?php |
| 784 |
do_action('fluent_cart/product/single/after_title_block', $gateContext); |
| 785 |
} |
| 786 |
|
| 787 |
public function renderStockAvailability($wrapper_attributes = '') |
| 788 |
{ |
| 789 |
if (!ModuleSettings::isActive('stock_management')) { |
| 790 |
return ''; |
| 791 |
} |
| 792 |
|
| 793 |
$stockAvailability = $this->product->detail->getStockAvailability(); |
| 794 |
|
| 795 |
|
| 796 |
if (!Arr::get($stockAvailability, 'manage_stock')) { |
| 797 |
return ''; |
| 798 |
} |
| 799 |
|
| 800 |
$isStock = $this->product->isStock(); |
| 801 |
|
| 802 |
// Check default variant stock for both simple and variable products |
| 803 |
if ($this->defaultVariant) { |
| 804 |
$isStock = $isStock && $this->defaultVariant->isStock(); |
| 805 |
} |
| 806 |
|
| 807 |
$stockLabel = Arr::get($stockAvailability, 'availability'); |
| 808 |
$statusClass = $stockAvailability['class'] ?? ''; |
| 809 |
|
| 810 |
// Optional per-status custom labels (e.g. set on the Bricks Product Stock |
| 811 |
// element via the fluent_cart/product_stock_availability filter). Emitted as |
| 812 |
// data-attributes so the frontend JS, which re-derives the badge text on load |
| 813 |
// and on variant switches, prefers them over the generic label map instead of |
| 814 |
// overwriting them. Absent for the default template, so behavior is unchanged. |
| 815 |
$inStockText = Arr::get($stockAvailability, 'in_stock_text'); |
| 816 |
$outOfStockText = Arr::get($stockAvailability, 'out_of_stock_text'); |
| 817 |
|
| 818 |
// The variant-level check above can override the aggregate stock_availability |
| 819 |
// used for $stockLabel/$statusClass (e.g. this specific default variant is out |
| 820 |
// of stock even though the product overall has other in-stock variants) — keep |
| 821 |
// the label and class in sync so the badge never shows mismatched text/color. |
| 822 |
// Honor the custom out-of-stock label here too so it survives this override on |
| 823 |
// first load, matching what the frontend JS shows after a variant switch. |
| 824 |
if (!$isStock) { |
| 825 |
$statusClass = 'out-of-stock'; |
| 826 |
$stockLabel = !empty($outOfStockText) ? $outOfStockText : __('Out of Stock', 'fluent-cart'); |
| 827 |
} |
| 828 |
|
| 829 |
$badgeAttributes = ''; |
| 830 |
if (!empty($inStockText)) { |
| 831 |
$badgeAttributes .= sprintf(' data-in-stock-text="%s"', esc_attr($inStockText)); |
| 832 |
} |
| 833 |
if (!empty($outOfStockText)) { |
| 834 |
$badgeAttributes .= sprintf(' data-out-of-stock-text="%s"', esc_attr($outOfStockText)); |
| 835 |
} |
| 836 |
|
| 837 |
echo sprintf( |
| 838 |
'<div class="fct-product-stock %1$s" role="status" aria-live="polite"> |
| 839 |
<div %2$s> |
| 840 |
<span class="fct-stock-label">%3$s</span> |
| 841 |
<span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock%5$s> |
| 842 |
%4$s |
| 843 |
</span> |
| 844 |
</div> |
| 845 |
</div>', |
| 846 |
esc_attr($statusClass), |
| 847 |
$wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 848 |
esc_html__('Availability:', 'fluent-cart'), |
| 849 |
esc_html($stockLabel), |
| 850 |
$badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above |
| 851 |
); |
| 852 |
} |
| 853 |
|
| 854 |
public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null) |
| 855 |
{ |
| 856 |
if (!$label) { |
| 857 |
$label = __('SKU:', 'fluent-cart'); |
| 858 |
} |
| 859 |
|
| 860 |
$labelHtml = ''; |
| 861 |
if ($showLabel && $label) { |
| 862 |
$labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label)); |
| 863 |
} |
| 864 |
|
| 865 |
if ($variant) { |
| 866 |
if (empty($variant->sku)) { |
| 867 |
return; |
| 868 |
} |
| 869 |
echo sprintf( |
| 870 |
'<div class="fct-product-sku"> |
| 871 |
<div %s> |
| 872 |
%s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span> |
| 873 |
</div> |
| 874 |
</div>', |
| 875 |
$wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 876 |
$labelHtml, |
| 877 |
esc_html($variant->sku) |
| 878 |
); |
| 879 |
return; |
| 880 |
} |
| 881 |
|
| 882 |
foreach ($this->product->variants as $v) { |
| 883 |
if (empty($v->sku)) { |
| 884 |
continue; |
| 885 |
} |
| 886 |
$isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : ''; |
| 887 |
echo sprintf( |
| 888 |
'<div class="fct-product-sku fluent-cart-product-variation-content%s" data-variation-id="%s"> |
| 889 |
<div %s> |
| 890 |
%s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span> |
| 891 |
</div> |
| 892 |
</div>', |
| 893 |
$isHidden, |
| 894 |
esc_attr($v->id), |
| 895 |
$wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 896 |
$labelHtml, |
| 897 |
esc_html($v->sku) |
| 898 |
); |
| 899 |
} |
| 900 |
} |
| 901 |
|
| 902 |
/** |
| 903 |
* @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly. |
| 904 |
* Kept as a compatibility shim for external callers (themes, extensions) — |
| 905 |
* package-description rendering now lives in PackageDescriptionRenderer. |
| 906 |
*/ |
| 907 |
public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null) |
| 908 |
{ |
| 909 |
(new PackageDescriptionRenderer($this->product))->renderPackageDescription( |
| 910 |
$wrapper_attributes, |
| 911 |
$showName, |
| 912 |
$showDimensions, |
| 913 |
$showProductWeight, |
| 914 |
$showTotalWeight, |
| 915 |
$variant, |
| 916 |
$this->defaultVariant |
| 917 |
); |
| 918 |
} |
| 919 |
|
| 920 |
/** |
| 921 |
* Build a JSON string of package info for a variant (used as data attribute for JS switching). |
| 922 |
*/ |
| 923 |
private function getVariantPackageInfoJson(ProductVariation $variant) |
| 924 |
{ |
| 925 |
if ($variant->fulfillment_type !== 'physical') { |
| 926 |
return ''; |
| 927 |
} |
| 928 |
|
| 929 |
$otherInfo = $variant->other_info ?: []; |
| 930 |
$packageSlug = Arr::get($otherInfo, 'package_slug', ''); |
| 931 |
$package = Helper::getPackageBySlug($packageSlug); |
| 932 |
|
| 933 |
if (!$package) { |
| 934 |
return ''; |
| 935 |
} |
| 936 |
|
| 937 |
static $storeWeightUnit = null; |
| 938 |
|
| 939 |
if ($storeWeightUnit === null) { |
| 940 |
$storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg'; |
| 941 |
} |
| 942 |
|
| 943 |
// Format dimensions |
| 944 |
$length = Arr::get($package, 'length', ''); |
| 945 |
$width = Arr::get($package, 'width', ''); |
| 946 |
$height = Arr::get($package, 'height', ''); |
| 947 |
$dimensionUnit = Arr::get($package, 'dimension_unit', 'cm'); |
| 948 |
$dimensionParts = array_filter([$length, $width, $height], function ($val) { |
| 949 |
return $val !== '' && $val !== null && $val != 0; |
| 950 |
}); |
| 951 |
$formattedDimensions = $dimensionParts |
| 952 |
? implode(' × ', $dimensionParts) . ' ' . $dimensionUnit |
| 953 |
: ''; |
| 954 |
|
| 955 |
// Calculate weights |
| 956 |
$productWeight = floatval(Arr::get($otherInfo, 'weight', 0)); |
| 957 |
$productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit); |
| 958 |
$convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit); |
| 959 |
|
| 960 |
$packageWeight = floatval(Arr::get($package, 'weight', 0)); |
| 961 |
$packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit); |
| 962 |
$convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit); |
| 963 |
$totalWeight = $convertedProductWeight + $convertedPackageWeight; |
| 964 |
|
| 965 |
// Format weights |
| 966 |
$formattedProductWeight = $convertedProductWeight |
| 967 |
? rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.') . ' ' . $storeWeightUnit |
| 968 |
: ''; |
| 969 |
|
| 970 |
$formattedShippingWeight = ($totalWeight && $convertedPackageWeight) |
| 971 |
? rtrim(rtrim(number_format($totalWeight, 2), '0'), '.') . ' ' . $storeWeightUnit |
| 972 |
: ''; |
| 973 |
|
| 974 |
return wp_json_encode([ |
| 975 |
'name' => Arr::get($package, 'name', ''), |
| 976 |
'dimensions' => $formattedDimensions, |
| 977 |
'product_weight' => $formattedProductWeight, |
| 978 |
'shipping_weight' => $formattedShippingWeight, |
| 979 |
]); |
| 980 |
} |
| 981 |
|
| 982 |
public function renderExcerpt() |
| 983 |
{ |
| 984 |
$excerpt = $this->product->post_excerpt; |
| 985 |
if (!$excerpt) { |
| 986 |
return; |
| 987 |
} |
| 988 |
|
| 989 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 990 |
|
| 991 |
if (!RenderGate::shouldRender('excerpt', $gateContext)) { |
| 992 |
return; |
| 993 |
} |
| 994 |
|
| 995 |
do_action('fluent_cart/product/single/before_excerpt_block', $gateContext); |
| 996 |
?> |
| 997 |
<div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title"> |
| 998 |
<p><?php echo wp_kses_post($excerpt); ?></p> |
| 999 |
</div> |
| 1000 |
<?php |
| 1001 |
do_action('fluent_cart/product/single/after_excerpt_block', $gateContext); |
| 1002 |
} |
| 1003 |
|
| 1004 |
public function renderDescription() |
| 1005 |
{ |
| 1006 |
$productPost = get_post($this->product->ID); |
| 1007 |
if (!$productPost || empty($productPost->post_content)) { |
| 1008 |
return; |
| 1009 |
} |
| 1010 |
|
| 1011 |
global $post; |
| 1012 |
$originalPost = $post; |
| 1013 |
$post = $productPost; |
| 1014 |
setup_postdata($post); |
| 1015 |
|
| 1016 |
$content = apply_filters('the_content', $productPost->post_content); |
| 1017 |
|
| 1018 |
$post = $originalPost; |
| 1019 |
if ($originalPost) { |
| 1020 |
setup_postdata($originalPost); |
| 1021 |
} else { |
| 1022 |
wp_reset_postdata(); |
| 1023 |
} |
| 1024 |
?> |
| 1025 |
<div class="fct-product-description"> |
| 1026 |
<?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> |
| 1027 |
</div> |
| 1028 |
<?php |
| 1029 |
} |
| 1030 |
|
| 1031 |
public function renderPrices() |
| 1032 |
{ |
| 1033 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1034 |
|
| 1035 |
if (!RenderGate::shouldRender('price', $gateContext)) { |
| 1036 |
return; |
| 1037 |
} |
| 1038 |
|
| 1039 |
if ($this->product->detail->variation_type === 'simple') { |
| 1040 |
// we have to render for the simple product |
| 1041 |
|
| 1042 |
$first_price = $this->product->variants()->first(); |
| 1043 |
|
| 1044 |
$itemPrice = $first_price ? $first_price->item_price : 0; |
| 1045 |
$itemPrice = apply_filters('fluent_cart/product/display_price', $itemPrice, [ |
| 1046 |
'product' => $this->product, |
| 1047 |
'variation' => $first_price, |
| 1048 |
]); |
| 1049 |
$itemPrice = (int)$itemPrice; |
| 1050 |
$comparePrice = $first_price ? (int)$first_price->compare_price : 0; |
| 1051 |
if ($comparePrice <= $itemPrice) { |
| 1052 |
$comparePrice = 0; |
| 1053 |
} |
| 1054 |
do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([ |
| 1055 |
'product' => $this->product, |
| 1056 |
'current_price' => $itemPrice, |
| 1057 |
'scope' => 'price_range' |
| 1058 |
])); |
| 1059 |
?> |
| 1060 |
<div class="fct-price-range fct-product-prices"> |
| 1061 |
|
| 1062 |
<?php if ($comparePrice): ?> |
| 1063 |
<span class="fct-compare-price"> |
| 1064 |
<span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span> |
| 1065 |
<del><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del> |
| 1066 |
</span> |
| 1067 |
<?php endif; ?> |
| 1068 |
<span class="fct-item-price"> |
| 1069 |
<span class="fct-sr-only"><?php echo $comparePrice ? esc_html__('Sale price:', 'fluent-cart') : esc_html__('Price:', 'fluent-cart'); ?></span> |
| 1070 |
<?php echo esc_html(Helper::toDecimal($itemPrice)); ?> |
| 1071 |
<?php do_action('fluent_cart/product/after_price', RenderContext::decorate([ |
| 1072 |
'product' => $this->product, |
| 1073 |
'variant' => $first_price, |
| 1074 |
'current_price' => $itemPrice, |
| 1075 |
'scope' => 'price_range' |
| 1076 |
])); ?> |
| 1077 |
<?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?> |
| 1078 |
</span> |
| 1079 |
</div> |
| 1080 |
<?php |
| 1081 |
do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([ |
| 1082 |
'product' => $this->product, |
| 1083 |
'current_price' => $itemPrice, |
| 1084 |
'scope' => 'price_range' |
| 1085 |
])); |
| 1086 |
return; |
| 1087 |
} |
| 1088 |
$min_price = $this->product->detail->min_price; |
| 1089 |
$max_price = $this->product->detail->max_price; |
| 1090 |
|
| 1091 |
do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([ |
| 1092 |
'product' => $this->product, |
| 1093 |
'current_price' => $min_price, |
| 1094 |
'scope' => 'price_range' |
| 1095 |
])); |
| 1096 |
?> |
| 1097 |
<div class="fct-product-prices fct-price-range"> |
| 1098 |
|
| 1099 |
<?php if ($max_price && $max_price != $min_price && $max_price > $min_price): ?> |
| 1100 |
<span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span> |
| 1101 |
<span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span> |
| 1102 |
<span class="fct-price-separator" aria-hidden="true">-</span> |
| 1103 |
<span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span> |
| 1104 |
<?php endif; ?> |
| 1105 |
<span class="fct-max-price"> |
| 1106 |
<?php echo esc_html(Helper::toDecimal($max_price)); ?> |
| 1107 |
</span> |
| 1108 |
|
| 1109 |
<?php do_action('fluent_cart/product/after_price', RenderContext::decorate([ |
| 1110 |
'product' => $this->product, |
| 1111 |
'current_price' => $min_price, |
| 1112 |
'scope' => 'price_range' |
| 1113 |
])); ?> |
| 1114 |
|
| 1115 |
</div> |
| 1116 |
<?php |
| 1117 |
do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([ |
| 1118 |
'product' => $this->product, |
| 1119 |
'current_price' => $min_price, |
| 1120 |
'scope' => 'price_range' |
| 1121 |
])); |
| 1122 |
} |
| 1123 |
|
| 1124 |
public function renderVariants($atts = []) |
| 1125 |
{ |
| 1126 |
if ($this->product->detail->variation_type === 'simple') { |
| 1127 |
return; |
| 1128 |
} |
| 1129 |
|
| 1130 |
$variants = $this->product->variants; |
| 1131 |
if (!$variants || $variants->isEmpty()) { |
| 1132 |
return; |
| 1133 |
} |
| 1134 |
|
| 1135 |
// Sort by serial_index ascending |
| 1136 |
$variants = $variants->sortBy('serial_index')->values(); |
| 1137 |
|
| 1138 |
$classes = array_filter([ |
| 1139 |
'fct-product-variants', |
| 1140 |
'column-type-' . $this->columnType, |
| 1141 |
Arr::get($atts, 'wrapper_class', ''), |
| 1142 |
]); |
| 1143 |
|
| 1144 |
?> |
| 1145 |
<div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup" |
| 1146 |
aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>"> |
| 1147 |
<?php foreach ($variants as $variant) { |
| 1148 |
do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([ |
| 1149 |
'product' => $this->product, |
| 1150 |
'variant' => $variant, |
| 1151 |
'scope' => 'product_variant_item' |
| 1152 |
])); |
| 1153 |
$this->renderVariationItem($variant, $this->defaultVariationId); |
| 1154 |
do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([ |
| 1155 |
'product' => $this->product, |
| 1156 |
'variant' => $variant, |
| 1157 |
'scope' => 'product_variant_item' |
| 1158 |
])); |
| 1159 |
} ?> |
| 1160 |
</div> |
| 1161 |
<?php |
| 1162 |
} |
| 1163 |
|
| 1164 |
public function renderItemPrice() |
| 1165 |
{ |
| 1166 |
// Same 'price' gate as renderPrices(): one filter hides the price |
| 1167 |
// wherever it appears, rather than making callers hunt for the second |
| 1168 |
// place the single product page prints one. |
| 1169 |
if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant))) { |
| 1170 |
return; |
| 1171 |
} |
| 1172 |
|
| 1173 |
if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) { |
| 1174 |
return; // for simple product we already rendered the price |
| 1175 |
} |
| 1176 |
|
| 1177 |
do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([ |
| 1178 |
'product' => $this->product, |
| 1179 |
'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0, |
| 1180 |
'scope' => 'product_variant_price' |
| 1181 |
])); |
| 1182 |
|
| 1183 |
foreach ($this->product->variants as $variant) { |
| 1184 |
if ($this->shouldRenderPriceInPriceSection()) { |
| 1185 |
$this->renderVariantPricingWrapperStart($variant); |
| 1186 |
$paymentType = Arr::get($variant->other_info, 'payment_type', 'onetime'); |
| 1187 |
if (!$this->hasSubscription) { |
| 1188 |
$this->renderVariationComparePrice($variant); |
| 1189 |
$this->applyVariationPriceFilter($variant, $paymentType); |
| 1190 |
} else { |
| 1191 |
|
| 1192 |
$atts = [ |
| 1193 |
'class' => 'fct-product-payment-type fluent-cart-product-variation-content' . ($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''), |
| 1194 |
'data-fluent-cart-product-payment-type' => '', |
| 1195 |
'data-variation-id' => $variant->id |
| 1196 |
]; |
| 1197 |
|
| 1198 |
$this->renderComparePriceWrapperStart($atts); |
| 1199 |
$this->renderVariationComparePrice($variant); |
| 1200 |
$this->applyVariationPriceFilter($variant, $paymentType); |
| 1201 |
$this->renderComparePriceWrapperEnd(); |
| 1202 |
} |
| 1203 |
|
| 1204 |
$this->renderVariantPricingWrapperEnd(); |
| 1205 |
} |
| 1206 |
|
| 1207 |
} |
| 1208 |
|
| 1209 |
|
| 1210 |
foreach ($this->product->variants as $variant) { |
| 1211 |
$this->renderVariationsBundleProduct($variant); |
| 1212 |
} |
| 1213 |
|
| 1214 |
|
| 1215 |
|
| 1216 |
|
| 1217 |
do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([ |
| 1218 |
'product' => $this->product, |
| 1219 |
'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0, |
| 1220 |
'scope' => 'product_variant_price' |
| 1221 |
])); |
| 1222 |
} |
| 1223 |
|
| 1224 |
public function shouldRenderPriceInPriceSection(): bool |
| 1225 |
{ |
| 1226 |
// simple_variations keeps the legacy inline price flow for the |
| 1227 |
// one-column layouts that render the variant title (text-only and |
| 1228 |
// image+text), where the price sits on each variant row. |
| 1229 |
// Every other variation type always renders the price in the dedicated |
| 1230 |
// below-block so the data-fluent-cart-product-item-price element keeps |
| 1231 |
// a consistent position across every view/column combination. |
| 1232 |
if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_SIMPLE_VARIATION) { |
| 1233 |
$isInlineRowLayout = in_array($this->viewType, ['text', 'both'], true) |
| 1234 |
&& $this->columnType === 'one'; |
| 1235 |
|
| 1236 |
return !$isInlineRowLayout; |
| 1237 |
} |
| 1238 |
|
| 1239 |
return true; |
| 1240 |
} |
| 1241 |
|
| 1242 |
public function applyVariationPriceFilter($variant, $paymentType = 'onetime') |
| 1243 |
{ |
| 1244 |
$priceText = $paymentType === 'onetime' ? Helper::toDecimal($variant->item_price) : $variant->getSubscriptionTermsText(true); |
| 1245 |
echo wp_kses_post(apply_filters('fluent_cart/single_product/variation_price', esc_html($priceText), [ |
| 1246 |
'product' => $this->product, |
| 1247 |
'variant' => $variant, |
| 1248 |
'scope' => 'product_variant_price' |
| 1249 |
])); |
| 1250 |
do_action('fluent_cart/product/after_price', RenderContext::decorate([ |
| 1251 |
'product' => $this->product, |
| 1252 |
'variant' => $variant, |
| 1253 |
'current_price' => $variant->item_price, |
| 1254 |
'scope' => 'product_variant_price' |
| 1255 |
])); |
| 1256 |
RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price'); |
| 1257 |
} |
| 1258 |
|
| 1259 |
public function renderComparePriceWrapperStart($atts = []) |
| 1260 |
{ |
| 1261 |
?> |
| 1262 |
<div <?php $this->renderAttributes($atts); ?> > |
| 1263 |
<?php |
| 1264 |
} |
| 1265 |
|
| 1266 |
public function renderComparePriceWrapperEnd() |
| 1267 |
{ |
| 1268 |
?> |
| 1269 |
</div> |
| 1270 |
<?php |
| 1271 |
} |
| 1272 |
|
| 1273 |
public function renderVariationComparePrice($variant) |
| 1274 |
{ |
| 1275 |
if (!$variant->compare_price) { |
| 1276 |
return; |
| 1277 |
} ?> |
| 1278 |
|
| 1279 |
<span class="fct-compare-price"> |
| 1280 |
<span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span> |
| 1281 |
<del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del> |
| 1282 |
</span> |
| 1283 |
<?php |
| 1284 |
} |
| 1285 |
|
| 1286 |
public function renderVariantPricingWrapperStart($variant) |
| 1287 |
{ ?> |
| 1288 |
<div |
| 1289 |
class="fct-product-item-price fluent-cart-product-variation-content <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>" |
| 1290 |
data-fluent-cart-product-item-price |
| 1291 |
data-variation-id="<?php echo esc_attr($variant->id); ?>" |
| 1292 |
aria-live="polite" |
| 1293 |
role="status" |
| 1294 |
> |
| 1295 |
<?php } |
| 1296 |
|
| 1297 |
|
| 1298 |
public function renderVariantPricingWrapperEnd() |
| 1299 |
{ |
| 1300 |
?> </div> <?php |
| 1301 |
} |
| 1302 |
|
| 1303 |
public function renderVariationsBundleProduct($variant) |
| 1304 |
{ |
| 1305 |
if (count($variant->bundleChildren) == 0) { |
| 1306 |
return; |
| 1307 |
} |
| 1308 |
|
| 1309 |
if(is_object($variant->bundleChildren)) { |
| 1310 |
$bundleProducts = $variant->bundleChildren->toArray(); |
| 1311 |
}else{ |
| 1312 |
$bundleProducts = $variant->bundleChildren; |
| 1313 |
} |
| 1314 |
|
| 1315 |
$total = count($bundleProducts); |
| 1316 |
?> |
| 1317 |
<div class="fluent-cart-product-variation-content fct-bundle-products <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>" |
| 1318 |
data-variation-id="<?php echo esc_attr($variant->id); ?>" |
| 1319 |
data-fluent-cart-collapsibles |
| 1320 |
> |
| 1321 |
<h4 class="fct-bundle-products-title"> |
| 1322 |
<?php echo esc_html__('Bundle of', 'fluent-cart') . ':'; ?> |
| 1323 |
</h4> |
| 1324 |
|
| 1325 |
<div class="fct-bundle-products-list"> |
| 1326 |
<?php foreach (array_slice($bundleProducts, 0, 2) as $product): ?> |
| 1327 |
<p> |
| 1328 |
<?php echo esc_html(Arr::get($product, 'product.post_title')); ?> - |
| 1329 |
<?php echo esc_html($product['variation_title']); ?> |
| 1330 |
</p> |
| 1331 |
<?php endforeach; ?> |
| 1332 |
|
| 1333 |
<?php if($total > 2): ?> |
| 1334 |
<div class="fct-bundle-products-more"> |
| 1335 |
<div class="fct-bundle-products-more-list"> |
| 1336 |
<?php foreach (array_slice($bundleProducts, 2) as $product): ?> |
| 1337 |
<p> |
| 1338 |
<?php echo esc_html(Arr::get($product, 'product.post_title')); ?> - |
| 1339 |
<?php echo esc_html($product['variation_title']); ?> |
| 1340 |
</p> |
| 1341 |
<?php endforeach; ?> |
| 1342 |
</div> |
| 1343 |
</div> |
| 1344 |
<?php endif;?> |
| 1345 |
</div> |
| 1346 |
|
| 1347 |
<?php if ($total > 2) : ?> |
| 1348 |
<button type="button" class="fct-see-more-btn" data-fluent-cart-collapsible-toggle> |
| 1349 |
<span class="see-more-text"> |
| 1350 |
<?php echo esc_html__('See More', 'fluent-cart'); ?> |
| 1351 |
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7" fill="none"> |
| 1352 |
<path d="M0.75 0.75L5.04289 5.04289C5.37623 5.37623 5.54289 5.54289 5.75 5.54289C5.95711 5.54289 6.12377 5.37623 6.45711 5.04289L10.75 0.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> |
| 1353 |
</svg> |
| 1354 |
</span> |
| 1355 |
<span class="see-less-text"> |
| 1356 |
<?php echo esc_html__('See Less', 'fluent-cart'); ?> |
| 1357 |
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 14 8" fill="none"> |
| 1358 |
<path d="M0.75 6.54297L6.04289 1.25008C6.37623 0.916742 6.54289 0.750076 6.75 0.750076C6.95711 0.750076 7.12377 0.916742 7.45711 1.25008L12.75 6.54297" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> |
| 1359 |
</svg> |
| 1360 |
</span> |
| 1361 |
</button> |
| 1362 |
<?php endif; ?> |
| 1363 |
</div> |
| 1364 |
<?php } |
| 1365 |
|
| 1366 |
public function renderQuantity() |
| 1367 |
{ |
| 1368 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1369 |
|
| 1370 |
if (!RenderGate::shouldRender('quantity', $gateContext)) { |
| 1371 |
return; |
| 1372 |
} |
| 1373 |
|
| 1374 |
$soldIndividually = $this->product->soldIndividually(); |
| 1375 |
|
| 1376 |
if (!$this->hasOnetime || $soldIndividually) { |
| 1377 |
return; |
| 1378 |
} |
| 1379 |
|
| 1380 |
$attributes = [ |
| 1381 |
'data-fluent-cart-product-quantity-container' => '', |
| 1382 |
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '', |
| 1383 |
'data-variation-type' => $this->product->detail->variation_type, |
| 1384 |
'data-payment-type' => 'onetime', |
| 1385 |
'class' => 'fct-product-quantity-container' |
| 1386 |
]; |
| 1387 |
|
| 1388 |
$defaultVariantData = $this->getDefaultVariantData(); |
| 1389 |
|
| 1390 |
if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') { |
| 1391 |
$attributes['class'] .= ' is-hidden'; |
| 1392 |
} |
| 1393 |
|
| 1394 |
do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([ |
| 1395 |
'product' => $this->product, |
| 1396 |
'scope' => 'product_quantity_block' |
| 1397 |
])); |
| 1398 |
?> |
| 1399 |
<div <?php $this->renderAttributes($attributes); ?>> |
| 1400 |
<label for="fct-product-qty-input" class="quantity-title"> |
| 1401 |
<?php esc_html_e('Quantity', 'fluent-cart'); ?> |
| 1402 |
</label> |
| 1403 |
|
| 1404 |
<div class="fct-product-quantity"> |
| 1405 |
<button class="fct-quantity-decrease-button" |
| 1406 |
data-fluent-cart-product-qty-decrease-button |
| 1407 |
title="<?php esc_html_e('Decrease Quantity', 'fluent-cart'); ?>" |
| 1408 |
aria-label="<?php esc_attr_e('Decrease Quantity', 'fluent-cart'); ?>" |
| 1409 |
> |
| 1410 |
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="0 0 14 2" fill="none"> |
| 1411 |
<path d="M12.3333 1L1.66659 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" |
| 1412 |
stroke-linejoin="round"></path> |
| 1413 |
</svg> |
| 1414 |
</button> |
| 1415 |
|
| 1416 |
<input |
| 1417 |
id="fct-product-qty-input" |
| 1418 |
min="1" |
| 1419 |
<?php echo $soldIndividually ? 'max="1"' : ''; ?> |
| 1420 |
class="fct-quantity-input" |
| 1421 |
data-fluent-cart-single-product-page-product-quantity-input |
| 1422 |
type="number" |
| 1423 |
inputmode="numeric" |
| 1424 |
pattern="[0-9]*" |
| 1425 |
placeholder="<?php esc_attr_e('Quantity', 'fluent-cart'); ?>" |
| 1426 |
value="1" |
| 1427 |
aria-label="<?php esc_attr_e('Product quantity', 'fluent-cart'); ?>" |
| 1428 |
/> |
| 1429 |
|
| 1430 |
<button class="fct-quantity-increase-button" |
| 1431 |
data-fluent-cart-product-qty-increase-button |
| 1432 |
title="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>" |
| 1433 |
aria-label="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>" |
| 1434 |
> |
| 1435 |
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none"> |
| 1436 |
<path d="M6.99996 1.66666L6.99996 12.3333M12.3333 6.99999L1.66663 6.99999" stroke="currentColor" |
| 1437 |
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path> |
| 1438 |
</svg> |
| 1439 |
</button> |
| 1440 |
</div> |
| 1441 |
</div> |
| 1442 |
<?php |
| 1443 |
do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([ |
| 1444 |
'product' => $this->product, |
| 1445 |
'scope' => 'product_quantity_block' |
| 1446 |
])); |
| 1447 |
} |
| 1448 |
|
| 1449 |
public function renderPurchaseButtons($atts = []) |
| 1450 |
{ |
| 1451 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1452 |
|
| 1453 |
if (!RenderGate::shouldRender('actions', $gateContext)) { |
| 1454 |
return; |
| 1455 |
} |
| 1456 |
|
| 1457 |
do_action('fluent_cart/product/single/before_actions_block', $gateContext); |
| 1458 |
|
| 1459 |
$buyNowButtonAtts = $atts; |
| 1460 |
$this->renderBuyNowButton($buyNowButtonAtts); |
| 1461 |
$this->renderAddToCartButton($atts); |
| 1462 |
|
| 1463 |
do_action('fluent_cart/product/single/after_actions_block', $gateContext); |
| 1464 |
} |
| 1465 |
|
| 1466 |
public function renderBuyNowButton($atts = []) |
| 1467 |
{ |
| 1468 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1469 |
|
| 1470 |
if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) { |
| 1471 |
return; |
| 1472 |
} |
| 1473 |
|
| 1474 |
// Stock management check using isStock() method |
| 1475 |
// if (ModuleSettings::isActive('stock_management')) { |
| 1476 |
// if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) { |
| 1477 |
// if (!$this->defaultVariant->isStock()) { |
| 1478 |
// echo '<span aria-disabled="true">' . esc_html__('Out of stock', 'fluent-cart') . '</span>'; |
| 1479 |
// return; |
| 1480 |
// } |
| 1481 |
// } |
| 1482 |
// } |
| 1483 |
|
| 1484 |
$defaults = [ |
| 1485 |
'buy_now_text' => __('Buy Now', 'fluent-cart'), |
| 1486 |
'add_to_cart_text' => __('Add To Cart', 'fluent-cart'), |
| 1487 |
]; |
| 1488 |
|
| 1489 |
$atts = wp_parse_args($atts, $defaults); |
| 1490 |
|
| 1491 |
$enableModalCheckout = Helper::isModalCheckoutEnabled(); |
| 1492 |
|
| 1493 |
$isInStock = true; |
| 1494 |
if (ModuleSettings::isActive('stock_management')) { |
| 1495 |
$isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock()); |
| 1496 |
} |
| 1497 |
|
| 1498 |
$stockStatus = $isInStock ? 'in-stock' : 'out-of-stock'; |
| 1499 |
|
| 1500 |
$variationClass = 'fluent-cart-direct-checkout-button'; |
| 1501 |
if (!$isInStock) { |
| 1502 |
$variationClass .= ' is-hidden'; |
| 1503 |
} |
| 1504 |
|
| 1505 |
$buyNowAttributes = [ |
| 1506 |
'data-fluent-cart-direct-checkout-button' => '', |
| 1507 |
'data-variation-type' => $this->product->detail->variation_type, |
| 1508 |
'class' => $variationClass, |
| 1509 |
'data-stock-availability' => $stockStatus, |
| 1510 |
'data-quantity' => '1', |
| 1511 |
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '', |
| 1512 |
'data-url' => site_url('?fluent-cart=instant_checkout&item_id='), |
| 1513 |
]; |
| 1514 |
|
| 1515 |
if ($isInStock) { |
| 1516 |
$buyNowAttributes['href'] = site_url('?fluent-cart=instant_checkout&item_id=') . ($this->defaultVariant ? $this->defaultVariant->id : '') . '&quantity=1'; |
| 1517 |
} |
| 1518 |
|
| 1519 |
if ($enableModalCheckout) { |
| 1520 |
$buyNowAttributes['data-fct-instant-checkout-button'] = ''; |
| 1521 |
$buyNowAttributes['data-enable-modal-checkout'] = 'yes'; |
| 1522 |
} |
| 1523 |
|
| 1524 |
$isShortcode = !empty($atts['is_shortcode']); |
| 1525 |
if ($isShortcode) { |
| 1526 |
ob_start(); |
| 1527 |
$this->renderAttributes($buyNowAttributes); |
| 1528 |
$wrapperAttributes = ob_get_clean(); |
| 1529 |
} else { |
| 1530 |
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes); |
| 1531 |
} |
| 1532 |
|
| 1533 |
$buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [ |
| 1534 |
'product' => $this->product |
| 1535 |
]); |
| 1536 |
|
| 1537 |
?> |
| 1538 |
<?php |
| 1539 |
$variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : ''; |
| 1540 |
$buyNowAriaLabel = $variantTitle |
| 1541 |
? sprintf( |
| 1542 |
/* translators: 1: Button text (e.g. "Buy Now"), 2: Variant name */ |
| 1543 |
__('%1$s - %2$s', 'fluent-cart'), |
| 1544 |
$buyButtonText, |
| 1545 |
$variantTitle |
| 1546 |
) |
| 1547 |
: $buyButtonText; |
| 1548 |
?> |
| 1549 |
<a <?php echo $wrapperAttributes; ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>"> |
| 1550 |
<?php echo wp_kses_post($buyButtonText); ?> |
| 1551 |
</a> |
| 1552 |
<?php |
| 1553 |
} |
| 1554 |
|
| 1555 |
public function renderBuyNowButtonBlock($atts = []) |
| 1556 |
{ |
| 1557 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1558 |
|
| 1559 |
// Same gate as the in-section button: a page assembled from standalone |
| 1560 |
// button blocks must honour catalog mode too, or the gate leaks. |
| 1561 |
if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) { |
| 1562 |
return; |
| 1563 |
} |
| 1564 |
|
| 1565 |
$text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart')); |
| 1566 |
$variantIds = Arr::get($atts, 'variant_ids', []); |
| 1567 |
$variantId = Arr::get($variantIds, 0); |
| 1568 |
$customClass = trim(Arr::get($atts, 'class', '')); |
| 1569 |
$extraClass = trim(Arr::get($atts, 'extra_class', '')); |
| 1570 |
|
| 1571 |
$defaults = [ |
| 1572 |
'buy_now_text' => $text, |
| 1573 |
'target' => '', |
| 1574 |
'rel' => '', |
| 1575 |
'is_shortcode' => false, |
| 1576 |
]; |
| 1577 |
|
| 1578 |
$atts = wp_parse_args($atts, $defaults); |
| 1579 |
|
| 1580 |
$enableModalCheckout = Arr::get($atts, 'enable_modal_checkout', false); |
| 1581 |
|
| 1582 |
$isInStock = true; |
| 1583 |
if (ModuleSettings::isActive('stock_management')) { |
| 1584 |
$isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock()); |
| 1585 |
} |
| 1586 |
$stockStatus = $isInStock ? 'in-stock' : 'out-of-stock'; |
| 1587 |
|
| 1588 |
$checkoutUrl = add_query_arg([ |
| 1589 |
'fluent-cart' => $enableModalCheckout ? 'modal_checkout' : 'instant_checkout', |
| 1590 |
'item_id' => $variantId ?? '', |
| 1591 |
'quantity' => 1 |
| 1592 |
], site_url()); |
| 1593 |
|
| 1594 |
$buyNowClass = $customClass ?: 'wp-block-button__link wp-element-button'; |
| 1595 |
if ($extraClass) { |
| 1596 |
$buyNowClass .= ' ' . $extraClass; |
| 1597 |
} |
| 1598 |
$buyNowClass = trim($buyNowClass); |
| 1599 |
if ($stockStatus === 'out-of-stock') { |
| 1600 |
$buyNowClass .= ' out-of-stock'; |
| 1601 |
} |
| 1602 |
|
| 1603 |
$buyNowAttributes = [ |
| 1604 |
'data-fluent-cart-direct-checkout-button' => '', |
| 1605 |
'data-variation-type' => $this->product->detail->variation_type, |
| 1606 |
'class' => $buyNowClass, |
| 1607 |
'data-stock-availability' => $stockStatus, |
| 1608 |
'data-quantity' => '1', |
| 1609 |
'data-cart-id' => $variantId ?? '', |
| 1610 |
'data-url' => $checkoutUrl, |
| 1611 |
]; |
| 1612 |
|
| 1613 |
if ($stockStatus === 'out-of-stock') { |
| 1614 |
$buyNowAttributes['aria-disabled'] = 'true'; |
| 1615 |
} else { |
| 1616 |
$buyNowAttributes['href'] = $checkoutUrl; |
| 1617 |
} |
| 1618 |
|
| 1619 |
$target = Arr::get($atts, 'target'); |
| 1620 |
if ($target) { |
| 1621 |
$buyNowAttributes['target'] = $target; |
| 1622 |
if (strtolower($target) === '_blank') { |
| 1623 |
$buyNowAttributes['rel'] = Arr::get($atts, 'rel', 'noopener noreferrer'); |
| 1624 |
} |
| 1625 |
} |
| 1626 |
if ($enableModalCheckout) { |
| 1627 |
$buyNowAttributes['data-fct-instant-checkout-button'] = ''; |
| 1628 |
$buyNowAttributes['data-enable-modal-checkout'] = 'yes'; |
| 1629 |
} |
| 1630 |
$isShortcode = !empty($atts['is_shortcode']); |
| 1631 |
if ($isShortcode) { |
| 1632 |
ob_start(); |
| 1633 |
$this->renderAttributes($buyNowAttributes); |
| 1634 |
$wrapperAttributes = ob_get_clean(); |
| 1635 |
} else { |
| 1636 |
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes); |
| 1637 |
} |
| 1638 |
|
| 1639 |
$buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [ |
| 1640 |
'product' => $this->product |
| 1641 |
]); |
| 1642 |
?> |
| 1643 |
<a <?php echo($wrapperAttributes); ?> aria-label="<?php echo esc_attr($buyButtonText); ?>"> |
| 1644 |
<?php echo wp_kses_post($buyButtonText); ?> |
| 1645 |
</a> |
| 1646 |
<?php |
| 1647 |
} |
| 1648 |
|
| 1649 |
public function renderAddToCartButton($atts = []) |
| 1650 |
{ |
| 1651 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1652 |
|
| 1653 |
if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) { |
| 1654 |
return; |
| 1655 |
} |
| 1656 |
|
| 1657 |
$defaults = [ |
| 1658 |
'buy_now_text' => __('Buy Now', 'fluent-cart'), |
| 1659 |
'add_to_cart_text' => __('Add To Cart', 'fluent-cart'), |
| 1660 |
]; |
| 1661 |
|
| 1662 |
$atts = wp_parse_args($atts, $defaults); |
| 1663 |
|
| 1664 |
$cartAttributes = [ |
| 1665 |
'data-fluent-cart-add-to-cart-button' => '', |
| 1666 |
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '', |
| 1667 |
'data-product-id' => $this->product->ID, |
| 1668 |
'class' => 'fluent-cart-add-to-cart-button', |
| 1669 |
'data-variation-type' => $this->product->detail->variation_type, |
| 1670 |
'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false', |
| 1671 |
]; |
| 1672 |
|
| 1673 |
$defaultVariantData = $this->getDefaultVariantData(); |
| 1674 |
|
| 1675 |
// If product is subscription-only, hide add-to-cart |
| 1676 |
if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') { |
| 1677 |
$cartAttributes['class'] .= ' is-hidden'; |
| 1678 |
} |
| 1679 |
|
| 1680 |
// Check stock availability using both product-level and variant-level |
| 1681 |
$isOutOfStock = false; |
| 1682 |
if (ModuleSettings::isActive('stock_management')) { |
| 1683 |
if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) { |
| 1684 |
$isOutOfStock = true; |
| 1685 |
$cartAttributes['disabled'] = 'disabled'; |
| 1686 |
$cartAttributes['class'] .= ' out-of-stock'; |
| 1687 |
$cartAttributes['aria-disabled'] = 'true'; |
| 1688 |
$atts['add_to_cart_text'] = __('Not Available', 'fluent-cart'); |
| 1689 |
} |
| 1690 |
} |
| 1691 |
|
| 1692 |
$addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [ |
| 1693 |
'product' => $this->product |
| 1694 |
]); |
| 1695 |
|
| 1696 |
$isShortcode = !empty($atts['is_shortcode']); |
| 1697 |
if ($isShortcode) { |
| 1698 |
ob_start(); |
| 1699 |
$this->renderAttributes($cartAttributes); |
| 1700 |
$wrapperAttributes = ob_get_clean(); |
| 1701 |
} else { |
| 1702 |
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes); |
| 1703 |
} |
| 1704 |
|
| 1705 |
|
| 1706 |
// Render add to cart when the product supports a one-time path, when out |
| 1707 |
// of stock (to show "Not Available"), or for advanced-variation products. |
| 1708 |
// The advanced selector toggles this button's visibility / disabled / |
| 1709 |
// payment-type state per selection, so it must exist in the DOM even for |
| 1710 |
// a subscription-only product whose in-stock default starts hidden via |
| 1711 |
// the is-hidden class applied above — otherwise an out-of-stock |
| 1712 |
// subscription combination has no button to surface "Not Available". |
| 1713 |
$isAdvancedVariation = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION; |
| 1714 |
if ($this->hasOnetime || $isOutOfStock || $isAdvancedVariation) : |
| 1715 |
?> |
| 1716 |
<?php |
| 1717 |
$variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : ''; |
| 1718 |
$addToCartAriaLabel = $variantTitle |
| 1719 |
? sprintf( |
| 1720 |
/* translators: 1: Button text (e.g. "Add To Cart"), 2: Variant name */ |
| 1721 |
__('%1$s - %2$s', 'fluent-cart'), |
| 1722 |
$addToCartText, |
| 1723 |
$variantTitle |
| 1724 |
) |
| 1725 |
: $addToCartText; |
| 1726 |
?> |
| 1727 |
<button <?php echo $wrapperAttributes; ?> |
| 1728 |
aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>"> |
| 1729 |
<span class="text"> |
| 1730 |
<?php echo wp_kses_post($addToCartText); ?> |
| 1731 |
</span> |
| 1732 |
<span class="fluent-cart-loader" role="status"> |
| 1733 |
<svg aria-hidden="true" |
| 1734 |
width="20" |
| 1735 |
height="20" |
| 1736 |
class="w-5 h-5 text-gray-200 animate-spin fill-blue-600" |
| 1737 |
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 1738 |
<path |
| 1739 |
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" |
| 1740 |
fill="currentColor"/> |
| 1741 |
<path |
| 1742 |
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" |
| 1743 |
fill="currentFill"/> |
| 1744 |
</svg> |
| 1745 |
</span> |
| 1746 |
</button> |
| 1747 |
<?php |
| 1748 |
endif; |
| 1749 |
} |
| 1750 |
|
| 1751 |
public function renderAddToCartButtonBlock($atts = []) |
| 1752 |
{ |
| 1753 |
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant); |
| 1754 |
|
| 1755 |
// Same gate as the in-section button — see renderBuyNowButtonBlock(). |
| 1756 |
if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) { |
| 1757 |
return; |
| 1758 |
} |
| 1759 |
|
| 1760 |
$text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart')); |
| 1761 |
$customClass = trim(Arr::get($atts, 'class', '')); |
| 1762 |
$extraClass = trim(Arr::get($atts, 'extra_class', '')); |
| 1763 |
|
| 1764 |
$defaults = [ |
| 1765 |
'add_to_cart_text' => $text, |
| 1766 |
]; |
| 1767 |
|
| 1768 |
$atts = wp_parse_args($atts, $defaults); |
| 1769 |
|
| 1770 |
$buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button']; |
| 1771 |
$buttonClass = $customClass ?: implode(' ', $buttonClasses); |
| 1772 |
|
| 1773 |
$cartAttributes = [ |
| 1774 |
'data-fluent-cart-add-to-cart-button' => '', |
| 1775 |
'class' => $buttonClass, |
| 1776 |
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '', |
| 1777 |
'data-product-id' => $this->product->ID, |
| 1778 |
'data-variation-type' => $this->product->detail->variation_type, |
| 1779 |
'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false', |
| 1780 |
]; |
| 1781 |
|
| 1782 |
if ($extraClass) { |
| 1783 |
$cartAttributes['class'] .= ' ' . $extraClass; |
| 1784 |
} |
| 1785 |
|
| 1786 |
// If the product does NOT support one-time purchase |
| 1787 |
if (!$this->hasOnetime) { |
| 1788 |
if (Helper::isAdminUser()) { |
| 1789 |
$view = '<p class="fct-admin-notice">' . esc_html__('Add to Cart is not supported for subscription product', 'fluent-cart') . '</p>'; |
| 1790 |
|
| 1791 |
FrontendView::make('', $view); |
| 1792 |
return; |
| 1793 |
} |
| 1794 |
|
| 1795 |
return; |
| 1796 |
} |
| 1797 |
|
| 1798 |
// Check stock availability using both product-level and variant-level |
| 1799 |
if (ModuleSettings::isActive('stock_management')) { |
| 1800 |
if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) { |
| 1801 |
$cartAttributes['disabled'] = 'disabled'; |
| 1802 |
$cartAttributes['class'] .= ' out-of-stock'; |
| 1803 |
$cartAttributes['aria-disabled'] = 'true'; |
| 1804 |
$atts['add_to_cart_text'] = __('Not Available', 'fluent-cart'); |
| 1805 |
} |
| 1806 |
} |
| 1807 |
|
| 1808 |
$addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [ |
| 1809 |
'product' => $this->product |
| 1810 |
]); |
| 1811 |
|
| 1812 |
$isShortcode = !empty($atts['is_shortcode']); |
| 1813 |
if ($isShortcode) { |
| 1814 |
ob_start(); |
| 1815 |
$this->renderAttributes($cartAttributes); |
| 1816 |
$wrapperAttributes = ob_get_clean(); |
| 1817 |
} else { |
| 1818 |
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes); |
| 1819 |
} |
| 1820 |
|
| 1821 |
?> |
| 1822 |
|
| 1823 |
<button <?php echo $wrapperAttributes; ?> |
| 1824 |
aria-label="<?php echo esc_attr($addToCartText); ?>"> |
| 1825 |
<span class="text"> |
| 1826 |
<?php echo wp_kses_post($addToCartText); ?> |
| 1827 |
</span> |
| 1828 |
<span class="fluent-cart-loader" role="status"> |
| 1829 |
<svg aria-hidden="true" |
| 1830 |
width="20" |
| 1831 |
height="20" |
| 1832 |
class="w-5 h-5 text-gray-200 animate-spin fill-blue-600" |
| 1833 |
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 1834 |
<path |
| 1835 |
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" |
| 1836 |
fill="currentColor"/> |
| 1837 |
<path |
| 1838 |
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" |
| 1839 |
fill="currentFill"/> |
| 1840 |
</svg> |
| 1841 |
</span> |
| 1842 |
</button> |
| 1843 |
|
| 1844 |
<?php |
| 1845 |
} |
| 1846 |
|
| 1847 |
public static function renderNoProductFound() |
| 1848 |
{ |
| 1849 |
?> |
| 1850 |
<div class="fluent-cart-shop-no-result-found" data-fluent-cart-shop-no-result-found role="status" |
| 1851 |
aria-live="polite"> |
| 1852 |
<p class="has-text-align-center has-large-font-size m-0"> |
| 1853 |
<?php echo esc_html__('No Product Found!', 'fluent-cart'); ?> |
| 1854 |
</p> |
| 1855 |
|
| 1856 |
<p class="has-text-align-center m-0"> |
| 1857 |
<?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?> |
| 1858 |
</p> |
| 1859 |
</div> |
| 1860 |
<?php |
| 1861 |
} |
| 1862 |
|
| 1863 |
protected function renderVariationItem(ProductVariation $variant, $defaultId = '', $extraClasses = []) |
| 1864 |
{ |
| 1865 |
$availableStocks = $variant->available; |
| 1866 |
if (!$variant->manage_stock) { |
| 1867 |
$availableStocks = 'unlimited'; |
| 1868 |
} |
| 1869 |
|
| 1870 |
$comparePrice = $variant->compare_price; |
| 1871 |
if ($comparePrice <= $variant->item_price) { |
| 1872 |
$comparePrice = ''; |
| 1873 |
} |
| 1874 |
|
| 1875 |
if ($comparePrice) { |
| 1876 |
$comparePrice = Helper::toDecimal($comparePrice); |
| 1877 |
} |
| 1878 |
|
| 1879 |
$paymentType = Arr::get($variant->other_info, 'payment_type'); |
| 1880 |
|
| 1881 |
$itemClasses = [ |
| 1882 |
'fct-product-variant-item', |
| 1883 |
'fct_price_type_' . $paymentType, |
| 1884 |
'fct_variation_view_type_' . $this->viewType, |
| 1885 |
]; |
| 1886 |
|
| 1887 |
if ($variant->media_id) { |
| 1888 |
$itemClasses[] = 'fct-item-has-image'; |
| 1889 |
} |
| 1890 |
|
| 1891 |
if ($variant->id == $defaultId) { |
| 1892 |
$itemClasses[] = 'selected'; |
| 1893 |
} |
| 1894 |
|
| 1895 |
$renderingAttributes = [ |
| 1896 |
'data-fluent-cart-product-variant' => '', |
| 1897 |
'data-cart-id' => $variant->id, |
| 1898 |
'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock', |
| 1899 |
'data-default-variation-id' => $defaultId, |
| 1900 |
'data-payment-type' => $paymentType, |
| 1901 |
'data-available-stock' => $availableStocks, |
| 1902 |
'data-item-price' => Helper::toDecimal($variant->item_price), |
| 1903 |
'data-compare-price' => $comparePrice, |
| 1904 |
'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no', |
| 1905 |
'data-sku' => $variant->sku ?? '', |
| 1906 |
'data-package-info' => $this->getVariantPackageInfoJson($variant), |
| 1907 |
]; |
| 1908 |
|
| 1909 |
if ($paymentType === 'subscription') { |
| 1910 |
$renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true); |
| 1911 |
$repeatInterval = Arr::get($variant->other_info, 'repeat_interval', ''); |
| 1912 |
$hasInstallment = Arr::get($variant->other_info, 'has_installment') === 'yes'; |
| 1913 |
|
| 1914 |
$itemClasses[] = 'fct_sub_interval_' . $repeatInterval; |
| 1915 |
if ($hasInstallment) { |
| 1916 |
$itemClasses[] = 'fct_sub_has_installment'; |
| 1917 |
} |
| 1918 |
} |
| 1919 |
|
| 1920 |
if ($extraClasses) { |
| 1921 |
$itemClasses = array_merge($itemClasses, $extraClasses); |
| 1922 |
} |
| 1923 |
|
| 1924 |
$itemClasses = array_filter($itemClasses); |
| 1925 |
$renderingAttributes['class'] = implode(' ', $itemClasses); |
| 1926 |
|
| 1927 |
$itemPrice = $variant->item_price; |
| 1928 |
$comparePrice = $variant->compare_price; |
| 1929 |
if (!$comparePrice || $comparePrice <= $itemPrice) { |
| 1930 |
$comparePrice = 0; |
| 1931 |
} |
| 1932 |
|
| 1933 |
?> |
| 1934 |
<div |
| 1935 |
<?php $this->renderAttributes($renderingAttributes); ?> |
| 1936 |
role="radio" |
| 1937 |
tabindex="<?php echo $variant->id == $defaultId ? '0' : '-1'; ?>" |
| 1938 |
aria-checked="<?php echo $variant->id == $defaultId ? 'true' : 'false'; ?>" |
| 1939 |
aria-label="<?php echo esc_attr($variant->variation_title); ?>" |
| 1940 |
> |
| 1941 |
<?php if ($this->viewType === 'image'): ?> |
| 1942 |
<?php $this->renderTooltip($variant); ?> |
| 1943 |
<?php endif; ?> |
| 1944 |
|
| 1945 |
<div class="variant-content"> |
| 1946 |
<?php |
| 1947 |
if ($this->viewType === 'both' || $this->viewType === 'image') { |
| 1948 |
$this->renderVariantImage($variant); |
| 1949 |
} |
| 1950 |
?> |
| 1951 |
<?php if ($this->viewType === 'both' || $this->viewType === 'text'): ?> |
| 1952 |
<div class="fct-product-variant-text"> |
| 1953 |
<div class="fct-product-variant-title"><?php echo esc_html($variant->variation_title); ?></div> |
| 1954 |
<?php if (!$this->shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?> |
| 1955 |
<?php $this->renderSubscriptionInfo($variant); ?> |
| 1956 |
<?php endif; ?> |
| 1957 |
</div> |
| 1958 |
<?php endif; ?> |
| 1959 |
</div> |
| 1960 |
|
| 1961 |
<?php if (!$this->shouldRenderPriceInPriceSection()): ?> |
| 1962 |
<div class="fct-product-variant-price"> |
| 1963 |
<?php if ($comparePrice): ?> |
| 1964 |
<div class="fct-product-variant-compare-price"> |
| 1965 |
<span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span> |
| 1966 |
<del> |
| 1967 |
<span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del> |
| 1968 |
</div> |
| 1969 |
<?php endif; ?> |
| 1970 |
<div class="fct-product-variant-item-price"> |
| 1971 |
<span class="fct-sr-only"><?php echo $comparePrice ? esc_html__('Sale price:', 'fluent-cart') : esc_html__('Price:', 'fluent-cart'); ?></span> |
| 1972 |
<span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span> |
| 1973 |
</div> |
| 1974 |
</div> |
| 1975 |
<?php endif; ?> |
| 1976 |
</div> |
| 1977 |
<?php |
| 1978 |
} |
| 1979 |
|
| 1980 |
protected function renderTooltip($variant) |
| 1981 |
{ |
| 1982 |
?> |
| 1983 |
<div class="fct-product-variant-tooltip" role="tooltip" id="tooltip-<?php echo esc_attr($variant->id); ?>"> |
| 1984 |
<?php echo esc_html($variant->variation_title); ?> |
| 1985 |
</div> |
| 1986 |
<?php |
| 1987 |
} |
| 1988 |
|
| 1989 |
public function renderVariantImage($variant) |
| 1990 |
{ |
| 1991 |
$image = $variant->thumbnail; |
| 1992 |
if (!$image) { |
| 1993 |
$image = Vite::getAssetUrl('images/placeholder.svg'); |
| 1994 |
} |
| 1995 |
?> |
| 1996 |
<div class="fct-product-variant-image"> |
| 1997 |
<img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>" |
| 1998 |
src="<?php echo esc_url($image); ?>"/> |
| 1999 |
</div> |
| 2000 |
<?php |
| 2001 |
} |
| 2002 |
|
| 2003 |
protected function renderSubscriptionInfo($variant = null) |
| 2004 |
{ |
| 2005 |
|
| 2006 |
if(!$variant){ |
| 2007 |
return ''; |
| 2008 |
} |
| 2009 |
$info = $variant->getSubscriptionTermsText(true); |
| 2010 |
|
| 2011 |
if (!$info) { |
| 2012 |
return ''; |
| 2013 |
} |
| 2014 |
|
| 2015 |
?> |
| 2016 |
<div class="fct-product-variant-payment-type" aria-live="polite"> |
| 2017 |
<div class="additional-info"> |
| 2018 |
<span><?php echo esc_html($info); ?></span> |
| 2019 |
</div> |
| 2020 |
</div> |
| 2021 |
<?php |
| 2022 |
} |
| 2023 |
|
| 2024 |
protected function renderAttributes($atts = []) |
| 2025 |
{ |
| 2026 |
foreach ($atts as $attr => $value) { |
| 2027 |
if ($value !== '') { |
| 2028 |
echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" '; |
| 2029 |
} else { |
| 2030 |
echo esc_attr($attr) . ' '; |
| 2031 |
} |
| 2032 |
} |
| 2033 |
} |
| 2034 |
|
| 2035 |
protected function renderTab($atts = []) |
| 2036 |
{ |
| 2037 |
?> |
| 2038 |
<div class="fct-product-tab" data-fluent-cart-product-tab> |
| 2039 |
<?php $this->renderTabNav(); ?> |
| 2040 |
|
| 2041 |
<div class="fct-product-tab-content" data-tab-contents> |
| 2042 |
<?php $this->renderTabPane($atts); ?> |
| 2043 |
</div> |
| 2044 |
</div> |
| 2045 |
<?php |
| 2046 |
|
| 2047 |
} |
| 2048 |
|
| 2049 |
protected function renderTabNav() |
| 2050 |
{ |
| 2051 |
?> |
| 2052 |
|
| 2053 |
<div class="fct-product-tab-nav" role="tablist"> |
| 2054 |
<div class="tab-active-bar" data-tab-active-bar></div> |
| 2055 |
<?php |
| 2056 |
foreach ($this->paymentTypes as $typeKey => $typeLabel) : ?> |
| 2057 |
<div |
| 2058 |
class="fct-product-tab-nav-item <?php echo esc_attr($this->activeTab === $typeKey ? 'active' : ''); ?>" |
| 2059 |
data-tab="<?php echo esc_attr($typeKey); ?>" |
| 2060 |
role="tab" |
| 2061 |
tabindex="<?php echo $this->activeTab === $typeKey ? '0' : '-1'; ?>" |
| 2062 |
aria-selected="<?php echo $this->activeTab === $typeKey ? 'true' : 'false'; ?>" |
| 2063 |
aria-controls="<?php echo esc_attr($typeKey); ?>" |
| 2064 |
> |
| 2065 |
<?php echo esc_html($typeLabel); ?> |
| 2066 |
</div> |
| 2067 |
<?php endforeach; |
| 2068 |
?> |
| 2069 |
</div> |
| 2070 |
|
| 2071 |
<?php |
| 2072 |
} |
| 2073 |
|
| 2074 |
protected function renderTabPane($atts = []) |
| 2075 |
{ |
| 2076 |
$variantsClasses = [ |
| 2077 |
'fct-product-variants', |
| 2078 |
'column-type-' . $this->columnType, |
| 2079 |
Arr::get($atts, 'wrapper_class', ''), |
| 2080 |
]; |
| 2081 |
|
| 2082 |
foreach ($this->variantsByPaymentTypes as $variantKey => $variants): ?> |
| 2083 |
<div |
| 2084 |
data-tab-content |
| 2085 |
id="<?php echo esc_attr($variantKey); ?>" |
| 2086 |
class="fct-product-tab-pane <?php echo esc_attr($this->activeTab === $variantKey ? 'active' : ''); ?>" |
| 2087 |
role="tabpanel" |
| 2088 |
aria-labelledby="<?php echo esc_attr($variantKey); ?>" |
| 2089 |
> |
| 2090 |
<div class="<?php echo esc_attr(implode(' ', $variantsClasses)); ?>" role="radiogroup" |
| 2091 |
aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>"> |
| 2092 |
<?php |
| 2093 |
//Convert to collection safely before sorting |
| 2094 |
$variants = (new Collection($variants))->sortBy('serial_index')->values(); |
| 2095 |
|
| 2096 |
foreach ($variants as $variant) { |
| 2097 |
do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([ |
| 2098 |
'product' => $this->product, |
| 2099 |
'variant' => $variant, |
| 2100 |
'scope' => 'product_variant_item' |
| 2101 |
])); |
| 2102 |
|
| 2103 |
$this->renderVariationItem($variant, $this->defaultVariationId); |
| 2104 |
|
| 2105 |
do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([ |
| 2106 |
'product' => $this->product, |
| 2107 |
'variant' => $variant, |
| 2108 |
'scope' => 'product_variant_item' |
| 2109 |
])); |
| 2110 |
} |
| 2111 |
?> |
| 2112 |
</div> |
| 2113 |
|
| 2114 |
</div> |
| 2115 |
<?php endforeach; ?> |
| 2116 |
|
| 2117 |
<?php |
| 2118 |
} |
| 2119 |
|
| 2120 |
protected function getDefaultVariantData() |
| 2121 |
{ |
| 2122 |
if (empty($this->variants) || !$this->defaultVariationId) { |
| 2123 |
return null; |
| 2124 |
} |
| 2125 |
|
| 2126 |
foreach ($this->variants as $variant) { |
| 2127 |
if ($variant['id'] == $this->defaultVariationId) { |
| 2128 |
return $variant; |
| 2129 |
} |
| 2130 |
} |
| 2131 |
|
| 2132 |
return null; |
| 2133 |
} |
| 2134 |
} |
| 2135 |
|