ID ?? 0) : 0; } /** @param object|null $product */ public static function formattedPrice($product): string { $detail = $product->detail ?? null; if (!$detail || !class_exists('FluentCart\\App\\Helpers\\Helper')) { return ''; } $minimum = (int) ($detail->min_price ?? 0); $maximum = (int) ($detail->max_price ?? 0); $price = \FluentCart\App\Helpers\Helper::toDecimal($minimum); if ($maximum > $minimum) { $price .= ' - ' . \FluentCart\App\Helpers\Helper::toDecimal($maximum); } return wp_strip_all_tags(html_entity_decode((string) $price, ENT_QUOTES, 'UTF-8')); } /** @param object|null $product */ public static function sku($product): string { $variation = ProductContext::defaultVariation($product); return $variation ? (string) ($variation->sku ?? '') : ''; } /** @param object|null $product */ public static function variationId($product): int { $variation = ProductContext::defaultVariation($product); return $variation ? (int) ($variation->id ?? 0) : 0; } /** @param object|null $product */ public static function formattedComparePrice($product): string { $variation = ProductContext::defaultVariation($product); $amount = $variation ? (int) ($variation->compare_price ?? 0) : 0; return $amount > 0 && class_exists('FluentCart\\App\\Helpers\\Helper') ? wp_strip_all_tags(html_entity_decode((string) \FluentCart\App\Helpers\Helper::toDecimal($amount), ENT_QUOTES, 'UTF-8')) : ''; } /** * Default/current-variation on-sale check. Behavior UNCHANGED (bugfix v0.4.14+1, * see {@see self::isAnyVariationOnSale()} for the new any-variation check): kept * exactly as-is for every caller that needs THE SPECIFIC shown/default variation's * own discount, not "does this product have a deal anywhere on its price ladder". * * Exactly three real call sites are left on this method, each pairing the boolean * with ONE specific variation's own price number, so any-variation semantics would * be genuinely wrong there (it would print "Sale" next to a price that did not * actually change): bd-elements/FC_Buy_Section/ssr.php (Sale badge on the buy box * for the variation actually being bought), bd-elements/FC_Product_Price/ssr.php * (same, for the price atom), and CardParts::renderPrice()'s compare-price gate, * which stays here because {@see self::formattedComparePrice()} is ALSO permanently * scoped to the default variation: pairing a struck-through price read from the * default variation with a boolean computed from a DIFFERENT variation would be * incoherent, not a fix. * * Every OTHER on-sale read in this plugin is a product-level "is this a deal, * yes/no" indicator with no price number attached, and belongs on * {@see self::isAnyVariationOnSale()} instead. That split is locked in BOTH * directions by tests/run.php (a whole-plugin scan asserting this method's caller * set is exactly the three above, plus the two any-variation reads outside * CardParts) and by tests/collections/run-collections.php (the CardParts reads). * * @param object|null $product */ public static function isOnSale($product): bool { $variation = ProductContext::defaultVariation($product); if (!$variation) { return false; } $price = (int) ($variation->item_price ?? 0); $compare = (int) ($variation->compare_price ?? 0); return $compare > 0 && $compare > $price; } /** * Any-variation on-sale check (bugfix v0.4.14+1). True when AT LEAST ONE of the * product's variations carries a real discount (compare_price > 0 AND compare_price * > item_price), matching FluentCart's own native definition exactly: this fork's * own query-side EXISTS predicate for the "On sale" collection source already uses * this same any-variation form (src/Query/BreakdanceQueryBridge.php, * fragmentForDescriptor(), case 'on_sale': "EXISTS (... WHERE fcbd_v.post_id = * post.ID AND fcbd_v.compare_price > 0 AND fcbd_v.item_price < fcbd_v.compare_price)", * live source verified, itself mirroring FluentCart's own ShopResource::get()). * Before this fix, {@see self::isOnSale()} (default-variation-only) was the only * on-sale read the card/badge layer had, so a real discount on a non-default price * tier (this shop's own products carry 8-10 variations each) never surfaced a Sale * badge, out of step with this fork's own query-side "On sale" source. * * Used by all four product-level "is this a deal, yes/no" indicators, none of which * has a specific price number attached, so none of them loses anything by not being * pinned to the default variation (unlike {@see self::isOnSale()}'s own three * remaining, price-paired callers): CardParts::renderBadge() (the fixed grid/card * Sale badge), CardParts::badgeConditionMet()'s on_sale/not_on_sale cases (the * badge-builder repeater), ConditionRegistry's 'fcbd-sale-state' condition callback, * and ProductStringField's 'sale_state' dynamic-data string. The condition is the * strictest of the four: its own queryCallback emits the any-variation EXISTS * predicate quoted above, so a default-variation element_display read there would * make the display condition and the query builder disagree about the same product. * * Reads `$product->variants`, the SAME already-loaded/lazily-cached collection * {@see \FluentCartBreakdance\Context\ProductContext::defaultVariation()} itself * already reads for every other adapter call already made per card (isOnSale() * itself, paymentType(), sku(), ...): no new query of its own, so calling this once * per card in a grid of 48 products costs nothing beyond what already happens on * that same product object today (no per-badge reload). * * @param object|null $product */ public static function isAnyVariationOnSale($product): bool { $variants = is_object($product) ? ($product->variants ?? []) : []; foreach ($variants as $variation) { $price = (int) ($variation->item_price ?? 0); $compare = (int) ($variation->compare_price ?? 0); if ($compare > 0 && $compare > $price) { return true; } } return false; } /** @param object|null $product */ public static function paymentType($product): string { $variation = ProductContext::defaultVariation($product); if (!$variation) { return ''; } $paymentType = (string) ($variation->payment_type ?? ''); if ($paymentType === '' && is_array($variation->other_info ?? null)) { $paymentType = (string) ($variation->other_info['payment_type'] ?? ''); } return $paymentType; } /** @param object|null $product */ public static function stockStatus($product): string { $detail = $product->detail ?? null; return $detail ? (string) ($detail->stock_availability ?? '') : ''; } /** @param object|null $product */ public static function stockLabel($product): string { $status = self::stockStatus($product); if ($status === '') { return ''; } return $status === 'out-of-stock' ? __('Out of stock', 'sparkcore-commerce') : __('In stock', 'sparkcore-commerce'); } /** * Low-stock check (bugfix v0.4.14+1): activates the previously dead Low Stock * Badge design tokens (src/Design/ControlPresets.php badgeSection(), src/Design/ * Tokens.php `--bde-fc-badge-stock-low-*`) by finally giving them a real, evaluable * condition in the badge-builder repeater. True when the product's default * variation has stock management ON (`ProductVariation::manage_stock`, live-verified * field name, wp-content/plugins/fluent-cart/app/Models/ProductVariation.php, * $fillable) AND its real remaining count (`ProductVariation::available`, same * model, cast integer) is at or below the given, editor-configured threshold. * * Deliberately variation-level, not `$product->detail->stock_availability` (the * coarse in-stock/out-of-stock rollup {@see self::stockStatus()} already reads for * the existing in_stock/out_of_stock badge conditions): "low stock" needs the * actual remaining count, which only exists on the variation row, and only means * anything once stock is actually being tracked there. `manage_stock` off (the * store-wide default on this install; `available` is then a stale/unused 0) must * always answer false, never a false "low stock" reading from an untracked zero. * * @param object|null $product */ public static function isLowStock($product, int $threshold): bool { $variation = ProductContext::defaultVariation($product); if (!$variation || empty($variation->manage_stock)) { return false; } return (int) ($variation->available ?? 0) <= $threshold; } /** @param object|null $product */ public static function fulfillmentType($product): string { $detail = $product->detail ?? null; return $detail ? (string) ($detail->fulfillment_type ?? '') : ''; } /** @param object|null $product */ public static function imageId($product): int { $productId = self::id($product); return $productId && function_exists('get_post_thumbnail_id') ? (int) get_post_thumbnail_id($productId) : 0; } /** @param object|null $product */ public static function renderProductAction($product, array $settings): string { $productId = self::id($product); if (!$productId) { return self::notice(__('Choose a FluentCart product for this action.', 'sparkcore-commerce'), 'empty'); } $variationId = isset($settings['variation_id']) ? absint($settings['variation_id']) : 0; if (!$variationId) { $variation = ProductContext::defaultVariation($product); $variationId = $variation ? (int) ($variation->id ?? 0) : 0; } if (!$variationId) { return self::notice(__('This product has no variation to render this action against.', 'sparkcore-commerce'), 'missing'); } $variation = ProductContext::findVariation($variationId); if (!$variation || (int) ($variation->post_id ?? 0) !== $productId) { return self::notice(__('The selected variation does not exist for this product.', 'sparkcore-commerce'), 'missing'); } $action = ($settings['action'] ?? '') === 'buy_now' ? 'buy_now' : 'add_to_cart'; $tag = $action === 'buy_now' ? 'fluent_cart_checkout_button' : 'fluent_cart_add_to_cart_button'; $attributes = [ 'variation_id' => $variationId, 'button_text' => sanitize_text_field((string) ($settings['button_text'] ?? '')), 'class' => 'fcbd-native-action', ]; if ($action === 'buy_now') { $attributes['instant_checkout'] = !empty($settings['instant_checkout']) ? 'yes' : 'no'; } AssetManager::singleProduct(); return self::shortcode($tag, $attributes); } /** @param object|null $product */ public static function renderProductCard($product, array $settings): string { $productId = self::id($product); if (!$productId) { return self::notice(__('Choose a FluentCart product for this card.', 'sparkcore-commerce'), 'empty'); } AssetManager::singleProduct(); return self::shortcode('fluent_cart_product_card', [ 'product_id' => $productId, 'price_format' => in_array(($settings['price_format'] ?? ''), ['starts_from', 'range'], true) ? $settings['price_format'] : 'starts_from', 'card_width' => max(160, min(1200, absint($settings['card_width'] ?? 320))), 'render_as_anchor' => !empty($settings['render_as_anchor']) ? '1' : '0', ]); } /** @param object|null $product */ public static function renderSingleProduct($product): string { $productId = self::id($product); AssetManager::singleProduct(); if (!$productId) { return self::notice(__('Choose a FluentCart product to preview the product layout.', 'sparkcore-commerce'), 'empty'); } if (function_exists('shortcode_exists') && shortcode_exists('fluent_cart_single_product')) { return self::shortcode('fluent_cart_single_product', ['product_id' => $productId]); } // De shortcode-klasse is in FluentCart 1.3.27 nooit geregistreerd // (alleen ProductCardShortCode wordt geboot, app/Hooks/actions.php:72). // Render daarom hetzelfde pad dat de shortcode intern zou nemen: // ProductRenderer::render() met de StoreSettings-weergaveconfig. if (!class_exists('FluentCart\\App\\Services\\Renderer\\ProductRenderer')) { return self::notice(__('FluentCart cannot render the product layout in this version. Check plugin compatibility.', 'sparkcore-commerce'), 'broken'); } $config = []; if (class_exists('FluentCart\\Api\\StoreSettings')) { $storeSettings = new \FluentCart\Api\StoreSettings(); $config = [ 'view_type' => $storeSettings->get('variation_view', 'both'), 'column_type' => $storeSettings->get('variation_columns', 'masonry'), ]; } $renderer = new \FluentCart\App\Services\Renderer\ProductRenderer($product, $config); ob_start(); try { $renderer->render(); return (string) ob_get_clean(); } catch (\Throwable $error) { ob_end_clean(); throw $error; } } public static function renderMiniCart(array $settings): string { return self::shortcode('fluent_cart_mini_cart', [ 'cart_icon' => in_array(($settings['cart_icon'] ?? ''), ['cart', 'bag', 'bag-alt'], true) ? $settings['cart_icon'] : 'bag', 'show_item_count' => in_array(($settings['show_item_count'] ?? ''), ['always', 'has_items', 'never'], true) ? $settings['show_item_count'] : 'has_items', 'show_total_price' => !empty($settings['show_total_price']) ? 'yes' : 'no', 'count_mode' => ($settings['count_mode'] ?? '') === 'total_quantity' ? 'total_quantity' : 'distinct_products', 'button_class' => 'fcbd-native-mini-cart', ]); } public static function renderCollection(array $settings): string { $sort = in_array(($settings['sort_by'] ?? ''), ['date-newest', 'date-oldest', 'name-asc', 'name-desc', 'price-low', 'price-high'], true) ? $settings['sort_by'] : 'date-newest'; return self::shortcode('fluent_cart_products', [ 'per_page' => max(1, min(100, absint($settings['per_page'] ?? 12))), 'columns' => max(1, min(6, absint($settings['columns'] ?? 3))), 'view_mode' => ($settings['view_mode'] ?? '') === 'list' ? 'list' : 'grid', 'with_cart' => !empty($settings['with_cart']) ? 'yes' : 'no', 'show_cat_filter' => !empty($settings['show_category_filter']) ? 'yes' : 'no', 'paginator' => ($settings['paginator'] ?? '') === 'numbers' ? 'numbers' : 'scroll', 'category_id' => absint($settings['category_id'] ?? 0), 'fulfillment_type' => in_array(($settings['fulfillment_type'] ?? ''), ['physical', 'digital'], true) ? $settings['fulfillment_type'] : '', 'on_sale' => !empty($settings['on_sale']) ? 'yes' : '', 'sort_by' => $sort, ]); } public static function renderCommerceView(string $view): string { $map = [ 'cart' => 'fluent_cart_cart', 'checkout' => 'fluent_cart_checkout', 'account' => 'fluent_cart_customer_profile', 'receipt' => 'fluent_cart_receipt', ]; $tag = $map[$view] ?? $map['cart']; return self::shortcode($tag); } /** @param object|null $product */ public static function renderProductPart($product, array $settings): string { if (!$product) { return self::notice(__('Choose a FluentCart product to render this product part.', 'sparkcore-commerce'), 'empty'); } if (!class_exists('FluentCart\\App\\Services\\Renderer\\ProductRenderer')) { return self::notice(__('The FluentCart product renderer is not available.', 'sparkcore-commerce'), 'broken'); } $part = in_array(($settings['part'] ?? ''), ['title', 'price', 'selected_price', 'stock', 'sku', 'excerpt', 'gallery', 'buy_section'], true) ? (string) $settings['part'] : 'price'; AssetManager::singleProduct(); $renderer = new \FluentCart\App\Services\Renderer\ProductRenderer($product); ob_start(); try { switch ($part) { case 'title': $renderer->renderTitle(); break; case 'stock': $renderer->renderStockAvailability(); break; case 'selected_price': $renderer->renderItemPrice(); break; case 'sku': $renderer->renderSku('', !isset($settings['show_label']) || !empty($settings['show_label'])); break; case 'excerpt': $renderer->renderExcerpt(); break; case 'gallery': $renderer->renderGallery(); break; case 'buy_section': // No settings map to a real ProductRenderer::renderBuySection() key in // FluentCart 1.3.27: only variation_atts and button_atts are read, and // the option previously passed here is not recognized at all (audit-fix // A3, see docs/audit/fluentcart-contracts.md par. 6). $renderer->renderBuySection([]); break; case 'price': default: $renderer->renderPrices(); break; } return (string) ob_get_clean(); } catch (\Throwable $error) { ob_end_clean(); throw $error; } } /** @param array $attributes */ private static function shortcode(string $tag, array $attributes = []): string { if (!function_exists('shortcode_exists') || !shortcode_exists($tag)) { return self::notice(sprintf( /* translators: %s shortcode tag. */ __('FluentCart shortcode %s is not registered in this version.', 'sparkcore-commerce'), $tag ), 'broken'); } $parts = []; foreach ($attributes as $key => $value) { if ($value === '' || $value === null) { continue; } $parts[] = sanitize_key((string) $key) . '="' . esc_attr((string) $value) . '"'; } $shortcode = '[' . $tag . ($parts ? ' ' . implode(' ', $parts) : '') . ']'; return (string) do_shortcode($shortcode); } /** * Renders a placeholder in one of four visually and semantically distinct states * (shared contract with the CSS side, see bd-elements/*\/default.css): * * - 'runtime': the element resolves and renders correctly on the live frontend; * the builder just isn't showing it here (e.g. Commerce_View without live * preview enabled). Informative, not an error. * - 'empty': no product/context has been chosen yet. Neutral, expected state * while authoring. * - 'missing': the chosen product, variation, or other referenced entity does * not exist (or no longer exists). Needs the editor's attention. * - 'broken': a required shortcode is not registered, or rendering threw an * exception. A contract-break, most urgent to surface. * * Unknown states fall back to 'empty' rather than silently rendering unstyled. */ public static function notice(string $message, string $state = 'empty'): string { $state = in_array($state, ['runtime', 'empty', 'missing', 'broken'], true) ? $state : 'empty'; return '
' . esc_html($message) . '
'; } }