Selected projects

Case Studies

All client details are anonymised. Technical specifics are real.

Context

This was built during my time on the optimisation team at one of Europe’s largest travel companies, working inside the checkout flow of the flight booking journey — the step where passengers choose hand luggage and check-in baggage allowance before paying.

The existing UI was functionally fine: each passenger got their own baggage selector, showing the included allowance (10kg hand luggage, 20kg check-in) alongside upgrade options at 25kg and 30kg, each with its own incremental price. It worked. But it treated every passenger as an independent decision, on a page where most bookings are families or groups who almost always want the same thing.

The problem

Watching session recordings of multi-passenger bookings made the friction obvious. A family of four booking together would set Adult 1’s baggage to 25kg, then have to repeat the identical action for Adult 2, Adult 3, and Adult 4 — four separate interactions to reach a decision they’d already made once. Some users did it. A meaningful number visibly hesitated on Adult 2 and just left everyone else on the default 20kg, not because they didn’t want more luggage, but because the page was asking them to make the same decision repeatedly instead of once.

That’s ancillary revenue left on the table by interface friction, not by price sensitivity — the more fixable kind of problem.

What was built

The fix was a single checkbox above the passenger list: “Upgrade all passengers to 25kg.” Checking it applies the 25kg selection to every passenger in one action. Individual passengers remain fully overridable below it — someone can still set Adult 2 back to 20kg or up to 30kg without affecting the others.

Baggage selection step showing the bulk 'Upgrade all passengers to 25kg' checkbox above individual per-passenger weight selectors
The bulk upgrade control, sitting above the existing per-passenger selectors it drives

The tricky part wasn’t the checkbox itself, it was keeping it honest. The page already had working logic for pricing, validation and state management tied to each individual passenger’s radio group — logic I didn’t want to duplicate or fork, because two implementations of the same price calculation is exactly the kind of thing that quietly drifts out of sync during someone else’s unrelated deploy six months later.

Instead, the bulk control drove the existing per-passenger components by dispatching the same change events they already listened for:

function applyBulkUpgrade(targetWeight: '20' | '25' | '30') {
  const passengerGroups = document.querySelectorAll<HTMLElement>('[data-passenger-baggage]');

  passengerGroups.forEach(group => {
    const target = group.querySelector<HTMLInputElement>(
      `input[type="radio"][value="${targetWeight}"]`
    );
    if (!target || target.checked) return;

    target.checked = true;
    target.dispatchEvent(new Event('change', { bubbles: true }));
  });
}

bulkUpgradeCheckbox.addEventListener('change', (e) => {
  if ((e.target as HTMLInputElement).checked) {
    applyBulkUpgrade('25');
  }
});

Because it reuses the existing change event contract, every downstream consumer — price recalculation, the order summary, analytics tracking — kept working without modification. The bulk control had no idea what any of that logic did, and didn’t need to.

The other half was making the checkbox state itself trustworthy. If a user checked “upgrade all” and then manually changed one passenger back to 20kg, leaving the checkbox ticked would be a lie about what was actually selected. A listener on each individual radio group unchecks the bulk control the moment any passenger’s selection stops matching it, so the checkbox always reflects true current state rather than “state at the time it was clicked.”

The outcome

The bulk control was tested against the existing passenger-by-passenger flow as the control. It outperformed by a wide margin — comfortably one of the strongest ancillary-revenue results run on that checkout that quarter — and was rolled out to 100% of bookings. The win wasn’t a pricing change or a new upsell; it was removing four clicks a family shouldn’t have had to make to do the same thing four times.

Context

Vertical bike storage racks have one recurring problem that generic e-commerce doesn’t: whether a given rack actually fits a given bike is a real, non-obvious technical question, not a preference. Tire width varies enormously between a road bike and a fat bike, and a rack sized for one will not hold the other. Before this work, that question was answered — badly — by a paragraph of spec text buried under the fold.

