Back to home

Tamora — beauty e-commerce platform

  • React 19
  • Express 5
  • TypeScript
  • PostgreSQL
  • Prisma
  • BullMQ
  • Vue 3
  • Naive UI
  • Stripe

tamorabeauty.com

A multi-brand beauty store, in the spirit of Sephora, for customers in the Middle East (mostly Dubai) and Hong Kong. I delivered all of it alone: the English storefront, the API and the Chinese admin console, the servers and deployment, and help with the Stripe merchant onboarding. Live at tamorabeauty.com.

Scope

The client wanted a store of its own, not a marketplace listing. Much of the early work was deciding what not to build, and writing each decision down with its date.

  • Payment through Stripe, with Apple Pay, Google Pay and Samsung Pay on top. No PayPal, which few local customers use, and no cash on delivery, because of return risk. Instalments were postponed because the site's owner and the local licence holder were different entities.
  • An English storefront and a Chinese admin console. Arabic is not in this phase.
  • Support and returns through WhatsApp and email rather than an on-site chat or parcel tracking.
  • Everything written from scratch rather than on Shopify or WordPress, which the client agreed to.
  • Addresses designed for international shipping, not hard-wired to the UAE.

System

Three apps on one Prisma schema of 29 tables: a React 19 storefront (Vite, React Router, Zustand, Tailwind), an Express 5 API split by domain, and a Vue 3 admin console, moved from Element Plus to Naive UI in May. A separate BullMQ worker handles email and webhooks; Redis is split into state and cache; images live in MinIO. Ten services in Docker Compose.

Three roles: admin, customer service and customer. Refunds, staff, the audit log, shipping and settings are admin only, and every admin change is logged with its before and after.

Storefront ReactAdmin VueOne APIExpressOne schemaPostgreSQL Storefront ReactAdmin VueOne API ExpressOne schema PostgreSQL

The hard parts

Stock: from "deduct after payment" to reservation

The first rule deducted stock once payment succeeded. With a 15-minute payment window, two customers could both buy the last item. In June I changed it: at checkout one transaction reserves every variant atomically, or the whole order rolls back, and payment only confirms. A timeout, an admin cancellation or a full refund releases the stock exactly once, guarded by an atomic status change; a partial refund does not. A concurrent integration test checks that it never oversells.

Stripe webhooks

Each event is first written to a table with a unique event ID, so duplicates are skipped. A worker claims it with a conditional update (pending, failed, or stuck in processing for over 5 minutes) and tries up to 8 times, 25 events every 30 seconds. Only a summary is stored, never the full payload. Refunds made directly in Stripe are synced back from Stripe's own list, idempotently. Checkout and payment requests carry idempotency keys, and risky writes have rate limits and a refund lock.

Variants arrived late

On day one we agreed on no variants. In May they were needed. Four tables now describe them (product, option, option value, variant), and the product keeps a cached default price, stock and SKU so older code keeps working. Cart, checkout and orders moved to variant IDs in June. The cache has a cost: the last commit, in August, fixed a price that had drifted out of sync.

Email that doesn't get lost

Order emails go through an outbox table, one row per order and type, with attempts, the next retry and a lock, and the worker delivers them. Password reset emails moved into it too.

Shipping it safely

  • Production images are built only from a pushed, clean commit, tagged with the date and the commit hash, and never overwritten. Only services that changed are rebuilt.
  • A deploy backs up the Compose config, pulls every image before changing anything, restores itself on failure and holds an exclusive lock. Every release is logged for rollback, and schema changes ship before the API that needs them.
  • CI runs lint, tests and builds for all three apps; there are 66 test files.
  • Images: jpg, png or webp under 10 MB, checked by their magic bytes, rotated, capped at 2,560 px on the long side and converted to WebP.
  • Search: one hook sets the title, description, canonical URL, Open Graph and Twitter cards; product pages carry structured data; the sitemap is generated from the live catalogue at build time.
  • Turnstile on every sign-in endpoint, login throttling, and sessions revoked on the server at sign-out.

After launch

In July I ran a black-box test of the live site: 29 public routes and 13 admin modules. It found a search link that led to a 404, Turnstile silently off in the admin because of a sitekey and siteKey mismatch, and dashboard filters that went nowhere. All fixed. In late July prices moved from AED to USD, shipping became a separate quote, and refunds made in Stripe started syncing back.

Fixing problems was not separate from development. It was the development.

The non-code work took longest. Company paperwork and the Stripe onboarding took more of my time than writing the code. And the most useful document was the one agreed at the start: a function list, treated like a contract.

Timeline

  1. 02.28First requirements call
  2. 03.01Payment, sign-in and WhatsApp scope agreed
  3. 04.08Repository created; all three apps and cart-to-order in a day
  4. 04.15Test site running
  5. 05.05Admin rebuilt on Naive UI; audit log, refunds, variants
  6. 06.25Stock reservation, Turnstile; moved to the production server
  7. 07.15Black-box test and fixes; accepted by the client
  8. 07.27USD pricing, shipping quoted separately, Stripe refund sync