Category: Resource

  • Postmortem:

    Rebuilding Ticket and Participant Forms: Lessons from a Deep WooCommerce Integration

    When an external agency specializing in enterprise WordPress handed off the final phase of our custom WooCommerce checkout, the project was already complex: conference tickets, on-demand courses, LearnDash enrollments, Salesforce synchronization, and WordPress VIP’s strict code constraints.
    By the time I stepped in, we had a skeleton system that worked in theory, but it couldn’t yet survive real-world use.

    This post documents how we finished it: the architecture, the UX and technical problems we faced, and the solutions that finally made it stable and production-ready.


    Background

    The AMA platform needed to handle conference tickets and course seats in one WooCommerce ecosystem. A buyer could purchase multiple seats and then assign them to coworkers or guests. Assignments would include many of whom didn’t yet exist in WordPress or Salesforce.

    The flow had to:

    • Let a purchaser fill in details for several people.
    • Create or link those users in Salesforce via a custom GraphQL API.
    • Integrate with LearnDash for course access.
    • Survive WordPress VIP restrictions on JavaScript, database queries, and performance.

    When vendors engagement ended, the remaining issues centered on the post-purchase ticket and participant forms—where the real complexity lived.


    Constraints and Design Goals

    We had four main constraints:

    1. WordPress VIP compliance
      No unsafe DOM manipulation, no direct SQL loops, no window.location tricks, strict PHPCS rules.
    2. Data durability
      Users could be editing five forms at once. Losing progress mid-edit was unacceptable.
    3. Salesforce consistency
      Every saved participant triggered a background getOrCreateUser GraphQL mutation. Bad inputs or timing issues could break the sync chain.
    4. Clean user experience
      These are event managers, not developers. The interface had to be forgiving, not punitive.

    Architectural Direction

    The old flow depended heavily on transients for temporary form state, which often collided between sessions and didn’t scale across VIP’s object cache.
    We scrapped that and built a session-based client-side persistence layer:

    • Each form gets a unique storage key (ama_ticket_… or ama_participant_…).
    • Data is saved in sessionStorage on every input.
    • On reload, values repopulate automatically.
    • When the form is successfully submitted, the stored data clears.

    This gave us transient-like behavior without hitting the database or violating VIP policy.


    The Front-End: From Fragile to Predictable

    1. Debounced Email Changes

    The email field drives everything: it identifies a Salesforce user, autofills data, and determines if the form represents a new or existing participant.
    Originally, changing the email instantly cleared every other field. That meant a small typo could wipe a user’s work.

    We fixed it by debouncing the clear operation and tracking touched fields:

    debouncedSelectiveClear = debounce(() => {
      const snapshotTouched = new Set(s.touchedSinceEmailChange);
      resetUnmodifiedFields(form, snapshotTouched); // Only clear untouched.
      s.pendingClear = false;
    }, 500);
    

    If the user keeps typing, nothing clears. Only when they pause for half a second do we remove values in untouched fields. It’s subtle, but it prevents the “why did everything disappear?” moment that frustrated users.


    2. Progressive Disclosure for Participant Fields

    Participant forms originally rendered every field at once—most of them required—which led to browsers complaining about missing required inputs before the user could even start.

    We changed the flow to progressively reveal fields only after a valid email is entered:

    function toggleParticipantFields(form, hide) {
      const nonEmailFields = form.querySelectorAll('.field:not([name="participant_email"])');
      nonEmailFields.forEach(field => {
        if (hide) {
          field.disabled = true;
          field.closest('.form-row').style.display = 'none';
        } else {
          field.disabled = false;
          field.closest('.form-row').style.display = '';
        }
      });
    }
    

    On page load, only the email field shows. Once an email passes basic validation, the rest of the form unfolds.
    It keeps the visual noise low and ensures the browser never blocks submission on invisible required fields.


    3. Dirty-State Tracking and Validation

    Multiple forms could be open at once. We needed to know whether any given form had changed since it was loaded.

    We used a WeakMap keyed to each form element:

    const formState = new WeakMap();
    
    $('.woocommerce-EditTicketForm, .woocommerce-EditParticipantForm').each(function () {
      formState.set(this, { dirty: false });
      $(this).on('input change', 'input, select, textarea', function () {
        if (!this.name || this.disabled || !$(this).is(':visible')) return;
        formState.get(this.form).dirty = true;
      });
    });
    

    Before submission we check:

    if (!isDirty) {
      e.preventDefault();
      showInfo($form, 'No changes to save.');
      return;
    }
    

    It’s a tiny feature with big impact—no more blank submissions spamming Salesforce.


    4. Safe Notice Rendering (VIP Compliance)

    VIP flagged our original jQuery .prepend() usage as potentially executing HTML.
    We rewrote the notice creation to use native DOM APIs:

    function showInfo($form, message) {
      const info = document.createElement('div');
      info.className = 'woocommerce-info';
      info.textContent = message;
      const wrapper = $form[0].querySelector('.woocommerce-notices-wrapper') || 
                      $form[0].appendChild(document.createElement('div'));
      wrapper.className = 'woocommerce-notices-wrapper';
      wrapper.prepend(info);
    }
    

    The difference looks trivial, but it eliminated several persistent VIP audit warnings.


    5. Session-Level Form Persistence

    Our sessionStorage layer keeps unsaved progress alive:

    const key = `ama_${keyPrefix}_${pageKey}_${String(keyField.value)
      .replace(/[^a-z0-9_-]/gi, '')
      .slice(0, 64)}`;
    
    form.addEventListener('input', () => {
      const data = {};
      new FormData(form).forEach((v, k) => { data[k] = v; });
      sessionStorage.setItem(key, JSON.stringify(data));
    });
    
    window.addEventListener('load', () => {
      const saved = sessionStorage.getItem(key);
      if (saved) Object.entries(JSON.parse(saved)).forEach(([k, v]) => {
        const el = form.elements[k];
        if (el) el.value = v;
      });
    });
    
    form.addEventListener('submit', () => sessionStorage.removeItem(key));
    

    It’s simple, isolated, and completely VIP-safe.


    The Back-End: Hardening the Salesforce Integration

    Every save triggers a Salesforce mutation:

    $result = Users::get_or_create_user( $email, $first_name, $last_name, ... );
    

    The early versions assumed all fields were valid strings. When they weren’t, PHP threw a fatal:

    Argument #2 ($first_name) must be of type string, null given
    

    We fixed it by validating and coercing before the call and wrapping everything in a try/catch that reports to Sentry:

    try {
        $email = sanitize_email( $input['email'] ?? '' );
        $first = sanitize_text_field( $input['first_name'] ?? '' );
        $last  = sanitize_text_field( $input['last_name'] ?? '' );
    
        if ( ! $email || ! $first || ! $last ) {
            throw new \Exception( 'Missing required fields.' );
        }
    
        $sf_user = Users::get_or_create_user( $email, $first, $last );
    } catch ( \Throwable $e ) {
        \AMA\Sentry\SentryHelper::log_sentry_breadcrumb(
            'Participant update failed.',
            [ 'error' => $e->getMessage() ],
            'graphql.update_participant',
            'error'
        );
        \Sentry\captureException( $e );
        wc_add_notice(
            __( 'There was a problem updating this participant. Please try again.', 'ama' ),
            'error'
        );
    }
    

    Now we get detailed Sentry breadcrumbs, and the customer only sees a friendly WooCommerce error.


    Removing Transients and Tracking Submission State

    Transients once held partial form data keyed by user ID. They were slow and sometimes persisted too long.

    We replaced them with a single post-meta flag on each ticket or participant:

    update_post_meta( $ticket_id, '_ticket_submitted_email', $email );
    

    That flag lets us render “Participant has been submitted” without needing to re-query Salesforce or WooCommerce order meta. It’s lightweight and easy to reason about.


    Tooling and Compliance

    • PHPCS + WordPress VIP ruleset enforced at commit time.
    • Sentry for tracing GraphQL calls and frontend exceptions.
    • Manual QA in Chrome, Safari, and Firefox with multiple parallel forms open.

    We also used wp db query checks to confirm no stray transient keys or serialized remnants remained.


    Key Challenges and Solutions

    ProblemRoot CauseSolution
    Email changes wiped user inputImmediate full-form clearDebounce + touched-field tracking
    Hidden required fields blocked submitBrowser validation before revealProgressive disclosure + disable hidden fields
    Restored forms couldn’t resubmitDirty state lost after reloadSubmitted-state meta + better dirty tracking
    VIP HTML-execution warningsjQuery .prepend() usageSafe DOM construction
    PHP type errors in GraphQLNull inputs from formGuard and sanitize all parameters

    Outcomes

    • Users can edit multiple forms concurrently without losing progress.
    • Front-end behavior is predictable: email first, details second.
    • Errors are logged silently to Sentry, never shown to the user.
    • The entire stack passes WordPress VIP code analysis.

    The end result feels invisible—which is the best compliment a form flow can get.


    Lessons Learned

    1. Draft UX should never be destructive.
      Small debounced changes protect the user’s confidence.
    2. Progressive disclosure reduces noise.
      Hiding irrelevant inputs at first isn’t just cosmetic—it prevents validation chaos.
    3. VIP rules are good design constraints.
      They forced safer, clearer code instead of quick jQuery shortcuts.
    4. Instrument everything.
      Sentry saved hours of guesswork by surfacing hidden GraphQL and JavaScript errors.

    Future Work

    • Inline async email checks to show whether a participant already exists.
    • A small audit log for seat assignments and changes.
    • Automated Cypress tests simulating multi-form editing and reloads.

    Closing Thoughts

    This project became a microcosm of modern enterprise WordPress: strict platform rules, deep API integrations, and UX that can’t afford to fail.
    Finishing what the vendor started required rebuilding key pieces from the inside out, but the result is a stable, maintainable system that quietly does the hard work behind every ticket and participant edit.

    The code might look simple now—that’s the point. Complexity moved out of the user’s way, which is exactly where it belongs.

  • Salesforce to WooCommerce: Solving Complex Membership and Subscription Imports

    When migrating customer data from Salesforce into WooCommerce, particularly when using WooCommerce Memberships, Subscriptions, the standard import tools fall short. I’m going to walkthrough how we overcame those limitations by building a custom WordPress plugin that operates at the subscription level to ensure accurate member assignment, product bundle tracking, and data syncing.

    This plugin is production-ready, extensible, and well-suited to organizations dealing with legacy Salesforce data and complex product hierarchies in WooCommerce.


    The Initial Problem

    Context

    WooCommerce’s Memberships plugin provides a basic importer that maps CSV data directly to individual memberships. However, our real-world data was organized around Salesforce Orders, not memberships. This presented three problems:

    1. Subscriptions drive membership access, not individual memberships.
    2. Group products and bundles needed to be tracked and assigned correctly.
    3. Multiple items were tied to a single order and had to be grouped without duplicating data.

    We needed to:

    • Start from Salesforce order exports.
    • Group and assign order items into core subscriptions.
    • Automatically build WooCommerce memberships.
    • Tie in metadata like Salesforce IDs.

    Our Solution

    Instead of trying to patch the existing Memberships importer, we implemented the WooCommerce Subscriptions Importer and built a custom system around it.

    Our importer:

    • Uses a CSV with enriched metadata.
    • Orchestrates import logic through a custom plugin.
    • Caches state between rows using wp_cache_set().
    • Imports subscriptions, creates parent orders, and assigns memberships dynamically.

    The Workflow in Action

    At a high level, the import works like this:

    1. Row is read from the CSV (containing an sf_order_number, product_code, etc.).
    2. The plugin determines if we are starting a new order or continuing an in-progress import.
    3. If new, we:
      • Create a WooCommerce subscription.
      • Assign a parent WooCommerce order.
      • Generate a user membership from the plan.
    4. If continuing:
      • Add the item to the existing subscription.
      • Assign bundled products to the parent order.

    Core Plugin Components

    1. Job.php

    Creates a representation of the current import job.

    $job['wp_user_id'] = get_customer_wp_id( $data['customer_email'] );
    $job['subscription_product'] = membership_product( $job['customer_type'], 'subscription_product' );
    

    This collects all contextual information for one order row: user ID, membership plan, Woo product IDs, etc.

    2. ProductBundler.php

    The main controller class. It drives the logic:

    • Determines if the row is a new order
    • Creates subscriptions
    • Adds order items
    • Links metadata
    if ( empty( $this->job['core_subscription_id'] ) ) {
        create_core_subscription( $subscription_product_sku, $this->data );
    }
    

    It avoids duplicate creation by checking cached values:

    Cache::get('core_subscription_id');
    

    3. SubscriptionHelper.php

    Handles subscription-level actions like:

    • Running WCS_Importer::import_subscription()
    • Adding order items to subscriptions
    function add_item_to_subscription_order( int $core_subscription_id, int $product_id, string $order_item, ... ) {
        $subscription_order_item_id = wc_add_order_item( ... );
        wc_add_order_item_meta( $subscription_order_item_id, '_product_id', $product_id );
    }
    

    It also adjusts legacy membership lengths by modifying subscription dates.

    4. MembershipHelper.php

    Creates the actual Memberships via:

    wc_memberships_create_user_membership( [
      'plan_id' => $plan_id,
      'user_id' => $user_id,
      'order_id' => $order_id,
      'product_id' => $product_id,
    ], 'create' );
    

    Also assigns the created Membership to the correct subscription:

    update_post_meta( $user_membership_id, '_subscription_id', $subscription_id );
    

    5. ParentOrder.php

    If no parent order exists, we create a shell order and set it as the subscription’s parent:

    $parent_order = new \WC_Order();
    $parent_order->set_created_via( 'importer' );
    ...
    wp_update_post([
      'ID' => $job['core_subscription_id'],
      'post_parent' => $parent_order->get_id(),
    ]);
    

    6. Cache.php

    We cache import state between rows:

    Cache::set( 'core_subscription_id', $subscription_id );
    Cache::get( 'product_code' );
    

    This approach ensures continuity during multi-row imports and minimizes redundancy.


    Challenges We Solved

    Problem: Tracking order context across CSV rows

    We solved this by caching state across rows (wp_cache_*). This allowed us to:

    • Avoid duplicate order creations.
    • Accumulate bundled items into a single subscription.

    Problem: Backward compatibility

    We needed to support legacy SKUs like MEMBER2YR, which required extending subscription lengths.

    $dates['next_payment'] = strtotime( "$start_date +$length years" );
    

    Problem: Preventing duplicate user creation

    We always resolve the WP user ID via email at the start:

    get_user_by( 'email', $customer_email );
    

    Final Thoughts

    This project transformed our import process from a manual, error-prone task into a seamless pipeline that:

    • Handles bundles
    • Links Salesforce data
    • Avoids duplicate orders
    • Ensures WooCommerce state integrity

    If you’re managing complex memberships in WooCommerce backed by Salesforce (or any CRM), this system gives you full control over your import logic. Remaining extensible for future needs like REST syncing, Sentry tracing, or CLI support.