product = $product;
$this->variants = $product->variants;
$this->storeSettings = new StoreSettings();
$this->viewType = $this->storeSettings->get('variation_view', 'both');
$this->columnType = $this->storeSettings->get('variation_columns', 'masonry');
$defaultVariationId = $config['default_variation_id'] ?? '';
// 'image', 'text','both'
$this->viewType = apply_filters('fluent_cart/single_product/variation_view_type', $this->viewType, [
'product' => $product,
'variants' => $this->variants,
'defaultVariationId' => $defaultVariationId,
]);
// 'one', 'two','three', 'four', 'masonry'
$this->columnType = apply_filters('fluent_cart/single_product/variation_column_type', $this->columnType, [
'product' => $product,
'variants' => $this->variants,
'defaultVariationId' => $defaultVariationId,
]);
$hasExplicitDefault = true;
if (!$defaultVariationId) {
$variationIds = $product->variants->pluck('id')->toArray();
$defaultVariationId = $product->detail->default_variation_id;
// For advanced variations the storefront selector only exposes ACTIVE
// variants (AdvancedVariationRenderer skips item_status != active), so
// resolve the default against the same set. default_variation_id is
// maintained stock/status-agnostically, so it can legitimately point
// at an inactive variant — accepting it here would render the inactive
// default's price/stock/button while the selector omits that id and
// falls back to a different active variant. Scoped to advanced so
// simple / simple_variations keep their existing default handling.
$isAdvanced = $product->detail
&& $product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
$eligibleVariants = $isAdvanced
? $product->variants->where('item_status', 'active')
: $product->variants;
$eligibleIds = $eligibleVariants->pluck('id')->toArray();
if (!$defaultVariationId || !in_array($defaultVariationId, $eligibleIds)) {
// No valid stored default — fall back to the FIRST eligible
// combination by serial_index (active-only for advanced), not the
// first by DB/id order, so the server-rendered price/stock/button
// match what the storefront selector highlights. Stock is ignored
// — first by order wins. A NULL serial_index (legacy/malformed row
// the merchant never ordered) is pushed AFTER numbered variants so
// it can't become the default ahead of an explicitly ordered one —
// PHP's default null-first sort would otherwise promote it, and the
// frontend mirrors this by treating null serial as last too.
$firstBySerial = $eligibleVariants
->sortBy(function ($variant) {
return is_null($variant->serial_index)
? PHP_INT_MAX
: (int) $variant->serial_index;
})
->first();
$defaultVariationId = $firstBySerial ? (int) $firstBySerial->id : Arr::get($variationIds, '0');
$hasExplicitDefault = false;
}
}
// Always set resolved default variation id
$this->defaultVariationId = $defaultVariationId;
// Gallery defaults to featured image (key 0) when no explicit default variation is set
$this->defaultGalleryImageId = $hasExplicitDefault ? $defaultVariationId : 0;
$this->product->variants->load('bundleChildren.product');
foreach ($this->product->variants as $variant) {
if ($variant->id == $this->defaultVariationId) {
$this->defaultVariant = $variant;
}
// Read the authoritative payment_type column, not other_info. The
// ProductVariation accessor only injects payment_type into other_info
// for subscriptions, so one-time variants (notably advanced-variation
// combinations generated by Pro) leave other_info['payment_type']
// unset. Anything that is not a subscription is a one-time path.
if ($variant->payment_type === 'subscription') {
$this->hasSubscription = true;
} else {
$this->hasOnetime = true;
}
}
$this->buildProductGroups();
}
public function buildProductGroups()
{
$groupKey = 'repeat_interval';
$otherInfo = (array)Arr::get($this->product->detail, 'other_info');
$groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
if ($groupBy !== 'none') {
if ($groupBy === 'payment_type') {
$groupKey = 'payment_type';
}
$paymentTypes = [];
if ($groupBy === 'repeat_interval') {
foreach ($this->variants as $key => $variant) {
$paymentType = 'onetime';
$type = Arr::get($variant, 'payment_type');
if ($type === 'subscription') {
$isInstallment = Arr::get($variant, 'other_info.installment', 'no');
if ($isInstallment === 'yes' && App::isProActive()) {
$paymentType = 'installment';
} else {
$paymentType = Arr::get($variant, 'other_info.repeat_interval', 'onetime');;
}
}
$paymentTypes[] = $paymentType;
if (!isset($this->variantsByPaymentTypes[$paymentType])) {
$this->variantsByPaymentTypes[$paymentType] = [];
}
$this->variantsByPaymentTypes[$paymentType][] = $variant;
if ($this->defaultVariationId == $variant['id']) {
$this->activeTab = $paymentType;
}
}
} else {
foreach ($this->variants as $key => $variant) {
$paymentType = 'onetime';
$type = Arr::get($variant, 'payment_type');
if ($type === 'subscription') {
$isInstallment = Arr::get($variant, 'other_info.installment');
if ($isInstallment === 'yes' && App::isProActive()) {
$paymentType = 'installment';
} else {
$paymentType = 'subscription';
}
}
$paymentTypes[] = $paymentType;
if (!isset($this->variantsByPaymentTypes[$paymentType])) {
$this->variantsByPaymentTypes[$paymentType] = [];
}
$this->variantsByPaymentTypes[$paymentType][] = $variant;
if ($this->defaultVariationId == $variant['id']) {
$this->activeTab = $paymentType;
}
}
}
$paymentTypes = array_unique($paymentTypes);
$intervalOptions = Helper::getAvailableSubscriptionIntervalOptions();
$groupLanguageMap = [
'onetime' => __('One Time', 'fluent-cart'),
'subscription' => __('Subscription', 'fluent-cart'),
'installment' => __('Installment', 'fluent-cart'),
];
foreach ($intervalOptions as $interval) {
$groupLanguageMap[$interval['value']] = $interval['label'];
}
foreach ($paymentTypes as $paymentType) {
$this->paymentTypes[$paymentType ?: 'onetime'] = Arr::get($groupLanguageMap, $paymentType ?: 'onetime');
}
}
}
public function render()
{
?>
renderGallery(); ?>
renderTitle();
$this->renderProductMeta();
$this->renderExcerpt();
$this->renderPrices();
if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
foreach ($this->product->variants as $variant) {
$this->renderVariationsBundleProduct($variant);
}
}
$this->renderPackageDescription();
$this->renderBuySection();
?>
renderStockAvailability(); ?>
renderSku(); ?>
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
// The buy section is the root the variation-selector JS binds to
// (data-fluent-cart-product-pricing-section). Gating it removes the
// variation picker along with the buttons, which is what a full catalog
// mode wants; to keep shoppers able to browse options while hiding only
// the purchase affordances, gate 'actions' instead.
if (!RenderGate::shouldRender('buy_section', $gateContext)) {
return;
}
// Render no buy section when there is nothing purchasable — avoids a
// broken quantity + "Not Available" block. Two cases:
// - no variants at all (any product type), or
// - an advanced-variation product with no attribute_config yet (e.g.
// switched on before options were configured). Its variants are kept
// until generation, so we key on the config, not the variant count,
// to hide it until real combinations exist.
$isUnconfiguredAdvanced = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION
&& empty(Arr::get((array) $this->product->detail->other_info, 'attribute_config'));
if ($this->product->variants->isEmpty() || $isUnconfiguredAdvanced) {
return;
}
$otherInfo = (array)Arr::get($this->product->detail, 'other_info');
$groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
$this->renderBuySectionWrapperStart();
$this->renderVariationDisplay($atts);
$this->renderItemPrice();
$this->renderQuantity();
?>
renderPurchaseButtons(Arr::get($atts, 'button_atts', [])); ?>
renderBuySectionWrapperEnd();
}
public function renderVariationDisplay($atts = [])
{
// Hand off rendering to the advanced-variation selector when the
// product is configured for advanced variations. The handler returns
// the filtered array with rendered=true after emitting its markup; if
// no listener handles it (e.g. an unconfigured advanced product), the
// filter is a no-op and we fall through to the simple-variation
// rendering below.
if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
$result = apply_filters('fluent_cart/product/render_advanced_variation', [
'product' => $this->product,
'selector_style' => Arr::get($atts, 'selector_style', 'auto'),
'rendered' => false,
]);
if (!empty($result['rendered'])) {
return;
}
}
$otherInfo = (array)Arr::get($this->product->detail, 'other_info');
$groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
if (count($this->paymentTypes) === 1 || $groupBy === 'none') {
$this->renderVariants(Arr::get($atts, 'variation_atts', []));
} else {
$this->renderTab(Arr::get($atts, 'variation_atts', []));
}
}
public function renderGalleryThumb()
{
$thumbnails = [];
$featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
// thumbnail can be an empty string (not null), so ?? above doesn't catch
// it — fall back to the placeholder so $featuredMedia is always a usable
// image URL for both the main and data-default-image-url.
if (!$featuredMedia || !\is_string($featuredMedia)) {
$featuredMedia = Vite::getAssetUrl('images/placeholder.svg');
}
$galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
if (!empty($galleryImage)) {
$thumbnails[0] = [
'media' => $galleryImage,
];
}
foreach ($this->variants as $variant) {
if (!empty($variant['media']['meta_value'])) {
$thumbnails[$variant['id']] = [
'media' => $variant['media']['meta_value'],
];
} else {
$this->defaultImageUrl = $featuredMedia;
$this->defaultImageAlt = Arr::get($variant, 'variation_title', '');
}
}
$images = empty($thumbnails) ? [] : $thumbnails;
$this->images = $images;
if (!empty($images)) {
$imageId = $this->defaultGalleryImageId;
if (isset($images[$imageId])) {
$imageMetaValue = $images[$imageId];
$this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', '');
$this->defaultImageAlt = Arr::get($imageMetaValue, 'media.0.title', '');
} else {
// Fallback to the first available thumbnail. Advanced-variation
// products often have per-variant images but no explicit
// default_variation_id — the thumbnails are keyed by real
// variant IDs while defaultGalleryImageId is 0, so the lookup
// above misses and the main image area renders blank with a
// broken-image icon.
$firstImage = reset($images);
$fallbackUrl = Arr::get($firstImage, 'media.0.url', '');
$this->defaultImageUrl = $fallbackUrl ?: ($featuredMedia ?: '');
$this->defaultImageAlt = $this->defaultImageAlt ?: Arr::get($firstImage, 'media.0.title', '');
}
}
// Nothing set a main image — e.g. a product with no variants, or no
// variant/gallery media. Fall back to the featured/placeholder image so
// the main area shows the placeholder instead of a broken .
if (empty($this->defaultImageUrl)) {
$this->defaultImageUrl = $featuredMedia;
}
$videoRenderer = $this->getProductVideoRenderer();
$videoRenderer->showFirstByDefault(!$this->hasGalleryImages());
$defaultVideoId = $videoRenderer->getDefaultMediaId();
?>
>
getProductVideoRenderer()->renderInlinePlayers(); ?>
images.
*/
protected function hasGalleryImages(): bool
{
foreach ($this->images as $image) {
foreach ((array) Arr::get($image, 'media', []) as $item) {
if (!empty(Arr::get($item, 'url'))) {
return true;
}
}
}
return false;
}
public function getProductVideoRenderer(): ProductVideoRenderer
{
if ($this->productVideoRenderer === null) {
$this->productVideoRenderer = new ProductVideoRenderer($this->product);
}
return $this->productVideoRenderer;
}
public function renderGalleryThumbControls($maxThumbnails = null)
{
$totalThumbImages = Arr::pluck($this->images, 'media.*.url');
// A lone image needs no strip, but a video thumb still has to be reachable.
if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1 && !$this->getProductVideoRenderer()->isAvailable()){
return '';
}
// A lone video without images is already showing in the main area.
if (!$this->hasGalleryImages() && count($this->getProductVideoRenderer()->getVideos()) === 1) {
return '';
}
// Build variantTermMap and variantFirstMediaMap.
// For advanced variations, Pro builds both maps via the gallery_variation_data
// filter — it has access to AttributeGroup types (color/image) that free cannot
// query directly. For all other product types, free builds variantFirstMediaMap
// from the already-loaded $this->images; variantTermMap stays empty.
$this->variantTermMap = [];
$variantFirstMediaMap = [];
if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
$galleryVariationData = apply_filters('fluent_cart/product/gallery_variation_data', [
'variant_term_map' => [],
'variant_first_media_map' => [],
], $this->product);
$this->variantTermMap = (array) Arr::get($galleryVariationData, 'variant_term_map', []);
$variantFirstMediaMap = (array) Arr::get($galleryVariationData, 'variant_first_media_map', []);
} else {
foreach ($this->images as $imageId => $image) {
if (empty($image['media']) || !is_array($image['media'])) {
continue;
}
$firstItem = $image['media'][0] ?? null;
if (!$firstItem) {
continue;
}
$firstUrl = Arr::get($firstItem, 'url', '');
$firstMediaId = (int) Arr::get($firstItem, 'id', 0);
if ($firstUrl) {
$variantFirstMediaMap[(int) $imageId] = [
'id' => $firstMediaId,
'url' => $firstUrl,
];
}
}
}
// Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs).
// For advanced variations, deduplicate by media ID (when > 0) or by URL (for imported images).
$allGalleryImages = [];
$addedMediaKeys = [];
$isAdvVariation = !empty($this->variantTermMap);
foreach ($this->images as $imageId => $image) {
if (empty($image['media']) || !is_array($image['media'])) {
continue;
}
foreach ($image['media'] as $item) {
$url = Arr::get($item, 'url', '');
$mediaId = (int) Arr::get($item, 'id', 0);
if (empty($url)) {
continue;
}
if ($isAdvVariation) {
$dedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
if (isset($addedMediaKeys[$dedupeKey])) {
continue; // skip — already added this image
}
$addedMediaKeys[$dedupeKey] = true;
}
$allGalleryImages[] = [
'url' => $url,
'title' => Arr::get($item, 'title', ''),
'variation_id' => (string) $imageId,
'term_id' => (int) ($this->variantTermMap[(int) $imageId] ?? 0),
'media_id' => $mediaId,
];
}
}
?>
renderGalleryThumbControl($maxThumbnails); ?>
variantTermMap);
$countedMediaKeys = [];
$count = 0;
$totalImages = 0;
$videoRenderer = $this->getProductVideoRenderer();
// The gallery opens on a video the admin put in front of every image,
// so no image thumb takes the selected slot in that case.
if ($videoRenderer->getDefaultMediaId()) {
$this->galleryActiveSet = true;
}
// Count unique images to render. For advanced variations, deduplicate by WP media ID
// when available, or by URL for externally imported images (media ID = 0).
foreach ($this->images as $imageId => $image) {
if (empty($image['media']) || !is_array($image['media'])) {
continue;
}
foreach ($image['media'] as $item) {
$url = Arr::get($item, 'url', '');
$mediaId = (int) Arr::get($item, 'id', 0);
if (empty($url)) {
continue;
}
if ($isAdvVariation) {
$mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
if (isset($countedMediaKeys[$mediaDedupeKey])) {
continue;
}
$countedMediaKeys[$mediaDedupeKey] = true;
}
$totalImages++;
}
}
$renderedMediaKeys = [];
// Render up to max; skip already-rendered media for advanced variation products.
foreach ($this->images as $imageId => $image) {
if (empty($image['media']) || !is_array($image['media'])) {
continue;
}
foreach ($image['media'] as $item) {
$url = Arr::get($item, 'url', '');
$mediaId = (int) Arr::get($item, 'id', 0);
if (empty($url)) {
continue;
}
if ($isAdvVariation) {
$mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
if (isset($renderedMediaKeys[$mediaDedupeKey])) {
continue;
}
$renderedMediaKeys[$mediaDedupeKey] = true;
}
if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
$videoRenderer->renderThumbControls(!$this->galleryActiveSet);
$this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
return;
}
// Videos the admin dragged in front of this image come first.
$videoRenderer->renderThumbControlsBefore($count, !$this->galleryActiveSet);
$termId = (int) ($this->variantTermMap[(int) $imageId] ?? 0);
$this->renderGalleryThumbControlButton($item, $imageId, $termId, $mediaId);
$count++;
}
}
$videoRenderer->renderThumbControls(!$this->galleryActiveSet);
}
public function renderGallerySeeMoreButton($remainingCount)
{
?>
defaultVariationId ? 'is-hidden' : '';
$itemUrl = Arr::get($item, 'url', '');
$itemTitle = Arr::get($item, 'title', '');
$isSelected = !$this->galleryActiveSet && $imageId == $this->defaultGalleryImageId;
if ($isSelected) {
$this->galleryActiveSet = true;
}
?>
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRender('image', $gateContext)) {
return;
}
$defaults = [
'thumbnail_mode' => 'all', // horizontal, vertical
'thumb_position' => 'bottom', // bottom, left, right, top
'scrollable_thumbs' => 'no', // yes / no
'max_thumbnails' => null, // null = no limit, integer = max visible
];
$atts = wp_parse_args($args, $defaults);
$thumbnailMode = $atts['thumbnail_mode'];
$wrapperAtts = [
'class' => 'fct-product-gallery-wrapper ' . 'thumb-pos-' . $atts['thumb_position'] . ' thumb-mode-' . $thumbnailMode,
'data-fct-product-gallery' => '',
'data-fluent-cart-product-gallery-wrapper' => '',
'data-thumbnail-mode' => $thumbnailMode,
'data-product-id' => $this->product->ID,
'data-scrollable-thumbs' => $atts['scrollable_thumbs'],
];
?>
>
renderGalleryThumb();
$this->renderGalleryThumbControls($atts['max_thumbnails']);
?>
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRender('title', $gateContext)) {
return;
}
do_action('fluent_cart/product/single/before_title_block', $gateContext);
?>
product->post_title); ?>
product->detail->getStockAvailability();
if (!Arr::get($stockAvailability, 'manage_stock')) {
return '';
}
$isStock = $this->product->isStock();
// Check default variant stock for both simple and variable products
if ($this->defaultVariant) {
$isStock = $isStock && $this->defaultVariant->isStock();
}
$stockLabel = Arr::get($stockAvailability, 'availability');
$statusClass = $stockAvailability['class'] ?? '';
// Optional per-status custom labels (e.g. set on the Bricks Product Stock
// element via the fluent_cart/product_stock_availability filter). Emitted as
// data-attributes so the frontend JS, which re-derives the badge text on load
// and on variant switches, prefers them over the generic label map instead of
// overwriting them. Absent for the default template, so behavior is unchanged.
$inStockText = Arr::get($stockAvailability, 'in_stock_text');
$outOfStockText = Arr::get($stockAvailability, 'out_of_stock_text');
// The variant-level check above can override the aggregate stock_availability
// used for $stockLabel/$statusClass (e.g. this specific default variant is out
// of stock even though the product overall has other in-stock variants) — keep
// the label and class in sync so the badge never shows mismatched text/color.
// Honor the custom out-of-stock label here too so it survives this override on
// first load, matching what the frontend JS shows after a variant switch.
if (!$isStock) {
$statusClass = 'out-of-stock';
$stockLabel = !empty($outOfStockText) ? $outOfStockText : __('Out of Stock', 'fluent-cart');
}
$badgeAttributes = '';
if (!empty($inStockText)) {
$badgeAttributes .= sprintf(' data-in-stock-text="%s"', esc_attr($inStockText));
}
if (!empty($outOfStockText)) {
$badgeAttributes .= sprintf(' data-out-of-stock-text="%s"', esc_attr($outOfStockText));
}
echo sprintf(
'',
esc_attr($statusClass),
$wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
esc_html__('Availability:', 'fluent-cart'),
esc_html($stockLabel),
$badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
);
}
public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
{
if (!$label) {
$label = __('SKU:', 'fluent-cart');
}
$labelHtml = '';
if ($showLabel && $label) {
$labelHtml = sprintf('%s ', esc_html($label));
}
if ($variant) {
if (empty($variant->sku)) {
return;
}
echo sprintf(
'',
$wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$labelHtml,
esc_html($variant->sku)
);
return;
}
foreach ($this->product->variants as $v) {
if (empty($v->sku)) {
continue;
}
$isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
echo sprintf(
'',
$isHidden,
esc_attr($v->id),
$wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$labelHtml,
esc_html($v->sku)
);
}
}
/**
* @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)
{
(new PackageDescriptionRenderer($this->product))->renderPackageDescription(
$wrapper_attributes,
$showName,
$showDimensions,
$showProductWeight,
$showTotalWeight,
$variant,
$this->defaultVariant
);
}
/**
* Build a JSON string of package info for a variant (used as data attribute for JS switching).
*/
private function getVariantPackageInfoJson(ProductVariation $variant)
{
if ($variant->fulfillment_type !== 'physical') {
return '';
}
$otherInfo = $variant->other_info ?: [];
$packageSlug = Arr::get($otherInfo, 'package_slug', '');
$package = Helper::getPackageBySlug($packageSlug);
if (!$package) {
return '';
}
static $storeWeightUnit = null;
if ($storeWeightUnit === null) {
$storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
}
// Format dimensions
$length = Arr::get($package, 'length', '');
$width = Arr::get($package, 'width', '');
$height = Arr::get($package, 'height', '');
$dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
$dimensionParts = array_filter([$length, $width, $height], function ($val) {
return $val !== '' && $val !== null && $val != 0;
});
$formattedDimensions = $dimensionParts
? implode(' × ', $dimensionParts) . ' ' . $dimensionUnit
: '';
// Calculate weights
$productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
$productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
$convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
$packageWeight = floatval(Arr::get($package, 'weight', 0));
$packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
$convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
$totalWeight = $convertedProductWeight + $convertedPackageWeight;
// Format weights
$formattedProductWeight = $convertedProductWeight
? rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
: '';
$formattedShippingWeight = ($totalWeight && $convertedPackageWeight)
? rtrim(rtrim(number_format($totalWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
: '';
return wp_json_encode([
'name' => Arr::get($package, 'name', ''),
'dimensions' => $formattedDimensions,
'product_weight' => $formattedProductWeight,
'shipping_weight' => $formattedShippingWeight,
]);
}
public function renderExcerpt()
{
$excerpt = $this->product->post_excerpt;
if (!$excerpt) {
return;
}
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRender('excerpt', $gateContext)) {
return;
}
do_action('fluent_cart/product/single/before_excerpt_block', $gateContext);
?>
product->ID);
if (!$productPost || empty($productPost->post_content)) {
return;
}
global $post;
$originalPost = $post;
$post = $productPost;
setup_postdata($post);
$content = apply_filters('the_content', $productPost->post_content);
$post = $originalPost;
if ($originalPost) {
setup_postdata($originalPost);
} else {
wp_reset_postdata();
}
?>
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRender('price', $gateContext)) {
return;
}
if ($this->product->detail->variation_type === 'simple') {
// we have to render for the simple product
$first_price = $this->product->variants()->first();
$itemPrice = $first_price ? $first_price->item_price : 0;
$itemPrice = apply_filters('fluent_cart/product/display_price', $itemPrice, [
'product' => $this->product,
'variation' => $first_price,
]);
$itemPrice = (int)$itemPrice;
$comparePrice = $first_price ? (int)$first_price->compare_price : 0;
if ($comparePrice <= $itemPrice) {
$comparePrice = 0;
}
do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
'product' => $this->product,
'current_price' => $itemPrice,
'scope' => 'price_range'
]));
?>
$this->product,
'variant' => $first_price,
'current_price' => $itemPrice,
'scope' => 'price_range'
])); ?>
product, $first_price, 'price_range'); ?>
$this->product,
'current_price' => $itemPrice,
'scope' => 'price_range'
]));
return;
}
$min_price = $this->product->detail->min_price;
$max_price = $this->product->detail->max_price;
do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
'product' => $this->product,
'current_price' => $min_price,
'scope' => 'price_range'
]));
?>
$min_price): ?>
-
$this->product,
'current_price' => $min_price,
'scope' => 'price_range'
])); ?>
$this->product,
'current_price' => $min_price,
'scope' => 'price_range'
]));
}
public function renderVariants($atts = [])
{
if ($this->product->detail->variation_type === 'simple') {
return;
}
$variants = $this->product->variants;
if (!$variants || $variants->isEmpty()) {
return;
}
// Sort by serial_index ascending
$variants = $variants->sortBy('serial_index')->values();
$classes = array_filter([
'fct-product-variants',
'column-type-' . $this->columnType,
Arr::get($atts, 'wrapper_class', ''),
]);
?>
$this->product,
'variant' => $variant,
'scope' => 'product_variant_item'
]));
$this->renderVariationItem($variant, $this->defaultVariationId);
do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
'product' => $this->product,
'variant' => $variant,
'scope' => 'product_variant_item'
]));
} ?>
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant))) {
return;
}
if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
return; // for simple product we already rendered the price
}
do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
'product' => $this->product,
'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
'scope' => 'product_variant_price'
]));
foreach ($this->product->variants as $variant) {
if ($this->shouldRenderPriceInPriceSection()) {
$this->renderVariantPricingWrapperStart($variant);
$paymentType = Arr::get($variant->other_info, 'payment_type', 'onetime');
if (!$this->hasSubscription) {
$this->renderVariationComparePrice($variant);
$this->applyVariationPriceFilter($variant, $paymentType);
} else {
$atts = [
'class' => 'fct-product-payment-type fluent-cart-product-variation-content' . ($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''),
'data-fluent-cart-product-payment-type' => '',
'data-variation-id' => $variant->id
];
$this->renderComparePriceWrapperStart($atts);
$this->renderVariationComparePrice($variant);
$this->applyVariationPriceFilter($variant, $paymentType);
$this->renderComparePriceWrapperEnd();
}
$this->renderVariantPricingWrapperEnd();
}
}
foreach ($this->product->variants as $variant) {
$this->renderVariationsBundleProduct($variant);
}
do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
'product' => $this->product,
'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
'scope' => 'product_variant_price'
]));
}
public function shouldRenderPriceInPriceSection(): bool
{
// simple_variations keeps the legacy inline price flow for the
// one-column layouts that render the variant title (text-only and
// image+text), where the price sits on each variant row.
// Every other variation type always renders the price in the dedicated
// below-block so the data-fluent-cart-product-item-price element keeps
// a consistent position across every view/column combination.
if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_SIMPLE_VARIATION) {
$isInlineRowLayout = in_array($this->viewType, ['text', 'both'], true)
&& $this->columnType === 'one';
return !$isInlineRowLayout;
}
return true;
}
public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
{
$priceText = $paymentType === 'onetime' ? Helper::toDecimal($variant->item_price) : $variant->getSubscriptionTermsText(true);
echo wp_kses_post(apply_filters('fluent_cart/single_product/variation_price', esc_html($priceText), [
'product' => $this->product,
'variant' => $variant,
'scope' => 'product_variant_price'
]));
do_action('fluent_cart/product/after_price', RenderContext::decorate([
'product' => $this->product,
'variant' => $variant,
'current_price' => $variant->item_price,
'scope' => 'product_variant_price'
]));
RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
}
public function renderComparePriceWrapperStart($atts = [])
{
?>
renderAttributes($atts); ?> >
compare_price) {
return;
} ?>
compare_price)); ?>
bundleChildren) == 0) {
return;
}
if(is_object($variant->bundleChildren)) {
$bundleProducts = $variant->bundleChildren->toArray();
}else{
$bundleProducts = $variant->bundleChildren;
}
$total = count($bundleProducts);
?>
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRender('quantity', $gateContext)) {
return;
}
$soldIndividually = $this->product->soldIndividually();
if (!$this->hasOnetime || $soldIndividually) {
return;
}
$attributes = [
'data-fluent-cart-product-quantity-container' => '',
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
'data-variation-type' => $this->product->detail->variation_type,
'data-payment-type' => 'onetime',
'class' => 'fct-product-quantity-container'
];
$defaultVariantData = $this->getDefaultVariantData();
if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
$attributes['class'] .= ' is-hidden';
}
do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
'product' => $this->product,
'scope' => 'product_quantity_block'
]));
?>
renderAttributes($attributes); ?>>
$this->product,
'scope' => 'product_quantity_block'
]));
}
public function renderPurchaseButtons($atts = [])
{
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRender('actions', $gateContext)) {
return;
}
do_action('fluent_cart/product/single/before_actions_block', $gateContext);
$buyNowButtonAtts = $atts;
$this->renderBuyNowButton($buyNowButtonAtts);
$this->renderAddToCartButton($atts);
do_action('fluent_cart/product/single/after_actions_block', $gateContext);
}
public function renderBuyNowButton($atts = [])
{
$gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) {
return;
}
// Stock management check using isStock() method
// if (ModuleSettings::isActive('stock_management')) {
// if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
// if (!$this->defaultVariant->isStock()) {
// echo '' . esc_html__('Out of stock', 'fluent-cart') . ' ';
// return;
// }
// }
// }
$defaults = [
'buy_now_text' => __('Buy Now', 'fluent-cart'),
'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
];
$atts = wp_parse_args($atts, $defaults);
$enableModalCheckout = Helper::isModalCheckoutEnabled();
$isInStock = true;
if (ModuleSettings::isActive('stock_management')) {
$isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
}
$stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
$variationClass = 'fluent-cart-direct-checkout-button';
if (!$isInStock) {
$variationClass .= ' is-hidden';
}
$buyNowAttributes = [
'data-fluent-cart-direct-checkout-button' => '',
'data-variation-type' => $this->product->detail->variation_type,
'class' => $variationClass,
'data-stock-availability' => $stockStatus,
'data-quantity' => '1',
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
'data-url' => site_url('?fluent-cart=instant_checkout&item_id='),
];
if ($isInStock) {
$buyNowAttributes['href'] = site_url('?fluent-cart=instant_checkout&item_id=') . ($this->defaultVariant ? $this->defaultVariant->id : '') . '&quantity=1';
}
if ($enableModalCheckout) {
$buyNowAttributes['data-fct-instant-checkout-button'] = '';
$buyNowAttributes['data-enable-modal-checkout'] = 'yes';
}
$isShortcode = !empty($atts['is_shortcode']);
if ($isShortcode) {
ob_start();
$this->renderAttributes($buyNowAttributes);
$wrapperAttributes = ob_get_clean();
} else {
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
}
$buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
'product' => $this->product
]);
?>
defaultVariant ? $this->defaultVariant->variation_title : '';
$buyNowAriaLabel = $variantTitle
? sprintf(
/* translators: 1: Button text (e.g. "Buy Now"), 2: Variant name */
__('%1$s - %2$s', 'fluent-cart'),
$buyButtonText,
$variantTitle
)
: $buyButtonText;
?>
aria-label="">
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
// Same gate as the in-section button: a page assembled from standalone
// button blocks must honour catalog mode too, or the gate leaks.
if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) {
return;
}
$text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
$variantIds = Arr::get($atts, 'variant_ids', []);
$variantId = Arr::get($variantIds, 0);
$customClass = trim(Arr::get($atts, 'class', ''));
$extraClass = trim(Arr::get($atts, 'extra_class', ''));
$defaults = [
'buy_now_text' => $text,
'target' => '',
'rel' => '',
'is_shortcode' => false,
];
$atts = wp_parse_args($atts, $defaults);
$enableModalCheckout = Arr::get($atts, 'enable_modal_checkout', false);
$isInStock = true;
if (ModuleSettings::isActive('stock_management')) {
$isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
}
$stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
$checkoutUrl = add_query_arg([
'fluent-cart' => $enableModalCheckout ? 'modal_checkout' : 'instant_checkout',
'item_id' => $variantId ?? '',
'quantity' => 1
], site_url());
$buyNowClass = $customClass ?: 'wp-block-button__link wp-element-button';
if ($extraClass) {
$buyNowClass .= ' ' . $extraClass;
}
$buyNowClass = trim($buyNowClass);
if ($stockStatus === 'out-of-stock') {
$buyNowClass .= ' out-of-stock';
}
$buyNowAttributes = [
'data-fluent-cart-direct-checkout-button' => '',
'data-variation-type' => $this->product->detail->variation_type,
'class' => $buyNowClass,
'data-stock-availability' => $stockStatus,
'data-quantity' => '1',
'data-cart-id' => $variantId ?? '',
'data-url' => $checkoutUrl,
];
if ($stockStatus === 'out-of-stock') {
$buyNowAttributes['aria-disabled'] = 'true';
} else {
$buyNowAttributes['href'] = $checkoutUrl;
}
$target = Arr::get($atts, 'target');
if ($target) {
$buyNowAttributes['target'] = $target;
if (strtolower($target) === '_blank') {
$buyNowAttributes['rel'] = Arr::get($atts, 'rel', 'noopener noreferrer');
}
}
if ($enableModalCheckout) {
$buyNowAttributes['data-fct-instant-checkout-button'] = '';
$buyNowAttributes['data-enable-modal-checkout'] = 'yes';
}
$isShortcode = !empty($atts['is_shortcode']);
if ($isShortcode) {
ob_start();
$this->renderAttributes($buyNowAttributes);
$wrapperAttributes = ob_get_clean();
} else {
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
}
$buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
'product' => $this->product
]);
?>
aria-label="">
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
return;
}
$defaults = [
'buy_now_text' => __('Buy Now', 'fluent-cart'),
'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
];
$atts = wp_parse_args($atts, $defaults);
$cartAttributes = [
'data-fluent-cart-add-to-cart-button' => '',
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
'data-product-id' => $this->product->ID,
'class' => 'fluent-cart-add-to-cart-button',
'data-variation-type' => $this->product->detail->variation_type,
'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
];
$defaultVariantData = $this->getDefaultVariantData();
// If product is subscription-only, hide add-to-cart
if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
$cartAttributes['class'] .= ' is-hidden';
}
// Check stock availability using both product-level and variant-level
$isOutOfStock = false;
if (ModuleSettings::isActive('stock_management')) {
if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
$isOutOfStock = true;
$cartAttributes['disabled'] = 'disabled';
$cartAttributes['class'] .= ' out-of-stock';
$cartAttributes['aria-disabled'] = 'true';
$atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
}
}
$addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
'product' => $this->product
]);
$isShortcode = !empty($atts['is_shortcode']);
if ($isShortcode) {
ob_start();
$this->renderAttributes($cartAttributes);
$wrapperAttributes = ob_get_clean();
} else {
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
}
// Render add to cart when the product supports a one-time path, when out
// of stock (to show "Not Available"), or for advanced-variation products.
// The advanced selector toggles this button's visibility / disabled /
// payment-type state per selection, so it must exist in the DOM even for
// a subscription-only product whose in-stock default starts hidden via
// the is-hidden class applied above — otherwise an out-of-stock
// subscription combination has no button to surface "Not Available".
$isAdvancedVariation = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
if ($this->hasOnetime || $isOutOfStock || $isAdvancedVariation) :
?>
defaultVariant ? $this->defaultVariant->variation_title : '';
$addToCartAriaLabel = $variantTitle
? sprintf(
/* translators: 1: Button text (e.g. "Add To Cart"), 2: Variant name */
__('%1$s - %2$s', 'fluent-cart'),
$addToCartText,
$variantTitle
)
: $addToCartText;
?>
aria-label="">
product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
// Same gate as the in-section button — see renderBuyNowButtonBlock().
if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
return;
}
$text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
$customClass = trim(Arr::get($atts, 'class', ''));
$extraClass = trim(Arr::get($atts, 'extra_class', ''));
$defaults = [
'add_to_cart_text' => $text,
];
$atts = wp_parse_args($atts, $defaults);
$buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button'];
$buttonClass = $customClass ?: implode(' ', $buttonClasses);
$cartAttributes = [
'data-fluent-cart-add-to-cart-button' => '',
'class' => $buttonClass,
'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
'data-product-id' => $this->product->ID,
'data-variation-type' => $this->product->detail->variation_type,
'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
];
if ($extraClass) {
$cartAttributes['class'] .= ' ' . $extraClass;
}
// If the product does NOT support one-time purchase
if (!$this->hasOnetime) {
if (Helper::isAdminUser()) {
$view = '' . esc_html__('Add to Cart is not supported for subscription product', 'fluent-cart') . '
';
FrontendView::make('', $view);
return;
}
return;
}
// Check stock availability using both product-level and variant-level
if (ModuleSettings::isActive('stock_management')) {
if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
$cartAttributes['disabled'] = 'disabled';
$cartAttributes['class'] .= ' out-of-stock';
$cartAttributes['aria-disabled'] = 'true';
$atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
}
}
$addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
'product' => $this->product
]);
$isShortcode = !empty($atts['is_shortcode']);
if ($isShortcode) {
ob_start();
$this->renderAttributes($cartAttributes);
$wrapperAttributes = ob_get_clean();
} else {
$wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
}
?>
aria-label="">
available;
if (!$variant->manage_stock) {
$availableStocks = 'unlimited';
}
$comparePrice = $variant->compare_price;
if ($comparePrice <= $variant->item_price) {
$comparePrice = '';
}
if ($comparePrice) {
$comparePrice = Helper::toDecimal($comparePrice);
}
$paymentType = Arr::get($variant->other_info, 'payment_type');
$itemClasses = [
'fct-product-variant-item',
'fct_price_type_' . $paymentType,
'fct_variation_view_type_' . $this->viewType,
];
if ($variant->media_id) {
$itemClasses[] = 'fct-item-has-image';
}
if ($variant->id == $defaultId) {
$itemClasses[] = 'selected';
}
$renderingAttributes = [
'data-fluent-cart-product-variant' => '',
'data-cart-id' => $variant->id,
'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
'data-default-variation-id' => $defaultId,
'data-payment-type' => $paymentType,
'data-available-stock' => $availableStocks,
'data-item-price' => Helper::toDecimal($variant->item_price),
'data-compare-price' => $comparePrice,
'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
'data-sku' => $variant->sku ?? '',
'data-package-info' => $this->getVariantPackageInfoJson($variant),
];
if ($paymentType === 'subscription') {
$renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true);
$repeatInterval = Arr::get($variant->other_info, 'repeat_interval', '');
$hasInstallment = Arr::get($variant->other_info, 'has_installment') === 'yes';
$itemClasses[] = 'fct_sub_interval_' . $repeatInterval;
if ($hasInstallment) {
$itemClasses[] = 'fct_sub_has_installment';
}
}
if ($extraClasses) {
$itemClasses = array_merge($itemClasses, $extraClasses);
}
$itemClasses = array_filter($itemClasses);
$renderingAttributes['class'] = implode(' ', $itemClasses);
$itemPrice = $variant->item_price;
$comparePrice = $variant->compare_price;
if (!$comparePrice || $comparePrice <= $itemPrice) {
$comparePrice = 0;
}
?>
renderAttributes($renderingAttributes); ?>
role="radio"
tabindex="id == $defaultId ? '0' : '-1'; ?>"
aria-checked="id == $defaultId ? 'true' : 'false'; ?>"
aria-label="variation_title); ?>"
>
viewType === 'image'): ?>
renderTooltip($variant); ?>
viewType === 'both' || $this->viewType === 'image') {
$this->renderVariantImage($variant);
}
?>
viewType === 'both' || $this->viewType === 'text'): ?>
variation_title); ?>
shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?>
renderSubscriptionInfo($variant); ?>
shouldRenderPriceInPriceSection()): ?>
variation_title); ?>
thumbnail;
if (!$image) {
$image = Vite::getAssetUrl('images/placeholder.svg');
}
?>
getSubscriptionTermsText(true);
if (!$info) {
return '';
}
?>
$value) {
if ($value !== '') {
echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
} else {
echo esc_attr($attr) . ' ';
}
}
}
protected function renderTab($atts = [])
{
?>
renderTabNav(); ?>
renderTabPane($atts); ?>
paymentTypes as $typeKey => $typeLabel) : ?>
columnType,
Arr::get($atts, 'wrapper_class', ''),
];
foreach ($this->variantsByPaymentTypes as $variantKey => $variants): ?>
sortBy('serial_index')->values();
foreach ($variants as $variant) {
do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
'product' => $this->product,
'variant' => $variant,
'scope' => 'product_variant_item'
]));
$this->renderVariationItem($variant, $this->defaultVariationId);
do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
'product' => $this->product,
'variant' => $variant,
'scope' => 'product_variant_item'
]));
}
?>
variants) || !$this->defaultVariationId) {
return null;
}
foreach ($this->variants as $variant) {
if ($variant['id'] == $this->defaultVariationId) {
return $variant;
}
}
return null;
}
}