A catalog with no cart (on purpose)
Most catalog apps end at a cart. Mogadget deliberately doesn't have one. There is no checkout, no online payment, and no customer account anywhere in the product — the store owner sells through WhatsApp and Instagram, so the app's job ends the moment it hands a ready-to-send message to the buyer's chat app. The product doc states it outright: "no step in this flow touches a cart, checkout, or account system." This post is about what replaces the cart, and how to instrument it without ever getting in the sale's way.
The hand-off is the order flow
Browse → product page → tap "Chat on WhatsApp" or "DM on Instagram" → a deep link
opens with a prefilled message that names the exact product and price →
negotiation and payment happen entirely in-chat. The whole "checkout" is one pure
function that builds a wa.me URL:
export function buildWhatsAppLink(p: { name: string; priceNaira: number; url?: string }): string {
const base = `Hi, I'm interested in the ${p.name} (${formatNaira(p.priceNaira)}) listed on MoGadget`;
const msg = p.url ? `${base} — ${p.url}` : base;
return `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(msg)}`;
}
That's the entire conversion mechanism. It's pure, so it's trivially unit-tested,
and it lives in a domain/ layer with no database in sight.
Analytics that can't cost you a sale
The one thing you still want to know is which channel a buyer chose — but
measuring it must never delay or block the tap. The client fires the beacon
before navigation, using navigator.sendBeacon (which survives the page
unload) with a keepalive fetch fallback, all wrapped so a failure is silent:
export function fireClickBeacon(slug: string, channel: TClickChannel): void {
const path = `/api/products/${encodeURIComponent(slug)}/click`;
const body = JSON.stringify({ channel });
try {
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
navigator.sendBeacon(path, new Blob([body], { type: "application/json" }));
return;
}
void fetch(path, { method: "POST", body, keepalive: true });
} catch { /* analytics are best-effort — never interrupt the sale */ }
}
The server side keeps the same posture: an atomic $inc on the product's channel
counter moves first, and only then does it try to append to a time-series log —
a failure there is logged and swallowed, never surfaced. The counter is the fast
path; the event log is the nice-to-have.
No cron, no PII
That event log is a clickEvents collection with a MongoDB TTL index for
180-day auto-retention — the database expires old rows, so there's no cron job to
run or forget — plus a { createdAt, channel } index behind a day-by-channel
trend aggregation. It stores a slug, a channel, and a timestamp; no person, no
device, nothing to leak.
The catalog's real rules live in a pure domain layer
Without a cart, the modelling weight shifts onto the catalog itself, and Mogadget
puts those rules in domain/product.ts rather than scattering them through
handlers. A NEW item must be restockable, carry no cosmetic grade, and track
an integer quantity; a used unit must be a unique unit that carries a grade
and has no quantity at all. assertProductInvariants rejects any mixture on every
write, and a companion rule auto-hides a restockable listing the instant its
quantity hits zero — but never auto-unhides it, because re-listing is a deliberate
decision, not a side effect of a stock bump.
An honest footnote
The product doc argued for one admin and no permission framework. The shipped app
has a full IAM stack behind a withPermission wrapper, plus passkeys and TOTP —
and the doc's own "Historical note" flags that pivot rather than pretending the
plan held. Dropping the cart didn't mean dropping the rigor; it just moved the
rigor to where a no-cart store actually needs it — the invariants, the hand-off,
and analytics that stay out of the way. 39 colocated Vitest specs guard
exactly those parts.