Syed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer Shah — home
● MenuAHMERAHMER
20+Certificates
PHPMERNMySQLSEO

Explore

01About→02Education→03Certificates→04Skills→05Projects→06Services→07Testimonials→08Contact→

Pages

01Blog→02Saved articles→03Privacy Policy→04Terms of Service→
SoundHire me →

Available for new work · --:--:-- PKT

Have an idea?

support@ahmershah.dev
Book a call WhatsApp +92 370 4831994

On this site

  • 01About
  • 02Education
  • 03Certificates
  • 04Skills
  • 05Projects
  • 06Services
  • 07Testimonials
  • 08Contact

Pages

  • Blog
  • Saved articles
  • RSS
  • Privacy Policy
  • Terms of Service
  • llms.txt

Elsewhere · official profiles

26 links

Professional

Work history, code and credentials.

  • in/syedahmershah
  • company/syedahmershah
  • @ahmershahdev
  • syed-ahmer-shah
  • syed-ahmer-shah
  • u/syedahmershah
  • @syedahmershah
  • AWS@syedahmershah

Coding & credentials

Problem solving, verified badges.

  • u/syedahmershah
  • syedahmershah
  • users/syedahmershah
  • learner/syedahmershah

Google & business

The official listings.

  • Google Business Profileg.page
  • Syed Ahmer Shah
  • syedahmershah
  • Beaconssyedahmershah
  • syedahmershah

Social (@ahmershahdev)

Build logs, clips, threads.

  • @ahmershahdev
  • @ahmershahdev
  • @ahmershahdev
  • ahmershahdev
  • @ahmershahdev
  • @bluesky.ahmershah.dev
  • ahmershahdev

Design

Interfaces and visuals.

  • syedahmershah
  • syedahmershah

Direct

  • support@ahmershah.dev

    Support & projects

  • syedahmershahofficial@gmail.com

    Personal

  • +92 370 4831994

    Phone · WhatsApp

  • Hyderabad, Sindh, Pakistan

    Remote-first · Asia/Karachi (UTC+5)

Résumé ↗
SYED AHMER SHAH

© 2026 Syed Ahmer Shah. All rights reserved.

PrivacyTermsRSS
Syed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer Shah — home
● MenuAHMERAHMER
20+Certificates
PHPMERNMySQLSEO

Explore

01About→02Education→03Certificates→04Skills→05Projects→06Services→07Testimonials→08Contact→

Pages

01Blog→02Saved articles→03Privacy Policy→04Terms of Service→
SoundHire me →

Available for new work · --:--:-- PKT

Have an idea?

support@ahmershah.dev
Book a call WhatsApp +92 370 4831994

On this site

  • 01About
  • 02Education
  • 03Certificates
  • 04Skills
  • 05Projects
  • 06Services
  • 07Testimonials
  • 08Contact

Pages

  • Blog
  • Saved articles
  • RSS
  • Privacy Policy
  • Terms of Service
  • llms.txt

Elsewhere · official profiles

26 links

Professional

Work history, code and credentials.

  • in/syedahmershah
  • company/syedahmershah
  • @ahmershahdev
  • syed-ahmer-shah
  • syed-ahmer-shah
  • u/syedahmershah
  • @syedahmershah
  • AWS@syedahmershah

Coding & credentials

Problem solving, verified badges.

  • u/syedahmershah
  • syedahmershah
  • users/syedahmershah
  • learner/syedahmershah

Google & business

The official listings.

  • Google Business Profileg.page
  • Syed Ahmer Shah
  • syedahmershah
  • Beaconssyedahmershah
  • syedahmershah

Social (@ahmershahdev)

Build logs, clips, threads.

  • @ahmershahdev
  • @ahmershahdev
  • @ahmershahdev
  • ahmershahdev
  • @ahmershahdev
  • @bluesky.ahmershah.dev
  • ahmershahdev

Design

Interfaces and visuals.

  • syedahmershah
  • syedahmershah

