Fourteen pull requests in January and thirty-eight contributions — the quietest month of my time on this codebase, quieter even than the May when one feature ate three weeks. Nine of the fourteen were the same ticket.
A year earlier, January had been about deleting half a megabyte from the homepage so Google would rank it. This January the shop started selling insurance.
CatCare
The ticket is the most unusual one I was given at 3cat, because it is not really a website feature:
We will be offering customers device protection insurance via AnyCover.
This insurance protects customers from costly repairs, by covering cost
of repair or replacement. Catcare insurace will be powered by AnyCover
(underwritten by Etiqa).A company that buys back used iPhones, repairs them and resells them with a one-year warranty was now also selling a real insurance product, underwritten by an actual insurer, at the point of purchase. The engineering is modest. What it represents is a shop that had grown a second thing to sell.
Two rules from the acceptance criteria did the most to shape the code. The premium is calculated from a device pricing tier and rounded up to the nearest ringgit. And:
Vouchers applied to customer purchase does not change the catcare
premium payable.That second one is the interesting constraint. Every other number on the checkout page had spent the previous three months learning to react to vouchers — the subtotal, the instalment calculation, the savings figure. The insurance premium is the one line that must not. It is priced against what the device is worth, not what the customer paid for it, and an insurer does not care that marketing ran a promotion.
By January the checkout was carrying two upsells side by side, each as its own cart item type — CatCareCartItem with its own price calculator, and GreatConditionUpgradeCartItem. The page I built in December 2023 to show one price for one phone now assembled a device, an insurance policy, a condition upgrade, a voucher and a delivery choice, and had to keep all of them consistent with each other.
UI 2, UI Fix 3, UI 4, UI 5
The pricing engine had gone in at the end of December. January was entirely presentation, delivered in numbered instalments, and the pull request titles are the honest record of it:
G 1028 UI 2 430 additions, 22 files
1028: UI 2 Fix 3 additions
1028: UI 3 209 additions, 16 files
1028: UI Fix 3 9 additions
1028: UI 4 38 additions, 10 files
1028: Adjust popup index 1 addition
1028: UI 5 5 additions
1028: Remove unused design 17 additions, 89 deletionsAn earlier attempt from 31 December — 567 additions across 28 files — was closed without merging, which is why January opens at UI 2. Each round was a design review turned into a diff, and two of them needed a fix the same day they landed. UI 5 is five changed lines.
There is no cleverness to report here and I think that is worth saying plainly. Selling an insurance product at checkout means somebody has to look at the page, on a phone, and decide whether the badge sits two pixels too low — four times. The work that gets a new product line live is mostly this.
A component that held every design at once
The last pull request in the sequence is the one I would show someone. It adds seventeen lines and deletes eighty-nine.
Across five rounds of design, the CatCare block had accumulated an optional variable for every version anybody had tried. By UI 5 the template opened like this:
{{ $catCarePopup ? 'pt-5 xl:pt-6 gap-4' : 'pt-6' }}
{{ isset($catCareGif) ? '' : 'xl:gap-3' }}
@if (isset($catCareImage) && isset($productName))
@if (isset($catCareTitleDesktop))A conditional for a GIF that one round had wanted, another for a separate desktop title, another for a product thumbnail with a shield badge overlaid on it. Every branch was a design that had been proposed, built, and then not chosen — and every one of them was still in the file, still being evaluated on every render, still something the next person would have to understand before changing anything.
So the removal pull request deleted the losers. The whole buy-with-confidence component, thirty lines, gone. The shield image, gone. Twelve lines of stylesheet, twenty-four lines of translations. And the surviving markup collapsed back to one code path:
- {{ $catCarePopup ? 'pt-5 xl:pt-6 gap-4' : 'pt-6' }} {{ isset($catCareGif) ? '' : 'xl:gap-3' }}
+ {{ $catCarePopup ? 'pt-5 xl:pt-6 gap-4 xl:gap-3' : 'pt-6' }}The translations also moved from web.pdp.buy_with_confidence.cat_care to web.checkout.cat_care, because over five rounds the feature had migrated from being a product-page reassurance block to being a checkout upsell. The key path was still describing where it used to live.
Iterating on a design in code leaves sediment. Nobody deletes the previous version while the next one is still under review, and after four rounds the component is a museum of every decision that did not survive. Cleaning that up is a separate act of will, and it is the pull request nobody asks you to open.
Remembering a choice across a page
The requirement said that if a customer enables CatCare on the product page, the selection should be remembered at checkout. The implementation is one line:
'catCareSelected' => request()->get('cat_care_opt_in') == 'true',The choice travels in the URL. Not a session, not a cookie, not a cart record — a query parameter on the link from the product page to the checkout.
I like it more than I expected to. August taught me that the checkout page should not write to the database in order to render, and this is that lesson honoured: the page is told what to show and stores nothing. It also means the customer can see and change the parameter, which sounds alarming until you notice the only thing they can do with it is decline to buy insurance, which they can already do with the toggle.
The loose comparison against the string 'true' is the part I would tighten. It works because the link is generated by our own template, and it will keep working exactly as long as that stays true.
Choosing a picture by reading a name
The confirmation popup shows an illustration matching the kind of device being insured — a laptop, a tablet, a watch, a phone. There is no field on the product that says which. So:
protected function getProductCategorySprite(string $productName): array
{
$productNameLower = strtolower($productName);
if (str_contains($productNameLower, 'mac')) {
return ['Macbook', 'sprite-cc-laptop-fix', 'sprite-cc-laptop-ori', ...];
}
if (str_contains($productNameLower, 'ipad')) {
return ['iPad', 'sprite-cc-tablet-fix', 'sprite-cc-tablet-ori'];
}
if (str_contains($productNameLower, 'watch')) { ... }
}The device category is inferred by searching the marketing name for a substring. It is correct today, and it is the same family of decision as the postcode I stored as an integer in December 2023 and the shipping code I extracted with explode in October: deriving structured meaning from a string written for humans.
The failure mode is quiet and specific. Every product whose name contains mac is a laptop, checked first, so anything ever named in a way that trips that gets a picture of a MacBook. Products have categories in the database for exactly this reason, and the reason I did not use them is that the mapping from category to illustration did not exist either, and inventing it was a bigger ticket than the one I had.
The keyboard that submitted the form
My favourite bug of the month, and it is the kind that only a used-Apple-device retailer would find so expensive:
On Android, tapping 'Next' on the keyboard correctly shifts focus to the
next field. On iOS, tapping 'Next' mistakenly submits the form instead of
moving to the next field.Customers filling in name, address and postcode on a phone. On iPhone — which is most of them, on a site that sells iPhones — the keyboard's Next key submitted a half-filled checkout instead of moving to the next box. Thirty-two additions, one file, no deletions.
Most of the fix is answering a harder question than it looks: is this an iPhone?
isIOS() {
return ['iPad', 'iPhone'].includes(navigator.userAgentData?.platform)
|| /iPad|iPhone/.test(navigator.userAgent)
|| (navigator.maxTouchPoints > 0 && /Mac/.test(navigator.userAgent));
}Three strategies, in descending order of respectability. The modern platform API, then the user-agent string for browsers that do not have it, and then the case that exists purely because an iPad reports itself as a Mac — so a Mac with a touchscreen is the tell, and Macs do not have touchscreens. Every line of that is a workaround for somebody else's decision.
Then it intercepts Enter on every text, email, telephone and select field and moves focus itself. One thing I would change: the wiring sits at the end of a voucher-related method rather than in the controller's connect, so it runs whenever that method runs and would attach a second set of listeners if it ran twice. It works. It is in the wrong place.
The warranty became free, and worth ten per cent
Thirty-five additions, twenty deletions, seven files, titled Highlight Free Warranty Sitewide. I did not mention it, and it is the most quietly loaded change of the month.
Almost all of it is one edit repeated:
- '1-year warranty',
+ '1-year warranty (FREE)',
Applied to the homepage value strip, the product-page strip, a new product-page-specific variant of the same list, and the three-item block above the footer. The same pull request also reorders that block so Great Condition comes first and the warranty second, and adds a span so the word FREE can be styled separately from the rest of the phrase.
The claim is true. The warranty costs the customer nothing. What makes it worth recording is what the codebase does with that free warranty three functions away:
function calculate_warranty(float $price): float
{
return $price * 0.1;
}
function calculate_savings(float $original, float $cartPrice, float $warranty,
float $shipping, float $discount = 0): float
{
return round(((max($original, $cartPrice)) - $cartPrice)
+ $warranty + $shipping + $discount);
}
The warranty is valued at ten per cent of the device price, and that figure is added to the savings total the customer is shown. So on a RM3,000 device, RM300 of the advertised saving is the warranty, and the RM300 comes from a multiplier written into a helper function with no comment, no config entry and no stated source.
I introduced that constant in February 2024 and wrote at the time that no real number had ever arrived for it. Eleven months later the shop began advertising the same warranty as FREE across every page, which is the two halves of the claim pointing in opposite directions: free to you, and simultaneously worth a tenth of whatever you paid, according to nobody in particular.
Both statements can be defended. A free one-year warranty on a used phone is genuinely worth something, and ten per cent is not an obviously silly guess. But it is a guess, it has been load-bearing in a customer-facing number for a year, and the month the shop made the word FREE bigger would have been a reasonable month to go and find out what the warranty actually costs 3cat to honour.
What January was
Fourteen pull requests, twelve merged. An insurance product live at checkout, eighty-nine lines of abandoned designs deleted, and an iPhone keyboard that stopped submitting half-finished orders.
The number I keep looking at is thirty-eight contributions, the lowest of the thirteen months. Nine of those pull requests moved a badge and a title around a box. Nothing in this month is architecture, and a year earlier I would have found that discouraging. What it actually reflects is that the interesting decisions had already been made — the component library in December 2023, the payment flow in February, the promotion system in June, the checkout that stopped writing on render in August — and January's job was to put a new product on top of all of it without breaking any of it. That took four rounds of pixels and one page of iPhone detection, which is a fair price.
Revised: this article originally omitted the sitewide free-warranty change, which is the month's clearest link between the shop's copy and a number nobody had ever sourced. The section above was added from the original diff.