A "mini Amazon for a single physical store." A store owner adds products in an admin dashboard and prints a QR code. A walk-in customer scans the QR, opens the store's web page, picks items, and pays online (Razorpay) or with cash. Stock is decremented atomically, every movement is logged, and the owner sees daily earnings, top sellers, and low-stock alerts.
- API: Node.js + Express + Prisma (PostgreSQL)
- Web: React (Vite) SPA — admin dashboard + public storefront in one app
- Payments: Razorpay (INR), plus cash
- Auth: JWT (store owners / staff / super admin)
- Motion: GSAP entrance/tilt animations, Lenis smooth scroll
storeapp/
├── docker-compose.yml # local Postgres (dev)
├── docker-compose.prod.yml # production stack: SPA+API in one container + DB
├── .env.prod.example # production env template
├── api/
│ ├── Dockerfile # multi-stage build (web SPA + API + Prisma)
│ ├── prisma/schema.prisma
│ ├── prisma/seed.js
│ └── src/
│ ├── server.js app.js
│ ├── lib/ # prisma, inventory (atomic stock), razorpay, utils, motion
│ ├── middleware/# auth, storeAccess, error+validate
│ └── routes/ # auth, stores, products, orders, public(checkout)
└── web/
└── src/
├── pages/ # Login, Register, Stores, Dashboard, Products,
│ # Orders, Storefront (customer), Receipt
├── components/ # AdminLayout
├── store/ # zustand: auth + cart
└── lib/ # api.js, motion.js (gsap + lenis helpers)
docker compose up -d # Postgres on :5434 (host) → :5432 (container)Host port is 5434 to avoid colliding with other local Postgres installations. The
DATABASE_URLinapi/.env.examplealready points there.
cd api
cp .env.example .env # fill in JWT_SECRET + Razorpay keys
npm install
npm run prisma:generate
npm run prisma:migrate -- --name init
npm run seed # demo store + products
npm run dev # http://localhost:4000Seeded login: owner@demo.com / password123
cd web
cp .env.example .env
npm install
npm run dev # http://localhost:5173Open http://localhost:5173 → log in → open the demo store → QR code button.
The QR points at /store/<slug> (the customer view). Scan it — or just open
the URL — to shop and check out.
The production build serves the API and the built SPA from one container behind a managed Postgres. Single image, single port.
cp .env.prod.example .env.prod # fill in real secrets
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --buildThis brings up:
db— Postgres 16 (named volumepgdata)app— multi-stage built image:- stage 1 builds the React SPA (
vite build→web/dist) - stage 2 installs API production deps +
prisma generate - runtime image runs as a non-root user, applies pending migrations on
boot (
prisma migrate deploy), then starts the Express server, which serves/api/*and falls through topublic/index.htmlfor SPA routes
- stage 1 builds the React SPA (
- Exposes the app on
APP_PORT(default80).
Required env vars (.env.prod) — see .env.prod.example.
At minimum set:
POSTGRES_PASSWORD— strong random passwordJWT_SECRET— ≥ 32 random charsCLIENT_ORIGIN/PUBLIC_WEB_URL— your public HTTPS originRAZORPAY_KEY_ID/_SECRET/_WEBHOOK_SECRET— live keys
Behind a reverse proxy: terminate TLS in your proxy (Caddy / nginx / fly.io / Render) and forward to the container's port. The CSP in api/src/app.js already allows Razorpay's checkout JS, the Razorpay frame, and Google Fonts.
Logs: Morgan in combined format on stdout. Pipe docker logs -f app
to your log shipper.
Health check: GET /api/health → { ok: true, ts: <epoch ms> }. The
Dockerfile registers it as the container HEALTHCHECK.
A render.yaml blueprint deploys the whole app as one Render
web service (Docker) that serves both /api/* and the React SPA, backed by a
Neon Postgres database.
- Neon — create a project, then copy the connection string. Keep the
?sslmode=requiresuffix (Neon refuses non-TLS connections). - Render — New → Blueprint, point it at this repo. Render reads
render.yamland creates thestoreappweb service. - Set the secret env vars (marked
sync: false) in the Render dashboard:DATABASE_URL— the Neon connection string from step 1JWT_SECRET— a long random string (≥ 32 chars)RAZORPAY_KEY_ID/_SECRET/_WEBHOOK_SECRET— live keys (optional; the cash flow works without them)
- First deploy runs
prisma migrate deployautomatically on boot. Once the service is live, setCLIENT_ORIGINandPUBLIC_WEB_URLto the service URL (https://<service>.onrender.com) and redeploy so CORS and the QR codes use the real origin.
Health check: Render polls GET /api/health. The server refuses to start in
production if DATABASE_URL, a real JWT_SECRET, or CLIENT_ORIGIN are missing.
To seed a demo store on Neon, run
DATABASE_URL=<neon-url> npm run seedfromapi/once, locally.
- Create a Razorpay account → Settings → API Keys → generate test keys.
- Put
RAZORPAY_KEY_IDandRAZORPAY_KEY_SECRETinapi/.env. - The storefront's
index.htmlalready loads Razorpay's checkout script. - Use Razorpay's test cards to simulate payments. Without keys, the Cash flow still works end-to-end.
For production, also set RAZORPAY_WEBHOOK_SECRET and point a Razorpay
webhook at your server (a webhook handler stub lives in lib/razorpay.js —
verifyWebhookSignature — wire it to a route to reconcile payments that
complete out-of-band).
- Atomic stock with a race-safe conditional UPDATE.
lib/inventory.jsusesUPDATE … WHERE stock + Δ >= 0 RETURNING stockso concurrent SALEs on the same product cannot oversell, even under READ COMMITTED. The inventory-log row writes in the same transaction. - Razorpay order is created BEFORE the DB transaction. A Razorpay failure can never leave stock decremented with no order in hand.
- Client-side cart. A QR customer has no login, so the cart lives in the browser (zustand) and only hits the DB at checkout. No abandoned-cart rows.
- Order line items snapshot
productName+unitPriceso changing a price later never rewrites historical invoices. orders → storesusesonDelete: Restrict(not cascade): you can't accidentally delete a store and wipe its sales history. Products soft-delete (isActive = false) for the same reason.- Prices are always recomputed server-side at checkout. The client's prices are never trusted.
- Razorpay verification uses HMAC signature checking, wrapped in a
try/catch so a malformed signature can't crash the route; on failed
verification the held stock is released (
RETURNlog) and the order is markedFAILED. - Dashboard analytics are computed live via SQL aggregates (today's
revenue, top sellers over 30 days, low-stock list, 14-day revenue
series). The
daily_analyticstable remains as an optional rollup cache you can populate with a cron job once volume grows. - Production CSP is set in
app.js(Razorpay + Google Fonts allowed).
POST /api/auth/register | /login GET /api/auth/me
GET /api/stores POST /api/stores
GET /api/stores/:id/qr
CRUD /api/stores/:id/products + /restock GET /api/stores/:id/categories
GET /api/stores/:id/dashboard | /orders | /analytics/daily | /inventory-logs
# Public (no auth — customer)
GET /api/public/storefront/:slug
POST /api/public/checkout
POST /api/public/payment/verify
GET /api/public/order/:orderNumber
- Migrations:
npm run prisma:deploy(prisma migrate deploy) is run automatically by the container on boot. To run by hand:docker compose -f docker-compose.prod.yml exec app npx prisma migrate deploy. - Database backup:
docker compose -f docker-compose.prod.yml exec db pg_dump -U $POSTGRES_USER $POSTGRES_DB > backup.sql. - The web
dist/bundle is split (react / charts / motion / vendor) so charts code (recharts) only loads on the admin Dashboard.