I was brought in to rebuild how fit information was presented on the product page, and separately to rework the cart drawer, which was a plain line-item list doing none of the merchandising work a cart page should do.

The problem

Two separate but related conversion leaks:

On the product page, customers had to self-diagnose their tire width from spec text and cross-reference it against a size chart to figure out which of the three rack variants (Narrow, Wide, Fat) they needed. Get it wrong, and the rack doesn’t fit — which shows up downstream as returns and “will this fit my bike?” support tickets, both of which are expensive relative to the order value.

In the cart, there was no incentive to add a second unit, no visibility into how close a customer was to free shipping, and no attempt to cross-sell the brand’s own merch — all standard cart-page levers that were simply absent.

What was built

The fit finder

A three-card selector — Narrow, Wide, Fat — each showing a labelled tire-width range against a product photo, the diameter range it fits, and a plain-language “Best for” line (Commuter/Road/Gravel/Urban for Narrow; MTBs/Hybrids/eMTBs/Gravel for Wide; Fat/Plus/Cargo/Cruisers for Fat). The Wide card carries a “Most household bikes” badge, since it’s correct for the largest share of visitors and reduces the number of people who have to think hard about which card applies to them.

Below the selector, an accordion answers the fender/mudguard compatibility question directly — the single most common pre-purchase question the support team was fielding, now answered on the page instead of in a ticket queue.

Each card links straight to the correctly-sized variant, so “which one fits my bike” and “add to cart” collapse into a single decision instead of two.

Product page fit finder showing three tire-width variant cards — Narrow, Wide and Fat — each with a width range, diameter fit and recommended use case
The product page fit finder — Wide carries the "most household bikes" badge

The cart drawer

The drawer needed to do three jobs on open: show progress toward free shipping, surface quantity pricing, and offer a relevant upsell — all reactively, without a page reload, since Shopify cart drawers live entirely off the Cart AJAX API.

The free-shipping bar recalculates against the live cart subtotal on every mutation:

document.addEventListener('cart:updated', async () => {
  const cart = await fetch('/cart.js').then(r => r.json());
  const remaining = Math.max(0, FREE_SHIPPING_THRESHOLD - cart.total_price);

  progressBar.style.width = `${Math.min(100, (cart.total_price / FREE_SHIPPING_THRESHOLD) * 100)}%`;
  remainingLabel.textContent = remaining > 0
    ? `You are ${formatMoney(remaining)} from Flat Rate Shipping!`
    : `You've unlocked Flat Rate Shipping!`;
});

Quantity pricing (“Buy 2, save 5%! Only $123.49 each” / “Buy 3+, save 10%! Only $116.99 each”) is rendered inline against the line item using the variant’s existing tier pricing, so the incentive to add a second rack is visible at the exact moment someone is looking at the one they already added — not buried on a separate volume-pricing page they’d have to go find.

Below the line items, a small horizontal carousel offers branded merch at a discount — a tote and a drawstring bag in the shipped version — pulled in as a lightweight cross-sell rather than a hard upsell modal, so it doesn’t interrupt the path to checkout for anyone who isn’t interested.

Cart drawer showing a free shipping progress bar, quantity discount tiers on the line item, and a branded merch upsell carousel
The rebuilt cart drawer — shipping progress, quantity pricing and a merch cross-sell in one view

The outcome

Both components are live on the client’s product and cart pages. The fit finder gives customers a direct, low-effort path from “which one do I need” to “add to cart,” and the cart drawer does the merchandising work — shipping incentive, quantity pricing, and cross-sell — that a bare line-item list wasn’t doing at all before.

Context

This platform sells a single high-ticket bundle — lifetime access to their full library of automotive tuning courses — at a price point where hesitation is the default customer reaction, not the exception. At several thousand dollars, “what exactly am I getting” and “why is this worth it compared to doing this the traditional way” are the two questions the sales page has to answer convincingly before anyone reaches for a card.

