cupsfactory. Docket · 31 Aug 2026 · live

Build docket: cupsfactory.om

Everything left to build, in the order it has to happen. 109 numbered jobs across eight phases. 60 are ticked. The multi-storefront engine is deployed to production, this domain is live on a Cloudflare origin cert, and the brand system is built off the real logo file. Phase 1 is part done, not done: Cups for Trade has its own ERP tenant with cupsbyaa and cupsfactory registered as its two storefronts, but the catalogue, the pricing ladders and the customer migration are all still to come, and they are what the site is waiting on. The site itself is now assembled and live at cupsfactory.om/preview (noindex until launch), in both languages.

Jobs open
19of 109
Engine
SHIPPED23 commits, live on bhd-main
Domain
cupsfactory.omlive, HTTPS, this page
Books
Cups for TradeCR 1393567, not VAT registered
Rebuild base
custom-v2quantity-first flow
90 / 109 jobs ticked Phase 1 12/14 · Phase 8 2/5
done and verified next up ! waiting on Ali open
#dd7d00  cups / dot / الأكواب
#2b2721  factory. / مصنع
Two inks only  read from Final Logo.svg

Read before starting anything

Phase 1 blocks everything else. You told me BHD and CupsByAA are separate legal entities and CupsFactory sits under CupsByAA financially. So CupsFactory's orders, money and production have to land in CupsByAA's books, and CupsByAA does not have an ERP tenant yet. Building the website first would leave it with nowhere to post.

One thing I cannot decide for you, flagged at job 24: whether CupsFactory takes card payments through CupsByAA's existing Paymob merchant or needs its own. That is a merchant-account and settlement question, not a code one.

0

Shipped, for the record

12 commits

The multi-storefront engine is built and reviewed, 22 commits after a final adversarial pass found three Criticals that were fixed. It is what lets cupsbyaa and cupsfactory share one set of books while staying separately trackable, which is exactly what “cupsfactory sits under cupsbyaa financially” requires. Not re-listed as tasks.

CommitWhat it does
da4f2d6…977735fStorefront registry, tenant-keyed cache, partial unique index so a retired brand's code is reusable
0c6f3d7…c6a3188Brand resolver on every public request, path-scoped after it was caught 400-ing the whole admin API
85e2e6e…22dcf2fStorefront label on all six chain documents, with real propagation tests
7b4e6daPer-suite test databases, fixing a 40% flake rate caused by a shared-DB wipe
c58808dOptional portal auth and price-list resolution
8747e24Per-brand invoiced-value endpoint
38b43f6…d6f03baBrand seed, per-tenant sets, refuses to guess a database or a set
505bfcfTrade pricing wired into product page and checkout, retail path proven unchanged (91 tests)
d2928f5Admin page for the per-brand report, tenant-neutral
1

CupsByAA gets its own ERP

Jobs 1–14

