Amir TabatabaeiWork with me
← All work

Personal project · Owner & Sole Engineer · Aug 2026 — present

HemaStore

A Telegram Mini App and an admin panel over one API

Shipped & running

FastifyReactPrismaPostgreSQLgrammYTypeScript

What mattered

Checkout snapshots price and decrements access slots inside the same order transaction.

  • Pricing is one pure function, shared by the API, the repricer and the admin preview: one rule, three callers.
  • A game sells as kinds of access with their own slot limits, not as stock; slots decrement inside the order transaction.
  • Money never becomes a JS number: amounts and ids are strings over the wire, BigInt in the client, formatted at the edge.
  • Notifications are written in the same transaction as the order, then drained with claim, retry and reclaim.

System map

INITDATASESSIONMIN()PRISMAENQUEUEDRAINPARSEMini AppAdmin panelFastify APIPricing enginePostgresOutboxBotsStock channel

Context

My own shop, replatformed as a pnpm/TypeScript monorepo: a Telegram Mini App storefront, a Persian-RTL admin panel, a Fastify API, two grammY bots and one Postgres. Owner and sole engineer.

The schema follows the way the shop sells access. A game sells as several kinds of access — Xbox Home/Non-Home, PSN Capacity, full account, code — rather than as stock, each with its own price and its own slot limit.

The Mini App

Telegram hands the app a signed initData blob and nothing else. Every request goes through one fetchJson wrapper that attaches it:

headers: {
  ...(opts.body != null && !isFormData ? { "Content-Type": "application/json" } : {}),
  "X-Telegram-Init-Data": getInitData(),
}

Both conditions in that spread are bugs I hit: a FormData receipt upload must not get an explicit content type or the browser cannot attach the multipart boundary, and a bodyless POST must not get application/json either, because Fastify 500s parsing an empty body.

Money never becomes a JS number. Toman amounts run into the millions and ids are large; the API serialises both as strings, and the wrapper passes the parsed JSON through untouched rather than mapping numeric-looking fields. Values are BigInt in the client and formatted at the edge.

RTL is where most of the fiddly work was. formatToman groups thousands with the Arabic thousands separator (U+066C) and converts to Persian digits, but the Money component renders inside dir="ltr": a grouped number in an RTL flow has its separators pushed to the wrong side otherwise.

Admin inputs need the inverse, so parseTomanInput accepts Persian digits, both separators, spaces and zero-width non-joiners and normalises back to BigInt. The channel-post formatter deliberately uses a different function with Latin digits and commas, because those posts have always been written that way.

The variant picker is where the capacity model becomes something a customer can choose between: a role="radiogroup" of rows, one per kind, with labels and explainers held in lookup tables keyed by kind rather than scattered through JSX.

The cart is localStorage only — no server session for an anonymous browser. useCart listens for the native storage event and a custom hema-cart-change event, because storage fires in every other tab but never the one that made the change.

The admin panel

Everything is inline-editable, which put the difficulty in commit semantics and layout stability rather than in the forms themselves.

NumberInput displays 1,000,000 while state holds "1000000": grouping is inserted as you type and the caret restored beside the digit it was next to, so editing the middle of a long number does not throw the cursor to the end. But the commit rule matters more than the formatting:

typing only updates local state, and onCommit runs on Enter or blur, never on a keystroke — an inline table editor must not fire a PATCH halfway through a number.

Enter and Escape blur the field themselves, so a skipBlurRef swallows the blur they cause; otherwise Enter double-PATCHes and Escape's cancel is immediately resurrected.

Two components keep the columns from moving. SaveIndicator keeps a fixed-width slot even when idle, so save feedback cannot resize its cell. DataTable uses table-layout: fixed with a generated <colgroup>, so pills, spinners and errors do not reflow neighbouring columns.

The pricing panel shows how each price was chosen. An automatic price is min(margin over buying cost, share of the US store price); the ceiling exists because a customer can always buy the game themselves with a gift card.

The engine returns boundBy, so the panel explains margin and ceiling differently and the admin sees why a number is what it is. The same pure function in packages/shared backs the API, the repricer job and this preview, so all three agree by construction.

Behind both

Checkout snapshots prices into OrderItem so a reprice mid-checkout cannot change what was agreed. Capacity slots decrement inside the order transaction, so two concurrent checkouts cannot claim the same seat. Promo caps are enforced by a guarded UPDATE where the affected-row count is the check:

UPDATE "PromoCode" SET "usedCount" = "usedCount" + 1
WHERE "id" = $1 AND ("maxUses" IS NULL OR "usedCount" < "maxUses")

And notifications are written to an outbox table in the same transaction as the order, then drained by the bots with pending → sending → sent, an attempt counter, and a claimedAt stamp so a row stranded by a worker that died mid-send is reclaimed rather than stuck forever.

Result

Running as my live shop since August 2026. The Mini App, admin panel and both bots are the ones in daily use, not portfolio rebuilds. It is still single-author and single-tenant, so multi-operator admin workflows are not the claim here.

Screens

Three screens of the Telegram Mini App side by side: a two-column catalogue of game covers with starting prices, a product page listing four access kinds as radio rows with plain-language explainers and guide links, and a cart with quantity steppers, a promo-code field and a total
The admin product page: product fields above, and below a variants table with one row per access kind, each with its price, an inline stock input, an active or inactive pill and an activate button
The admin pricing screen: exchange rates for USD, EUR, GBP and TRY in Toman with fetch timestamps and a manual override, above a per-region source table setting each region's buying mode and rate percentage