---
title: "Zovita+: Building an Online Pharmacy That Adapts to Each Shopper"
description: "How I rebuilt Zovita+, an online pharmacy with 1,186 real medicines, a 3D symptom body map, personal offers, drug-interaction warnings, refill reminders, pharmacist-reviewed prescriptions, Urdu, and card or cash payments, on Laravel, React and MySQL: stack, data model, checkout locking, payments, admin, security, SEO, testing, what broke and what I'd change."
author: "Syed Ahmer Shah"
date: 2026-10-02
url: https://ahmershah.dev/blogs/zovita-case-study-online-pharmacy-platform
tags: ["zovita", "case-study", "laravel", "react", "mysql", "system-design", "web-security", "ecommerce"]
series: "The Engineering Logs" (part 11)
---

# Zovita+: Building an Online Pharmacy That Adapts to Each Shopper

_A case study on selling medicine online when the stakes are higher than a late parcel: real stock, prescriptions, a store that notices what you need, and the bugs that only showed up on a 320 px phone_

![Zovita+: Building an Online Pharmacy That Adapts to Each Shopper — Ahmer in a Zovita+ cap and white coat beside the Zovita+ home page](https://ahmershah.dev/blog/zovita-case-study-online-pharmacy-platform/cover-b803452c.webp)


Buying medicine online in Pakistan usually means one of two things. Either a big pharmacy chain's site that feels like a spreadsheet, or a WhatsApp number you message with a photo of the box. Neither tells you whether the thing is in stock before you pay, neither knows what you bought last month, and neither has an answer when you don't know the name of the medicine, only where it hurts.

Zovita+ is my attempt at the pharmacy I'd actually want to use. It has 1,186 real medicines, syrups and supplements, a 3D body map you can tap when you don't know what to search for, a store that quietly learns what you need and offers you personal discounts, warnings when two medicines in the bag shouldn't be taken together, refill reminders, pharmacist-reviewed prescriptions, English and Urdu, and card payments or cash on delivery. I designed and built it alone: research, UI, database, backend, admin panel, security, SEO, tests and documentation.

This is how it's put together, what went wrong, and what I'd change.

* * *

## Two versions of the same idea

Zovita started in March 2026 as a set of flat PHP pages with hand-written Tailwind: a home page, a catalogue, a cart and a prescription upload form. It looked fine in screenshots. Underneath, every page included its own header file, the cart lived in JavaScript, and "checkout" was a form that sent an e-mail. By May I was spending more time fixing the off-canvas menu than building features, and I stopped.

In late September 2026 I rebuilt it from an empty Laravel project. Same name, same brand, nothing else kept. The rebuild is what this case study is about.

## The one rule

"Never sell what isn't on the shelf, and never charge a price you didn't show."

A shop can apologise for a late parcel. Telling someone with a fever that the paracetamol they paid for doesn't exist, or charging them more than the bag said, is a different kind of failure. Most of the design decisions below come back to that sentence.

![The Zovita+ home page: a 3D pill in the hero, "Care, delivered with calm", search, and shortcuts to the shop, the body map and prescription upload.](/blog/zovita-case-study-online-pharmacy-platform/home.webp)

## Who uses it

- **Shoppers** browse 8 departments, 118 categories and 212 brands, filter by form, prescription status, stock and price, search with ⌘K, tap the body map when they only know the symptom, ask the assistant where their order is, upload a prescription, and pay by card or cash on delivery. Guests can do all of it; signing in merges the guest wishlist and history into the account.
- **Pharmacy staff** sign in at a separate, unlinked `/admin` login, as an **owner**, **pharmacist** or **support**, each with its own permissions. They review prescriptions, confirm and dispatch orders, refund card payments, watch stock, read each customer's activity timeline and, when it's needed, ban someone.

![The shop: department and category filters, prescription and stock toggles, price range, sorting and 1,000+ products with real prices and discounts.](/blog/zovita-case-study-online-pharmacy-platform/shop.webp)

## Choosing the stack

I wanted an interface that feels like an app, with no full page reloads, and a backend where routing, authentication and validation stay on the server where I can reason about them.

**Laravel 12 and React 19, joined by Inertia 2,** gave me both. Every page is a React component, but there is no public JSON API to secure. A controller validates the request, checks who is asking and hands props to the page. The store layout never unmounts, so the header, the bag count and the assistant survive navigation.

| Layer | Choice | Why |
|---|---|---|
| Backend | Laravel 12, PHP 8.2 | Routing, Eloquent, queues, scheduler, rate limiting, mail |
| Bridge | Inertia 2 + Ziggy | SPA navigation with server-side routing and auth |
| Frontend | React 19, Tailwind CSS 4 | Components and one set of design tokens for two themes |
| Motion and 3D | GSAP (ScrollTrigger, SplitText), Lenis, Three.js via @react-three/fiber | Page motion, smooth scroll, the pill hero and the body map |
| Database | MySQL / MariaDB | Foreign keys, unique guards, row locks |
| Maps | Leaflet, OpenStreetMap, Nominatim | The address picker and the contact map, with no API key |
| Payments | Stripe Checkout (a sandbox gateway locally) | Card payments confirmed only by signed webhooks; cash on delivery always works |
| Mail and bots | Resend, reCAPTCHA v3 + v2 | Transactional e-mail; invisible bot scoring with a checkbox fallback |
| Rendering and scale | Inertia SSR, Redis, ClamAV | Full HTML for crawlers, shared rate limits and bans across servers, scanned uploads |

## Architecture

![How a request flows: React pages in the browser, Laravel middleware (request guard, security headers, locale, ban check), thin controllers, actions and services, then MySQL, the private prescription disk and external services.](/blog/zovita-case-study-online-pharmacy-platform/architecture.webp)

The rule I kept coming back to was **thin controllers, and one class for every set of rules that must hold together**. Placing an order has to lock stock, check prescriptions, store the prescription file, apply personal offers, create the order, mark offers as redeemed, and only after that commits, clear the bag, record the purchase and send e-mail. All of that lives in one `PlaceOrder` action, so there is exactly one place where an order can come into existence.

Other decisions that paid off:

- **One source of truth per concern.** `App\Support\Seo` builds titles, Open Graph and JSON-LD for every page. The FAQ and policies live in `resources/content/*.json` and feed the pages, their JSON-LD and `llms-full.txt` from the same file.
- **Personalisation is a set of small services**, not a model: `Visitor` (who is this, signed in or not), `Interactions` (what did they do), `OfferEngine` (what does that earn them), `Pricing` (what does it cost now) and `Recommender` (what should they see next).
- **The scheduler does the boring, important work.** Every ten minutes it accepts prescriptions nobody has reviewed in 24 hours, so a customer is never stuck because the pharmacist was busy; every five it cancels and restocks card orders nobody paid for; every morning it sends refill reminders.

## The data model

![Zovita+'s entity relationship diagram: 22 domain tables in six groups (catalogue, orders and payments, customers and staff, personalisation, security and inbox) with every column and the 27 foreign keys between them.](/blog/zovita-case-study-online-pharmacy-platform/erd.webp)

The schema is 22 domain tables and 27 foreign keys. The catalogue is simple: a **department** has **categories**, and a **product** belongs to a department, a category and (optionally) a **brand**. A product carries real pharmacy data: price and sale price, stock, a per-order cap, whether it needs a prescription, its active ingredients (`generics`), uses, dosage, precautions and warnings.

An **order** has **order items** that copy the product's name, price and image at the moment of sale, so editing a product later never rewrites someone's receipt. An order can point to a **prescription**, which points to the staff member who reviewed it.

What makes it trustworthy is what the database itself refuses:

- `orders.number` is unique, and so is `orders.checkout_token`: a double-tapped "Place order" can only ever create one row.
- `prescriptions.reference`, `products.slug`, `users.username` and `users.email` are unique.
- Foreign keys decide what happens on delete: removing a product cascades its wishlist entries and interaction history but only nulls `order_items.product_id`, because the order has to survive.

Money has its own tables: a **payment** per checkout attempt and a **refund** per refund, with `webhook_events` storing every gateway event id once so a replayed webhook is a no-op.

The other half of the schema is about people: `product_interactions` (one row per visitor and product), `offers`, `refill_reminders`, `experiment_events` and `experiment_assignments`, `login_codes`, `bans`, `ban_identifiers` and `user_activities`. I'll come back to those.

## Ordering

![A product page: photo and 3D pack viewer, price with the discount, the "same salt, other brands" rail, a delivery estimate by city and the price-insight histogram against the category.](/blog/zovita-case-study-online-pharmacy-platform/product.webp)

The bag is a session, and every price in it is re-read from the database on each request. Nothing the browser says about price is ever trusted. Checkout, whether cash on delivery or card, is a single transaction:

![Checkout as one transaction: lock the products in id order, re-check stock and caps, decrement with a conditional update, require a prescription for Rx items, lock the visitor's live offers, price with the same function as the bag, create the order with a unique number and token, redeem the offers, and only after commit clear the bag and send e-mail.](/blog/zovita-case-study-online-pharmacy-platform/checkout.webp)

1. **Lock.** The products in the bag are read with `SELECT … FOR UPDATE`, in ascending id order. Two checkouts that share products queue behind each other instead of deadlocking, because they always take locks in the same order.
2. **Re-check and decrement.** Stock is checked under the lock and then decremented with `UPDATE … WHERE stock >= qty`. If that affects no row, the whole transaction rolls back. Stock can't go negative even if a future change forgets the lock.
3. **Prescriptions.** If any item needs one and no file is attached, the order stops there. The file is stored on a private disk; if the transaction rolls back, the file is deleted again.
4. **Offers.** The visitor's live offers are locked too, so one offer can't be redeemed by two parallel checkouts, and the discount is computed by `Pricing::apply()`, the same function that priced the bag preview.
5. **Create.** The order gets a random number under a unique index (a collision just retries inside a savepoint) and the checkout token under another one. Deadlocks retry up to three times.

Only after the commit does the bag clear, the purchase get recorded and the e-mails go out. The loser of a "one unit left, two buyers" race sees a normal message asking them to review the bag, not an error page.

When something is sold out, the product page offers in-stock alternatives with the same active ingredient first, then the same category at a similar price, with a one-tap swap in the bag.

### Paying by card

Card payments go through Stripe Checkout in production and a built-in sandbox gateway locally, so the whole flow can be tested without keys. The order is created *awaiting payment* with its stock reserved for 30 minutes. The page the customer returns to never marks anything paid: only a webhook does, and only if its HMAC-SHA256 signature checks out in constant time, its timestamp is under five minutes old, its event id has never been seen, and the amount and currency match the order. If nobody pays, `payments:expire` cancels the order, puts the stock back and releases its offers; money that arrives after that is refunded automatically. Owners refund part or all of an order from the admin panel, and refunds can never exceed what was captured.

### Medicine safety

Every product stores its active ingredients, so the bag can do what a careful pharmacist would: notice two medicines that shouldn't be taken together. An `InteractionChecker` maps ingredients to drug classes (NSAIDs, blood thinners, SSRIs, nitrates, macrolides…) with tolerant patterns, so the catalogue's own spellings still match, and checks them against 24 conservative, well-established rules: two paracetamol products, a blood thinner with an anti-inflammatory painkiller, sildenafil with a nitrate, and so on. Creams, shampoos and drops are ignored, so a ketoconazole shampoo never warns about a statin. Each warning says what to do; a serious one must be acknowledged before the order goes through, and the pharmacist sees it on the order. It's an automatic check, not medical advice, and the page says so.

The same purchase history drives **refill reminders**. For a medicine someone buys again and again, the median gap between their real orders (7 to 120 days) gives the rhythm, and an e-mail goes out three days before they should run out, once per purchase cycle, with a signed one-tap link that puts it back in the bag.

## The 3D body map

Plenty of people know where it hurts but not what to search for. The body map is for them: rotate a mannequin, tap where it hurts, pick a symptom, and get self-care tips, red flags and pharmacist picks from the catalogue. Emergency symptoms (chest pain, trouble breathing) show urgent-care guidance instead of products.

![The body map: a sculpted mannequin with glowing regions, symptom chips for the tapped region, and pharmacist picks with self-care advice and red flags.](/blog/zovita-case-study-online-pharmacy-platform/body-map.webp)

I didn't want to ship a stock 3D model I couldn't control, so the figure is sculpted in code. A Node script defines the body as a signed distance field (ellipsoids and tapered capsules blended with a smooth minimum so joints flow without seams), meshes it with surface nets, and bakes a binary file where every vertex carries its body region id. The shader uses that id for the region glow and the picking. The result is 775 KB raw and 463 KB with Brotli, and it only loads on the body map page.

## A store that notices

Every visitor, signed in or not, builds a quiet profile: how many times they viewed each product, how long they stayed, what they added to the bag and what they bought. From that, an offer engine creates personal discounts: 8% on something you keep coming back to, 5% on something left in the bag, 10% on something you buy every month, a welcome or loyalty discount on the whole order. Every offer is time-limited, the total is capped at 15%, and it's locked and redeemed inside the checkout transaction.

It also powers "picked for you", "buy again" and "pairs well with" rails, and a guided assistant. The assistant has **no free-text box**: you pick from preset questions ("Where is my order?", "Do I have any discounts?", "What's in my bag?") and the answer is built on the server from your data. Nothing a visitor types is sent anywhere, so there is nothing to prompt-inject and no made-up medical advice.

![The assistant: preset questions, and a personal answer listing the visitor's live offers with links to the products.](/blog/zovita-case-study-online-pharmacy-platform/assistant.webp)

I wrote a separate article about how the offers work and the bugs I found in them: [The discount that knew you were hesitating](/blogs/the-discount-that-knew-you-were-hesitating-rule-based-personalisation-in-zovita).

## The admin panel

The admin panel is for pharmacy staff, not developers, so I wrote it around one question: "what does this person need to do right now?"

- **It hides.** It has its own sign-in at `/admin/login`, every admin URL answers **404** to anyone who isn't staff, sessions end after 30 minutes idle, and staff login locks for 15 minutes after five failures. Each staff member can turn on two-step sign-in with an authenticator app from **My security**; from then on every sign-in needs its code, a used code can't be replayed, and hashed single-use recovery codes cover a lost phone.
- **Roles decide what you see.** Owners can do everything, including refunds, bans and managing staff; pharmacists handle prescriptions, stock and prices; support sees orders and customers but can't refund or ban. Permissions are checked on every route and the sidebar only shows what a role can open. The last owner can never be demoted.
- **"Today at Zovita"** opens with to-do cards (prescriptions to review, orders to confirm, low and sold-out stock), then revenue, orders, average order and new customers against the previous period, and charts for revenue per day, the shopper funnel, top products, departments and A/B results. Every chart has a table view.
- **It explains itself.** Every page has a "How this page works" guide, every heading has a **?** with a plain-language explanation, and statuses are big coloured pills, not codes.

![The admin dashboard: to-do cards, revenue and orders against the previous period, and the revenue, funnel and A/B charts.](/blog/zovita-case-study-online-pharmacy-platform/admin-dashboard.webp)

**Prescriptions** can be accepted, rejected or left pending with a note that is e-mailed to the customer. Anything not reviewed within 24 hours is accepted automatically and labelled "auto-approved", so it's obvious which ones a person actually looked at.

![Prescription review: pending, accepted and auto-approved prescriptions with the file, the customer and a note field.](/blog/zovita-case-study-online-pharmacy-platform/admin-prescriptions.webp)

**Customers** have a full activity timeline (sign-ins, views, bag, orders, prescriptions, profile changes, with device and IP) and a ban wizard with three levels: **temporary** (until a date), **permanent** (the email, phone and device can't register again) and **deep** (also the IP, its /24 or /64 network and the browser fingerprint, across the whole site). E-mail matching is canonical, so Gmail dots and `+aliases` are folded and `j.o.h.n+1@gmail.com` is the same person as `john@gmail.com`.

![A customer's page: profile, recent activity with devices, the admin-only username field and the three-step ban wizard.](/blog/zovita-case-study-online-pharmacy-platform/admin-user.webp)

## Design and language

The brief I set myself was "calm": deep navy ink, a soft mint accent, warm paper, and generous space, because people buying medicine are often unwell or worried. Motion is there to explain, not to impress: an item you add to the bag flies into it as a little parcel with a trail, moving it to "saved for later" flies it back, and un-saving from the wishlist plays a small heart-break. All of it switches off under `prefers-reduced-motion`.

![The home page in the dark theme: the same tokens redefined for night, with the mint accent kept for the one action that matters.](/blog/zovita-case-study-online-pharmacy-platform/home-dark.webp)

**Urdu** is a first-class language, not a translation layer bolted on. Switching is instant with no reload, the whole layout flips right-to-left, and the type changes to Noto Nastaliq Urdu. The dictionary has about 1,460 entries and covers the storefront, policies, FAQ, checkout, the admin panel and the error pages; values that change (counts, prices, names) go through placeholders, and dates use Urdu month names. Product names, prices and order numbers stay in Latin script so they match the box and the receipt.

![The home page on a phone in English and in Urdu, with the right-to-left layout and Nastaliq type.](/blog/zovita-case-study-online-pharmacy-platform/m-urdu.webp)

Even the 404 is useful: it has search and shortcuts instead of a dead end.

## SEO, and search for AI

Every storefront page is server-rendered (Inertia SSR) and hydrates without a mismatch, and the server does the rest of the work for crawlers:

- **Server-rendered head.** Every page gets a unique title and description, a canonical URL, Open Graph and Twitter cards, a robots rule, and `hreflang` alternates for English and Urdu.
- **JSON-LD.** `Pharmacy` and `OnlineStore` site-wide, `WebSite` with a search action, and per page `Product` (offers, availability, brand), `BreadcrumbList`, `FAQPage`, `ItemList` and `ContactPage`.
- **A generated sitemap** with every product, department, category, brand and page, and **`llms.txt` / `llms-full.txt`** for AI assistants. `robots.txt` lets named AI crawlers in and keeps everyone out of the bag, checkout, account and admin.

Lighthouse 13 scores **100 for accessibility, best practices and SEO** on the home, shop, product, body map, FAQ, contact, policy, login, register, prescription and about pages. The bag and checkout are `noindex` on purpose and lose SEO points for it.

## Security

| Area | What protects it |
|---|---|
| SQL injection | Eloquent and the query builder with bound parameters everywhere; sort and filter values are allow-listed. Tests send classic payloads. |
| XSS | React escaping plus a nonce-based Content-Security-Policy, so no inline script runs without the per-request nonce |
| CSRF and clickjacking | CSRF tokens on every state change, `SameSite=Lax` cookies, `frame-ancestors 'self'`, HSTS in production |
| Brute force | Per-route throttles, each with its own counter; 5 failed sign-ins lock for a minute (15 on the staff login); reCAPTCHA v3 with the v2 checkbox as a fallback |
| Floods | A `RequestGuard` middleware rejects unexpected methods, huge URLs, parameter floods, oversized bodies and NUL bytes before any session or database work; a per-client budget; nginx `limit_req` at the edge |
| Races | Row-locked stock and offers, conditional decrements, idempotency tokens |
| Enumeration | Password reset, login and order tracking answer the same way whether or not the account or order exists |
| Uploads | MIME and extension allow-list, size cap, UUID names, a private disk, streamed only to staff |
| Admin | 404 for non-staff, a separate login, optional authenticator-app two-step sign-in, role permissions on every route, idle timeout, every admin action in the activity log |
| Payments | Server-computed amounts; webhooks verified by HMAC with a 5-minute window, each event applied once, amount and currency must match; refunds capped at what was captured |
| Malicious files | Prescriptions and avatars are streamed to ClamAV before they're stored; if the scanner is down, the upload is refused |

## Testing

| Suite | What it covers | Result |
|---|---|---|
| PHPUnit | Checkout (stock locking, idempotency, throttle isolation), auth, TOTP against the RFC 6238 vectors, roles, bans and evasion, offers, assistant, A/B, admin gating, prescriptions and the 24 h auto-accept, payments (forged, stale, duplicate and wrong-amount webhooks, expiry, late-payment refunds), interactions, refills, ClamAV, SQLi/XSS/CSP/CSRF, SEO outputs | **145 passed**, 973 assertions |
| Playwright | Every page on desktop and a phone (no console errors, one `h1`, no sideways scroll), fly-to-bag, wishlist and bag moves, guest checkout, keyboard use of the themed dropdowns, body map, assistant, Urdu round-trip, A/B exposure, prefetch, admin review, admin pages on a phone, interaction warnings, a card payment through the sandbox gateway | **56 passed** |
| Load | autocannon on a single XAMPP dev box: shop and product pages about 32 requests a second, FAQ about 55, static images about 830 | No errors |
| Abuse | 25 connections flooding the shop from one client for 20 s | 293 served, then 429 for the rest, no 5xx |

CI runs Pint, the production build and the PHPUnit suite on every push. The repository has a single branch, `main`, protected by rulesets: no force-pushes or deletion, linear history, signed commits only, and no other branch can be created.

## Where it broke

This is the part I'd want to read in someone else's case study.

**Shopping used up the checkout limit.** Every write route had a throttle, and I was proud of that. But Laravel's `throttle:60,1` keys the counter by user or IP, not by route, so all of them shared one bucket. A shopper who added and removed a few things from the bag, toggled the wishlist and changed the language could hit "Too many requests" on **Place order**, the one request that matters. Every throttle now names its own counter (`throttle:6,1,checkout.store`), and a test proves bag activity can't exhaust checkout.

**The mobile menu closed itself.** Hovering a link prefetches its page, so clicks feel instant. The mobile menu and the overlays closed themselves on Inertia's `start` event, so they could close themselves the moment a prefetch began, which is the opposite of what anyone wants from a menu they just opened. Overlays now ignore visits that are prefetches.

**The header pushed its own menu button off-screen.** Between 320 and 375 px, and again at 1024 px, the header was wider than the screen and the menu button sat past the right edge, so on the smallest phones you couldn't open the menu at all. The smoke test that checks "no sideways scroll" now runs on a phone profile for every page.

**Nastaliq got its tops cut off.** Nastaliq is a tall, joined script: the strokes of letters like کاف and گاف rise well above the line box. The text reveals mask each line with `overflow: hidden`, and the rolling button labels clip to one line height, so both sliced the tops off Urdu letters. Under `html[lang='ur']`, line masks and labels now leave room for the strokes.

**The admin dashboard scrolled sideways on phones.** I found this one while writing this case study. The dashboard's chart grid had no column template below the `xl` breakpoint, so its single implicit column grew to fit the widest chart and the page became 698 px wide on a 375 px phone. The customers list had the same problem. Adding `grid-cols-1` (which is `minmax(0, 1fr)`) fixed both. The same bug bit me on BookMyMovie, and the lesson is the same: a check that runs every page at 320, 375, 414, 768, 1024, 1280 and 1920 px catches what my eyes don't. It now covers the admin pages too.

**A section that was invisible everywhere.** The "Shop by concern" grid never appeared, on any screen. Its cards fade in on scroll with `gsap.from()`, which animates *from* a value *to* whatever the element's opacity is when the tween is set up. The cards also had a CSS `transition` on opacity, so when ScrollTrigger refreshed after the web fonts loaded, GSAP re-read the opacity in the middle of that CSS transition, got roughly zero, and animated from zero to zero. Giving the tween explicit end values with `fromTo()` fixed it. The lesson: never let CSS transitions and GSAP fight over the same property.

**A hundred and fifty products called "None".** The catalogue source writes `None` when a product has no listed ingredient. Stored as-is, it showed up as an ingredient chip on product cards, and worse, "same salt, other brand" treated all 150 of those products as substitutes for each other. A migration now stores it as unknown, and the importer refuses the placeholder.

**English text, flipped right to left.** The Urdu layer lived in the store layout, but the admin panel, the admin login and the error pages each have their own layout. In Urdu they rendered English text with `dir="rtl"`, which is worse than either language. Monospace labels had a subtler problem: the mono font has no Urdu glyphs, and the browser's fallback drew the letters unjoined. Every layout now mounts the language layer, and every font stack falls back to Nastaliq.

**CI that couldn't start.** CI copies `.env.example`, where a few optional settings are blank. `env('CACHE_LIMITER_STORE')` returns an empty string, not null, so Laravel looked for a cache store named `""` and crashed before the first test ran. Empty now means "not set".

## Downsides I'd be honest about in a review

- **Cards, but no local wallets.** Stripe covers cards; most Pakistani shoppers would also expect JazzCash and Easypaisa.
- **Two-step sign-in is opt-in for staff.** The demo accounts need to work with a password, so the panel nudges each staff member to turn it on rather than forcing it. A real pharmacy should make it mandatory.
- **Auto-accepting prescriptions is a product decision, not a medical one.** It keeps customers from waiting on a busy pharmacist, and it's clearly labelled, but a real pharmacy would need a regulator's view on it.
- **The catalogue is borrowed.** Names, prices and images come from a public pharmacy storefront for demonstration only. A real launch needs a licensed catalogue and live stock from the pharmacy's own system.
- **Personalisation is rules, not learning.** The rules are explainable and testable, which I wanted, but they won't find patterns I didn't think of.
- **Interaction checks are deliberately narrow.** Twenty-four well-established rules catch the common, dangerous pairs; they are not a clinical decision-support system and don't know the patient's other medicines.

## Where it stands

Zovita+ is finished as a portfolio project. The code is on GitHub with a step-by-step setup guide; it runs locally with no third-party keys at all, and one `migrate --seed` loads the catalogue and the demo accounts.

The first version of this case study ended with a list of what I'd build next. All of it has since shipped: staff roles with two-step sign-in, card payments with signed webhooks and refunds, refill reminders, drug-interaction warnings, server-side rendering, Redis-ready rate limits and bans, and malware scanning on uploads, along with e-mail automation and a full Urdu pass over every page. If I picked it up for a real pharmacy, the next list would be:

1. JazzCash and Easypaisa next to cards, and mandatory two-step sign-in for staff.
2. Passkeys, and SMS or WhatsApp updates for orders and refills.
3. A pharmacist chat handoff from the assistant.
4. Back-in-stock alerts and verified-purchase reviews.

## What it taught me

The interesting work in a pharmacy isn't the product grid. It's the rate limit that quietly blocks checkout, the offer that two tabs try to redeem at once, the menu that closes because a link was hovered, and the Urdu letter that loses its top to an animation mask. Almost everything I'm proud of in Zovita+ is invisible when it works.

The code is on [GitHub](https://github.com/ahmershahdev/zovita) under the MIT licence, with the full data model, security design, test results and every screen in the README.


---

*Originally published at https://ahmershah.dev/blogs/zovita-case-study-online-pharmacy-platform — © Syed Ahmer Shah*