Done. A Paper & Pen tenant with its own database and its own general ledger, serving two storefronts. BHD's ERP is not touched. The monthly BHD↔CupsByAA intercompany settlement continues unchanged, because the two stay separate entities.

  1. 01

    Provision the cupsbyaa P&P tenant

    erp-tenancy Tenant provisioned on production. DB pnp_tenant_cupsbyaa. The Oman profile seeded a 5% VAT default onto a business with no VAT certificate; the script disabled it.

  2. 02

    Decide and register the ERP host

    erp.cupsbyaa.com is LIVE: DNS, certificate, nginx vhost, tenant map and a verified customdomains row. Isolation proven: X-Storefront cupsfactory returns 200 there, bhdoman returns 400 because that brand lives in BHD’s tenant.

  3. 03

    Seed the storefront registry for this tenant

    Script shipped and proven: the bhd set is seeded on staging AND production. The cupsbyaa set runs once the tenant exists.

  4. 04

    Chart of accounts for CupsByAA

    erp-finance Its own COA. Do not copy BHD's seed endpoint, which is gated to the bhd tenant for exactly this reason.

  5. 05

    !Opening balances and cutover date

    needs Ali Real figures from the accountant. I will not derive these.

  6. 06

    Company identity: CR, VATIN, address, bank

    Verbatim from CR 1393567: Cups for Trade, South Al Hayl Al Seeb, reg 1393567, tax 1742971, VAT number deliberately EMPTY.

  7. 07

    Tenant branding: logo, colours, PDF letterhead

    Tenant branding and email templates seeded with the brand orange.

  8. 08

    Module entitlements

    growth plan. Manufacturing stays with BHD: Cups orders already mirror there as ManufacturingOrders.

  9. 09

    Migrate the cup product catalogue

    Nine products migrated into the Cups for Trade tenant, eight visible to the storefront, and the board renders real prices from the engine: 0.044 OMR per cup at 20,000, total 880.000, straight from the published ladder with nothing recomputed. The migration takes an explicit allowlist rather than copying, because Product carries eighteen references to BHD vendors, stock and bills of material that resolve to nothing in another company’s books. It asserts through the SHOP’S OWN filter after writing: the first version proved only that rows landed, which was true of nine products the shop could not see. Add-ons are grouped apart, because ranking a straw at 0.004 against a cup and telling a buyer “cheapest is Straw” is plainly wrong.

  10. 10

    Migrate the pricing ladders

    Came across with the catalogue and proven, not assumed. Both ladder fields are copied verbatim, quantityBreaks for the storefront and variantBreaks for the pricing engine, and the migration reads the row back and DELETES it if the ladder did not round-trip byte for byte. At a published tier the unit price is derived from the line total, so a ladder that changed in transit is the one defect that would not surface until a customer was billed the wrong number. Live proof: Plastic Cup carries 18 tiers and prices 0.044 at 20,000, which is its 5,000-tier rate, not its base rate.

  11. 11

    Create the CupsFactory wholesale price list

    A real PriceList of type wholesale exists in pnp_tenant_cupsbyaa and is attached to the cupsfactory storefront's defaultPriceListId. Proven live: POST /v2/products/:id/calculate-price with Origin cupsfactory.om answers priceListName “CupsFactory Wholesale”, so the storefront really is resolving it and a margin set here will reach the board. It sits at 0 percent, which prices identically to retail, and that is deliberate: the trade margin is a commercial number and one I derive myself is indistinguishable from one Ali set once it is in the database. The wiring is done; the percentage is one field, and it is Ali's.

  12. 12

    Migrate customers and open balances from the Laravel app

    NOT blocked after all: the Laravel app exposes an ERP integration API with 4,779 order records, and I had written this off without testing it. 2,143 customers migrated, every one with a phone, 2,143 distinct dedupe keys, no duplicates. Contact records ONLY: open balances stay with job 05, because a customer carrying a balance I derived myself is the exact mistake this estate has already paid for. Three things the data decided: payment_status is CAPITALISED, so a case-sensitive filter reports 0 paid orders out of 3,651; dedupe is on the phone’s last 8 digits, because 274 phones carry more than one spelling of the same name; and the winning spelling comes from the most recent PAID order. A unique index on {type,email} then stopped the first run at 27 rows, because two phone numbers share one inbox: the email attaches to the first holder and is omitted from the other 23 rather than MERGING two real people’s order histories.

  13. 13

    Migrate order history

    4,496 orders imported, reconciled to the riyal: 114,790.785 in the source, 114,790.785 imported, difference 0.000. GL untouched, verified 0 journal entries. I got it WRONG first and the source’s own /stats caught me: I collapsed 487 rows as duplicates, and reading the actual groups showed 02147-1 and 02147-3 are two LINES of one order and 02089/02090/02091 are three separate orders seconds apart. An order is a BASE number; 198 orders have more than one line. The dual-table split the docket warned about is real and visible in the PREFIXES: WC (WooCommerce) 775, HOS 519, MG 454, BH 3, MAZE 1, numeric 3,027. Payment per Ali: every unpaid row carried a numeric order number while every WC, HOS and MG order was already paid, which is an app taking Paymob payment and never writing status back. 990 orders asserted paid behind an explicit flag, each row recording who said so and what the source held.

  14. 14

    Staff accounts and OTP login

    Provision the CupsByAA team on the new tenant. Roles mirrored from the Laravel admin.

2

Infrastructure for cupsfactory.om

Jobs 15–24

