The quiet upsell: designing a cross-sell for a booking checkout

· TicketingHub · Research, UX, prototype, UX copy, frontend polish

TicketingHub operators can link two products with a discount, so that someone who books the food tour gets the kayak tour cheaper. 291 products use this today. The old widget sold these deals, but the new booking widget, which I design and build the frontend for, did not show them at all. This is the story of the cross-sell we added to its checkout.

A team of four worked on it for about three weeks. I did the research, the flow, the prototype, the UX copy and the final frontend polish. Bartosz Blimke built the backend and the integration, Carl Pihl made the product calls, and Attila Csizmadia kept the work moving.

Here is what we shipped. It is a live piece of the widget, so go ahead and use it.

Live · the shipped flow

Basket

Old Town Food Tour

Friday, 21 August 2026 @ 09:00
2xAdult
€98.00

🎉 Special offer unlocked

Sunset Kayak Tour

15% off
VAT 20%€16.33
Total to pay€98.00
⏰ Time remaining:9:37

Press Add, pick tickets and a time, then try to remove the food tour. Dummy products and prices; the markup and copy are the shipped ones.

Start with the data, not the screens

Before drawing anything I ran two queries on a production snapshot, because I wanted the design to rest on real numbers instead of my guesses.

QuestionAnswer
How many offers will a shopper see?61.5% of products have one offer and 92% have three or fewer. The most any product has is eight.
Do people book both products for the same slot?Out of 5,962 dated bookings only 5.6% did. 62.1% picked the same day at a different time, and 32.3% picked a different day.

Both answers turned into design rules. The offer block has to look right with a single offer and still hold together with eight, so there is no carousel and no cap. And since almost nobody books both products for the same slot, a one-click add was never going to work. The second product needs its own date and time.

A prototype the engineer could build from

I built six screens out of the widget's real components and tokens, and made one of them fully clickable. Next to each screen I wrote down which template it touches, which data it needs and what the backend cannot do yet. Engineering built the feature from that page.

Writing those notes meant reading the code, and that paid off early. I found that the old widget picks the "best" discount by sorting text, which means 5% off beats 15% off. I also learned that the system only stores the final ticket price, so there was no original price to cross out. Both findings shaped what we could promise the shopper.

Where should a cross-sell live in the checkout?

The first review disagreed with my placement. The argument was that offers at the bottom of the basket read like more of the receipt, that an upsell should be pushed at the customer, and that it deserved its own step.

I thought half of that was right, so I went looking for evidence on the other half.

  • Baymard found that 66% of users who were forced through a separate cross-sell step showed extreme frustration.
  • A hotel booking site removed its add-ons step and saw about 30% more users reach payment, with orders up about 5% (112k users over 45 days).
  • In the only direct A/B test on styling I could find, a background tint alone lost 7% of revenue per visitor, while moving the same offers into a block of their own at the bottom of the cart gained 12.5%.

Two more sources spoke to the idea that a basket is a receipt. Nielsen Norman Group found that shoppers use the cart as a holding area, "like a dressing room in a clothing store", where the final decision is still being made. And in a 2007 study in the Journal of Marketing Research, having just bought one item raised the share of people who bought a second one from 55% to 77.5%. When the money for the two purchases came from separate accounts, that share fell from 70% to 42.5%. I read that as a reason to keep the offer inside the purchase the shopper is already making.

Booking platforms for tours and attractions point the same way. Rezdy places upsells below the order totals so that the checkout flow is not affected. Ventrata gives offers a page of their own before payment, and the customer can continue without taking one.

So the offer stayed in the basket, and its look had to change.

Making the upsell look like an offer, not a receipt

The part of that review I agreed with is that an offer must never look like something you already bought. Products that land in a cart without the shopper's consent are a documented dark pattern, listed as "sneak into basket" in a Princeton study of 11,000 shopping sites, and an offer that looks like a basket item can be mistaken for one. The block now has nothing in common with a basket item. It sits on a grey surface and has a centred blue heading, a round image and a discount pill.

The first build still had problems. A reviewer on the pull request told me the block blended into the page, and that my 🔓 looked like a locked padlock at that size. Both points were fair, so I reworked the block directly on the branch.

Before · first prototype and first build

Basket

Old Town Food Tour

Friday, 21 August 2026 @ 09:00
2xAdult
€98.00

Combo deals unlocked

Sunset Kayak Tour

15% off
VAT 20%€16.33
Total to pay€98.00

A near-white surface between the items and the total. The padlock reads as locked. Add is plain text.

After · shipped

Basket

Old Town Food Tour

Friday, 21 August 2026 @ 09:00
2xAdult
€98.00

🎉 Special offer unlocked

Sunset Kayak Tour

15% off
VAT 20%€16.33
Total to pay€98.00

