Skip to content

Practice Sessions Booking Widget (mu-plugin)

Created 2026-02-27
Updated 2026-03-20
Tags pluginwoocommercewp-fusionpractice-sessionsmu-pluginwoocommerce-paymentssession-booking

Custom WordPress mu-plugin implementing the returning-participant booking widget for the Practice Sessions Montreal 2026 page. Handles WP Fusion access gating, AJAX add-to-cart, and a WooCommerce sliding-scale discount.


Path
Server/var/www/baseworks.com/wp-content/mu-plugins/baseworks-practice-sessions.php
Local trackingbaseworks-changelog/sites/baseworks.com/mu-plugins/baseworks-practice-sessions.php
Changelog entriesbaseworks-changelog/changelog-entries/20260227-143000-practice-sessions-booking-widget.md, 20260305-190000-practice-sessions-v2-complete.md
Shortcode`[practice_session_booking program=”…” tier=“alumni
Page ID47154 (baseworks.com)

Six functional components in one file:

Accepts program (required) and tier ("alumni" or "standard") parameters. Reads all product IDs, tag IDs, and pricing params from bw_sb_get_programs() in baseworks-session-booking.php.

tier="alumni" render states:

  • Logged out — login prompt + intake form CTA
  • Logged in, no tag 97 — message explaining alumni-only access + intake form CTA
  • Logged in + tag 97 — live quantity selector with dynamic sliding-scale pricing and AJAX checkout

tier="standard" render states:

  • Logged out — login prompt + intake form CTA
  • Logged in — variable product quantity/variation selector with AJAX checkout (no tag gating)

Inline CSS + JS registered once per page via static flag. Safe for multiple shortcode instances on the same page.

  • Verifies nonce, authentication, and WP Fusion tag 97
  • Removes any existing product 47188 items from cart (idempotent — fresh per transaction)
  • Adds requested quantity
  • Returns wc_get_checkout_url() — JS redirects directly to checkout

4. Discount hook woocommerce_before_calculate_totals (priority 10)

Section titled “4. Discount hook woocommerce_before_calculate_totals (priority 10)”

Sliding-scale pricing applied to all alumni products found in the program registry:

discount = min( (qty - 1) × 0.035, 0.49 )
per-session price = base_usd × (1 - discount)

Uses get_regular_price('edit') as base — see critical note below. Hook iterates all programs from bw_sb_get_programs() to find alumni product IDs rather than relying on a hardcoded constant.

On the checkout page, strips add-to-cart and quantity query params from the URL using history.replaceState(). WooCommerce appends these during the redirect chain even when using AJAX add-to-cart.

Reads CAD rate from wcpay_multi_currency_cached_currencies WP option for live price preview in the widget. Actual currency conversion is handled by WCPay at totals calculation time — this is display-only.


Base price read live from WooCommerce product (alumni product ID in program registry, get_regular_price('edit')). Current alumni product: 47188, $27 USD/session base (~$38 CAD at current rate). Page copy lists prices in CAD; WooCommerce products are priced in USD.

SessionsDiscountPer session (USD)Per session (CAD ~$38 base)
10%$27.00~$38.00
514%$23.22~$32.68
1031.5%$18.49~$26.03
1649%$13.77~$19.38

QC tax: GST 5% + QST 9.975% = 14.975% — applied at checkout.

Source: practice-sessions-pricing


get_regular_price('edit') — Double-conversion prevention

Section titled “get_regular_price('edit') — Double-conversion prevention”

WCPay hooks FrontendPrices::get_product_price_string() into woocommerce_product_get_regular_price at priority 99. In view context, calling get_regular_price() fires this filter and converts the USD price to CAD before we apply the discount. Then set_price() stores the CAD-discounted value. When WooCommerce reads get_price() again during totals calculation, WCPay converts again — double-conversion.

Using get_regular_price('edit') bypasses all filters via WC_Data::get_prop(), returning the raw USD value stored in the database. WCPay then converts our discounted USD price correctly once at totals time.

woocommerce-multilingual (WCML) is installed but enable_multi_currency: 0. All currency conversion is handled by WooCommerce Payments.

Currency switcher block: woocommerce-payments/multi-currency-switcher (not a WCML widget).

If WC_Payments_Multi_Currency()->is_provider_connected() returns false, get_account_available_currencies() returns an empty array → only USD loads → switcher block renders nothing. Resolved by keeping WCPay account connected in WP Admin → Payments.

Our discount hook runs at priority 10. WCML hooks woocommerce_before_calculate_totals at priority 100 (if ever re-enabled). Our hook runs first.


VersionDateSummary
v1.0.02026-02-27Initial: shortcode + AJAX + discount hook + CSS/JS
v1.1.02026-02-27Currency investigation; switched to WCPay; get_regular_price('edit') fix; CSS fixes
v1.2.02026-02-27Gate/intro text color; URL cleanup via history.replaceState()
v2.0.02026-03-05tier parameter added to [practice_session_booking]"alumni" (existing) and "standard" (new variable-product widget). Reads config from bw_sb_get_programs() registry; PHP constants removed. Discount hook generalised to iterate all programs. readyState guard init pattern for Perfmatters compatibility. Full detail: changelog-entries/20260305-190000-practice-sessions-v2-complete.md.

Both widget init functions (bwPsInit, bwStdInit) are in the Perfmatters delay_js_exclusions list. This is required because Perfmatters uses delay_js_behavior: "all", which marks every script as delayed and only executes them on first user interaction (or after a 30-second fallback). Without the exclusion, widget content would not render for up to 30 seconds.

If Perfmatters is reconfigured or its exclusions are reset, re-add both identifiers to delay_js_exclusions via WP Admin → Perfmatters → Assets or via WP-CLI.


  • WCPay must stay connected. Account disconnect causes currency switcher to go blank and CAD display to break.
  • Exchange rate cache. Widget display rate comes from wcpay_multi_currency_cached_currencies. If this option is stale or empty, the displayed CAD prices may be inaccurate. WCPay normally refreshes this automatically.
  • Discount is per-transaction only. No accumulation across orders. Each purchase calculates fresh from get_regular_price('edit').
  • Standard variation naming. The standard widget derives credits from intval() of the pass-size variation attribute. Variations must be named "N session" or "N sessions" (e.g. "5 sessions"). See naming convention in the program registry section below.


Added 2026-02-28

A separate, second mu-plugin that handles the actual session booking flow after purchase. Distinct from the purchase/pricing widget above.

Path
Server/var/www/baseworks.com/wp-content/mu-plugins/baseworks-session-booking.php
Changelog entriesbaseworks-changelog/changelog-entries/20260228-120000-session-booking-system.md, 20260305-190000-practice-sessions-v2-complete.md
Shortcode[bw_booking_dashboard program="ps_spring_2026" title="..."]
Account pagebaseworks.com/available-programs/
VersionDateSummary
v1.0.02026-02-28Initial build — DB, program registry, credit system, WC hook, booking/cancel logic, shortcode, admin page
v1.1.02026-02-28Baseworks Blue color scheme; spots display logic; title attribute; email toggle in admin; attendee list in admin; past session handling
v1.2.02026-02-28Re-booking bug fix (UNIQUE constraint); first name in emails; booking status summary in emails; shortcode reference in admin
v2.0.02026-03-05Extended program config format (alumni_tag_id, purchase_url, pricing, updated products array). Credit-granting hook branched to qty/variation_attr/fixed modes. bw_sb_get_cad_rate() helper added. Removed redundant global wp_enqueue_scripts hook. ([bw_ps_price_table] shortcode was included in initial deploy and removed same day — unused.)
v2.1.02026-03-20Admin email notifications — new independent toggle in admin UI; bw_sb_admin_emails_enabled(), bw_sb_send_admin_booking_email(), bw_sb_send_admin_cancellation_email() added. Admin emails go to get_option('admin_email') and identify the participant by full name and email address.

Data flow:

  1. WooCommerce order → woocommerce_order_status_completed hook → credits added to bw_credits_{program_id} user meta (credit count determined by product’s credits mode in program config)
  2. User visits account page → shortcode renders session list with checkboxes
  3. User selects sessions → AJAX → bw_sb_book_sessions() → rows in wp_bw_bookings, credits deducted
  4. User cancels → AJAX → bw_sb_cancel_session() → row updated, credit refunded

Key functions:

  • bw_sb_get_programs() — program registry (filterable, add new programs here)
  • bw_sb_get_sessions( $program_id ) — reads session list from WP option
  • bw_sb_get_credits / set_credits / add_credits — user credit management
  • bw_sb_book_sessions( $user_id, $program_id, $session_ids[] ) — batch booking
  • bw_sb_cancel_session( $user_id, $program_id, $session_id ) — single cancellation
  • bw_sb_emails_enabled( $program_id ) — checks WP option (admin toggle) then falls back to program config
  • bw_sb_admin_emails_enabled( $program_id ) — checks bw_sb_admin_emails_{program_id} WP option; off by default
  • bw_sb_send_admin_booking_email( $user_id, $program_id, $session_ids[] ) — notifies admin on booking; identifies participant by full name and email
  • bw_sb_send_admin_cancellation_email( $user_id, $program_id, $session_id ) — notifies admin on cancellation

Database table wp_bw_bookings:

id | user_id | program_id | session_id (YYYYMMDD_HHMM) | status | created_at | cancelled_at

UNIQUE KEY on (user_id, program_id, session_id) — one row per user/program/session across all time. Re-booking a cancelled session UPDATEs the row rather than INSERTing.

Session data stored in WP option bw_sb_sessions_{program_id} as a JSON array. Each session: id, week, date, day, time_start, time_end, location, room. Seeded once on plugin init via a guard option.

Credits stored in WP user meta as bw_credits_{program_id} (integer). Visible and editable in WP Admin → Users → Edit User → “Session Credits”.


a. Add a new entry to bw_sb_get_programs() in the plugin (see v2 config format below) b. Seed sessions into WP option bw_sb_sessions_my_new_program_id (array of session objects) c. Add shortcode to account page: [bw_booking_dashboard program="my_new_program_id" title="..."]

The admin page, user profile credit fields, and email system all pick up the new program automatically.

  • Admin UI — program creation form, session schedule editor, product ID fields, program archive. Makes season setup self-service without code deploys.
  • Credit transfer between programs — not implemented; each credit is program-specific
  • Waitlists — no waitlist logic; full sessions block booking
  • Batch cancellation — currently one session at a time
  • Email HTML templates — emails are plain text
  • French language support — booking UI is English-only


Program configuration lives in bw_sb_get_programs() in baseworks-session-booking.php. Both plugins read from this registry. Adding a new season is a config-only operation — no code changes needed in either plugin.

'ps_spring_2026' => [
// ── existing keys (unchanged) ────────────────────────────────────────
'name' => 'Practice Sessions — Montreal, Spring 2026',
'capacity' => 20,
'cancel_hours' => 24,
'timezone' => 'America/Toronto',
'location' => 'Proto Studio',
'emails' => false,
// ── new keys ─────────────────────────────────────────────────────────
// WP Fusion tag ID that gates alumni access (used by the booking widget
// and the price table CTA gating)
'alumni_tag_id' => 97,
// Page where the alumni booking widget lives (used by price table CTA)
'purchase_url' => '/practice-sessions/',
// Products — one entry per WooCommerce product that grants credits
// for this program. Replaces the old single-product products array.
'products' => [
// Alumni: simple product. Credits = quantity purchased.
// Sliding scale discount applied at cart via WC hook.
47188 => [
'tier' => 'alumni',
'credits' => 'qty',
'label' => 'Alumni',
],
// Standard: variable product. Credits derived from the variation's
// 'pass-size' attribute via intval() — see naming convention below.
// No custom price hook needed; WC handles variation prices natively.
47343 => [
'tier' => 'standard',
'credits' => 'variation_attr',
'attr' => 'pass-size',
'label' => 'Standard',
],
// Intro: simple product. Always 1 credit regardless of qty.
47341 => [
'tier' => 'intro',
'credits' => 'fixed',
'count' => 1,
'label' => 'Intro',
],
],
// Pricing — parameters for the price table shortcode and the alumni
// booking widget. Decoupled from the credit-granting logic above.
'pricing' => [
'tax_rate' => 0.14975, // QC: GST 5% + QST 9.975%
'alumni' => [
'type' => 'sliding_scale',
'product_id' => 47188, // base USD price read live from WC regular_price
'step' => 0.035, // 3.5% discount per additional session
'cap' => 0.49, // maximum discount: 49%
'max_qty' => 16,
],
'standard' => [
'type' => 'tiers_from_variations',
'product_id' => 47343, // tiers + prices read live from WC variations
'attr' => 'pass-size',
],
],
],

The woocommerce_order_status_completed hook in baseworks-session-booking.php checks the credits field in the program config and branches:

ModeKeyLogic
qtyAlumni, legacy products$credits = $item->get_quantity() — unchanged
variation_attrStandard variable product$credits = intval( wc_get_product( $item->get_variation_id() )->get_attribute( 'pass-size' ) )
fixedIntro$credits = $program_product['count'] — ignores quantity

The hook looks up the ordered product ID across all programs in the registry to determine which mode to use. If a product ID doesn’t appear in any program, it falls through without granting credits.

Variation Naming Convention — MUST FOLLOW

Section titled “Variation Naming Convention — MUST FOLLOW”

The variation_attr mode derives credit count from intval() of the attribute value. This means the first number in the attribute value must equal the intended credit count.

Required format: "N session" or "N sessions" where N is a positive integer.

Examples: "1 session" → 1, "5 sessions" → 5, "10 sessions" → 10, "16 sessions" → 16.

Do not name variations anything else (e.g., “Pack of 5” would give 0). When creating products for a new season, copy the attribute name (pass-size) and value format exactly from the Spring 2026 product (ID 47343).


When launching a future season (e.g., ps_fall_2026):

  1. Create WooCommerce products — Alumni (simple), Standard (variable), Intro (simple). For Standard variations, use the naming format "N session(s)" — see naming convention above.
  2. Add program entry to bw_sb_get_programs() in baseworks-session-booking.php. Copy the ps_spring_2026 entry, update the program ID, product IDs, name, and any changed pricing params.
  3. Seed session schedule into WP option bw_sb_sessions_ps_fall_2026.
  4. Update shortcodes on the public landing page and account page to reference the new program ID.

No other plugin changes required. Admin page, credit system, booking/cancel logic, and email system all pick up the new program automatically from the registry.


  • WP Admin → Session Bookings — session attendance, all bookings, credit balances, email toggles, shortcode reference
  • Email notifications (participants) — off by default per program; toggle via admin page; sends one confirmation email per batch booking, one email per cancellation
  • Email notifications (admin) — separate toggle; off by default; sends to the WordPress admin email (Settings → General → Administration Email Address); booking emails include participant name, email, sessions booked, and their upcoming schedule; cancellation emails include participant name, email, cancelled session, and remaining schedule
  • Manual credit adjustment — WP Admin → Users → Edit User → Session Credits