Direct

  • support@ahmershah.dev

    Support & projects

  • syedahmershahofficial@gmail.com

    Personal

  • +92 370 4831994

    Phone · WhatsApp

  • Hyderabad, Sindh, Pakistan

    Remote-first · Asia/Karachi (UTC+5)

Résumé ↗
SYED AHMER SHAH

© 2026 Syed Ahmer Shah. All rights reserved.

PrivacyTermsRSS
Syed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer Shah — home
● MenuAHMERAHMER
20+Certificates
PHPMERNMySQLSEO

Explore

01About→02Education→03Certificates→04Skills→05Projects→06Services→07Testimonials→08Contact→

Pages

01Blog→02Saved articles→03Privacy Policy→04Terms of Service→
SoundHire me →
← Engineering Logs/The Engineering Logs · part 11 of 12

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

Written by

Syed Ahmer Shah

Software Engineer

Oct 2, 2026Updated Oct 3, 202622 min read4,763 words—reading now▼
Zovita+: Building an Online Pharmacy That Adapts to Each Shopper — Ahmer in a Zovita+ cap and white coat beside the Zovita+ home page
Read this articlevoice
Markdown

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.
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.

#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.
The shop: department and category filters, prescription and stock toggles, price range, sorting and 1,000+ products with real prices and discounts.

#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.
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.

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.
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.

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.
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.

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.
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.
  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.
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.

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.
The assistant: preset questions, and a personal answer listing the visitor's live offers with links to the products.

I wrote a separate article about how the offers work and the bugs I found in them: The discount that knew you were hesitating.

#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.
The admin dashboard: to-do cards, revenue and orders against the previous period, and the revenue, funnel and A/B charts.

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.
Prescription review: pending, accepted and auto-approved prescriptions with the file, the customer and a note field.

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.
A customer's page: profile, recent activity with devices, the admin-only username field and the three-step ban wizard.

#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.
The home page in the dark theme: the same tokens redefined for night, with the mint accent kept for the one action that matters.

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.
The home page on a phone in English and in Urdu, with the right-to-left layout and Nastaliq type.

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 under the MIT licence, with the full data model, security design, test results and every screen in the README.

Find me across the web.

Same person, every platform

Work & code

  • ASPortfolioahmershah.dev
  • LinkedInin/syedahmershah
  • GitHub@ahmershahdev
  • AWSAWS Builder Center@syedahmershah
  • Crunchbasesyed-ahmer-shah
  • LinkedIn · Companycompany/syedahmershah

Writing

  • Dev.tosyedahmershah
  • Medium@syedahmershah
  • Hashnode@syedahmershah
  • Substack@syedahmershah
  • HackerNoonu/syedahmershah
  • CLCoderLegionuser/syedahmershah

Reviews & listings

  • Google Knowledge PanelSyed Ahmer Shah
  • Google Business Profileg.page
  • ClClutch
  • TpTrustpilot
  • DRDesignRush
  • TBTechBehemoths

Social

  • YouTube@ahmershahdev
  • Instagram@ahmershahdev
  • TikTok@ahmershahdev
  • Facebookahmershahdev
  • X@ahmershahdev
#zovita#casestudy#laravel#react#mysql#systemdesign#websecurity#ecommerce
ShareXLinkedInRedditWhatsApp

← Previous in series

One Seat, Two Buyers: Race Conditions, IDOR and Keys in a Cinema Booking System

Next in series →

The Discount That Knew You Were Hesitating: Rule-Based Personalisation in an Online Pharmacy

On this page

  • Two versions of the same idea
  • The one rule
  • Who uses it
  • Choosing the stack
  • Architecture
  • The data model
  • Ordering
  • Paying by card
  • Medicine safety
  • The 3D body map
  • A store that notices
  • The admin panel
  • Design and language
  • SEO, and search for AI
  • Security
  • Testing
  • Where it broke
  • Downsides I'd be honest about in a review
  • Where it stands
  • What it taught me