The same grey as the total, directly under the items. A celebratory title, and an outline Add that turns blue on hover without competing with Checkout.

How loud should an upsell be?

The final review asked for more: a darker background, a solid blue Add button and a description on every card. This time I kept my position, for three reasons.

  • Relevance does the work here, not contrast. Baymard reports that 52% of sites show irrelevant cross-sells, and that a single bad suggestion makes people ignore all of them. A combo offer only exists because of what the shopper just added, so it is relevant by definition.
  • A screen should have one primary action. A solid blue Add button would compete with Checkout. Add became an outline button that turns blue on hover, and I added it to the design system as a button style for grey surfaces.
  • Descriptions do not fit. Long supplier copy breaks a card in a widget that is 430px wide, and the product page has already done that job.

The reply was: "Fair points, let's go with your design as it is."

What happens when you press Add

Add starts the normal booking flow with the discount already applied. Every price the shopper sees is the discounted one, the pill under the product name explains why, and Back returns them to the basket instead of the product list.

One small decision here saved a whole build. Some widgets start from a calendar of all events instead of a product list, and sending an offer through that flow would have meant building a new event panel filtered by product. I suggested we skip it, because the shopper has already chosen the product by pressing Add. They pick tickets, then a date and time, and land back in the basket. Engineering agreed and the panel was never built.

Removing the tour the deal depends on

This decision went full circle.

1 · First design

Remove Old Town Food Tour?

Sunset Kayak Tour loses its combo discount. The price goes back up by €5.84.

Warn inline, keep the other product, show the exact price change.

2 · Cut for scope

Remove Old Town Food Tour?

Sunset Kayak Tour was added with a combo discount that needs Old Town Food Tour in your order. Removing it will empty your basket.

No repricing logic needed. Also no basket left.

3 · Shipped

Remove Old Town Food Tour?

Without Old Town Food Tour, Sunset Kayak Tour will lose its combo discount.

The first behaviour, in the widget's existing modal. The amount waits until the system can produce it.

My first design warned the shopper inline and kept the other product in the basket at full price. It was cut, first because it seemed like a rare edge case and then in favour of a cheaper rule that warned the shopper and emptied the whole basket. I redesigned the flow around that rule.

While building it, Bartosz found that emptying the basket felt bad in use. It also wiped gift cards, and the shopper had no way of knowing why everything disappeared. The build went back to warning the shopper, removing only that product and returning the other one to full price, which is what my first design did.

The exact amount is missing from the final message because the system cannot calculate it yet. "Keep it" is the primary button, because the safe choice should also be the easy one.

What we left for later

Asked forDecisionWhy
Crossed-out original priceSeparate ticketThe only number available is wrong for suppliers who set a real recommended price. I would rather show less than show something incorrect.
Discount on full-price tickets onlyDroppedIt is a change to core pricing, not to the widget.
Upgrade upsell, such as a 24h ticket to 48hSeparate ticketIt is a different feature and needs its own dashboard design.
Offers for products that are not on the widgetNot in v1The widget's product list is also its security boundary.
Discount when the products are added in reverse orderNot in v1See below.

That last row came from my own prototype. If a shopper adds the discounted product first and the qualifying one second, they end up with the same basket as everyone else but pay €5.84 more. My prototype quietly fixed the price. The real system did not, and nobody had planned that logic, so I flagged my prototype as wrong instead of letting it promise something we had not agreed to build.

I also raised one risk for after launch. The old widget ties offers to the sales channel, so they appear as soon as a supplier creates a discount. The new rule is stricter, and suppliers who migrate will wonder where their offers went unless we tell them first.

Outcome

The new widget now sells combination discounts for every supplier who has set them up. The design was approved as it was, and the feature is live.

Now that it is live, we will compare how often an offer is shown with how often it is added. If the block turns out to be too quiet, the first thing I will try is a single expand-in animation at the moment the offer appears. It would play once, right after the shopper's own action, and never loop.

If the product ever needs an offer that is pushed at the customer, I would put it on the confirmation page, after payment, where it cannot cost a booking. Bókun already offers upsells at that point.

What I took from it

Data ended arguments faster than opinions did. Two simple queries settled the one-click question before it became a debate, and outside research did the same for placement and styling.

I gave up the removal design too easily. It came back only because the cheaper version felt wrong once it was built. Next time I will prototype the cheap option as well, so the team can feel the difference before anyone writes code.

I should have tested the icon at its real size. A padlock that reads as locked is a small thing, but a second pair of eyes caught it in seconds and I had looked at it for weeks.

Working together

My part here was the research, the flow, the prototype, the UX copy and the frontend polish, inside a team of four.

If you are planning something similar, I am taking on new work. Write to hello@edigil.com or use the contact page.