I was brought in to build the two page sections doing the heaviest lifting on that: the curriculum browser and the value-comparison table feeding the main CTA.

The problem

High-ticket course pages have a specific trust problem: vague curriculum descriptions (“comprehensive training across all areas of tuning”) read as marketing copy, and marketing copy is exactly what a sceptical buyer discounts. The fix isn’t a better description, it’s proof — a specific, browsable list of what’s actually included, in enough detail that it stops reading like a pitch and starts reading like a syllabus.

Separately, a price on its own has no anchor. $2,497 for lifetime course access means nothing until it’s placed next to what the alternative actually costs.

What was built

The curriculum browser

A collapsible accordion listing every course category with its course count — EFI Tuning (12), Motorsport Wiring (5), Engine Building (3), Diesel Tuning (2), Suspension and Car Setup (6), and five more categories through Electric Vehicles — with an “Expand All” control at the top for anyone who wants to scan the entire catalogue in one pass rather than clicking through ten sections individually.

The accordion had to stay honest about state with itself: expanding all ten sections individually and then hitting “Expand All” shouldn’t do nothing, and collapsing one section manually after using “Expand All” shouldn’t leave the toggle claiming a state that’s no longer true. It tracks aggregate open/closed state across all sections rather than treating “Expand All” as a one-off action, so the control’s label always matches what’s actually on screen:

function syncExpandAllState() {
  const sections = document.querySelectorAll('[data-course-section]');
  const allOpen = [...sections].every(s => s.hasAttribute('open'));
  expandAllToggle.textContent = allOpen ? 'Collapse All' : 'Expand All';
}

sections.forEach(section => {
  section.addEventListener('toggle', syncExpandAllState);
});

expandAllToggle.addEventListener('click', () => {
  const shouldOpen = expandAllToggle.textContent === 'Expand All';
  sections.forEach(s => s.toggleAttribute('open', shouldOpen));
  syncExpandAllState();
});

The category counts do real work here too — “(12)” next to EFI Tuning is a small, specific, unglamorous detail, and specific unglamorous details are what make a claim like “42+ courses” feel counted rather than asserted.

Collapsible curriculum browser listing course categories with counts, such as EFI Tuning Courses (12) and Motorsport Wiring Courses (5), with an Expand All control
The curriculum browser — every category collapsible, with an aggregate "Expand All" control

The value-anchoring table

A straight two-column comparison — Traditional Education against the platform’s VIP Package — across the criteria that actually differ: instant access, access anytime, lifetime access, money-back guarantee, course count, and price. Traditional education gets a green tick only where it’s genuinely true (applicable to all vehicles, constantly updated) and a red cross everywhere it isn’t (no instant start, no lifetime access, no guarantee, not 42+ courses for one price). The $10,000+ figure sits directly across from $2,497 in the price row, doing the anchoring silently — no copy has to tell the reader the VIP package is inexpensive by comparison, the row does it.

The table feeds straight into the enrolment CTA below it: a single high-contrast button showing the payment-plan framing (“16 easy payments of only $118.56 USD”) rather than the lump sum, with card and PayPal logos and the lifetime-access and 60-day money-back guarantee restated immediately beneath it — so the last thing anyone reads before clicking is the risk-reversal, not the price.

Comparison table titled 'Why Learn with HPA?' contrasting Traditional Education against the VIP Package across price, access and guarantee criteria, feeding into a high-contrast enrolment CTA
The value-anchoring table, sitting directly above the enrolment CTA

The outcome

Both components shipped as the core of the platform’s course sales page. The curriculum browser replaces a vague claim with a countable one, and the comparison table gives the price a reference point instead of asking visitors to judge $2,497 in isolation — the two things a high-ticket page needs to do before its CTA has any chance of working.

Want work like this for your team?

Let's talk through your testing programme and where I can help.

Book an intro call ↗