A Next.js 15 ferry / sea-travel ticketing system for the Philippines. Reserve online, pay over the counter, board with an offline-capable QR e-ticket. Built to the conventions in CLAUDE.md.
Next.js 15 (App Router, RSC) · React 19 · TypeScript · Supabase (Postgres + Auth + RLS) · TanStack Query + Table · Tailwind v4 + shadcn/ui · Serwist (PWA) · Vercel.
pnpm install
cp .env.example .env.local # fill in Supabase + app secrets
pnpm devThe app runs without Supabase for UI work — voyage search shows demo data. Booking, auth, and manifests require a connected database.
Apply the migrations in order, then (optionally) the seed and the cron job:
# Using the Supabase SQL editor or psql against your project:
db/migrations/0001_schema.sql
db/migrations/0002_functions.sql
db/migrations/0003_rls.sql
db/migrations/0005_seed_cokaliong.sql # REAL data: Cokaliong fleet/routes/schedules/fares
db/migrations/0006_booth.sql # booth (agent) walk-in + voyage-manifest RPCs
db/cron.sql # schedules expire_holds() every minuteUse either
0004_seed.sql(tiny demo) or0005_seed_cokaliong.sql(real Cokaliong network: 16 ports, 11 vessels, 37 routes, 197 sailings, real fares) — not both (their port codes collide).0005is the realistic dataset for the booth.
Regenerate typed bindings after schema changes:
pnpm db:typesThere is no public sign-up. Create the auth user first, then grant a role in the staff
table. Roles: cashier (booth agent — sells walk-in tickets), purser (view + download
manifests), ops, admin.
-- 1) create the auth user (Supabase dashboard → Authentication → Add user), then:
insert into staff (id, role, full_name)
values ('<auth-user-uuid>', 'cashier', 'Booth Agent 1');The customer-facing pages are temporarily hidden (they redirect to the agent app; remove
the redirect() in app/(public)/layout.tsx to re-enable). The booth flow:
- Agent signs in at
/login→ lands on the dashboard. /admin/booth— filter by route/date and pick a vessel/voyage./admin/booth/[voyageId]— choose a class, enter passenger details, take the cash, type the OR number, and Confirm & issue tickets. The seat hold, booking, passengers, payment, and e-tickets are written atomically (create_walkin_booking), so a voyage can never be oversold. The per-vessel passenger list updates live.
Manifest download (/admin/manifests, CSV) remains limited to purser/ops/admin.
| Command | Description |
|---|---|
pnpm dev |
Dev server |
pnpm build |
Production build (also builds the Serwist service worker) |
pnpm typecheck |
tsc --noEmit |
pnpm lint |
ESLint |
pnpm test |
Vitest unit tests |
pnpm format |
Prettier |
- Booth walk-in (agent). Agent picks a vessel/voyage → enters passengers → takes cash →
create_walkin_bookingholds seats, creates the confirmed booking + passengers + payment + e-tickets in one transaction. Overselling is impossible. - Manifests. Per-voyage passenger list is visible to the booth agent live;
purser/ops/admincan also download CSV at/admin/manifests. - Holds expire.
expire_holds()(scheduled viadb/cron.sql) releases unpaid online holds. (Booth sales are paid immediately, so they don't expire.) - (Hidden for now) Customer self-service (search → reserve → pay OTC → offline QR
e-ticket) is built via
create_reservationbut disabled while the focus is the agent app.
- Nonce-based CSP in
middleware.ts; static headers (HSTS, X-Frame-Options, etc.) innext.config.ts— targets securityheaders.com Grade A (Referrer-Policy intentionally omitted; see CLAUDE.md §8). - RLS on every table;
service_rolekey is server-only. - All input validated with Zod at every boundary.
Push to a Vercel project, set the environment variables from .env.example, and deploy.
After deploy, scan the URL on https://securityheaders.com to confirm the grade.