..., 'data' => ['type' => , ...], 'children' => * TreeNode[]]`, verified against `plugin/render/renderer.php:1219-1232` where * `$node['data']['type']` is exactly the string `getElementFromNodeType()` resolves the * element class from), locates the (hopefully single) FC_Checkout node, and hands its * children's types to `validate()`. * * Restrisico (flagged for the integrator, not silently assumed): there is no documented, * official Breakdance API for "give me my OWN node's children from inside my own * Element::ssr()" -- `Element::ssr($propertiesData, $parentPropertiesData, $isBuilder, * $repeaterItemNodeId)` (verified, `plugin/elements/Element.class.php:218`) receives none * of that. `evaluateForPost()` therefore falls back to `get_tree()` plus a best-effort * node search by TYPE, not by node id (the id is not available to ssr.php either). When * that search finds zero or more than one FC_Checkout node, `determinable` is false for * the zero case (nothing to disprove; the common single-instance-per-page case always * resolves cleanly) and the "more than one" case is reported as an explicit structural * error (this itself is invalid: `nestingRule()` on FC_Checkout is * `container-excluding-self`, the same native Breakdance rule already verified and used by * `bd-elements/FC_Product_Builder/element.php` to stop builder-UI nesting/duplication, but * that is a builder-UI-level guard, not a server-side one, so this is the defense-in-depth * layer for a hand-edited or migrated tree). */ final class CheckoutStructureValidator { public const SLUG_NAME_EMAIL = 'FluentCartBreakdanceElements\\FCCheckoutNameEmail'; public const SLUG_BILLING = 'FluentCartBreakdanceElements\\FCCheckoutBilling'; public const SLUG_SHIPPING_ADDRESS = 'FluentCartBreakdanceElements\\FCCheckoutShippingAddress'; public const SLUG_SHIP_DIFFERENT = 'FluentCartBreakdanceElements\\FCCheckoutShipDifferent'; public const SLUG_SHIPPING_METHODS = 'FluentCartBreakdanceElements\\FCCheckoutShippingMethods'; public const SLUG_PAYMENT_METHODS = 'FluentCartBreakdanceElements\\FCCheckoutPaymentMethods'; public const SLUG_ORDER_SUMMARY = 'FluentCartBreakdanceElements\\FCCheckoutOrderSummary'; public const SLUG_COUPON = 'FluentCartBreakdanceElements\\FCCheckoutCoupon'; public const SLUG_ORDER_NOTES = 'FluentCartBreakdanceElements\\FCCheckoutOrderNotes'; public const SLUG_AGREE_TERMS = 'FluentCartBreakdanceElements\\FCCheckoutAgreeTerms'; public const SLUG_CREATE_ACCOUNT = 'FluentCartBreakdanceElements\\FCCheckoutCreateAccount'; public const SLUG_SUBMIT = 'FluentCartBreakdanceElements\\FCCheckoutSubmit'; /** * All 12 recognized sub-element slugs. * * @var array */ public const ALL_SLUGS = [ self::SLUG_NAME_EMAIL, self::SLUG_BILLING, self::SLUG_SHIPPING_ADDRESS, self::SLUG_SHIP_DIFFERENT, self::SLUG_SHIPPING_METHODS, self::SLUG_PAYMENT_METHODS, self::SLUG_ORDER_SUMMARY, self::SLUG_COUPON, self::SLUG_ORDER_NOTES, self::SLUG_AGREE_TERMS, self::SLUG_CREATE_ACCOUNT, self::SLUG_SUBMIT, ]; /** * Every sub-element is a singleton within one checkout: task instruction 4 spells this * out explicitly for billing/shipping/payment/terms/submit ("maximaal 1 * billing/shipping/payment/terms/submit"). This registry deliberately extends the same * "maximaal 1" rule to the remaining kinds too (name/email, ship-to-different, shipping * methods, order summary, coupon, order notes, create account): two of any of them * would duplicate form field ids/names (e.g. two `agree_terms` checkboxes, two * `
`-scoped `order_notes` textareas) and silently break the native checkout JS's * field lookups, which is exactly the class of bug the validator exists to catch. This * is a deliberate strengthening beyond the literal task list, flagged here and in the * final report for the integrator to confirm. * * @var array */ public const SINGLETON_SLUGS = self::ALL_SLUGS; /** * Parts a checkout cannot function without (task instruction 4, "verplichte * onderdelen mogen niet ontbreken"): the task text calls out email/name explicitly as * "verplicht"; this validator also treats Payment Methods and Submit as required, * since a checkout tree missing either can never actually place an order (there is * nothing to select/submit), which is the concrete failure mode the validator is * meant to prevent. Billing/shipping/order-summary/notes/coupon/create-account/terms * are all real FluentCart features but are NOT required by this registry, matching the * task's narrower "maximaal 1" (optional, capped) framing for those. Flagged as an * explicit interpretation decision in the final report. * * @var array */ public const REQUIRED_SLUGS = [ self::SLUG_NAME_EMAIL, self::SLUG_PAYMENT_METHODS, self::SLUG_SUBMIT, ]; private const LABELS = [ self::SLUG_NAME_EMAIL => 'Name / Email', self::SLUG_BILLING => 'Billing Address', self::SLUG_SHIPPING_ADDRESS => 'Shipping Address', self::SLUG_SHIP_DIFFERENT => 'Ship to a Different Address', self::SLUG_SHIPPING_METHODS => 'Shipping Methods', self::SLUG_PAYMENT_METHODS => 'Payment Methods', self::SLUG_ORDER_SUMMARY => 'Order Summary', self::SLUG_COUPON => 'Coupon', self::SLUG_ORDER_NOTES => 'Order Notes', self::SLUG_AGREE_TERMS => 'Agree to Terms', self::SLUG_CREATE_ACCOUNT => 'Create Account', self::SLUG_SUBMIT => 'Submit', ]; /** * Pure structural check. No WordPress/Breakdance runtime required. * * @param array $childSlugs Flat list of the checkout's direct children's * element class-name slugs (duplicates included, i.e. NOT pre-deduplicated). * @return array{valid: bool, errors: array} */ public static function validate(array $childSlugs): array { $errors = []; $counts = array_count_values($childSlugs); foreach (self::REQUIRED_SLUGS as $slug) { if (empty($counts[$slug])) { $errors[] = sprintf('Missing required section: %s.', self::LABELS[$slug] ?? $slug); } } foreach (self::SINGLETON_SLUGS as $slug) { $count = $counts[$slug] ?? 0; if ($count > 1) { $errors[] = sprintf('%s appears %d times; only 1 is allowed.', self::LABELS[$slug] ?? $slug, $count); } } $unknown = array_diff(array_unique($childSlugs), self::ALL_SLUGS); // Unknown children (any other Breakdance element dropped inside FC_Checkout) are // not themselves an error: FluentCart's own native checkout markup already allows // arbitrary surrounding layout elements (see the OxyFluentCart precedent's // `contentControls()`'s free-form empty_cart_template chooser), and a designer may // legitimately want a decorative wrapper Div next to a required section. Only the // 12 recognized kinds are counted for the singleton/required rules above. unset($unknown); return [ 'valid' => empty($errors), 'errors' => $errors, ]; } /** * Best-effort runtime bridge (see class docblock's documented restrisico). Never * throws; any failure to read/parse the tree degrades to "not determinable" (treated * by the caller as "render normally", since `defaultChildren()` already guarantees a * valid tree for the common case and a false positive here would break real, * structurally sound installs). * * @return array{determinable: bool, valid: bool, errors: array} */ public static function evaluateForPost(int $postId, string $ownClassName): array { $none = ['determinable' => false, 'valid' => true, 'errors' => []]; if ($postId <= 0 || !function_exists('\\Breakdance\\Data\\get_tree')) { return $none; } try { $tree = \Breakdance\Data\get_tree($postId); } catch (\Throwable $error) { return $none; } if (!is_array($tree) || empty($tree['root']['children']) || !is_array($tree['root']['children'])) { return $none; } $matches = self::findNodesByType($tree['root']['children'], $ownClassName); if (count($matches) === 0) { return $none; } if (count($matches) > 1) { // findNodesByType() recurses into EVERY node's children regardless of // whether that node itself matched, so this branch already covers both a // sibling duplicate (two FC_Checkout nodes at the same level) AND a nested // checkout (an FC_Checkout found inside another FC_Checkout's own // descendants) -- a nested instance is, by construction, a second match // found during the same recursive walk. One message covers both concrete // failure shapes; the count itself already tells the builder something is // structurally wrong. return [ 'determinable' => true, 'valid' => false, 'errors' => [sprintf('%d FC_Checkout instances were found on this page (including any nested inside one another); only 1 is allowed.', count($matches))], ]; } $node = $matches[0]; $childTypes = self::directChildTypes($node['children'] ?? []); $result = self::validate($childTypes); return [ 'determinable' => true, 'valid' => $result['valid'], 'errors' => $result['errors'], ]; } /** * @param array> $nodes * @return array> */ private static function findNodesByType(array $nodes, string $type): array { $matches = []; foreach ($nodes as $node) { if (!is_array($node)) { continue; } if (($node['data']['type'] ?? null) === $type) { $matches[] = $node; } if (!empty($node['children']) && is_array($node['children'])) { $matches = array_merge($matches, self::findNodesByType($node['children'], $type)); } } return $matches; } /** * @param array> $nodes * @return array */ private static function directChildTypes(array $nodes): array { $types = []; foreach ($nodes as $node) { if (is_array($node) && isset($node['data']['type']) && is_string($node['data']['type'])) { $types[] = $node['data']['type']; } } return $types; } }