---
module: frequently-bought-together
---

# frequently-bought-together

## Responsibility

Owns the Frequently Bought Together feature end-to-end in CartFlows **free**: the `_cartflows_fbt` product-meta blob (shape, sanitisation, read/write API), the WooCommerce product-editor tab and its save path, the single-product-page widget render and its own add-to-cart AJAX, the SQL that feeds FBT numbers into BSF Analytics, and the in-admin activation nudges (products-list notice, self-expiring NEW tab badge, Free-tier panel callout).

It also owns the companion **rules** for every caller, not just its own widget: what counts as a configured companion, how a variation resolves, what refuses to be added from a widget at all. Pro's Instant Checkout popup builds its cart lines by calling into this module rather than reimplementing the checks, so a rule fixed here is fixed on both paths. Two rules stayed with Pro and differ from what the widget does: the popup always passes an empty bundle key, so a merchant's `add_separately = no` is not honoured there, and it caps a request at its own limit rather than `max_products()`.

The bundle discount (`discount_type` / `discount_value`) is owned here from the panel through to the cart. The panel writes it, the widget renders the struck-through row prices, the struck old total and the saving line, and `apply_bundle_discount()` makes the cart charge it on companion lines. The cart price is recalculated on every totals pass instead of being stored on the line at add time, so it follows the merchant's current settings and the cart's current contents; a stored `custom_price` would go stale, and CartFlows' own `custom_price` hooks only run under `wp_doing_ajax()`, so the classic cart page and the Store API would charge full price. The discount is counted per unit because WooCommerce merges a re-added companion into any existing line with the same key: without the count, adding one bundle discounted every unit already sitting on the line. Units are capped by the parent's quantity because each parent unit is one bundle, so a shopper cannot take the bundle price on add-ons alone or on more companions than bundles. That cap is worked out per pass and never stored, because the parent is sometimes missing only briefly — a replace-main order bump swaps it out and puts it back when unticked — and a stored cap would turn that pause into a permanent loss. The qty buttons cannot raise the count either way: only a bundle add does. Keying off `_wcf_fbt_parent_id` means Pro's Instant Checkout lines get the discount too, without `build_add_queue()` changing shape. Its arithmetic deliberately mirrors `Cartflows_Pro_Utils::get_calculated_discount()` rather than inventing a third formula, so the number a shopper sees here matches what an order bump or a checkout product would have quoted. The two surfaces state it at different grains on purpose: the panel shows the bundle figure once, beside the selected-count chip, because it is one setting for the whole bundle, while each price cell carries its own struck figure because that part really does differ per product.

Ownership stops at the Auto Suggest *data* — Pro owns the REST route and the drawer/linked-products markup, which this module only styles and drives — plus account auth, the analytics transport, and the cart/order machinery itself.

## Why it is this way

Pro consumes this module through two seams — `build_add_queue()` and `window.wcfFbt.getSelection()` — rather than reading the widget's DOM and building its own cart lines. A copy would have to reproduce membership, variation resolution, the own-page backstop, the attribution marker and the manual validation pass, from a plugin that cannot see the markup they depend on: the variation select is a *sibling* of its row, not an ancestor, so a copied reader drifts on any markup change, silently.

The builder returns per-item outcomes rather than one verdict because the two callers must differ. This module's endpoint is all-or-nothing — a rejected companion must not leave a half-built bundle. The popup keeps what succeeded and names what it dropped, because a shopper mid-purchase should not be sent back to the product page over one out-of-stock companion.

That all-or-nothing guarantee covers the rules stage only. Once the queue is built, this module's endpoint ignores an `add_to_cart()` refusal as long as one line went in, so an out-of-stock companion is dropped silently under a success notice — long-standing, and the one place the popup behaves better than the widget it reads from.

This module is also one half of a free/Pro split it cannot see: free JS calls a Pro REST route and binds to markup Pro renders, and the free `SOURCE_VALUES` whitelist silently voids a mode the free UI still offers.

The Auto Suggest drawer renders warnings it does not write. Pro sends a per-row badge and a response notice, so a new kind of suggestion Pro adds later is labelled correctly without a free release. The reverse direction is handled by an opt-in: the drawer asks for Upsell alternatives with `include_alternatives`, because an older drawer cannot render their warning and would show cheaper products as upgrades. The notice is also read out through one persistent live region inside the drawer: content swapped in with `.html()` is not reliably announced, and the drawer is an `aria-modal` dialog, so a region outside it is ignored by screen readers while it is open.

The nudges live free-side but are not free-only: Pro users run this plugin too, so the notice and badge render for every tier and only the panel callout is gated to Free. That gate exists because Pro already ships its own once-per-user driver.js tour over this same panel, so a second Free-shaped pointer would nag the same user twice on one screen. The nudges also read licence state through Pro's `cartflows_pro_is_active_license()` rather than free's own helper, which returns a raw status string that stays truthy after deactivation.

Around that sits a dense layer of theme-specific and WooCommerce-core-specific workarounds — Astra sticky bars, the block-theme loop exemption, the stripped `add-to-cart` field, the observer self-exclusion, Blocksy's button argument. Each looks like removable cruft to anyone who does not know which bug it closes.
