Sandi Maulana Juhana

Case Study

4 items

January 7, 2025 at 3:00 PM

Behind the Counter: Engineering Altaday, an Online Pharmacy Platform

A pharmacy is not a general store. Building Altaday meant handling dual payment gateways, multi-branch operations, real-time courier tracking, and staff coordination — all through a single coherent interface. A technical deep dive into the decisions that made it work.

Og Image

Notes on Building Software That Runs a Business

A pharmacy platform is not a generic e-commerce site with a different product catalog.

The constraints are different. A customer selecting between a courier and branch pickup, choosing between QRIS and bank transfer, expecting their order notification to reach the right branch pharmacist — these are not edge cases. They are the entire product.

Altaday is an online pharmacy built for the Indonesian market, serving multiple branch locations. This is a breakdown of the technical decisions behind it.

1. The Stack and Why

The core stack is Next.js App Router, Firebase (Firestore + Auth + Functions), and Vercel.

The choice to use Firebase as the primary backend is deliberate. Firestore's document model maps naturally to the order lifecycle — a single order document that evolves through states, with nested payment attempts, delivery updates, and item history all in one place. No JOIN queries. No ORM overhead. The order is the document.

Firebase Functions handle the parts that cannot run on the client: webhook verification, signature validation, order status transitions, Telegram notifications. They run close to the data.

Next.js App Router gives the hybrid rendering model that pharmacy products need. Product pages are server-rendered with ISR — fresh content on a controlled schedule, served from the CDN edge. The interactive layer — cart, checkout, authentication, account management — is purely client-side state.

Server components use the Firebase Admin SDK. Client components use the Firebase Client SDK. The boundary is enforced, not assumed.

2. One Drawer, Ten States

The most visible architectural decision is the drawer.

Every secondary interaction on the site — cart, checkout, product detail, login, signup, account, order history, order detail, email verification — lives inside a single right-side panel. Not separate routes. Not separate modals. One component, driven by a state machine.

// src/stores/ui/useDrawerStore.ts

type DrawerState = {
  open: boolean;
  showCart: boolean;
  showCheckout: boolean;
  showDetails: boolean;
  showAuth: boolean;
  showSignup: boolean;
  showAccount: boolean;
  showMyOrders: boolean;
  showOrderDetail: boolean;
  showVerifyEmail: boolean;
  menu: boolean;
};

The store exposes a dispatch function that mutates this state. Opening the cart sets showCart: true and clears everything else. Proceeding to checkout sets showCheckout: true and clears showCart. The drawer never has to negotiate with itself.

There is a side effect on body: when the drawer opens, a drawer-open class is added to prevent background scroll. A MutationObserver watches for this class to hide the Chatwoot chat widget, preventing UI overlap without coupling the drawer to the chat implementation.

The benefit is focus. A user in the checkout flow cannot accidentally open a product detail panel. The state machine makes invalid transitions impossible.

3. Payments: Two Gateways, One Order

Altaday supports two payment methods: Midtrans (card, bank transfer, e-wallets) and Xendit (QRIS).

Each gateway has its own API route. Midtrans issues a Snap token that opens a payment popup on the client. Xendit returns a QR string that renders a scannable code. The user sees a single "Confirm Order" button — the gateway selection happens behind the scenes based on payment method chosen.

The harder problem is retries.

Midtrans requires a unique order_id per transaction. If a user opens the payment popup, closes it, and tries again, the original order_id is already registered at Midtrans — and will be rejected on resubmit.

The solution is a suffix system:

// Each retry appends _pay{n} to the base order ID
// Base order (Firestore document): ALT-20260115-A9F3
// First attempt (Midtrans):        ALT-20260115-A9F3_pay1
// Second attempt (Midtrans):       ALT-20260115-A9F3_pay2

The Firestore document tracks the current attempt number and a full history of attempts — each with its own Midtrans order ID, token, status, and timestamp.

payment: {
  current_attempt: 2,
  attempts: [
    { attempt: 1, midtrans_order_id: "ALT-..._pay1", status: "EXPIRED", created_at: ... },
    { attempt: 2, midtrans_order_id: "ALT-..._pay2", status: "PENDING", created_at: ... }
  ]
}

When the Midtrans webhook fires with _pay2, the handler strips the suffix, finds the base order, and updates the correct Firestore document. One order in the database. Multiple payment attempts at the gateway. The mapping is deterministic.

4. Webhooks That Trigger Webhooks

The order lifecycle is driven by a cascade of webhooks and Firestore triggers, each decoupled from the next.

When a Midtrans payment is confirmed:

1. Midtrans sends a webhook to /api/midtrans/webhook (a Firebase Function) 2. The function verifies the SHA512 signature (order_id + status_code + gross_amount + serverKey) 3. It updates the Firestore order document: payment.status = "PAID", status = "processing" 4. That document update triggers a Firestore onDocumentUpdated function 5. The trigger detects the status change to "processing" and sends a Telegram message to the branch's group chat

The same pattern applies to delivery. Biteship sends webhook events as the order moves through logistics. Each event updates the delivery subdocument in the order — tracking ID, waybill, driver info, status history.

No component knows the full chain. The Midtrans webhook handler only updates Firestore. The Firestore trigger only sends Telegram messages. The Biteship handler only writes delivery data. The chain emerges from composition, not orchestration.

5. Telegram as the Operations Layer

The admin interface for branch staff is Telegram.

When an order is paid, each branch receives a Telegram message in their group chat. The message includes the order ID, customer name, delivery address, itemized list, total, and courier selection. Two inline buttons are attached: one to mark the order as prepared, one to contact the courier.

This is not a cost-cutting shortcut. It is a real design decision.

A custom admin panel would require a separate web application, user management, notification infrastructure, and staff adoption. Telegram is already on every pharmacist's phone. The inline buttons provide the two actions that matter most at the moment an order is received.

The Telegram bot callback handler (onTelegramCallback) processes button presses and updates the order in Firestore — the same document that drives the customer-facing order detail view.

6. Search Without a Search API

Every product is fetched from Firestore at build time, with ISR revalidation every 60 seconds.

// src/app/page.tsx
export const revalidate = 60;

const [products, categories] = await Promise.all([
  getProducts(),
  getCategories(),
]);

The full product list is passed to the client as server props. Search runs in-memory on the client — no debounce, no API call, no latency. Filtering by category works the same way.

This is a practical trade-off. A pharmacy catalog of a few hundred products fits comfortably in memory. Client-side filtering is instantaneous. If the catalog grows to tens of thousands of SKUs, the approach would need to change — but for the current scale, the complexity of a search backend is not justified.

ISR means the client always has data that is at most 60 seconds stale. For a pharmacy where stock changes matter, that interval was chosen deliberately. Not real-time. Not stale for hours.

7. Cart That Survives Everything

The cart is persisted with LocalForage — an abstraction that uses IndexedDB when available, with localStorage as a fallback.

Unlike a cookie or a URL parameter, IndexedDB persists through page refreshes, tab closures, and browser restarts. A user who adds a product to the cart, closes the tab, and returns an hour later finds their cart intact.

The Zustand store wraps this with a hydration guard:

// Cart only renders after LocalForage has rehydrated from IndexedDB
// Prevents server/client mismatch on first render
hasHydrated: false;

On first mount, the store loads from LocalForage and sets hasHydrated: true. Components that depend on cart state wait for this flag before rendering cart-sensitive UI. No flicker. No stale cart count on the badge.

The storage is versioned. Future schema changes can include migration logic rather than forcing users into an empty cart.

Closing Reflection

A pharmacy platform is operational software first.

The customer experience — browsing, searching, adding to cart, checking out — is table stakes. The harder work is everything that happens after the order is placed: routing to the right branch, coordinating with a courier, updating stock, confirming payment to the right pharmacist at the right moment.

The technical decisions in Altaday are mostly invisible to users. A payment retry that works silently. A Telegram notification that arrives before the customer finishes their confirmation screen. A search that filters before the keystroke is finished.

That is what the work actually looks like.