product = $product; $this->viewUrl = $product->view_url; $this->config = $config; } public function renderWrapperStart() { } public function renderWrapperEnd() { } public function render() { AssetLoader::loadSingleProductAssets(); // Lets extensions (e.g. Pro's advanced-variation selector) enqueue the // CSS/JS a product purchase UI needs. Backs the card surfaces that go // through this renderer's render() — Shop, Product List, the product // shortcode, and the quick-view modal. Surfaces that render card parts // via the individual render* methods instead of render() (Product // Carousel, Related Products) fire this same hook from their own block // render. Guarded with a static flag so a grid of N cards fires it once // per request, not once per card. No payload — assets are uniform. static $assetsHookFired = false; if (!$assetsHookFired) { $assetsHookFired = true; do_action('fluent_cart/advanced_variation/enqueue_assets'); } $cursor = ''; if (!empty($this->config['cursor'])) { $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"'; } $cardWidth = ''; $cardWidthValue = Arr::get($this->config, 'card_width', ''); if ($cardWidthValue) { $widthValue = $cardWidthValue === '100%' ? '100%' : intval($cardWidthValue) . 'px'; $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"'; } // Card wrapper classes are filterable so an integration can tag cards // (sale, low-stock, layout variants) without wrapping or re-rendering // the whole card. Sanitised in RenderGate, so listeners may return a // plain array of class names. $cardClasses = RenderGate::cardClasses(['fct-product-card'], [ 'product' => $this->product, 'scope' => RenderGate::SCOPE_CARD, ]); do_action('fluent_cart/product/group/before_card', RenderContext::decorate([ 'product' => $this->product, 'scope' => RenderGate::SCOPE_CARD, ])); ?>
aria-label="product->post_title)); ?>"> renderProductImage(); ?> renderTitle(); ?> config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?> renderPrices(); ?> showBuyButton(); ?>
$this->product, 'scope' => RenderGate::SCOPE_CARD, ])); } /** * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly. * Kept as a compatibility shim for external callers (themes, extensions). * * @param array $otherInfo Order item's other_info array * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg" */ public static function buildPackageInfoFromOtherInfo($otherInfo) { return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo); } /** * @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly. * Kept as a compatibility shim for external callers (themes, extensions) — * package-description rendering now lives in PackageDescriptionRenderer. */ public function renderPackageDescription( $wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null, $defaultVariant = null ) { (new PackageDescriptionRenderer($this->product))->renderPackageDescription( $wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant, $defaultVariant ); } public function renderExcerpt($atts = '') { if (empty($this->product->post_excerpt)) { return; } $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD); if (!RenderGate::shouldRender('excerpt', $gateContext)) { return; } do_action('fluent_cart/product/group/before_excerpt_block', $gateContext); echo sprintf( '

%2$s

', $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped wp_kses_post($this->product->post_excerpt), ); do_action('fluent_cart/product/group/after_excerpt_block', $gateContext); } public function renderTitle($atts = '', $config = []) { $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD); if (!RenderGate::shouldRender('title', $gateContext)) { return; } do_action('fluent_cart/product/group/before_title_block', $gateContext); $link = Arr::get($config, 'isLink', true); $target = Arr::get($config, 'target', '_self'); $titleText = esc_html($this->product->post_title); if ($link) { // Render as link $targetAttr = $target === '_blank' ? 'target="_blank" rel="noopener noreferrer"' : ''; echo sprintf( '

%6$s

', $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_attr($this->product->ID), esc_url($this->product->view_url), esc_attr($this->product->post_title), $targetAttr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_html($titleText) ); } else { // Render as plain text (no link) echo sprintf( '

%2$s

', $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_html($titleText) ); } do_action('fluent_cart/product/group/after_title_block', $gateContext); } public function renderProductImage() { if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) { return; } $image = $this->product->thumbnail; $isPlaceholder = false; if (!$image) { $image = Vite::getAssetUrl('images/placeholder.svg'); $isPlaceholder = true; } $altText = $isPlaceholder ? sprintf( /* translators: %s: product title */ __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title) : $this->product->post_title; do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([ 'product' => $this->product, 'scope' => 'product_card' ])); ?> <?php echo esc_attr($altText); ?> $this->product, 'scope' => 'product_card' ])); } public function renderPrices($wrapper_attributes = '') { if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) { return; } $priceFormat = Arr::get($this->config, 'price_format', 'starts_from'); $isSimple = $this->product->detail->variation_type === 'simple'; $minPrice = $this->product->detail->min_price; $maxPrice = $this->product->detail->max_price; $comparePrice = 0; $firstVariant = null; if ($isSimple) { $firstVariant = $this->product->variants->first(); if ($firstVariant) { $minPrice = $firstVariant->item_price; $minPrice = apply_filters('fluent_cart/product/display_price', $minPrice, [ 'product' => $this->product, 'variation' => $firstVariant, ]); $minPrice = (int)$minPrice; if ($firstVariant->compare_price > $minPrice) { $comparePrice = $firstVariant->compare_price; } } } $formattedMinPrice = Helper::toDecimal($minPrice); $formattedMaxPrice = Helper::toDecimal($maxPrice); $formattedComparePrice = Helper::toDecimal($comparePrice); do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([ 'product' => $this->product, 'current_price' => $minPrice, 'scope' => 'product_card' ])); ?>
class="fct-product-card-prices" role="region" aria-label=""> $minPrice): ?> $this->product, 'variant' => $firstVariant, 'current_price' => $minPrice, 'scope' => 'product_card' ])); RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card'); ?>
$this->product, 'current_price' => $minPrice, 'scope' => 'product_card' ])); } public function showBuyButton($atts = '') { $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD); // 'actions' gates the whole affordance slot, including the disabled // out-of-stock placeholder — hiding the row should not leave a stray // "Not Available" button behind. if (!RenderGate::shouldRender('actions', $gateContext)) { return; } do_action('fluent_cart/product/group/before_actions_block', $gateContext); $this->renderBuyButtonMarkup($atts, $gateContext); do_action('fluent_cart/product/group/after_actions_block', $gateContext); } protected function renderBuyButtonMarkup($atts, array $gateContext) { $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock(); if ($isOutOfStock) { $soldOutText = __('Not Available', 'fluent-cart'); ?> product->detail->variation_type === 'simple'; $firstVariant = null; $buttonHref = $this->viewUrl; if ($isSimple) { $firstVariant = $this->product->variants->first(); if ($firstVariant) { // return ''; } } $isInstantCheckout = false; $hasSubscription = $this->product->has_subscription; $buttonText = __('View Options', 'fluent-cart'); $ariaLabel = sprintf( /* translators: %s: product title */ __('View options for %s', 'fluent-cart'), $this->product->post_title); if ($isSimple) { if ($hasSubscription) { $buttonText = __('Buy Now', 'fluent-cart'); $ariaLabel = sprintf( /* translators: %s: product title */ __('Buy %s now', 'fluent-cart'), $this->product->post_title); $buttonHref = $firstVariant->getPurchaseUrl(); $isInstantCheckout = true; } else { $buttonText = __('Add To Cart', 'fluent-cart'); $ariaLabel = sprintf( /* translators: %s: product title */ __('Add %s to cart', 'fluent-cart'), $this->product->post_title); } } // The card fills this slot with one of three things: an instant-checkout // Buy Now anchor (simple + subscription), an Add to Cart button (simple), // or a "View Options" link through to the product page (variable). The // first two are purchase affordances and answer to their own gates. // "View Options" is navigation, so it stays under 'actions' alone — a // catalog-mode listener hides the buying, not the browsing. if ($isInstantCheckout) { if (!RenderGate::shouldRender('buy_now_button', $gateContext)) { return; } } elseif ($firstVariant) { if (!RenderGate::shouldRender('add_to_cart_button', $gateContext)) { return; } } $buttonAttributes = [ 'class' => 'fct-product-view-button fct-single-product-card-view-button', 'data-product-id' => $this->product->ID, 'data-fluent-cart-single-product-card-view-button' => '', 'aria-label' => $ariaLabel ]; if ($firstVariant) { $buttonAttributes = [ 'data-cart-id' => $firstVariant->id, 'class' => 'fluent-cart-add-to-cart-button', 'data-variation-type' => $this->product->detail->variation_type, 'data-fluent-cart-add-to-cart-button' => '', 'data-is-custom' => false, 'aria-label' => $ariaLabel ]; } $customAttributes = $this->parseAttributes($atts); if (!empty($customAttributes)) { if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) { $buttonAttributes['class'] .= ' ' . $customAttributes['class']; unset($customAttributes['class']); } $buttonAttributes = array_merge($buttonAttributes, $customAttributes); } $anchorAttributes = [ 'href' => $buttonHref, 'class' => 'fct-product-view-button', 'aria-label' => $ariaLabel, ]; if ($enableModalCheckout) { $anchorAttributes['data-fct-instant-checkout-button'] = ''; $anchorAttributes['data-enable-modal-checkout'] = 'yes'; } if ($isInstantCheckout) { $parsedCustomAttributes = $this->parseAttributes($atts); if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) { $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class']; unset($parsedCustomAttributes['class']); } $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes); } ?> renderAttributes($anchorAttributes); ?>> $value) { if ($value !== '') { echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" '; } else { echo esc_attr($attr) . ' '; } } } private function parseAttributes($atts) { if (empty($atts)) { return []; } $attributes = []; // Match attribute="value" or attribute='value' or attribute=value preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER); foreach ($matches as $match) { if (!empty($match[1])) { // Quoted value $attributes[$match[1]] = $match[3]; } elseif (!empty($match[4])) { // Unquoted value $attributes[$match[4]] = $match[5]; } } return $attributes; } }