Mai-Milu (Balinese: "Let's Join") is a mobile-first carpooling web application that connects drivers and passengers on one-way routes across Bali, Indonesia.
- Overview
- Key Features
- Tech Stack
- Architecture
- Database Schema
- Getting Started
- Environment Variables
- Running Tests
- Project Structure
- Contributing
- Author
Mai-Milu digitises the informal ride-sharing culture of Bali into a structured, secure, and efficient ecosystem. It reduces daily commuting costs and traffic congestion by matching verified drivers who have empty seats with passengers heading in the same direction.
The app is built with a modern, serverless stack and is fully Progressive Web App (PWA) compliant β users can install it directly from their mobile browser with no app store required.
"Mai Milu" literally means "Let's Join Together" in Balinese, reflecting the community spirit at the heart of the platform.
- KTP-based identity verification β all users submit a national ID photo and selfie before being approved by an admin
- Multi-layer role system β
admin,verified user, andpending userroles with enforced Row Level Security (RLS) at the database level - Atomic booking transactions β PostgreSQL RPCs with row-locking prevent race conditions and double-booking
- Secure password recovery β
check_email_registered()RPC prevents email enumeration attacks on the forgot-password flow
- Post single rides or recurring weekly schedules (MonβFri, up to 8 weeks ahead) with a live ride-count preview
- OpenRouteService + Nominatim integration β calculates driving distance and auto-suggests a fuel cost price
- Automatic ride expiry β past rides are hidden from the listing via a server-side time filter and a scheduled
expire_past_rides()database function - Driver dashboard β manage all rides across four status tabs: Active, Completed, Cancelled, Expired
- Passengers can reserve a seat directly in the app (not just via WhatsApp)
- Booking status flow:
pendingβaccepted/rejected, with seat count updating atomically - Drivers see pending booking notifications on their ride cards and can approve or reject with one tap
- Passengers can cancel pending bookings; seats are restored automatically
- Search & filter β live text search by origin/destination with yellow highlight, sort by departure time or price, free-rides-only toggle
- Ride matching algorithm β scores each ride 0β100 against the user's home address (token overlap), departure urgency, price, and recurring status; top 3 matches appear above the full listing
- Passengers can leave 1β5 star ratings with optional comments after a ride
- Ratings aggregate into a
driver_ratingsview for efficient display on ride cards - Public driver profile pages at
/driver/[id]β stats (completion rate, avg rating), rating distribution bars, review list, upcoming rides, and ride history
- Installable on Android and iOS via the browser ("Add to Home Screen")
- Standalone display mode β no browser chrome, feels native
- 8 icon sizes + Apple touch icon + wide/narrow screenshots
- Client-side image compression (Canvas API, zero dependencies) before upload: selfies at 800 px/0.82 JPEG quality, KTP photos at 1400 px/0.88 quality
- Explicit camera buttons per upload field: front camera (
capture="user") for selfie, rear camera (capture="environment") for KTP
- All ride contact flows through WhatsApp deep links with pre-filled messages β no internal messaging system to maintain
- WhatsApp links are automatically formatted from the driver's registered phone number
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 16.2 (App Router, Turbopack) | SSR, routing, server actions |
| Language | TypeScript (strict mode) | Type safety throughout |
| UI | React 19 + Tailwind CSS v4 | Components and styling |
| Icons | Phosphor Icons | Consistent icon system |
| Forms | React Hook Form + Zod | Validation and form state |
| Backend | Supabase (PostgreSQL) | Database, Auth, Storage, RLS |
| Auth helper | @supabase/ssr | Cookie-based session management |
| Maps | OpenRouteService + Nominatim (OSM) | Route distance calculation |
| Resend (custom SMTP) | Transactional password reset emails | |
| Deployment | Vercel | CI/CD via GitHub |
| Unit tests | Jest + React Testing Library | Algorithm and component tests |
| E2E tests | Cypress | Full user flow tests |
mai-milu/
βββ app/ # Next.js App Router pages
β βββ (auth)/
β β βββ login/ # Email/password login with show/hide toggle
β β βββ register/ # KTP + selfie upload with compression
β β βββ forgot-password/ # Reset with email existence guard
β β βββ update-password/ # Post-reset password form
β β βββ auth/callback/ # Supabase OAuth code exchange
β βββ home/ # Ride listing (server component)
β βββ offer-ride/ # Post single or recurring rides
β βββ my-rides/ # Driver's ride history + booking management
β βββ my-bookings/ # Passenger's booking history
β βββ driver/[id]/ # Public driver profile
β βββ admin/ # KTP verification + user management
β βββ verification/ # Pending verification screen
β βββ error.tsx # Global error boundary (Bahasa Indonesia)
β βββ loading.tsx # Global loading spinner
β
βββ components/
β βββ Navbar.tsx # Sticky nav with active-link highlighting
β βββ RideList.tsx # Search, filter, matching algorithm, rating modal
β βββ MyRidesList.tsx # Tabbed ride manager with booking panel
β βββ BookingButton.tsx # Seat reservation CTA with confirmation modals
β βββ DriverProfile.tsx # Public profile with stats, ratings, ride history
β βββ StarDisplay.tsx # Inline star rating renderer
β βββ StarRatingModal.tsx # 1-5 star rating with comment submission
β βββ ZoomableImage.tsx # Admin KTP photo viewer
β
βββ lib/supabase/
β βββ server.ts # createServer() β typed server-side client
β βββ client.ts # createClient() β typed browser-side client
β
βββ types/
β βββ supabase.ts # Auto-generated DB types via Supabase CLI
β
βββ __tests__/ # Jest unit tests
βββ cypress/ # E2E test suites
βββ public/ # PWA icons, manifest, screenshots
-- Core tables
profiles -- extends auth.users with KTP, avatar, role, verification_status
rides -- driver posts with origin, destination, time, seats, price, status
-- is_recurring + recurring_days[] for repeating schedules
bookings -- passenger reservations (pending β accepted/rejected/cancelled)
ratings -- 1-5 star reviews with optional comment (one per passenger per ride)
-- View
driver_ratings -- pre-aggregated avg_score + total_ratings per driver
-- Planned (schema exists, UI not yet built)
communities -- location-based groups
trips -- community-linked rides with price_per_km| Function | Description |
|---|---|
book_ride(ride_id, passenger_id) |
Atomically inserts booking + decrements seats (row-locked) |
cancel_booking(booking_id, passenger_id) |
Cancels booking + restores seat atomically |
respond_to_booking(booking_id, driver_id, action) |
Driver approves or rejects; reject restores seat |
expire_past_rides() |
Marks stale rides as kadaluarsa (called by pg_cron hourly) |
check_email_registered(email) |
Guards password reset against non-existent emails (SECURITY DEFINER) |
- Node.js 18+
- A Supabase project
- A Vercel account (or any Node.js hosting)
- An OpenRouteService API key (free tier)
git clone https://github.com/gdsuta/mai-milu.git
cd mai-milunpm installcp .env.example .env.local
# Then fill in the values β see the section belowRun the SQL migration files in your Supabase SQL Editor in this order:
sql/01_schema.sql -- tables and enums
sql/02_rls_policies.sql -- row-level security
sql/03_storage_policies.sql -- storage bucket policies
sql/04_rpc_functions.sql -- book_ride, cancel_booking, respond_to_booking, etc.
Create two storage buckets in your Supabase dashboard:
avatarsβ set to Publicidentity_docsβ set to Private
npm run devOpen http://localhost:3000 in your browser.
Create a .env.local file in the project root:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# OpenRouteService (for route distance calculation)
NEXT_PUBLIC_ORS_API_KEY=your-ors-api-keyNever commit
.env.localto version control. It is already listed in.gitignore.
npm test # run once
npm run test:watch # watch mode
npm run test:coverage # with coverage reportThe unit test suite covers:
- Ride matching algorithm β
tokenize(),routeScore(),timeScore(),formatBytes()(25 assertions) - StarDisplay component β empty state, score display, count, size variants (7 assertions)
# Start the dev server first
npm run dev
# Then in a separate terminal:
npm run cy:open # interactive UI mode (recommended for development)
npm run cy:run # headless mode (for CI)Create a cypress.env.json file with your test account credentials (already gitignored):
{
"TEST_USER_EMAIL": "your-test-user@gmail.com",
"TEST_USER_PASSWORD": "your-password",
"TEST_ADMIN_EMAIL": "your-admin@gmail.com",
"TEST_ADMIN_PASSWORD": "your-admin-password"
}E2E suites cover: authentication flows, home page search/filter/matching, offer-ride form (single + recurring), and the Tumpangan Saya driver dashboard.
Every ride is scored 0β100 against the current user before rendering:
Route score (0β100) β word overlap between user's home_address and ride origin/destination
Time score (5β40) β departing in β€2h = 40pts, today = 30pts, this week = 10pts
Price bonus (0β10) β free rides +10pts, β€Rp10.000 +5pts
Recurring (+8) β reliable repeating drivers get a trust boost
Top 3 rides scoring above 5 appear in the "Cocok untuk Anda" section above the main listing.
BEGIN
SELECT * FROM rides WHERE id = p_ride_id FOR UPDATE; -- row lock
-- validate: ride is active, passenger β driver, no duplicate, seats > 0
INSERT INTO bookings ...
UPDATE rides SET available_seats = available_seats - 1 ...
COMMITThis guarantees seat counts never go negative even under concurrent requests.
// Uses browser Canvas API β zero npm dependencies
// Selfie: max 800px wide, JPEG 0.82 quality β ~150β250 KB
// KTP: max 1400px wide, JPEG 0.88 quality β ~300β500 KB
// (wider + higher quality so admin can read the ID text)Contributions, bug reports, and feature requests are welcome! Here are the best areas to contribute:
| Area | Description |
|---|---|
| In-app chat | Replace WhatsApp deep links with Supabase Realtime WebSocket chat |
| Payment gateway | Integrate Midtrans or Xendit for in-app transactions |
| Push notifications | Web Push API for new ride alerts |
| Automated ride matching | ML-based route similarity beyond keyword overlap |
| E2E test coverage | Expand Cypress suite to cover booking and rating flows |
- Fork the repository
- Create a feature branch:
git checkout -b feat/your-feature-name - Make your changes and write tests where applicable
- Commit using Conventional Commits:
git commit -m "feat: add push notifications" - Push and open a Pull Request
Please read through the existing issues before opening a new one.
Gede Suta Pinatih
Built as a real-world solution to the daily commuting challenges faced by people across Bali, combining modern web technology with local cultural context.
- GitHub: @gdsuta
- App: mai-milu.vercel.app
Made with β€οΈ in Bali, Indonesia
Mai Milu β Bali Carpool Community