Skip to content

Add the product Subscriptions panel and the PDP plan picker#13

Draft
vbelolapotkov wants to merge 9 commits into
trunkfrom
add/product-plans-panel-and-pdp-picker
Draft

Add the product Subscriptions panel and the PDP plan picker#13
vbelolapotkov wants to merge 9 commits into
trunkfrom
add/product-plans-panel-and-pdp-picker

Conversation

@vbelolapotkov

@vbelolapotkov vbelolapotkov commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Lite half of the buy-path product page slice (WOOSUBS-1772): merchants configure how a product sells (one-time only by default, or on storewide subscription plans - all of them or a selected subset, with one-time purchase optionally allowed alongside), and shoppers see the applicable plans on the product page.

Depends on the engine's flattened selling-plans catalog: woocommerce/woocommerce#66313 (must land and ship in the engine package before this merges; local testing consumes its head via the composer path repository).

Merchant side (product edit screen)

  • Admin\ProductPlansPanel: a "Subscriptions" product-data tab for simple + variable products - purchase-options mode (sell one-time only / use storewide subscription plans), all-vs-select plan selection (one labeled checkbox row per plan), and an "Allow one-time purchase" toggle (default on). A "Manage plans" link points at the plans manager under WooCommerce > Settings > Subscriptions.
  • Applicability is Lite-owned: saves go through Plans\ApplicabilityStore (product meta - mode, attached plan ids, allow-one-time), which validates plan ids against the engine's catalog read facade (Api\SellingPlans, an instance constructed scoped to Lite's slug). The engine stores only the plans catalog; which products a plan applies to is Lite's data. Unsupported product types are skipped; store rejections surface as admin notices via WC_Admin_Meta_Boxes::add_error() (never thrown - core fires woocommerce_admin_process_product_object unwrapped).
  • Panel toggling is a small vanilla script; styles ride @wordpress/base-styles tokens and unfloat wp-admin's panel label layout so the fieldset renders in the content pane.

Shopper side (product page)

  • ProductPage\PlanPicker: server-rendered picker on woocommerce_before_add_to_cart_button (works in classic themes, block themes, and the shortcode), via the overridable template templates/single-product/plan-picker.php. One-time option renders first and preselected when allowed; subscribe-only products get a plain always-enabled select that works without JavaScript. No plans resolved means no output at all.
  • Plans\ProductPlanResolver resolves which plans apply (variation ids resolve to their parent; mode drives the catalog read) and exposes the woocommerce_subscriptions_lite_product_plans filter as Lite's eligibility extension point over the resolved set.
  • The select posts _selling_plan_id - the key the checkout contract-creation handler already reads; actual add-to-cart handling is the next slice.
  • ProductPage\PlanOptionFormatter: engine-computed prices (PricingPolicy::calculate_price()) formatted server-side ($21.60 every 1 month (10% off)); no client-side price math.
  • ProductPage\VariationPlanData: per-variation option HTML injected into the woocommerce_available_variation payload (memoized per parent, wp_kses_post-sanitized); the view module swaps option text on found_variation and restores on reset.
  • Picker behavior is an Interactivity API module (client/frontend/plan-picker/view.js); the build is split by runtime system - all Interactivity API view modules (plan picker + customer portal) build through the new webpack.modules.config.js (build:modules) with @wordpress/interactivity externalized, classic scripts stay in webpack.scripts.config.js.

Testing

Integration suite (wp-env): composer test:integration - full suite green (157 tests). Lint: composer lint, npm run lint:js, npm run lint:css. Build: npm run build.

Manual (needs the engine flatten branch in the vendored package + 2+ storewide plans seeded under WooCommerce > Settings > Subscriptions):

  1. Edit a simple product: the Subscriptions tab appears; default mode is "Sell one-time only"; a fresh product's PDP has no picker markup.
  2. Switch to "Use storewide subscription plans" + "Use all": PDP shows the picker above Add to cart with one-time preselected and correct price strings; archiving a plan removes it from the picker.
  3. Choose "Select storewide subscription plans", check a subset of plans, save: the PDP picker renders exactly the checked plans.
  4. Untick "Allow one-time purchase": the PDP becomes subscribe-only (no radios, select enabled; still functional with JavaScript disabled).
  5. Variable product: choosing a variation swaps the option price strings; "Clear" restores them; browser console stays clean.
  6. Save a grouped or external product with the plugin active: saves normally (no notice, no error).