The Cloudflare zone is active with zero DNS records. Everything here is greenfield.

  1. 15

    DNS: A and www, proxied

    cloudflare-api-call Point at the group VPS. Proxied orange-cloud.

  2. 16

    Cloudflare Origin CA certificate

    cf-origin-ca-cert Cloudflare Origin CA, apex + wildcard, valid to 2041. LE retired.

  3. 17

    nginx vhost on the VPS

    PHP-FPM 8.3 runs as www behind a www-data nginx. http2 on; is unknown to this nginx build.

  4. 18

    Laravel app skeleton at the web root

    Correct ownership from the start. A Mac UID on the web root breaks checkout and every page still returns 200.

  5. 19

    Mail: DKIM, SPF, DMARC for cupsfactory.om

    bhd-mail MX, SPF, DMARC and a 2048-bit DKIM key are live. opendkim-testkey returns "key OK". DMARC rua points at a sink, never a person.

  6. 20

    Mailboxes: info@ and sales@

    info@ and sales@ created on the BHD mail stack, postfix resolves them, passwords stored gitignored.

  7. 21

    Deploy script with a fail-closed preflight

    deploy-project scripts/deploy.sh, fail-closed. It refuses openrsync (which rejects --chmod), sets 755/644 explicitly, then proves every file at ORIGIN before checking the edge, because a query string cannot reveal a stale edge. It caught the openrsync trap on first run.

  8. 22

    Staging host before production

    staging.cupsfactory.om is live on the same wildcard origin cert, so it needed no second certificate. noindex is enforced by an X-Robots-Tag header at the SERVER, not a meta tag, because a staging copy outranking production is a real way to lose a launch.

  9. 23

    ERP bridge credentials

    64-hex key set as a Setting on pnp_tenant_cupsbyaa, compared timing-safe, rotatable without a redeploy. Stored gitignored, never in git.

  10. 24

    Paymob merchant decision

    decided Same bank account, so CupsFactory runs on CupsByAA’s existing Paymob merchant. Settlement stays one reconciliation, not two.

3

Brand system

Jobs 25–36

Two inks and a bilingual lowercase wordmark is a tight brief, which is a gift. The whole identity has to come from type, spacing and paper, not from a colour palette. Arabic is set at parity with English in the logo, so it must be at parity on the site too.

  1. 25

    Extract logo masters from Final Logo.ai

    5 SVG variants and 9 PNGs from Final Logo.svg, with a usage spec. Full lockup dies below 90px, so a favicon needs its own mark (job 34).

  2. 26

    Identify the wordmark typeface

    5 SVGs and 9 PNGs shipped; no font name is embedded in the .ai, .pdf or .svg, so the face is characterised not identified. Single-storey geometric a rules out Montserrat, Nunito, DM Sans and Poppins.

  3. 27

    Host the type on fonts.bhd.om

    brand rule Figtree (variable 100-900) and Almarai both already hosted on fonts.bhd.om and now serving this page. No Google, no gstatic.

  4. 28

    Arabic face paired to the Latin

    Almarai, paired to Figtree and loaded from fonts.bhd.om. Set on :lang(ar) and [dir=rtl] in the token layer, so Arabic is chosen, not left to the browser.

  5. 29

    Build the token layer on design.bhd.om

    bhd-design themes/cupsfactory.css: brand inks, a measured 10-step orange ramp, semantic tokens, light and dark.

  6. 30

    Tailwind config bound to the tokens

    tailwind.tokens.js bound to the CSS variables, with the runtime-class purge trap safelisted.

  7. 31

    Wire FontAwesome Pro 7.2 from design.bhd.om/fa

    bhd-design Pinned to /fa/v7.2.0/, not /latest/, so a silent major bump cannot change glyphs under a live site. Verified 200.

  8. 32

    OMR rendering via BHDCurrency

    The official Central Bank Rial sign. Never the string “OMR”, never ‏ر.ع, never the generic ligature. Three decimals throughout.

  9. 33

    Accessible contrast pass on the two inks

    Measured. Orange on white is 3.00:1 and FAILS AA. Split the role: #dd7d00 for identity, #9f5a00 (5.33:1) for text and buttons.

  10. 34

    Favicon, app icons, maskable icon

    A mark drawn on a 64 grid, legible at 16px, in 8 sizes. Not a shrunken logo, because the lockup dies below 90px.

  11. 35

    Open Graph share card, 1200×630

    og-share-card Bilingual 1200x630 built from the real logo master, product drawn in the two inks so it needs no photography.

  12. 36

    Brand sheet for the team

    One A4 page, live at /brand-sheet.pdf. Carries the measured contrast numbers, so "do not put orange on white" is a fact on the page rather than an opinion.

4

The site: rebuilding v2

Jobs 37–68

