Case study
W.R. Wright & Sons
Liverpool food-trade supplier, kitting out professional kitchens since 1923. A WooCommerce trade store built on top of the Sage 200 system the business already runs on.

The Problem
W.R. Wright have supplied the food trade for over a hundred years, out of Cherry Lane in Liverpool. The business runs on Sage 200 and on the phone. Reps know the customers, the customers know what they order, and none of that existed anywhere a new buyer could actually buy from.
What they needed was a trade store, not a brochure site with a shop bolted on the side. The catalogue runs to around 2,000 lines. Prices are ex VAT and most of the food is zero-rated. Some customers are trade on account, some are members of the public, and the products, the prices and the VAT status all live in a Sage database nobody was ever going to re-key by hand.
The approach
I built it in slices. Storefront and page builder first, then the catalogue, then basket and checkout, then accounts. Each slice went out for review before the next one started, so there was never a six week stretch where the client had nothing to look at.
The decision that shaped everything else was working out what the website owns and what Sage owns. Sage owns products, prices, VAT status and stock. WordPress owns descriptions, images, categories and every word a buyer actually reads. The import is a WP-CLI command that diffs field by field and leaves anything edited in WordPress alone.
The catalogue got the same treatment. Two thousand lines is not something you launch, it’s something you get wrong slowly, so the first hundred went live and the rest follow in stages. The launch set wasn’t picked on what photographs well, it came off twenty-four months of real sales in Sage, ranked on revenue, order count and how many separate customers bought it. Products 101 to 1,000 are being written and imported now. After that comes a two way sync so the site stops being a snapshot of a Sage export, then trade pricing, then the long tail of everything else.
I got that wrong the first time. I wrote the change detection to short-circuit on a hash of the Sage payload, which sounds sensible right up until you notice the hash only tells you whether Sage changed. A price edited in WordPress slipped straight past it, silently. An integration test caught it. It diffs every field now.

The product listing page, with filtering by category, price and availability. Product images are a mix of supplier and placeholder shots for now, and improved photography is coming in phase 2.
The publish gate is in code, not a reminder in a spreadsheet. A food product without allergen data cannot go live by accident.
The build
The catalogue came out of Sage in two stages, an export off the live database and an import into WooCommerce. Ninety-nine products, not a round hundred, because the hundredth turned out to be a postage and packing line that had crept in carrying a real stock code. Food detection needed two signals rather than one. The product group finds 56 of the 60 food lines and the VAT zero-rating finds 59, and only the two together find all 60, because four zero-rated pastry lines sit in a group called Catering Sundries.
Allergen data was the slow part. Fifty-six supplier spec sheets, a mix of PDFs and Word files, covering 55 of the 60 food products. Every supplier declares differently. Two of them mark allergens in capitals, three don’t emphasise anything at all, one ships two ingredient lists where only one is label-legal, and one under-declares soya against its own ingredients, so that sheet didn’t get imported. The join has to run on the manufacturer part number, because three of the twenty-four bracketed codes in the Verstegen product names are simply wrong.
A lot of the smaller decisions got measured rather than eyeballed. The product grid stays at three columns because I ran all 80 product names through the real card font at both widths: none of them wrap to two lines at three columns and twelve of them do at four. The names carry their disambiguator at the end, so a line clamp would truncate exactly the bit a buyer orders on. The sort dropdown originally offered two options both promising popularity, and neither of them sorted by anything, because the shop has never traded here so WooCommerce’s own sales figures are all zero. The real signal was twenty-four months of sales sitting in Sage, so that’s what feeds the ordering now.
Trade and retail share one store. The difference between them is a single WordPress role and nothing else, which is the second version of that. The first carried a role and a user meta field saying the same thing, and the two drifted apart within a fortnight, so the field came out. The role decides who sees a company field on registration and at checkout, and it’s what the trade pricing work will read when it lands.
Shipping caught me out for an afternoon. An Isle of Man address was matching no zone at all and coming back with no rates, which stops the order dead. WooCommerce ANDs postcodes with the country when a zone has both, so the Isle of Man needs a zone of its own with no postcodes in it.
The one thing I pulled from the launch was knives. There was a single bladed product in the range, and selling it remotely without an age check at the point of sale means there’s no defence if it goes to an under-18. A tickbox doesn’t count, the Home Office guidance names it specifically, and the enforcement is real rather than theoretical. So the product went to draft and the sharps section came off the delivery page until we can implement a legally compliant approach to distance selling sharps. Better to be short one product than to be the reason a hundred year old family firm gets test purchased.
What it's built with
WordPress and WooCommerce on a custom theme, PHP and SCSS with Vite, no page builder. ACF flexible content for the page layouts, ACF Pro throughout. A Sage 200 to WooCommerce import running through WP-CLI, product search on a custom index and REST endpoint rather than a hosted search service, Stripe for payments, and PHPUnit round the parts that would be expensive to get wrong.