The Engineering Logs

  1. 01The Edge Latency Lie: Solving Global Consistency
  2. 02Top 10 AI Tools Every Frontend Developer Should Know (2026 Guide)
  3. 03Change One Number. Take Everything.
  4. 04How Databases Work: From Tables to Query Execution
  5. 05The New Era of Software Engineering
  6. 06The Art of Object-Oriented Programming
  7. 07GleanGrid: Building a Pre-Order Marketplace for Farmers Markets
  8. 08The Last Mango Problem: Race Conditions in a Real Marketplace
  9. 09BookMyMovie: Building a Cinema Ticketing Platform
  10. 10One Seat, Two Buyers: Race Conditions, IDOR and Keys in a Cinema Booking System
  11. 11Zovita+: Building an Online Pharmacy That Adapts to Each Shopper
  12. 12The Discount That Knew You Were Hesitating: Rule-Based Personalisation in an Online Pharmacy

Up next

Keep reading.

All 50 articles→
  1. 01

    BookMyMovie: Building a Cinema Ticketing Platform

    How I built BookMyMovie on my own, a Laravel, React and MySQL cinema booking platform with live seat maps, trailers, QR e-tickets that work offline, online payments and an Urdu interface: stack, architecture, data model, design, SEO, security, what broke and what I would change.

    Sep 29, 202617 minThe Engineering Logs

  2. 02

    GleanGrid: Building a Pre-Order Marketplace for Farmers Markets

    How I designed and built GleanGrid on my own, a Laravel and React marketplace where Hyderabad's farmers publish weekly stock and customers reserve it, pay online or at the stall, and collect it: architecture, data model, payments, security and UX.

    Sep 27, 202615 minThe Engineering Logs

  3. 03

    The Discount That Knew You Were Hesitating: Rule-Based Personalisation in an Online Pharmacy

    How Zovita+ turns browsing into personal discounts without a machine-learning model: one visitor key for guests and customers, interaction counters, six offer rules, one pricing function for the bag and the charge, offers locked and redeemed once at checkout, guest-to-account merging, A/B tests, and the edge cases that shaped it.

    Oct 2, 202610 minThe Engineering Logs

Available for new work · --:--:-- PKT

Have an idea?

support@ahmershah.dev
Book a call WhatsApp +92 370 4831994

On this site

  • 01About
  • 02Education
  • 03Certificates
  • 04Skills
  • 05Projects
  • 06Services
  • 07Testimonials
  • 08Contact

Pages

  • Blog
  • Saved articles
  • RSS
  • Privacy Policy
  • Terms of Service
  • llms.txt

Elsewhere · official profiles

26 links

Professional

Work history, code and credentials.

  • in/syedahmershah
  • company/syedahmershah
  • @ahmershahdev
  • syed-ahmer-shah
  • syed-ahmer-shah
  • u/syedahmershah
  • @syedahmershah
  • AWS@syedahmershah

Coding & credentials

Problem solving, verified badges.

  • u/syedahmershah
  • syedahmershah
  • users/syedahmershah
  • learner/syedahmershah

Google & business

The official listings.

  • Google Business Profileg.page
  • Syed Ahmer Shah
  • syedahmershah
  • Beaconssyedahmershah
  • syedahmershah

Social (@ahmershahdev)

Build logs, clips, threads.

  • @ahmershahdev
  • @ahmershahdev
  • @ahmershahdev
  • ahmershahdev
  • @ahmershahdev
  • @bluesky.ahmershah.dev
  • ahmershahdev

Design

Interfaces and visuals.

  • syedahmershah
  • syedahmershah

Direct

  • support@ahmershah.dev

    Support & projects

  • syedahmershahofficial@gmail.com

    Personal

  • +92 370 4831994

    Phone · WhatsApp

  • Hyderabad, Sindh, Pakistan

    Remote-first · Asia/Karachi (UTC+5)

Résumé ↗
SYED AHMER SHAH

© 2026 Syed Ahmer Shah. All rights reserved.

PrivacyTermsRSS