@vbelolapotkov vbelolapotkov force-pushed the add/product-plans-panel-and-pdp-picker branch from 7851657 to e47095b Compare July 7, 2026 09:55
The Subscriptions product-data tab lets a merchant sell a simple or
variable product on the storewide selling plans (all of them, or a
selected subset) with one-time purchase optionally allowed alongside;
all reads and writes go through the engine's SellingPlans facade, and a
facade rejection surfaces as a product-screen admin error. The PDP
picker renders the one-time vs subscribe choice inside the add-to-cart
form from a theme-overridable template, posts the chosen plan as
_selling_plan_id, and swaps per-variation option strings from the
variation payload over the woocommerce-subscriptions-lite/plan-picker
Interactivity API store, built as a script module to build/modules.

WOOSUBS-1772
The panel save paths, render markup, picker gating and states, variation
payloads, and formatter strings run end to end on the wp-env suite: real
nonces and capabilities, real products, plans resolved through the
SellingPlans facade from real applicability meta, the packaged template
through the real wc_get_template(), and rejections through the real
WC_Admin_Meta_Boxes error list. Also migrates the shared plan-seeding
helper to the flattened selling-plans catalog (Plan::create with no
plan-group argument), which the whole suite now targets.

WOOSUBS-1772
…nfig

The scripts config previously hand-rolled an ESM build of the
customer-portal Interactivity store into build/scripts next to the
classic admin bundles, forcing each compilation's output-clean to carve
out the other's files with keep regexes. The split is now by runtime
system: webpack.scripts.config.js builds only the classic admin bundles
(build/scripts), and webpack.modules.config.js builds all Interactivity
API view modules - the plan picker and the customer-portal store - via
the wp-scripts --experimental-modules variant (build/modules). Each
output directory is owned by exactly one compilation, so the standard
output-clean applies with no cross-bundle carve-outs.

The modules config gains RtlCssPlugin (the upstream module variant
emits no RTL stylesheets, and the portal styles are direction
sensitive) and the standard output.clean. The portal asset loader now
enqueues from build/modules, and the plan picker registers its RTL
stylesheet variant.

WOOSUBS-1772
Product plan applicability is consumer-owned: the engine keeps the plans
catalog and its read facade (list_plans, get_plans), and Lite now owns
which products sell on which plans. The new Plans namespace carries the
ProductApplicability value object (strict constructor, lenient
from_storage), the ApplicabilityStore writing the _wcsl_plans_apply_mode,
_wcsl_plans_plan_ids, and _wcsl_plans_allow_one_time product meta with
all-or-nothing write validation (simple/variable parents only, plan ids
must be active Lite-owned plans), and the ProductPlanResolver mapping a
product to its plans through the woocommerce_subscriptions_lite_product_plans
filter - Lite's eligibility extension point.

The applicability semantics tests move in with the code: the VO contract,
the store's meta reconciliation, the write validation, and the resolver
modes plus filter behavior all run against the real WordPress test
environment.

WOOSUBS-1772
…lity layer

The product plans panel, the PDP plan picker, and the variation payload
filter now call the Lite store and resolver directly instead of the
removed engine applicability facade, and the callable constructor seams
(reader/writer/lister on the panel, resolver/reader/renderer on the
picker, resolver on the variation filter) are gone - the tests exercise
the production wiring end to end, shaping resolution through the Lite
product-plans filter where they previously injected fakes. The canonical
simple/variable type list lives on the store, shared by the picker's
render gate and the panel's save gate.

WOOSUBS-1772
The engine's catalog read facade dropped its static entry points: consumers
now construct an instance scoped to their extension slugs and call
list_plans() / get_plans() on it. Each call site constructs the facade in
its constructor and holds it on a private property - the read semantics
(active plans, slug-scoped, display order) are unchanged.

Re-pins the vendored engine to the current flatten-catalog head.
@vbelolapotkov vbelolapotkov force-pushed the add/product-plans-panel-and-pdp-picker branch from 4241847 to 25d2eb9 Compare July 7, 2026 13:32
- Render the purchase-mode help as a full-width block below the select
  (WooCommerce floats panel selects; un-float inside the panel) and adopt
  the clearer copy: the product will use the storewide plans from the
  subscription settings.
- Style the three section headings (Purchase options, Subscription plan
  selection, One-time purchases) as small uppercase labels and give the
  blocks a consistent vertical rhythm, tokens from @wordpress/base-styles.
- Hide the plans-table checkbox column in all-scope (nothing to pick when
  every plan applies): the server renders an is-scope-all table class and
  the toggle script flips it live with the scope radios; the cells stay in
  the markup, disabled, for the no-reload switch to select-scope.
- Nest the all-scope helper inside its radio label so it sits as indented
  text under the radio instead of a stray full-width block between the two
  options, and adopt the reference wording.
- Give the one-time block a section heading and relabel the checkbox to
  'Customers can buy this product without subscribing'.

Save contract, field names, nonce, and aria-describedby wiring unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant