Sandi Maulana Juhana

Case Study

4 items

January 7, 2025 at 3:00 PM

Di Balik Konter: Arsitektur Altaday, Platform Apotek Online

Apotek bukan toko biasa. Membangun Altaday berarti menangani dual payment gateway, operasional multi-cabang, pelacakan kurir real-time, dan koordinasi staf — semuanya melalui satu antarmuka yang koheren. Deep dive teknis tentang keputusan-keputusan yang membuatnya berjalan.

Og Image

Catatan tentang Membangun Software yang Menjalankan Bisnis

Platform apotek bukan e-commerce generik dengan katalog produk yang berbeda.

Kendalanya berbeda. Pelanggan yang memilih antara kurir dan ambil di cabang, memilih antara QRIS dan transfer bank, berharap notifikasi pesanan mereka sampai ke apoteker cabang yang tepat — ini bukan edge case. Ini adalah seluruh produknya.

Altaday adalah apotek online yang dibangun untuk pasar Indonesia, melayani beberapa lokasi cabang. Ini adalah breakdown keputusan teknis di baliknya.

1. Stack dan Alasannya

Stack utamanya adalah Next.js App Router, Firebase (Firestore + Auth + Functions), dan Vercel.

Keputusan menggunakan Firebase sebagai backend utama adalah keputusan yang disengaja. Model dokumen Firestore memetakan secara alami ke siklus hidup pesanan — satu dokumen pesanan yang berkembang melalui status, dengan nested payment attempts, update pengiriman, dan riwayat item semuanya di satu tempat. Tidak ada query JOIN. Tidak ada overhead ORM. Pesanan adalah dokumennya.

Firebase Functions menangani bagian yang tidak bisa berjalan di client: verifikasi webhook, validasi signature, transisi status pesanan, notifikasi Telegram. Mereka berjalan dekat dengan data.

Next.js App Router memberikan model rendering hybrid yang dibutuhkan produk farmasi. Halaman produk di-render server-side dengan ISR — konten segar dengan jadwal terkontrol, disajikan dari edge CDN. Layer interaktif — cart, checkout, autentikasi, manajemen akun — adalah state client-side murni.

Server components menggunakan Firebase Admin SDK. Client components menggunakan Firebase Client SDK. Batasannya ditegakkan, bukan diasumsikan.

2. Satu Drawer, Sepuluh State

Keputusan arsitektur yang paling terlihat adalah drawer.

Setiap interaksi sekunder di site — cart, checkout, detail produk, login, signup, akun, riwayat pesanan, detail pesanan, verifikasi email — ada di dalam satu panel sisi kanan. Bukan route terpisah. Bukan modal terpisah. Satu komponen, dikendalikan oleh 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;
};

Store mengekspos fungsi dispatch yang memutasi state ini. Membuka cart menetapkan showCart: true dan membersihkan semua yang lain. Melanjutkan ke checkout menetapkan showCheckout: true dan membersihkan showCart. Drawer tidak pernah harus bernegosiasi dengan dirinya sendiri.

Ada side effect pada body: ketika drawer terbuka, kelas drawer-open ditambahkan untuk mencegah scroll background. Sebuah MutationObserver memantau kelas ini untuk menyembunyikan widget chat Chatwoot, mencegah overlap UI tanpa mengkopel drawer ke implementasi chat.

Keuntungannya adalah fokus. Pengguna yang ada di alur checkout tidak bisa secara tidak sengaja membuka panel detail produk. State machine membuat transisi yang tidak valid menjadi tidak mungkin.

3. Pembayaran: Dua Gateway, Satu Pesanan

Altaday mendukung dua metode pembayaran: Midtrans (kartu, transfer bank, e-wallet) dan Xendit (QRIS).

Setiap gateway punya API route-nya sendiri. Midtrans menerbitkan Snap token yang membuka popup pembayaran di client. Xendit mengembalikan string QR yang merender kode yang bisa dipindai. Pengguna melihat satu tombol "Konfirmasi Pesanan" — pemilihan gateway terjadi di balik layar berdasarkan metode pembayaran yang dipilih.

Masalah yang lebih sulit adalah retry.

Midtrans membutuhkan order_id unik per transaksi. Jika pengguna membuka popup pembayaran, menutupnya, dan mencoba lagi, order_id asli sudah terdaftar di Midtrans — dan akan ditolak saat disubmit ulang.

Solusinya adalah sistem suffix:

// Setiap retry menambahkan _pay{n} ke base order ID
// Base order (dokumen Firestore): ALT-20260115-A9F3
// Percobaan pertama (Midtrans):   ALT-20260115-A9F3_pay1
// Percobaan kedua (Midtrans):     ALT-20260115-A9F3_pay2

Dokumen Firestore melacak nomor percobaan saat ini dan riwayat lengkap percobaan — masing-masing dengan Midtrans order ID, token, status, dan timestamp sendiri.

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: ... }
  ]
}

Ketika webhook Midtrans mengirim _pay2, handler-nya memotong suffix, menemukan base order, dan memperbarui dokumen Firestore yang benar. Satu pesanan di database. Beberapa percobaan pembayaran di gateway. Pemetaannya deterministik.

4. Webhook yang Memicu Webhook

Siklus hidup pesanan dikendalikan oleh cascade webhook dan Firestore trigger, masing-masing terpisah dari yang berikutnya.

Ketika pembayaran Midtrans dikonfirmasi:

1. Midtrans mengirim webhook ke /api/midtrans/webhook (sebuah Firebase Function) 2. Function memverifikasi signature SHA512 (order_id + status_code + gross_amount + serverKey) 3. Ia memperbarui dokumen pesanan Firestore: payment.status = "PAID", status = "processing" 4. Pembaruan dokumen itu memicu Firestore onDocumentUpdated function 5. Trigger mendeteksi perubahan status ke "processing" dan mengirim pesan Telegram ke group chat cabang

Pola yang sama berlaku untuk pengiriman. Biteship mengirim event webhook saat pesanan bergerak melalui logistik. Setiap event memperbarui subdokumen delivery dalam pesanan — tracking ID, waybill, info driver, riwayat status.

Tidak ada komponen yang tahu keseluruhan rantainya. Handler webhook Midtrans hanya memperbarui Firestore. Trigger Firestore hanya mengirim pesan Telegram. Handler Biteship hanya menulis data pengiriman. Rantai muncul dari komposisi, bukan orkestrasi.

5. Telegram sebagai Layer Operasional

Antarmuka admin untuk staf cabang adalah Telegram.

Ketika pesanan dibayar, setiap cabang menerima pesan Telegram di group chat mereka. Pesan berisi ID pesanan, nama pelanggan, alamat pengiriman, daftar item, total, dan pilihan kurir. Dua tombol inline dilampirkan: satu untuk menandai pesanan sebagai siap, satu untuk menghubungi kurir.

Ini bukan jalan pintas penghemat biaya. Ini adalah keputusan desain yang nyata.

Panel admin khusus akan membutuhkan aplikasi web terpisah, manajemen pengguna, infrastruktur notifikasi, dan adopsi staf. Telegram sudah ada di setiap ponsel apoteker. Tombol inline menyediakan dua tindakan yang paling penting di momen pesanan diterima.

Handler callback Telegram bot (onTelegramCallback) memproses penekanan tombol dan memperbarui pesanan di Firestore — dokumen yang sama yang menggerakkan tampilan detail pesanan di sisi pelanggan.

6. Search Tanpa Search API

Setiap produk diambil dari Firestore pada saat build, dengan revalidasi ISR setiap 60 detik.

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

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

Daftar produk lengkap diteruskan ke client sebagai server props. Pencarian berjalan in-memory di client — tanpa debounce, tanpa API call, tanpa latency. Filter berdasarkan kategori bekerja dengan cara yang sama.

Ini adalah trade-off yang praktis. Katalog apotek dengan beberapa ratus produk masuk dengan nyaman dalam memori. Filtering client-side bersifat instan. Jika katalog berkembang menjadi puluhan ribu SKU, pendekatannya perlu berubah — tapi untuk skala saat ini, kompleksitas search backend tidak dibenarkan.

ISR berarti client selalu punya data yang paling basi 60 detik. Untuk apotek di mana perubahan stok penting, interval itu dipilih dengan sengaja. Bukan real-time. Bukan basi selama berjam-jam.

7. Cart yang Bertahan dari Segalanya

Cart dipersistkan dengan LocalForage — sebuah abstraksi yang menggunakan IndexedDB ketika tersedia, dengan localStorage sebagai fallback.

Tidak seperti cookie atau URL parameter, IndexedDB bertahan melalui refresh halaman, penutupan tab, dan restart browser. Pengguna yang menambahkan produk ke cart, menutup tab, dan kembali satu jam kemudian menemukan cart mereka utuh.

Zustand store membungkus ini dengan hydration guard:

// Cart hanya render setelah LocalForage melakukan rehidrasi dari IndexedDB
// Mencegah mismatch server/client pada render pertama
hasHydrated: false;

Pada mount pertama, store memuat dari LocalForage dan menetapkan hasHydrated: true. Komponen yang bergantung pada state cart menunggu flag ini sebelum merender UI yang sensitif terhadap cart. Tidak ada flicker. Tidak ada jumlah cart yang basi di badge.

Storage memiliki versi. Perubahan skema di masa depan dapat menyertakan logika migrasi daripada memaksa pengguna ke cart kosong.

Penutup

Platform apotek adalah software operasional yang utama.

Pengalaman pelanggan — browsing, mencari, menambahkan ke cart, checkout — adalah standar minimum. Pekerjaan yang lebih sulit adalah semua yang terjadi setelah pesanan ditempatkan: merutekan ke cabang yang tepat, berkoordinasi dengan kurir, memperbarui stok, mengkonfirmasi pembayaran ke apoteker yang tepat di momen yang tepat.

Keputusan teknis di Altaday sebagian besar tidak terlihat oleh pengguna. Retry pembayaran yang bekerja secara diam-diam. Notifikasi Telegram yang tiba sebelum pelanggan selesai melihat layar konfirmasi mereka. Pencarian yang memfilter sebelum penekanan tombol selesai.

Itulah seperti apa pekerjaan ini sebenarnya.