The base is custom-v2.blade.php plus the OrderFormV2 Livewire component: pick a quantity, then compare what every cup costs at that quantity, then check out. The audit (job 37) established that v2 is a VIEW on a shared engine, so this phase rebuilds the view against that engine. Refactoring the engine is real work but it is shared with the live cupsbyaa.com surface, so it is deliberately NOT bundled in here.

  1. 37

    Audit v2 honestly before copying it

    Done, and it changes the plan: v2 is a VIEW, not an engine. OrderFormV2 is 46 lines subclassing a 2,291-line OrderForm, and 241 of the page’s 374 lines are inline CSS hardcoding CupsByAA pink ~30 times. So jobs 43-48 rebuild a view against the existing engine. Full audit in docs/v2-audit.md.

  2. 38

    Three creative directions, not one

    Three concepts that would each produce a different site: The Line (the factory is the product), Quantity First (the price board is the home page), Proof of Work (show the last thousand cups). Each names what it answers badly and what it needs that we do not have.

  3. 39

    Run the jury and pick a direction

    Quantity First wins 31 of 35, on a rubric weighting the trade question and buildability double. Its 3 on distinctiveness is recorded as a real finding, not softened: the build grafts Proof of Work’s palette discipline and The Line’s number treatment, neither of which adds a dependency it does not already have.

  4. 40

    Page architecture and routes

    Ten routes, each answering one trade question. /order is deliberately NOT noindex: on CupsByAA that flow sits behind noindex canonicalised onto /custom because it was an experiment, and copying that across would hide this site’s most important page. See docs/site-architecture.md.

  5. 41

    RTL as a first-class layout

    Logical properties throughout, ONE view per page serving both locales so the pair cannot drift (CupsByAA’s Arabic twins 404’d for months), numbers kept LTR inside RTL with bdi, and the phone chip stays LEFT in both directions.

  6. 42

    Hero: the factory thesis

    The quantity field IS the hero, with the cursor in it, plus the two grafts the jury required. Accepts what people actually type, including Arabic-Indic digits and the Arabic thousands separator. axe clean in both directions and both themes, and a token defect it exposed is fixed: every contrast figure in the palette had been measured against white while the page ground is --stock, so --ink-faint passed its own documentation at 4.52 and failed at 4.19 on the real ground.

  7. 43

    Quantity ladder, rebuilt

    Wrapping chip grid via auto-fill, never a scroller. 46px tap targets, tabular numerals, and the selected state uses the DARKER orange because white on #dd7d00 is 3.00:1 and fails AA.

  8. 44

    Trade-scale tiers

    Ladder runs to pallet quantities. CupsByAA starts at 20 because it serves a retail box buyer; a cafe chain orders by the pallet.

  9. 45

    Price board, rebuilt

    Two cards per row on the narrowest phone (140px minimum fits twice in a 324px column). Comparing side by side IS the page.

  10. 46

    Unavailable tiers stay readable

    A cup that cannot be made at the chosen quantity is dimmed but still states its real minimum, because that number is the answer the buyer needs.

  11. 47

    Best-value marker that is actually true

    markBestValue computes the lowest unit price from the engine’s own rows. A tie marks NOBODY, because an arbitrary winner is a lie.

  12. 48

    Live unit price and line total

    The component renders what the engine returned and computes nothing. At a published tier the total is authoritative and the unit price is derived from it; re-multiplying a rounded rate is what gets a draft rejected.

  13. 49

    ·To-scale size guide

    UNTICKED. components/cup-size-guide.js is written and renders nothing: not imported by site/app.js, not referenced by build.mjs, and renderCupSizeGuide appears zero times in the deployed bundle. The to-scale guide exists as source only.

  14. 50

    !Product photography set

    needs Ali I had this recorded as “no photos” and that was wrong: every product has one. The real position is worse. Of the FOUR products on the comparison board, exactly one has a publishable image. Plastic Cup is a watermarked stock mockup reading “COMPANY NAME”, sitting in the LIVE BHD catalogue and served on the public shop API right now. Cup Sleeve shows a named customer’s branding, Daily Grind, with no permission asked. Ramadan Cup Box is a phone snapshot on a sofa. A board with one photo and three blanks looks broken, so none are wired in. Cutouts cannot be automated either: most of these products are WHITE, so a flood fill eats them. Needs three photographs and one conversation. Full audit in docs/product-photography-audit.md.

  15. 51

    ·Artwork upload with real validation

    UNTICKED. components/artwork-check.js is written and unreachable: checkArtwork appears zero times in the deployed bundle. The validation against the measured 8oz die is real code that no visitor can run.

  16. 52

    ·Live cup preview from uploaded artwork

    UNTICKED. Depends on the artwork surface in job 51, which does not ship.

  17. 53

    ·Dieline download per size

    UNTICKED. components/dieline.js is written and unreachable: buildDieline appears zero times in the deployed bundle. There is no download because there is no page to download from.

  18. 54

    Digital proof and approval step

    Wired to the live proof chain: draft, render, approve. Approval is not optional, because checkout refuses any design without it, so a cart carrying only a draft token would fail at the till. Low resolution and an unverified die are both said BEFORE the customer commits.

  19. 55

    Phone field with country selector

    intl-phone-input ResizeObserver measures the chip when it becomes VISIBLE, replacing CupsByAA’s three guessed setTimeouts at 60/300/900ms. Chip stays LEFT in both directions; the input is forced LTR so the number does not reverse.

  20. 56

    ·Trade account application

    UNTICKED. Both halves are unreachable: components/trade-account.js (validateApplication) and components/account.js (listOrders) appear zero times in the deployed bundle.

  21. 57

    Logged-in trade pricing on the storefront

    The session token goes on every price call, not just at checkout. calculate-price takes optionalPortalAuth, so sending it IS the mechanism: with it the buyer’s price list resolves while browsing, without it they see retail right up until they pay, which reads as a bait and switch.

  22. 58

    Reorder from a past order

    A reorder is not a copy. Every line is re-priced through the engine, because carrying yesterday’s price forward is a promise that breaks at checkout, and a line whose design is gone comes back flagged rather than silently reordered as a blank cup.

  23. 59

    Order tracking page

    New ERP endpoint. An order is as far along as its LEAST advanced line, because it ships when the last line is done. And the customer never sees a step backwards: a QC reject correctly returns a job to printing internally, and a buyer who saw Ready become Printing reads it as us losing their order. No invented dates.

  24. 60

    Customer portal: quotes, invoices, statements

    Quotes, invoices, payments and the statement, all from ERP portal endpoints that already existed. The statement is fetched and handed over as a blob, never a plain link: a sandbox blocks a download a page starts itself, and a button that does nothing is worse than no button.

  25. 61

    Capability page: machines, capacity, lead times

    Structure written; the NUMBERS are blocked. Capacity and lead times need real figures from the floor, and a factory site that prints a round number nobody measured is one a buyer plans around. Honest framing included: 2.5oz is hand-pasted single wall, 4/6.5/8oz run in house, everything else is outsourced.

  26. 62

    Quality and food-safety page

    Standards researched and verified: there is NO cup-specific Gulf standard, so a cup is a generic food package under GSO 2231:2012, 839:2021 and 1863:2021, adopted by Oman dual-numbered. G-Mark does not cover cups. ISO 9001 is not a food safety scheme. Still blocked on the supplier spec sheet and a BRCGS or FSSC 22000 certificate belonging to Cups for Trade.

  27. 63

    ·Sample request flow

    UNTICKED, and this was my error. I marked it done and wrote “now a real form on the page, not just a component, verified end to end”. components/sample-request.js exists and is 163 lines, and nothing imports it: it is not in site/app.js, not in build.mjs, and its exported validateSampleRequest appears zero times in the deployed bundle app.92a43739ec.js. A component that ships to nobody is not a form on the page. Reopening until the page mounts it.

  28. 64

    Motion pass

    One moment: the price board arriving, staggered 40ms so the eye reads the cards in the order they are ranked. That is information, not decoration, which is the only justification for motion on a page whose job is to give a number. The price never counts up, because a rolling number is unreadable while a buyer is comparing eight tiers. Reduced motion is the DEFAULT state in the stylesheet, so a page that fails to load the enhancement is the still version, never the invisible one.

  29. 65

    Empty, loading and error states

    Every state designed. fetchOrError guarantees an error never renders as an empty result, because a money screen that swallows a 403 tells an operator they have no sales. No apologies in any message.

  30. 66

    WhatsApp contact and share links

    brand rule whatsappLink builds api.whatsapp.com/send?phone= and strips non-digits. assertNoBareWaMe throws on a wa.me link, so it cannot reach production.

  31. 67

    Short links for anything sent to a customer

    bhd-link-shortener shortLink FAILS rather than falling back to the long URL: silently sending the raw link is the exact failure the rule exists to prevent.

  32. 68

    Cookie and consent handling

    Decline is the default and both buttons are the same weight. No advertising category, because there are no advertising cookies and listing one to look thorough would misstate our own processing. Analytics fails closed: no decision is not permission, and the undecided visitor is the first-time visitor.

