The summer weekday discount was live. The site had a banner scrolling it across the top. A promo card described the terms in full. And the two pages a paid Google Ads click actually landed on — the property pages — showed neither.
That’s the gap this work closed. It sounds like a one-line fix, and the commit that fixed it is one line per page: drop a PromoCardList component onto each property landing page. But the reason it was worth doing, and the reason it took a small system to do it right, is where the interesting part is.
The ad and the promotion were two separate stories
This site exists so the client owns its own bookings instead of renting them from OTAs at commission rates higher than most people realize. The whole point of running Google Ads to a direct-booking site is that the money spent on the click has to land somewhere that converts. If the campaign is bidding to send people to the property page, then the property page is the only page that matters for that spend.
So here’s the thing that was quietly wrong: the client had a summer weekday multi-night discount running Jul 11–31. It was announced two ways — a scheduled banner at the top of the site, and a full promo card with the terms, the eligible dates, and a call to action. Both of those are self-service; the operator publishes them from the admin console with no developer involved. The marginal cost of a new promotion is already near zero for them.
But the promo cards were rendering on general pages, not on the two property landing pages that the ad campaigns actually point at. A paid visitor arrived on the exact page built to convert them — and the live promotion wasn’t on it. The banner scrolled by at the top, sure, but the card that actually explains “pay full first night, 20% off from the second” and gives a booking CTA wasn’t in the flow.
Adding <PromoCardList locale={locale} /> to both property pages put the promotion where the money was already sending people. That’s the whole macro story: the ad spend, the landing page, and the promotion are three parts of one signal path, and one of them was disconnected.
Why a card list, not a hardcoded banner on the page
The cards are declared in a single JSON file, newest-on-top, and rendered by one component. That ordering rule — newer above older — is deliberate. When the operator adds a promotion, it appears at the top of the stack automatically; they never touch layout order. The comment in the source is literally the contract: newest declared on top.
This matters because the alternative — hardcoding the current promo into each property page — would mean a developer edit every time the promotion changes. For a client whose whole value proposition is “you don’t need me for content,” that’s the wrong design. The card list is a rendered view over a data file the operator already controls.
The part nobody asked for: a themeable media panel
Here’s where judgment showed up beyond the requirement. The photo cards have a colored media panel behind the image — the block that frames the photo and holds the little badge (“24 罐,” that sort of thing). It started life as a single dark red gradient baked into the component’s CSS.
That red was a problem for a reason that only surfaces once you have more than one promotion. A dark red media panel behind a BBQ card is fine. Behind a bright summer beach promotion, it fights the content — a wintry, heavy frame around a page selling private pools and shoreline. The first fix was pragmatic: swap the clashing dark red for warm gold. But swapping one fixed color for another fixed color just moves the problem to the next season.
So the real fix was to make the panel color a choice, tied to the card, editable from the admin. I pulled the theme into a small typed catalog — amber (the default, BBQ/food/general), festive red-gold (CNY, Christmas), rose (Mother’s Day, Valentine’s), brand teal (summer ocean, year-round), spring green — and later added a bright sunny-beach gradient specifically for the Kenting shoreline summer feel. Each theme carries its own gradient and a matching badge color, so the little tag stays legible instead of vanishing into whatever background it’s on. That badge-color pairing is the detail a color picker alone would have missed.
The theme id lives on the card’s photo data, defaults to amber when unset, and is validated on save — an unknown theme id is rejected with a clear message rather than silently rendering a broken gradient. On the page, one helper resolves the id to its gradient and badge color; if the id is bad, it falls back rather than throwing.
The swatch that turns a dropdown into something the operator can actually use
A dropdown of theme names — “amber,” “festive,” “rose” — is a developer’s mental model, not an operator’s. The operator doesn’t think in theme ids; they think “does this color go with a beach photo.”
So next to the dropdown I put a live color swatch that updates the instant the selection changes. Each option carries its gradient as a data attribute; a swatch element next to the select repaints to match. The operator picks by looking, not by decoding a label. That’s the difference between a feature that technically exists and one that gets used — and it’s exactly the kind of friction reduction that decides whether the whole self-service layer holds up over months.
The practical result: the operator can now spin up a seasonal promotion, put it on the pages the ads actually point at, and color it to match the photo — banner, card, landing-page placement, and theme — without a single line of code or a message to me. Season by season, the marginal cost of a promotion stays at zero.
What still needs me is the line I’ve drawn deliberately: new card templates, new page types, structural layout. A new color option now sits on the self-service side, because I built the catalog to be extended by data, not by a developer touching CSS. That boundary — content and theme choice for the operator, structure and new capabilities for the developer — is the one that keeps a static site cheap to run and safe to hand over.
The promotion was live everywhere except the one page the ad budget was paying to fill.