Plan trips together. A real-time, visual itinerary builder with a drag-and-drop timeline, animated map routes, live budget tracking, and multi-user collaboration.
Click Try Demo on the landing page or login screen to explore with a pre-seeded account — no sign-up required.
- Editorial Visual Tone: Upgraded color system to deep petrol teal with desaturated brass/gold accents, custom layered shadows, and subtle noise texturing (
.bg-grain). - Google Sans Typography: Body and typography powered by self-hosted Google Sans via
next/font/local. - Split Auth Layout: Desktop login and signup pages feature a split visual panel with animated route arcs and rotating value-prop copy.
- Interactive Landing Sandbox: Inline live drag-and-drop itinerary demo allowing visitors to reorder activities and test live budget rollups directly on the landing page without signing in.
- GDPR Privacy Policy: Accessible accordion policy at
/privacycovering sub-processors (Supabase, Vercel, MapTiler), data retention, and user rights.
| Landing Page & Interactive Sandbox | Drag-and-Drop Timeline | Animated Map Route |
|---|---|---|
| Hero with Three.js globe, interactive DnD sandbox & FAQ accordions | Day-by-day card editor with @dnd-kit reorder animations |
MapLibre GL route drawn in real time as activities update |
The problem: In the scroll-driven feature walkthrough (ScrollRoute.tsx), pin dot circles calculated their SVG cx/cy coordinates, but when animated with GSAP scale: 1, pins jumped across the screen during scroll.
Root cause: GSAP overrides element transform state when tweening CSS transform: scale, recalculating SVG transform-origin around its internal bounding box center rather than honoring the inline CSS transform-origin.
The fix: Instead of scaling SVG elements via CSS transforms, the initial circle geometry attribute r is set directly (r={0}) and animated using GSAP's native attribute plugin (attr: { r: 8 }), resolving origin calculation conflicts across all browsers.
Building multi-user collaboration without a full CRDT implementation required a deliberate, defensible conflict resolution strategy.
The problem: Two users can simultaneously edit the same activity. Supabase Realtime broadcasts Postgres row changes to all connected clients, but there's no built-in merge — the client has to decide what to do when an incoming UPDATE conflicts with a local in-flight save.
The solution: Each activity row carries an updated_at timestamp set by a BEFORE UPDATE Postgres trigger (not the client). When an UPDATE arrives over the Realtime channel:
- Compare incoming row's
updated_atwith local row'supdated_at. - If incoming timestamp is older than local, silently discard the remote event — local version wins.
- If incoming timestamp is newer, replace local row and show a non-blocking toast: "Updated by [name]".
The presence layer (Supabase Realtime Presence) shows who's currently on the page, reducing the chance of collision in the first place.
┌─────────────────────────────────────────┐
│ Browser (Next.js 15) │
│ │
│ App Router (RSC + Client Components) │
│ ┌───────────┐ ┌──────────────────────┐│
│ │ Three.js │ │ GSAP ScrollTrigger ││
│ │ Hero Globe│ │ Landing animations ││
│ └───────────┘ └──────────────────────┘│
│ ┌───────────┐ ┌──────────────────────┐│
│ │ @dnd-kit │ │ MapLibre GL JS ││
│ │ Timeline │ │ Animated route map ││
│ └───────────┘ └──────────────────────┘│
└────────┬────────────────┬────────────────┘
│ HTTPS │ WebSocket
┌─────────────▼─────────────────▼──────────────┐
│ Supabase │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Postgres │ │ Auth │ │ Realtime │ │
│ │ (trips, │ │ (JWT + │ │ (Postgres │ │
│ │ days, │ │ PKCE) │ │ Changes │ │
│ │ activities│ │ │ │ Presence) │ │
│ └──────────┘ └──────────┘ └───────────┘ │
└───────────────────────────────────────────────┘
│
┌─────────────────────▼─────────────────────────┐
│ MapTiler │
│ (Vector tile server for MapLibre GL) │
└───────────────────────────────────────────────┘
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router, React Server Components) |
| Language | TypeScript 5 (strict mode) |
| Styling | Tailwind CSS + semantic token design system (petrol teal / brass) |
| Typography | Google Sans (self-hosted via next/font/local) |
| 3D / WebGL | Three.js via @react-three/fiber + @react-three/drei |
| Animations | GSAP 3 + ScrollTrigger (gated on prefers-reduced-motion) |
| Drag & Drop | @dnd-kit/core + @dnd-kit/sortable |
| Maps | MapLibre GL JS + MapTiler vector tiles |
| Backend | Supabase (Postgres, Auth PKCE, Realtime, Storage) |
| Deployment | Vercel (Edge middleware for auth, Speed Insights) |
| Testing | Vitest (unit) + Playwright (e2e smoke) |
| CI | GitHub Actions (lint → typecheck → unit → build → e2e) |
# 1. Clone the repo
git clone https://github.com/x0911/waypoint.git
cd waypoint
# 2. Install dependencies
npm install
# 3. Configure environment variables
cp .env.example .env.local
# Edit .env.local — fill in your real values (never commit this file)Open .env.example to see which variables are required:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
NEXT_PUBLIC_MAPTILER_KEY=
SUPABASE_SECRET_KEY=
SUPABASE_JWKS_URL=
⚠️ Real credentials are never committed..env.localis in.gitignore. Only.env.examplewith empty placeholder values is tracked.
# 4. Run database migrations (in Supabase SQL editor or CLI)
# Apply the SQL from supabase/migrations/ in order
# 5. Start the dev server
npm run dev
# → http://localhost:3000npm test # Vitest unit tests (fast, no browser)
npm run test:e2e # Playwright smoke tests (requires running dev server)
npm run test:coverage # Vitest with v8 coverage reportwaypoint/
├── app/
│ ├── (app)/ # Authenticated routes (trips, dashboard)
│ ├── (auth)/ # Login, signup pages (split layout)
│ ├── (marketing)/ # Landing page & /privacy page
│ └── t/[shareSlug]/ # Public read-only share view
├── components/
│ ├── features/
│ │ ├── budget/ # Budget rollup panel + utils
│ │ ├── collaboration/ # Presence avatars + live indicator
│ │ ├── landing/ # Three.js hero, scroll route, comparison, sandbox, FAQ
│ │ ├── map/ # MapLibre route map + layout
│ │ ├── sharing/ # Publish toggle
│ │ └── timeline/ # DnD itinerary editor
│ ├── theme/ # Theme toggle, provider
│ └── ui/ # Button, Card, Badge, Modal, Toast, Input
├── hooks/ # useRealtimeTrip, useTripPresence, useReducedMotion
├── lib/
│ ├── supabase/ # Client, server, types
│ └── validation/ # Zod schemas
├── public/fonts/ # Google Sans woff2 files
├── supabase/migrations/ # Postgres schema + RLS policies
└── __tests__/
├── unit/ # Vitest unit tests
└── e2e/ # Playwright smoke tests
- No CRDT: Last-write-wins with
updated_atcomparison. Simpler, explainable, appropriate for this domain. - SVG Attribute Animation: GSAP animates SVG geometry attributes (
r) directly to prevent transform-origin misalignment. - SSR-first: Trip editor data is fetched in Server Components and passed as props. Realtime updates are overlaid by client hooks.
- Reduced motion everywhere: Every GSAP animation (hero, scroll route, drag reorder, map line) is gated on
useReducedMotion(). Three.js auto-rotation stops. CSS transitions are handled byprefers-reduced-motionin globals.css. - Semantic color tokens only: No raw hex, no raw Tailwind palette classes anywhere in components. Every color goes through the design token layer in
tailwind.config.ts. - Public repo safety: Only
.env.example(empty values) is committed. The CI pipeline uses stub env vars for the build step.
MIT — see LICENSE