['ALL']` regardless of what is passed in. Golf 0's conditions * could therefore never have gained `query_builder`/`queryCallback` support by adding * those keys to the old call site; they would have been silently dropped. Calling * `registerCondition()` directly is the only way to add `queryCallback` at all, per the * task brief's core requirement. * 2. The single-value operand vocabulary changed from the golf-0 ad hoc strings * `'equals'`/`'not equals'` to Breakdance's real, first-party constants `OPERAND_IS` * ('is') / `OPERAND_IS_NOT` ('is not') (`plugin/themeless/rules/constants.php:12-13`, * the exact vocabulary TemplateRegistry.php's own single-value conditions already use). * Both vocabularies are internally self-consistent (Breakdance evaluates a condition's * callback with whatever operand string THAT condition itself declared, * `plugin/themeless/request.php:239-247`, so 'equals'/'not equals' was never actually * broken), but aligning to Breakdance's own constants is a real consistency fix: every * other condition in this plugin (TemplateRegistry.php) and every first-party * WooCommerce condition already uses 'is'/'is not', so a site owner would otherwise see * two different operand vocabularies across supposedly-similar conditions. Documented * here because it is a genuine, if narrow, breaking change for any already-saved * condition rule using the old strings (none are expected to exist yet: this plugin is * still pre-1.0/Golf 1 in active development). * * Dimensions registered here, matching the binding task scope exactly (ELITE-PLAN.md par. * 219, "categorie/brand via tax_query; prijs, sale, fulfillment, payment type, voorraad, * product-type via de adapter"): * - Category / brand ({@see self::registerProductCategory()}/{@see self::registerProductBrand()}): * `queryCallback` appends a REAL `tax_query` entry directly (never a descriptor), mirroring * Breakdance's own shipped `product-categories.php` pattern exactly. `element_display` * evaluation resolves the current PRODUCT via `ProductContext::resolve()` (loop/scope-aware), * NOT the ambient queried post the way TemplateRegistry's own (deliberately different, * template-matching-flavored) taxonomy condition does -- a real, evidenced reason these * two files cannot share that one private method even though the taxonomy names are the * same constants (`TemplateRegistry::CATEGORY_TAXONOMY`/`BRAND_TAXONOMY`, read here, never * written). * - Price ({@see self::registerPrice()}): a free-numeric-input condition (`IS`/`GREATER_THAN`/ * `LESS_THAN`, mirroring Woo's own `product-price.php`), converted to * `ProductQueryAdapter`'s `price_range` descriptor shape for `query_builder`. * - Sale state, fulfillment type, payment type, stock status, product type: existing (or, * for product type, new) single-value conditions upgraded with `query_builder` + * `queryCallback`, translating straight into the shared `{field, value, negate?}` * descriptor shape `BreakdanceQueryBridge` consumes (see that class's docblock). * - Deliberately NOT given `query_builder` support: "Is FluentCart product" * ({@see self::registerIsProduct()}) and "Has SKU" ({@see self::registerHasSku()}). * Neither dimension is in the task's listed adapter scope (a query builder for FC * Products already implicitly scopes to `fluent-products`, and FluentCart has no * evidenced SKU-existence query primitive in either of its own native paths), so adding * one here would be inventing an unproven filter dimension rather than translating an * evidenced one. * * This file has ZERO dependency on `src/Query/*`: `queryCallback` closures only ever * append a plain array into the inert `$query['fcbd_conditions']` key (or a real * `tax_query` entry for category/brand); `BreakdanceQueryBridge` is the sole consumer of * that shape, loosely coupled entirely through that array contract, never a direct class * reference. */ final class ConditionRegistry { public static function register(): void { if (!function_exists('\\Breakdance\\Themeless\\registerCondition')) { return; } self::registerIsProduct(); self::registerStock(); self::registerFulfillment(); self::registerPaymentType(); self::registerSaleState(); self::registerHasSku(); self::registerProductType(); self::registerPrice(); self::registerProductCategory(); self::registerProductBrand(); } private static function registerIsProduct(): void { self::registerSimpleCondition([ 'slug' => 'fcbd-is-product', 'label' => __('Is FluentCart product', 'sparkcore-commerce'), 'supports' => ['element_display'], // deliberately no query_builder, see class docblock. 'requiresProduct' => false, // the whole point of this condition is detecting absence too. 'items' => [ 'yes' => __('Yes', 'sparkcore-commerce'), 'no' => __('No', 'sparkcore-commerce'), ], 'resolve' => static function (): string { return ProductContext::resolve() ? 'yes' : 'no'; }, ]); } private static function registerStock(): void { self::registerSimpleCondition([ 'slug' => 'fcbd-stock-status', 'label' => __('Stock status', 'sparkcore-commerce'), 'supports' => ['element_display', 'query_builder'], 'queryField' => 'stock_availability', 'items' => [ 'in-stock' => __('In stock', 'sparkcore-commerce'), 'out-of-stock' => __('Out of stock', 'sparkcore-commerce'), ], 'resolve' => static function (): string { $product = ProductContext::resolve(); return $product ? FluentCartAdapter::stockStatus($product) : ''; }, ]); } private static function registerFulfillment(): void { self::registerSimpleCondition([ 'slug' => 'fcbd-fulfillment-type', 'label' => __('Fulfillment type', 'sparkcore-commerce'), 'supports' => ['element_display', 'query_builder'], 'queryField' => 'fulfillment_type', 'items' => [ 'physical' => __('Physical', 'sparkcore-commerce'), 'digital' => __('Digital', 'sparkcore-commerce'), ], 'resolve' => static function (): string { $product = ProductContext::resolve(); return $product ? FluentCartAdapter::fulfillmentType($product) : ''; }, ]); } private static function registerPaymentType(): void { self::registerSimpleCondition([ 'slug' => 'fcbd-payment-type', 'label' => __('Payment type', 'sparkcore-commerce'), 'supports' => ['element_display', 'query_builder'], 'queryField' => 'payment_type', 'items' => [ 'onetime' => __('One time', 'sparkcore-commerce'), 'subscription' => __('Subscription', 'sparkcore-commerce'), ], 'resolve' => static function (): string { $product = ProductContext::resolve(); return $product ? FluentCartAdapter::paymentType($product) : ''; }, ]); } private static function registerProductType(): void { self::registerSimpleCondition([ 'slug' => 'fcbd-product-type', 'label' => __('Product type', 'sparkcore-commerce'), 'supports' => ['element_display', 'query_builder'], 'queryField' => 'product_type', 'items' => [ 'simple' => __('Simple', 'sparkcore-commerce'), 'simple_variations' => __('Simple variations', 'sparkcore-commerce'), 'advanced_variations' => __('Advanced variations', 'sparkcore-commerce'), ], 'resolve' => static function (): string { $product = ProductContext::resolve(); $detail = $product->detail ?? null; return $detail ? (string) ($detail->variation_type ?? '') : ''; }, ]); } private static function registerSaleState(): void { $isNot = defined('OPERAND_IS_NOT') ? OPERAND_IS_NOT : 'is not'; \Breakdance\Themeless\registerCondition([ 'proOnly' => false, 'supports' => ['element_display', 'query_builder'], 'availableForType' => self::productConditionPostTypes(), 'slug' => 'fcbd-sale-state', 'label' => __('Sale state', 'sparkcore-commerce'), 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => [defined('OPERAND_IS') ? OPERAND_IS : 'is', $isNot], 'values' => static fn(): array => self::values([ 'on-sale' => __('On sale', 'sparkcore-commerce'), 'regular' => __('Regular price', 'sparkcore-commerce'), ]), 'callback' => static function ($operand, $value) use ($isNot): bool { $product = ProductContext::resolve(); if (!$product) { return false; } // Any-variation, matching this condition's own queryCallback below, which // emits an EXISTS over every variation. Reading only the default variation // here would make the display condition and the query builder disagree on // the same product. $actual = FluentCartAdapter::isAnyVariationOnSale($product) ? 'on-sale' : 'regular'; $matches = $actual === (string) $value; return $operand === $isNot ? !$matches : $matches; }, // Emits BOTH polarities (on_sale => true for 'on-sale', => false for // 'regular'), the false branch translating to a NOT EXISTS wrapper of the // SAME on-sale predicate in BreakdanceQueryBridge. This exact NOT-EXISTS shape // was not itself measured in the spike's EXPLAIN evidence (only the positive // EXISTS form was); flagged as a residual, structurally-analogous-but-unmeasured // risk in this fork's final report. 'queryCallback' => static function ($query, $operand, $value) use ($isNot): array { $wantsOnSale = ((string) $value) === 'on-sale'; if ($operand === $isNot) { $wantsOnSale = !$wantsOnSale; } $query['fcbd_conditions'][] = ['field' => 'on_sale', 'value' => $wantsOnSale]; return $query; }, 'templatePreviewableItems' => false, ]); } private static function registerHasSku(): void { self::registerSimpleCondition([ 'slug' => 'fcbd-has-sku', 'label' => __('Has SKU', 'sparkcore-commerce'), 'supports' => ['element_display'], // deliberately no query_builder, see class docblock. 'items' => [ 'yes' => __('Yes', 'sparkcore-commerce'), 'no' => __('No', 'sparkcore-commerce'), ], 'resolve' => static function (): string { $product = ProductContext::resolve(); return $product && FluentCartAdapter::sku($product) !== '' ? 'yes' : 'no'; }, ]); } /** * Price condition: free numeric input, three operands (mirrors Breakdance's own * shipped `product-price.php` exactly). `element_display` compares the resolved * product's DEFAULT variation's raw `item_price` (server-static/default-variation, * never a live-just-selected variant); `query_builder` converts the operand into a * `price_range` descriptor for `ProductQueryAdapter`/`BreakdanceQueryBridge`. */ private static function registerPrice(): void { $isGreater = defined('OPERAND_GREATER_THAN') ? OPERAND_GREATER_THAN : 'is greater than'; $isLess = defined('OPERAND_LESS_THAN') ? OPERAND_LESS_THAN : 'is less than'; $is = defined('OPERAND_IS') ? OPERAND_IS : 'is'; \Breakdance\Themeless\registerCondition([ 'proOnly' => false, 'supports' => ['element_display', 'query_builder'], 'availableForType' => self::productConditionPostTypes(), 'slug' => 'fcbd-price', 'label' => __('Price', 'sparkcore-commerce'), 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => [$is, $isGreater, $isLess], 'valueInputType' => 'number', 'values' => static function () { return false; }, 'callback' => static function ($operand, $value) use ($isGreater, $isLess): bool { $product = ProductContext::resolve(); if (!$product) { return false; } $variation = ProductContext::defaultVariation($product); if (!$variation) { return false; } $actual = (int) ($variation->item_price ?? 0); $compare = self::toMinorUnits($value); if ($operand === $isGreater) { return $actual > $compare; } if ($operand === $isLess) { return $actual < $compare; } return $actual === $compare; }, 'queryCallback' => static function ($query, $operand, $value) use ($isGreater, $isLess): array { $compare = self::toMinorUnits($value); if ($operand === $isGreater) { $query['fcbd_conditions'][] = ['field' => 'price_range', 'min' => $compare + 1, 'max' => null]; } elseif ($operand === $isLess) { $query['fcbd_conditions'][] = ['field' => 'price_range', 'min' => null, 'max' => $compare - 1]; } else { $query['fcbd_conditions'][] = ['field' => 'price_range', 'min' => $compare, 'max' => $compare]; } return $query; }, 'templatePreviewableItems' => false, ]); } /** * Mirrors `FluentCart\App\Helpers\Helper::toCent()`'s own documented formula * (`round(floatval($amount) * 100)`) without a hard runtime dependency on that exact * method, since it is not (yet) a `Contracts.php`-checked capability -- flagged as a * follow-up contract entry in this fork's final report. Falls back to the identical * formula locally when the real helper is unavailable, so behavior is identical * either way; only the source of truth differs. */ private static function toMinorUnits($value): int { if (class_exists('FluentCart\\App\\Helpers\\Helper') && method_exists('FluentCart\\App\\Helpers\\Helper', 'toCent')) { return (int) \FluentCart\App\Helpers\Helper::toCent($value); } return (int) round(((float) $value) * 100); } /** * Category and brand: real public taxonomies registered on `fluent-products` * (`product-categories`/`product-brands`, TemplateRegistry::CATEGORY_TAXONOMY/ * BRAND_TAXONOMY, read-only reference to that fork's constants -- not redefined here, * to avoid the two files' taxonomy names ever silently diverging). */ private static function registerProductCategory(): void { self::registerTaxonomyCondition( 'fcbd-product-category', __('Product category', 'sparkcore-commerce'), TemplateRegistry::CATEGORY_TAXONOMY ); } private static function registerProductBrand(): void { self::registerTaxonomyCondition( 'fcbd-product-brand', __('Product brand', 'sparkcore-commerce'), TemplateRegistry::BRAND_TAXONOMY ); } private static function registerTaxonomyCondition(string $slug, string $label, string $taxonomy): void { \Breakdance\Themeless\registerCondition([ 'proOnly' => false, 'supports' => ['element_display', 'query_builder'], 'availableForType' => self::productConditionPostTypes(), 'slug' => $slug, 'label' => $label, 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => self::multiValueOperands(), 'values' => static function () use ($taxonomy, $label): array { return [[ 'label' => $label, 'items' => self::taxonomyTermItems($taxonomy), ]]; }, 'callback' => static function ($operand, $value) use ($taxonomy): bool { return self::evaluateTaxonomyCondition($operand, $value, $taxonomy); }, // Real tax_query passthrough (never a descriptor): mirrors Breakdance's own // shipped product-categories.php exactly. 'queryCallback' => static function ($query, $operand, $value) use ($taxonomy): array { if (empty($value)) { return $query; } $termIds = array_values(array_filter(array_map('absint', (array) $value))); if (!$termIds) { return $query; } $isNoneOf = defined('OPERAND_NONE_OF') && $operand === OPERAND_NONE_OF; $query['tax_query'][] = [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $termIds, 'operator' => $isNoneOf ? 'NOT IN' : 'IN', ]; return $query; }, 'templatePreviewableItems' => false, ]); } /** * Deliberately resolves via ProductContext::resolve() (loop/scope-aware), NOT the * ambient queried post. TemplateRegistry's own taxonomy-condition evaluator uses * get_post() instead, which is correct for ITS purpose (matching a template against * the actual queried request) but wrong for element_display evaluation inside, say, a * Posts Loop card -- hence this is a deliberate, small duplication of shape, not a * missed reuse opportunity. * * @param mixed $operand * @param mixed $value */ private static function evaluateTaxonomyCondition($operand, $value, string $taxonomy): bool { if (!$value || !function_exists('has_term')) { return false; } $product = ProductContext::resolve(); $postId = $product ? FluentCartAdapter::id($product) : 0; if ($postId < 1) { return false; } $results = array_map( static fn($termId): bool => (bool) has_term((int) $termId, $taxonomy, $postId), (array) $value ); if (defined('OPERAND_ONE_OF') && $operand === OPERAND_ONE_OF) { return in_array(true, $results, true); } if (defined('OPERAND_NONE_OF') && $operand === OPERAND_NONE_OF) { return !in_array(true, $results, true); } if (defined('OPERAND_ALL_OF') && $operand === OPERAND_ALL_OF) { return !in_array(false, $results, true); } return false; } /** @return array */ private static function taxonomyTermItems(string $taxonomy): array { if (!function_exists('get_terms')) { return []; } $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false]); if (!is_array($terms)) { return []; } $items = []; foreach ($terms as $term) { if ($term instanceof \WP_Term) { $items[] = ['text' => $term->name, 'value' => (string) $term->term_id]; } } return $items; } /** @return array */ private static function multiValueOperands(): array { $operands = []; foreach (['OPERAND_ONE_OF', 'OPERAND_NONE_OF', 'OPERAND_ALL_OF'] as $constant) { $operands[] = defined($constant) ? constant($constant) : strtolower(str_replace('OPERAND_', '', $constant)); } return $operands; } /** * Shared helper for a single-value IS/IS-NOT `element_display` (optionally also * `query_builder`) condition whose truth depends only on `ProductContext::resolve()` * and a fixed vocabulary of string values. Centralizes: the real Breakdance operand * constants (see class docblock point 2), the direct `registerCondition()` call (see * class docblock point 1), and the "no product resolved -> false regardless of * operand" safety rule every one of these conditions except "Is FluentCart product" * needs (`requiresProduct` defaults true; "Is FluentCart product" passes `false`, * since detecting absence IS that condition's entire purpose). * * @param array{ * slug: string, label: string, supports: string[], items: array, * resolve: \Closure(): string, queryField?: string, requiresProduct?: bool * } $spec */ private static function registerSimpleCondition(array $spec): void { $isNot = defined('OPERAND_IS_NOT') ? OPERAND_IS_NOT : 'is not'; $requiresProduct = $spec['requiresProduct'] ?? true; $condition = [ 'proOnly' => false, 'supports' => $spec['supports'], 'availableForType' => self::productConditionPostTypes(), 'slug' => $spec['slug'], 'label' => $spec['label'], 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => [defined('OPERAND_IS') ? OPERAND_IS : 'is', $isNot], 'values' => static fn(): array => self::values($spec['items']), 'callback' => static function ($operand, $value) use ($spec, $isNot, $requiresProduct): bool { if ($requiresProduct && !ProductContext::resolve()) { return false; } $actual = ($spec['resolve'])(); $matches = $actual === (string) $value; return $operand === $isNot ? !$matches : $matches; }, 'templatePreviewableItems' => false, ]; if (in_array('query_builder', $spec['supports'], true) && isset($spec['queryField'])) { $field = $spec['queryField']; $condition['queryCallback'] = static function ($query, $operand, $value) use ($field, $isNot): array { $query['fcbd_conditions'][] = [ 'field' => $field, 'negate' => $operand === $isNot, 'value' => (string) $value, ]; return $query; }; } \Breakdance\Themeless\registerCondition($condition); } /** * Post types a product-scoped element_display/query_builder condition should be * offered on: `fluent-products` itself, plus Breakdance's own header/footer/block/ * popup/ACF-block CPTs (mirrors Woo's own `getProductConditionPostTypes()`, * `plugin/themeless/rules/woocommerce/base.php:33-41`), so a condition on a Posts * Loop card embedded in a header/footer/block/popup is still offered. Falls back to * `fluent-products` only when Breakdance's constants are not defined (e.g. this * plugin's own standalone test harness). * * @return string[] */ private static function productConditionPostTypes(): array { $types = [TemplateRegistry::PRODUCT_POST_TYPE]; foreach (['BREAKDANCE_HEADER_POST_TYPE', 'BREAKDANCE_BLOCK_POST_TYPE', 'BREAKDANCE_FOOTER_POST_TYPE', 'BREAKDANCE_POPUP_POST_TYPE', 'BREAKDANCE_ACF_BLOCK_POST_TYPE'] as $constant) { if (defined($constant)) { $types[] = constant($constant); } } return $types; } /** @param array $items */ private static function values(array $items): array { return [[ 'label' => 'FluentCart', 'items' => array_map( static fn(string $value, string $label): array => ['text' => $label, 'value' => $value], array_keys($items), array_values($items) ), ]]; } }