Dansday

The Month Order 119 Became Order 423828

Published on Mar 31, 2024

Some months you build features. March was mostly about consequences — of decisions made in January, of assumptions in the data, and of what a number tells a customer without meaning to.

Sixteen pull requests, my busiest month of the six. Two of them had been open since January.

Campaign work

The month opened with a campaign. A Super Sale needed new banner creatives across the site, which sounds like swapping image files and largely was, except the marketing team supplied them as WebP because of the format work from January. Two pull requests, some unused assets deleted, done. Not every ticket is interesting, and a month of only interesting tickets would mean somebody else was doing the unglamorous half.

The trade-in list

Then the trade-in list, which is where March got its teeth. The page lists every device 3cat will buy back, grouped by category, with a price per storage size. It worked, until it met products that had no category at all.

The original code simply skipped them:

foreach ($products as $product) {
    if (!$product->categories->isEmpty()) {
        // ... build the row
    }
}

A device with no category was silently invisible. Not broken, not erroring — just quietly absent from a page whose entire purpose is telling customers what you will pay for their phone. The sorting had the same blind spot, reaching for the first category's position on a collection that might be empty:

return [$product->categories[0]->position ?? 0, $product->name];

The null-coalescing operator looks like a guard, and does nothing here — indexing [0] on an empty collection throws before ?? is ever consulted. It has to be checked before the index, not after:

return [
    $product->categories->isEmpty() ? '' : $product->categories[0]->position ?? 0,
    $product->name
];

I tried hiding the empty category heading first, then thought better of it and gave those products a real home under an Others grouping. Hiding the symptom would have left the products in a nameless band of the table; giving them a category meant a customer could actually find them.

Undoing January

Mid-month I had to undo my own work. January's ticket had been lazy-load everything, and I had done exactly that — including the homepage banner, which is the first thing a visitor sees and therefore the Largest Contentful Paint element. Lazy-loading the LCP image is a way of instructing the browser to be slow at precisely the moment it is being measured.

There are two commits called Revert and Revert one line from that week. The fix was to make the first slide the exception:

<img
    data-slide-target="img"
    data-src="{!! full_asset($slide['image']) !!}"
    @if($i === 0)
        src="{!! full_asset($slide['image']) !!}"
    @endif
/>

Every other slide keeps its source in a data attribute and loads on demand. The first one ships with a real src so the browser starts fetching it immediately. I also dropped the intersection observer threshold from 0.1 to 0.01, so images begin loading when a sliver appears rather than a tenth of the element — the difference between an image that is ready when you arrive and one that fades in after you do.

The lesson from January and March together: lazy-loading is not a virtue you apply uniformly. It is a trade, and above the fold you are on the wrong side of it.

The pull request that took two months

On 13 March a pull request I had opened on 15 January finally merged. Search-optimised copy on category pages, editable by the marketing team, 304 additions across fifteen files. Fifty-seven days in review.

Very little of that delay was code. It was a feature that needed the marketing team to have opinions about content that did not exist yet, screenshots at three breakpoints, and a QA pass I had already labelled QA Not finalized once. The migration file inside it is stamped 2024_03_05 even though the pull request predates February, because by the time it landed I had rebased it enough times that it was easier to date it honestly.

It merged alongside a one-line pull request called Update build for PLP, which added the listing page's stylesheet to the asset build. Fifteen files of feature work are worth nothing if the CSS never gets compiled, and I found that out in the order you would expect.

A week later the same machinery went onto product pages. That ticket was twelve additions across four files, because January had already done the hard part — the column, the editor, the styling. All that remained was to render it, and to stop the specifications block from drawing an empty frame when a product had no description:

@if(!empty($product->description))
    @include('web.components.pdp-specifications')
@endif

@if(!empty($product->bottom_text))
    <x-partials.seo-content-text class="pb-16 xl:py-10"
        content="{!! $product->bottom_text !!}" />
@endif

I also renamed the admin templates from top-text to categories-content, and corrected the event hooks they were registered against — they had been listening on seo_titles and bottom_texts, two names for one thing, neither of which described it. Renaming a thing you built two months ago, once you finally know what it is, is cheap. Leaving it misnamed charges interest forever.

Bullet points that came back

The bug I found most satisfying was a mismatch between what content editors saw and what visitors got. Text written in the CMS came out with its bullet points and numbering stripped — lists rendered as plain lines. The cause was Tailwind's preflight, which deliberately removes default list styling on the assumption you will add your own. Reasonable for a design system, wrong for content pasted in by a human who can see bullets in the editor. The styles also lived in the product-listing stylesheet, so they only applied on one page type. I moved them into the shared stylesheet and put the list defaults back:

.content ul {
    display: block;
    list-style-type: disc;
    margin-block-start: 1em;
    margin-block-end: 1em;
    padding-inline-start: 40px;
}

Words are a feature too

Two pull requests in the last week of March changed no logic at all and were among the more valuable things I shipped. Both were edits to a single translation file.

The first rewrote the value propositions across the product page, checkout and repair pages. The old trade-in line had a number in it:

- 'Like starting from RM320.90. We’ll buy back your old tech for cash.'
+ 'Maximize savings with the highest trade-in value in the market.'