5

Commerce and money

Jobs 69–80

Every job here can lose a real order or misstate a real number, so each one gets a test that fails when it is wrong.

  1. 69

    Paymob integration

    bhd-paymob Intentions API, 3DS, OmanNet. Merchant per job 24.

  2. 70

    Apple Pay, including non-Safari desktop

    Apple's JS SDK plus the <apple-pay-button> component and a QR handoff.

  3. 71

    Inline Pixel card form

    No hosted redirect. The checkout stays on cupsfactory.om.

  4. 72

    Webhook with HMAC verification

    HMAC is mandatory and returns 403 before anything else runs; a provider transaction id is required so idempotency has a key, a mismatched one is rejected rather than overwritten, and a second completion is flagged so every post-commit effect is skipped. 12 tests, and the first version of them was VACUOUS: the signing helper called a name that does not exist, so five negative tests would all have passed against a verifier that returned false unconditionally. It now proves a correct HMAC verifies, and that lowering the amount or flipping success invalidates a signature valid for the original. The full replay test of completeCapturedPayment is raised separately: that function credits an invoice and has no test at all, and it needs accounting fixtures a half-attempt would get wrong.

  5. 73

    !Bank transfer and credit-terms checkout

    needs Ali Blocked on a missing value, not on code. The checkout accepts ONLINE PAYMENT ONLY today, which is a real gap, but the customer-facing half cannot ship regardless: company_bank_account is EMPTY on the Cups for Trade tenant, so a bank-transfer checkout could not tell a buyer where to send the money, and I will not invent an account number. This is wider than job 73: that same setting prints on Quote, Invoice, Offer, Credit Note and Credit Agreement, and the template omits the block SILENTLY when it is empty, so every document this tenant issues today tells the customer nothing about where to pay. Credit terms are a separate matter and route through the existing per-quote approval, which needs KYC and a signed agreement.

  6. 74

    Order pushes into the CupsByAA ERP

    Already built and proven: 18 tests carry the storefront label through ShopOrder, Quote, Invoice, SaleOrder, DeliveryNote and ManufacturingOrder, including the one that matters most, a cupsfactory quote producing an MO that is a cupsfactory sale BHD still owns the ledger for. source and storefront answer two different questions and stay separate fields.

  7. 75

    Two-way production status sync

    The map was written in order and its comment called it monotonic, but the only guard in the code was a same-value dedup, so an out-of-order push went straight through. A QC reject returning a job from ready to printing was pushing “received” to a live customer whose tracker said “production done”. Declaring an invariant is not enforcing one: the rank comparison is now in the code, the refusal is logged rather than silent, and forward jumps still push because a small job can skip a stage.

  8. 76

    Stale-order notification guard

    Already built in the ERP and better calibrated than what I started to write: 15 days on the order, plus a 72-hour dwell guard that catches a stale kanban reorg specifically. I reverted my duplicate, which would have blocked every caller that passes no date. The real remaining gap is that these skips write nothing to MessageLog, so nobody can see how often they fire: raised separately.

  9. 77

    VAT handling and the tax invoice

    CupsByAA's own VATIN on the document. Prices inclusive or exclusive, decided once and applied everywhere.

  10. 78

    Abandoned-cart definition

    Now proven against 4,292 real orders rather than fixtures: the definition classifies exactly 656 abandoned carts, worth 24,252.778 OMR that is NOT revenue and would have inflated every count on the customer dashboard. The clause that earns its place is “status past pending”: without it every bank transfer and credit order confirmed by hand reads as a cart.

  11. 79

    Order confirmation email and WhatsApp

    Found the real defect rather than the one the job named. The customer email was hardcoded to BHD’s blue and BHD’s name, and the staff notification defaulted to [email protected], so a Cups Factory order arrived wearing BHD’s identity and told BHD’s inboxes about a sale belonging to Cups for Trade, a separate legal entity with separate books. Every field looked correct because it WAS correct for the brand it was written for. Name, colour and recipients now come from the storefront row, each falling back to exactly what it used before, so bhdoman is unchanged.

  12. 80

    End-to-end money test on staging

    A real card, a real order, a real ledger entry, then reversed. Not a mocked callback.

