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(); ?>
renderStarRating(); ?>
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 renderStarRating()
{
if (!ModuleSettings::isActive('reviews')) {
return;
}
// Store-level toggles: Settings → Store Settings → Product Page →
// Product Rating. Shop grid/carousels gate on show_rating_in_shop;
// relevant/related product sections gate on show_rating_in_relevant
// (callers tag those cards with config rating_context = 'relevant').
// The explicit product-rating block (renderStarRatingBlock) stays
// unaffected — it is user-placed.
$ratingContext = Arr::get($this->config, 'rating_context', 'shop');
$ratingVisibilitySettingKey = $ratingContext === 'relevant'
? 'show_rating_in_relevant'
: 'show_rating_in_shop';
if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
return;
}
$otherInfo = $this->product->detail->other_info ?: [];
$avgRating = Arr::get($otherInfo, 'average_rating', 0);
$reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
if ($reviewCount < 1) {
return;
}
$fullStars = (int) floor($avgRating);
$halfStar = ($avgRating - $fullStars) >= 0.5;
$emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
?>
★';
}
if ($halfStar) {
echo '★★';
}
for ($i = 0; $i < $emptyStars; $i++) {
echo '★';
}
?>
()
product->detail->other_info ?: [];
$avgRating = Arr::get($otherInfo, 'average_rating', 0);
$reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
$fullStars = (int) floor($avgRating);
$halfStar = ($avgRating - $fullStars) >= 0.5;
$emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
?>
role="img" aria-label="">
★';
}
if ($halfStar) {
echo '★★';
}
for ($i = 0; $i < $emptyStars; $i++) {
echo '★';
}
?>
()
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(
'',
$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;
}
// Product::$thumbnail already falls back to the placeholder, which
// hides whether this is one, so ask for the featured image directly.
$detail = $this->product->detail;
$image = $detail ? Arr::get((array) $detail->featured_media, 'url') : '';
$isPlaceholder = false;
if (!$image) {
$image = Helper::getProductPlaceholderUrl();
$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'
]));
?>
$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;
}
}