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.