6

Content, SEO and AI engines

Jobs 81–94
  1. 81

    Bilingual copy pass, written not translated

    Both locales written from the same brief, not machine-translated, and gated: scripts/copy-parity.mjs fails the build on a missing key, a lost {placeholder} or a Latin word left in the Arabic. Tested by injecting all three faults; the first version missed “per cup”, so the heuristic was tightened.

  2. 82

    Keyword and intent map

    16 clusters, EN + Omani Arabic. Trade intent is structural, not a filter: it always carries a procurement word, a unit word, a business qualifier or spec depth. So every trade page ends in Request a quote, never Add to cart, and trade does not share URLs with cupsbyaa.com’s retail funnel. No search volumes were available and none were invented.

  3. 83

    ·Landing pages per product and city

    UNTICKED. components/landing-pages.js is written and unreachable: productFacts appears zero times in the deployed bundle, and the build emits exactly two HTML files, index.html and ar/index.html. No landing page exists to be found.

  4. 84

    Structured data

    Organization, Product, Offer, FAQ and Breadcrumb builders. Deliberately NO aggregateRating and no review markup: we have no reviews, and inventing them is the commonest way a small site earns a penalty.

  5. 85

    hreflang for the EN and AR mirror

    Reciprocal en/ar with a correct x-default pointing at English, emitted in both the head tags and the sitemap.

  6. 86

    Sitemap and robots

    robots.txt and a 16-URL sitemap, generated not hand-maintained, with per-crawler rules that allow the AI engines deliberately. Both live and the XML parses.

  7. 87

    Canonicals

    /order canonicalises to ITSELF. On CupsByAA the same flow sits behind noindex canonicalised onto /custom, and copying that across would hide this site’s most important page from search.

  8. 88

    llms.txt, llms-full.txt, ai.txt

    bhd-aieo llms.txt and ai.txt live. llms.txt carries a “what we do not claim” section, so an answer engine has our own words for the limits rather than reconstructing them from third-party listings.

  9. 89

    Entity and sameAs graph

    One @graph, so the company is described exactly once and the copies cannot drift. sameAs carries only cupsbyaa.com: BHD is a SEPARATE legal entity, so claiming identity would merge two companies’ reputations in both directions, and no internal host is listed because a login page is not the company. parentOrganization says the true relationship instead. No SearchAction, because we have no search endpoint and a declared one that 404s fails the moment it is tested.

  10. 90

    IndexNow wired to deploy

    Fires from the deploy, after every file is proven at origin, so a crawl never lands on stale bytes. Refuses to submit an empty set, an off-host URL, or anything at all when the key file does not serve the key: IndexNow answers 202 and drops it silently, so a 202 is not evidence.

  11. 91

    Google Search Console verification

    Verified, and without granting Google anything. The console offers “verify via Cloudflare.com”, which is an OAuth grant over the whole DNS account: I took the provider dropdown instead, read the TXT value off the page, wrote the record through the Cloudflare API and confirmed it on the zone’s own authoritative nameserver before pressing Verify. Ownership verified by domain name provider. Submitting the sitemap then exposed a real defect: the hand-written one listed 16 URLs and 15 of them 404, because it was generated from the ROUTE PLAN and never revisited once unknown paths started returning a real 404. The build now generates it from the pages it actually produced, and today that is deliberately empty because every page carries noindex until launch.

  12. 92

    Bing verification, by API

    Verified, but NOT by API: a banner in Bing Webmaster Tools reads “Legacy SOAP and POX APIs will be retired on August 31, 2026”, which is today, and ssl.bing.com/webmaster/api.svc is exactly that API. The recipe I had was four days old and already dead; /webmasters/apiaccess now 404s. Added through the UI instead, declining “Import from Google Search Console” because that grants Bing read access to every GSC property. CNAME written through the Cloudflare API, DNS-only because proxying breaks it, confirmed on the zone’s authoritative nameserver before pressing Verify. The property now loads its full navigation and its Sitemaps page, which an unverified site does not.

  13. 93

    Analytics and conversion events

    Seven funnel steps with a hard ALLOWLIST, so phone and email are dropped rather than sent. Steps dedupe per session, because a buyer who changes size three times is one buyer.

  14. 94

    Truth pass on every public claim

    verify-cited-claims Written FIRST, not last. Eight verified claims with their sources, and eight that must not appear until evidenced, including capacity, lead times, any certification and dimensions for the five unmeasured sizes.

