MajiFlow is a marketplace for ordering drinking water from independent refill stations. Customers browse stations near them, build a cart with a per-station delivery fee, check out, and track the delivery; stations get a storefront and a dashboard to take orders, manage their catalogue and see their numbers.
Customers sign in with their phone number (a one-time code by SMS) or, for existing accounts, email and password. The header keeps the current order type visible everywhere: delivery to a saved or pinned address, pickup from the station, or sending water to someone else.
Business identity and onboarding data is prepared for hosted Neon PostgreSQL. Orders remain in the existing JSON store until a separate order migration is planned.
- Phone-first sign-in with a 6-digit SMS code, plus the original email and password for existing accounts.
- Browse stations near you on a map, search by city, estate or name, and see which stations deliver to you (with ETA and any nearby discount).
- Station pages with products, prices, options (empty-can swap or new can with a refundable deposit), delivery fee, opening hours and live open/closed status.
- One station per cart, with that station's delivery fee and free-delivery minimum, plus delivery or pickup.
- Checkout with M-Pesa or card, a saved profile and delivery addresses, or a pinned location for a rider; optional live-location sharing.
- Order tracking from placed through accepted, out for delivery and delivered.
- A local cart and order context that survive reloads, and an offline banner instead of a failed page.
- A dashboard with an overview, orders, products, customers, analytics, promotions, staff, billing and settings.
- Analytics computed from the station's own stored orders: revenue, repeat rate, best sellers and stock alerts.
- A public storefront page per station.
- A control room listing every station, its plan, monthly fee and catalogue size.
- Next.js 16 (App Router, Turbopack) and React 19
- TypeScript in strict mode
- Tailwind CSS v4 with design tokens in
src/app/globals.css - framer-motion for the hero and a few transitions
- Leaflet for the live order map (loaded on demand)
- Vitest for tests
pnpm install
pnpm devOpen http://localhost:3000. If pnpm is not on PATH,
use corepack pnpm. Do not commit package-lock.json (this repo uses pnpm).
| Command | What it does |
|---|---|
pnpm dev |
Start the dev server |
pnpm build |
Production build |
pnpm start |
Run the production build |
pnpm lint |
ESLint over src |
pnpm test |
Run the Vitest suite once |
pnpm test:watch |
Run Vitest in watch mode |
Release order (see RELEASE.md): typecheck → lint → test → build.
npx tsc --noEmit
npx eslint src
npx vitest run
npx next buildAll variables are optional; without them the app runs and logs to stderr.
| Variable | Purpose |
|---|---|
MF_DATA_DIR |
Development and tests only: keep orders and payments in JSON files in this directory instead of Postgres. Never set it in production. |
MF_DB_POOL_MAX |
Database connections per server instance (default 5). Keep it small on serverless and use Neon's pooled (-pooler) host. |
CRON_SECRET |
Authorizes the daily retention sweep at /api/cron/retention (Vercel Cron sends it automatically). |
MF_ALERT_WEBHOOK_URL |
Where order, payment, notification, store and geocode failures are POSTed. Without it, failures go to stderr only. |
MF_NOTIFY_WEBHOOK_URL |
Provider that sends order-status messages to customers. |
MF_ADMIN_KEY |
Signs admins in at /admin and authorizes admin APIs (refunds, linking an approved owner to a station via POST /api/admin/applications/[id]/approve). Without it, admin access is disabled. |
NEXT_PUBLIC_SITE_URL |
Absolute site URL used for metadata. Defaults to http://localhost:3000. |
DATABASE_URL |
Neon PostgreSQL connection string. Keep it in .env.local or your deployment provider's secret manager. |
MF_OTP_SECRET |
HMAC key for hashing phone sign-in codes at rest. Set a random value in every deployed environment. |
MF_SMS_PROVIDER |
dev (log the code), africastalking or twilio. Inferred from the credentials below when unset. |
MF_SMS_DEV_ECHO |
Set to 0 to stop logging dev codes to the server console. |
MF_SMS_WEBHOOK_URL |
Generic SMS provider: receives { to, message }. |
MF_SMS_AT_USERNAME, MF_SMS_AT_API_KEY, MF_SMS_SENDER |
Africa's Talking credentials and sender id. |
MF_SMS_TWILIO_ACCOUNT_SID, MF_SMS_TWILIO_AUTH_TOKEN, MF_SMS_TWILIO_FROM |
Twilio credentials and sender. |
Phone sign-in is on by default. Without SMS credentials the code is written to
the server console and returned as devCode in development, so the flow stays
testable. Set MF_OTP_SECRET (and a provider, directly or through credentials)
before going live. Codes last 10 minutes, allow five attempts, and can be
re-requested after 60 seconds; only one code is live per number and purpose.
- Create a Neon project and database outside this repository.
- Copy the connection string from Neon into a local
.env.localfile asDATABASE_URL. Do not use aNEXT_PUBLIC_prefix and do not commit the file or its value. - Generate migrations after schema changes with
pnpm db:generate. - Apply committed migrations to the configured database with
pnpm db:migrate.0007adds phone sign-in and saved order preferences;0008adds the notify-me list for stations that do not deliver to an area yet.
The Drizzle CLI loads DATABASE_URL from .env.local when it is not already
set in the environment. The database client is server-only. Neon credentials
must be configured as server-side environment variables in deployment and
should be rotated through Neon and the hosting provider if exposed.
Operations access is not available through either public registration endpoint. An authorized database operator provisions an operations account by first creating a normal account, then running this targeted update against the intended account:
UPDATE users SET role = 'operations' WHERE email = '<verified-operations-email>';The update must be performed through the approved database access process; never expose database
credentials to the browser or accept a role in a user-controlled request. The
0002_add_operations_role migration must be applied before provisioning.
/admin is double-gated: the layout requires an mf_admin cookie (exchanged
from MF_ADMIN_KEY), and every admin page additionally requires a signed-in
operations account. The admin APIs accept the raw MF_ADMIN_KEY as a bearer
token instead, so server-to-server callers need neither cookie.
To enable the platform admin on a local machine:
- Set
MF_ADMIN_KEYin.env.local(generate one withopenssl rand -hex 32). - Provision an
operationsaccount using the procedure above. - Visit
/admin, enter the key, then sign in with the operations account.
The two cookies are established by:
POST /api/admin/session # { "key": "<MF_ADMIN_KEY>" } -> sets mf_admin
POST /api/auth/login # { "email": "...", "password": "..." } -> sets mf_session
Both are required for the /admin pages. Without a valid MF_ADMIN_KEY, all
admin sessions fail closed and /admin stays behind the key prompt.
| Page | Purpose |
|---|---|
/admin |
KPIs for the last 7, 30 or 90 days (orders, delivered value, delivery rate, new customers), daily charts, top stations, payment split, order outcomes, cash still to collect on pay-on-delivery orders, and alerts for pending applications and published stations customers cannot see. |
/admin/stations |
Every station in any state (live, published but not visible, ready, setting up, suspended), with why it is not visible, plan, products, 30-day orders and value. Suspend (with a reason) or reinstate; a suspended station leaves browse and checkout at once. |
/admin/users |
Search accounts by name, email or phone and filter by role. Each account shows its orders and stations, and can be disabled (signs it out everywhere and blocks sign-in) or restored. Operations accounts cannot be disabled from the console. |
/admin/orders, /admin/applications |
Live order feed and business application review. |
CSV exports: GET /api/admin/export?kind=stations|users|orders&days=30. Writes
and exports need the admin key session and an operations account.
Stations, products, customers, addresses, preferences, phone codes, orders
and payments live in Neon PostgreSQL. Orders and payments are stored as JSON
documents beside indexed columns (src/lib/server/doc-store.ts); every change
to one record is a row-locked transaction, and orders are validated
server-side (totals are recomputed, never trusted from the client).
Without DATABASE_URL, development falls back to JSON files in .data/;
production refuses to start the order store without a database. Unit tests set
MF_DATA_DIR to a temporary directory. The Postgres backend has its own test,
src/lib/server/doc-store.postgres.test.ts, which runs when
MF_TEST_DATABASE_URL points at a throwaway migrated database.
src/
app/ routes (marketing, account, business shell, admin, api)
components/ UI by area: home, shared, order, business, account, admin, ui
lib/
data/ seeded stations, plans and FAQs
server/ database, order and payment stores, auth, payments
analytics.ts station analytics (pure functions)
business-hours.ts opening hours and availability
pricing.ts cart totals
orders.ts client over the orders API
The app keeps all state in Postgres, so it runs on Vercel or any Node host:
- Set
DATABASE_URL(Neon pooled host),NEXT_PUBLIC_SITE_URL,MF_ADMIN_KEY,MF_OTP_SECRET,CRON_SECRETand an SMS provider. - Apply migrations before each release:
pnpm db:migrate. - Set
MF_ALERT_WEBHOOK_URL(andMF_NOTIFY_WEBHOOK_URLif you have a messaging provider) so failures and status changes are reported. vercel.jsonschedules the daily retention sweep. On another host, callGET /api/cron/retentiondaily withAuthorization: Bearer $CRON_SECRET.- Build and run:
pnpm install --frozen-lockfile
pnpm build
pnpm startRolling back is a redeploy of the previous build; keep the data volume.
MajiFlow ships a web app manifest (src/app/manifest.ts) and a service worker
(public/sw.js) so it can be installed to a phone home screen and keeps a light
offline fallback. The worker is registered in production builds only.
- Path alias
@/*→./src/*. - No em-dashes; JSX uses
'for apostrophes. - The order context (delivery/pickup, address, last station) is this device's
mf:orderSession; signed-in customers also sync it to/api/account/preferencesso a new device resumes where they left off. - Tests use
vi.useFakeTimers({ toFake: ["Date"] })withvi.setSystemTime, because the order guard reads the wall clock. AGENTS.mdis generated bynext dev; do not hand-edit it.