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.
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.