7

Quality gates

Jobs 95–104

Deterministic checks, not opinions. Each produces evidence that can be re-run.

  1. 95

    Responsive and overflow audit

    scripts/audit.mjs, four viewports from 320px. It NAMES the widest offending element and its overflow in px, not just “it scrolls”. Live site passes all four.

  2. 96

    Accessibility audit

    design-stack scripts/a11y.mjs runs axe at BOTH colour schemes plus a real keyboard Tab and a reduced-motion check. It found a h1→h3 skip on this page and 163 contrast violations. Both fixed. Now zero violations in both themes.

  3. 97

    Contrast audit against the two inks

    Measured, not eyeballed. Six pairs failed and every one is now fixed with headroom past 4.5:1. The worst was 1.83:1: the docket bar inverts in dark mode and its muted colour was a literal chosen for a dark bar.

  4. 98

    Lighthouse to 100 on mobile

    Mobile: accessibility 100, best practices 100, performance 98. The first run said performance 100 and it was a lie: the pages asked for /app.js while served under /preview/, the server answered HTML, the browser refused it on MIME type and the runtime never ran. A page with no JavaScript is very fast. With it running the real number was 88, and inlining the font faces (two render-blocking requests to a third origin) took FCP from 2.9s to 1.5s. The last two points are simulated TTFB on throttled 4G with the HTML deliberately uncached while I redeploy it every few minutes.

  5. 99

    Core Web Vitals on real hardware

    All three pass: LCP 2.1s (good is under 2.5), CLS 0, TBT 0ms. CLS is zero by construction, not luck: the reduced-motion state is the DEFAULT in the stylesheet so nothing starts invisible, and there is no auto-focus to pop a phone keyboard and shove the layout. The consent bar was the LCP element with 80% of its time spent waiting for the module to load; it now ships visible and an inline head script hides it before paint for anyone who already answered.

  6. 100

    Link and asset audit

    Per-link status codes and a response listener that fails on any 4xx/5xx asset. Live site: no failing asset requests.

  7. 101

    Playwright end-to-end suite

    12 tests against the DEPLOYED site, not a local build, and that choice paid immediately: they caught a stale bundle Cloudflare had cached for four hours. Origin had 9,323 bytes, the edge served 2,588, every origin check passed and clicking “No thanks” did nothing because the code handling it was in a build no browser ever received. Fixed with a content hash in the filename, since a cache-busting query only makes a NEW object and leaves the old URL cached.

  8. 102

    Cross-browser and real-device pass

    48 tests: Chromium, Firefox, WebKit and an iPhone profile. WebKit found a real defect the other two did not: Safari leaves plain links out of the Tab order unless the user turns it on, so the skip link was unreachable there. Measured in WebKit, not assumed: a plain link is skipped, the same link with tabindex="0" is focused on the first Tab.

  9. 103

    Security review

    Found and fixed cross-site scripting in every renderer: product labels, image URLs and ERP error messages all went into innerHTML raw, and app.js passes json.message straight into renderError, so an error body containing markup would have run on the page reporting the error. Added a CSP with the one inline script allowed by hash, never unsafe-inline. The CSP then caught something real: Cloudflare injects an analytics beacon into every page on this zone, before any consent decision. It is blocked and a test keeps it blocked, but turning it off is a zone setting and needs Ali. Full write-up in docs/security-review.md.

  10. 104

    Independent design jury before launch

    FAILED it at 24 of 35, then acted on it. The most serious finding was self-inflicted and not blocked: the site had no phone, no WhatsApp and no email anywhere, on a TRADE site whose buyers phone before they order. Fixed, with every value checked against its source rather than assumed. Also added four size cards carrying the real measured print area, which finally gave the number treatment something to treat. Re-scored 29. Still not a launch, and now for exactly one reason: criterion 1 is the catalogue, and that is job 09.