A specific figure in a static string is a promise the code cannot keep. It was not derived from anything — it was typed, and it would be wrong the moment trade-in prices moved. Removing a number is sometimes the honest fix, the same lesson as December's hardcoded discount badge arriving in a different costume.

The rest was tone. second hand is the new new became secondhand is the new 'new', which is the same sentence and reads properly. And the environmental claim moved from what the customer gains to what the purchase avoids:

- 'Reduce your carbon footprint by extending your device’s lifespan.'
+ 'Reduce harmful impact of electronic waste on our environment.'

The repair page gained a paragraph I thought was the smartest thing in the ticket — an unprompted promise about privacy, because handing a stranger your unlocked phone is the actual objection:

'Rest assured that during the process, we will not access any of your
 personal information. Only essential apps like the camera, dialer,
 and settings will be used to ensure the functionality of your
 device remains intact.'

The second was a full rebuild of the FAQ. The old list opened with How can I contact customer support? — a question you ask when something has already gone wrong. The new one opens with what somebody actually wants to know before spending RM 999 on a used phone:

'question' => '1. What is your product condition?',
'answer'   => 'Our used devices are in Great condition with battery
               health guaranteed at least 80% (if not better), which
               is sufficient for typical full day usage.'

Then warranty and returns, with the actual terms — refund within ten days, one-to-one exchange within thirty. Then the question I would not have thought to answer, about whether repair parts are genuine, which admits the constraint rather than dodging it: only authorised shops may advertise parts as original, so 3cat grades them A and B and uses only A. Then the instalment plans, naming all nine banks.

Two things about that file are worth admitting. The numbering lives inside the question strings, so reordering the list means renumbering seven of them by hand — a data-modelling shortcut that will punish the next person who inserts a question in the middle. And the fifth question shipped reading Do you have physical strores?, which stayed live until somebody noticed. I wrote a lot about honest prices this year and then misspelled stores in production.

A status that did not stick

On 19 March I opened a pull request called Testing custom status and closed it the next day. Thirteen lines: a new order state threaded through the model, the admin grid, the labels and the stylesheet.

public const STATUS_RESERVED = 'reserved';

The idea was that a customer could hold a device for in-store pickup without having completed a purchase — not pending, not processing, not cancelled. None of Bagisto's built-in states meant it. I gave it the same yellow badge as pending, proved the grid could filter on it, and threw it away, because the checkout flow it needed did not exist yet.

It exists now. Two years later this codebase runs a paid-reservation flow with deposits, gateway handling and its own failure paths. The one-day experiment did not become that feature, but it is where the question was first asked out loud, and I only recognise the shape of it in hindsight.

Order 119

The last thing I did in March was tell a small lie with a migration. Order confirmations were showing the raw database id, which meant the first customers were seeing order numbers like 119. That number is a fact, and the fact was that almost nobody had ordered anything yet.

public function up()
{
    DB::statement('ALTER TABLE orders AUTO_INCREMENT = 423828;');
}

public function down()
{
    DB::statement('ALTER TABLE orders AUTO_INCREMENT = 119;');
}

I also switched the confirmation page from $order->id to $order->increment_id, so the customer-facing reference is a display value rather than a primary key — the two should never have been the same field, because one is yours and one is theirs.

The preview and the page used different stylesheets

A pull request I skipped: 138 additions, 106 deletions, four files, filed as Content not reflecting as displayed in CMS. Operations wrote a page in the admin editor, it looked right there, and it came out unstyled on the site.

The cause is two lines:

-<div class="seo-content break-words {{ $class ?? '' }}">
+<div class="content break-words {{ $class ?? '' }}">

The class was seo-content, and the rules for seo-content lived in plp.css — the product-listing stylesheet. A CMS page does not load plp.css; it loads static.css. So the markup was correct, the styles existed, and they were in a file the page never fetched. The fix renames the class and moves 104 lines of CSS from one stylesheet into the other.

The reason it looked fine in the editor is the part worth keeping. The admin preview and the public page were never rendering through the same stylesheet, so the editor was showing operations a version of their page that only existed in the editor. A preview that does not share the page's CSS is not a preview; it is a second, more optimistic renderer.

That file is seo-content-text.blade.php, and it comes back. In March 2025 — exactly a year later — I would add 57 lines to the same component to truncate its text to six lines on mobile and fade the rest behind a Read More button, because the copy that exists for search engines was getting in a customer's way. In March 2024 the problem was that nobody could see it. In March 2025 the problem was that everybody could.

Also unrecorded from the month: 134 additions across nine files on the homepage banner and above-the-fold section, which introduced a homepage-specific Tailwind config, and a small FAQ text update.

What March was

Sixteen pull requests. Products that had been invisible became findable, a banner that had been slow became fast again, bullet points came back, a marketing team got the keys to their own copy after fifty-seven days of waiting, and order #119 became order #423828.

There is also a pull request I opened on 8 March called Do not merge - Akbar | To open local db port. Two lines, for my own convenience, clearly labelled. It was closed in October, seven months later, by someone finally tidying up. Every repository has one of these, and mine had my name on it.

Revised: this article originally omitted the CMS stylesheet-scoping bug and the homepage banner work. The section above was added from the original diffs.