8

Launch

Jobs 105–109
  1. 105

    Staging sign-off

    Full suite, real payment, real ERP order, then reversed.

  2. 106

    Seed the registry on production

    GATE CLOSED, the manual step is done: both storefront rows are live and active in pnp_tenant_cupsbyaa, and brand isolation is proven both ways (erp.bhd.om refuses cupsfactory with 400, erp.cupsbyaa.com refuses bhdoman with 400). Then I broke it: filling in per-brand staff inboxes, I ran the seed with BHD’s connection string and it created cupsbyaa and cupsfactory rows inside BHD’s OWN database, so BHD’s ERP resolved cupsfactory.om as one of its brands and an order through that host would have posted to BHD’s ledger. Soft-deleted, isolation re-proven, and the seed now REFUSES a brand set in the wrong company’s database.

  3. 107

    Cut over DNS and watch

    Origin, then edge with a real cache-buster. A stale edge can serve the old bytes while origin is correct.

  4. 108

    Post-launch monitoring

    Eight checks from OUTSIDE the box, because a monitor running on the server it watches reports nothing on the day it matters. Exit code is the contract: 0 healthy, 1 degraded, 2 down, proven by pointing it at a dead host. It watches the browser’s view of the ERP with a real preflight, not a server-side curl, because those two disagreed here for a whole sub-project. It resolves the hashed bundle the HTML actually names rather than a fixed filename. And it checks the DMARC rua points at a sink, not a person. Two of my own mistakes it caught: querying DNS over an HTTPS host that is blocked from some networks, and a failed DMARC lookup recording nothing at all, which reads exactly like a pass.

  5. 109

    Log the launch and hand over

    What shipped, what is deferred, what needs watching in week one.