<<<<<<< HEAD
Ask what matters. Ask it once. Make it feel like the end of the trip.
VAR is a working Next.js + Supabase + OpenAI prototype built for the 2026 Wharton AI & Analytics Hack-AI-Thon (presented by Expedia). Instead of forcing travelers through a static post-trip review form, VAR turns the review into the closing beat of the trip: a cinematic JFK → destination → property flight, two AI-generated questions grounded in that property's own review history, and a one-click AI-synthesized review (title + body + per-category ratings) at the end.
The core bet: one gap-targeted question + one verification question generated from a property-specific review corpus outperforms a generic multi-field form on every dimension that matters — submission rate, data freshness, and structured signal per review.
- What VAR Actually Does
- The User Flow
- Architecture at a Glance
- Tech Stack
- Quick Start
- Environment Variables
- Data Layer — Supabase Schema
- The AI Pipeline
- The Animation State Machine
- Project Layout
- API Routes
- Design System
- How VAR Maps to the Hackathon Brief
- Contributing / Extending
- Troubleshooting
Traditional post-trip review forms suffer from five pain points (per the hackathon brief):
- Some topics are over-covered while important details are missing.
- Reviews go stale — amenities, policies, and renovations change.
- Updates come reactively from suppliers, not proactively from reviewers.
- Static prompts ask travelers about things the platform already knows ("how great was the pool?").
- Long forms crush submission rates.
VAR addresses all five with one idea: read the existing review corpus first, compute what's missing and what's stale, then ask one targeted question and one structured verification question. The output is a complete AI-synthesized review with inferred per-category ratings, written back into the database and immediately usable for discovery and ranking.
Everything is wrapped in a continuous globe-to-map-to-globe animation so the review never feels like homework.
┌────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐ ┌──────────────┐ ┌────────┐ ┌─────────┐
│ Setup │ ──▶ │ Globe │ ──▶ │ Crossfade │ ──▶ │ Map │ ──▶ │ Crossfade │ ──▶ │ Globe │ ──▶ │ Summary │
│ Picker │ │ Outbound │ │ globe → map │ │ Review │ │ map → globe │ │ Return │ │ + Share │
└────────┘ └──────────────┘ └──────────────┘ └────────┘ └──────────────┘ └────────┘ └─────────┘
pick JFK → DEST opacity tween 2 Qs opacity tween DEST → AI review
property + review card per stop JFK + clipboard
- Setup (yellow landing screen). The user picks one of 13 real Supabase properties from a dropdown. Every trip always departs JFK.
- Globe outbound. Camera flies from JFK to the destination airport along a yellow great-circle arc with animated dash. A review card slides in on arrival with the first AI-generated question.
- Crossfade to map. Globe and Mapbox dark-v11 map are mounted simultaneously and tweened via Framer Motion opacity so there's no hard cut.
- Map review. Camera lands on the property marker. User answers the gap question, then the verification question (voice or text).
- Crossfade back to globe. Map fades out, globe fades in, already centered on the destination.
- Globe return. Reverse arc to JFK. While the plane is in the air,
submitAIReviews()POSTs every answered stop to/api/reviews/submitin parallel. - Summary card. The AI-synthesized review title, body, and inferred ratings appear per stop, alongside the raw Q&A. Share-to-Instagram-Story / SMS / clipboard buttons close the loop.
The whole thing fits in a 3–4 minute real-time demo — no speed-ups, no cuts — exactly as the hackathon rules require.
┌──────────────────────────── BROWSER ────────────────────────────┐
│ │
│ TripSetupPanel ──▶ ExperienceFlow (phase state machine) │
│ │ │
│ ┌──────────────────────┼──────────────────────┐ │
│ ▼ ▼ ▼ │
│ GlobeExperience DestinationMap TripSummaryCard │
│ (react-globe.gl) (Mapbox GL JS) (recap + share) │
│ │
└──────────────────────────────┬──────────────────────────────────┘
│ fetch
▼
┌────────────────────────── NEXT.JS API ──────────────────────────┐
│ │
│ GET /api/properties → list (DB join lookup) │
│ GET /api/properties/[id]/insights → gap/freshness vector │
│ GET /api/properties/[id]/questions→ gap + verification Qs │
│ GET /api/properties/[id]/reviews → recent reviews │
│ POST /api/reviews/submit → synthesize + persist │
│ POST /api/reviews/analyze-typing → typing cadence signal │
│ │
└────────────────────┬────────────────────────┬───────────────────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Supabase │ │ OpenAI API │
│ (Postgres) │ │ gpt-4o-mini │
└────────────────┘ └────────────────┘
description insights.ts
properties_aggregated questions.ts
property_reviews review-synthesis.ts
Key principle: the pipeline is stateless per request. One API key, one DB, one model — no custom infra, no fine-tuning, no over-engineering.
| Layer | Tech |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) · TypeScript |
| Styling | Tailwind CSS with custom brand.* tokens · Framer Motion |
| 3D / Maps | react-globe.gl (Three.js, blue-marble Earth) · Mapbox GL JS |
| State | React hooks · phase state machine (lib/experience.ts) |
| Validation | Zod v4 end-to-end (TripConfig, review submission payloads) |
| Backend | Next.js Route Handlers (App Router) |
| Database | Supabase (Postgres) — 3 tables, typed accessors |
| LLM | OpenAI gpt-4o-mini — insights, questions, review synthesis |
| Recency | Half-life weighted decay (1-year half-life) in lib/backend/halflife.ts |
# 1. Install
npm install
# 2. Environment
cp .env.example .env.local
# Edit .env.local (see Environment Variables section below)
# 3. Run
npm run dev
# → open http://localhost:3000The dev server runs under Turbopack. The home page is the yellow Setup panel; pick any property from the dropdown and click Start trip →.
Other scripts:
npm run build # Production build
npm start # Serve the production build
npm run lint # ESLint (Next config)Create .env.local in voyage-app/ with:
# ── Public (shipped to the client) ──
NEXT_PUBLIC_MAPBOX_TOKEN=pk.eyJ1... # required for the city map
# ── Server only ──
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_SERVICE_KEY=<service-role-key> # used by the API route handlers only
OPENAI_API_KEY=sk-... # gpt-4o-miniFiles that read these:
| Variable | File |
|---|---|
NEXT_PUBLIC_MAPBOX_TOKEN |
components/DestinationMapExperience.tsx |
SUPABASE_URL / SUPABASE_SERVICE_KEY |
lib/backend/supabase.ts |
OPENAI_API_KEY |
lib/backend/openai.ts |
🔒 Never commit your
.env.local. The OpenAI and Supabase service keys grant full read/write access. GitHub will auto-disable any OpenAI key that appears in a public repo.
VAR connects to a Supabase Postgres instance with three tables that mirror the hackathon challenge dataset:
| Table | Purpose |
|---|---|
description |
One row per property: eg_property_id, city, province, country, star rating, Expedia guest rating |
properties_aggregated |
Rollup metrics per property (avg ratings, topic coverage) |
property_reviews |
Every individual review with text, timestamp, category ratings, synthesized title (from VAR) |
Typed data access lives in lib/backend/database.ts:
getAllProperties() // drives the setup dropdown
getPropertyInsights(id) // topic coverage + staleness
getRecentReviews(id, { limit }) // half-life-weighted
insertReview(id, payload) // writes AI-synthesized reviewBecause the description table has no lat/lng columns, VAR ships a static in-code lookup at lib/propertyLocations.ts keyed on eg_property_id. It contains 13 real properties + their nearest major airport (IATA, name, coordinates). The /api/properties route INNER JOINS the live DB rows against this lookup so the dropdown only ever shows properties that have both a DB row and a known location.
| City | Country | Airport |
|---|---|---|
| Pompei | Italy | NAP |
| Rome | Italy | FCO |
| Monterey, CA | USA | MRY |
| Frisco, TX | USA | DFW |
| Broomfield, CO | USA | DEN |
| Bangkok | Thailand | BKK |
| Mbombela | South Africa | MQP |
| Freudenstadt | Germany | STR |
| San Isidro de El General | Costa Rica | SJO |
| Bochum | Germany | DUS |
| Bell Gardens, CA | USA | LAX |
| New Smyrna Beach, FL | USA | DAB |
| Ocala, FL | USA | MCO |
VAR uses OpenAI gpt-4o-mini in three strict-JSON stages. Each stage is a single request with a system prompt that locks the response format.
Input: recent reviews for a property, weighted by lib/backend/halflife.ts using a 1-year half-life.
Output: a structured coverage vector — which topics are over-covered, which are missing, which are stale.
This is how VAR formalizes "missing" and "stale" as computable properties rather than gut calls.
Input: the Stage 1 insight vector. Output:
The verification type rotates deterministically across reviewers so the property's review stream collects yes/no signal, multi-choice signal, and free-text signal over time — not all free-text. The reasoning field is surfaced to the user as the review-card subtitle and makes the "why this question" requirement from the brief trivially auditable.
Input: the two user answers + the two questions. Output:
{
"review_title": "Charming Trastevere pocket, thin walls",
"review_body": "...",
"inferred_ratings": {
"cleanliness": 4,
"staff": 5,
"amenities": 3,
"location": 5,
"value": 4
}
}A full Expedia-style review — headline, prose, per-category 1–5 ratings — built from two short answers. The output is persisted to property_reviews via insertReview() and returned to the client so the summary card can show the AI-generated headline the user effectively authored.
lib/backend/sentiment.ts— sentiment analysis on the answer body.lib/backend/translation.ts— language detection + passthrough translation.lib/backend/typing-analysis.ts— cadence analysis (exposed viaPOST /api/reviews/analyze-typing), used to gauge confidence and effort.
The entire UX is driven by a 7-state machine defined in lib/experience.ts:
type ExperiencePhase =
| "setup"
| "globe-outbound"
| "transition-to-map"
| "map-review"
| "transition-to-globe"
| "globe-return"
| "summary";components/ExperienceFlow.tsx is the orchestrator. It mounts the globe and the map simultaneously during transition phases and opacity-tweens between them using Framer Motion. The tween duration is centralized in lib/animation.ts as GLOBE_TO_MAP_CROSSFADE_MS.
Globe modes. components/GlobeExperience.tsx accepts a mode: "outbound" | "return" prop:
- Outbound: intro pause → departure camera beat → arc flight → destination emphasis → stop reveal → review card.
- Return: opens already centered on the destination (no intro), plays the reverse arc (start/end swapped so the dash direction reverses visually), holds briefly at origin, then calls
onReturnComplete().
Parallel review submission. When handleItineraryComplete() fires, the return flight starts and submitAIReviews() POSTs every stop to /api/reviews/submit in parallel via Promise.allSettled. By the time the user lands back at JFK, the synthesized title + body are already back from the server. A "Saving reviews to database…" indicator appears in the bottom-right if the network is slow.
voyage-app/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # mounts ExperienceFlow + CinematicOverlay
│ └── api/
│ ├── properties/
│ │ ├── route.ts # GET list
│ │ └── [id]/
│ │ ├── insights/route.ts # GET coverage vector
│ │ ├── questions/route.ts # GET gap + verification Qs
│ │ └── reviews/route.ts # GET recent reviews
│ └── reviews/
│ ├── submit/route.ts # POST synthesize + persist
│ └── analyze-typing/route.ts # POST typing cadence
│
├── components/
│ ├── ExperienceFlow.tsx # phase state machine orchestrator
│ ├── TripSetupPanel.tsx # yellow landing screen, dropdown picker
│ ├── GlobeExperience.tsx # react-globe.gl; outbound + return modes
│ ├── DestinationMapExperience.tsx # Mapbox GL JS; per-stop camera beats
│ ├── ReviewCard.tsx # gap + verification question UI
│ ├── StopReviewCard.tsx # per-stop review panel
│ ├── TripSummaryCard.tsx # AI review display + share menu
│ ├── RecapHighlightsPanel.tsx # local recap section
│ ├── CinematicOverlay.tsx # vignette + grain (z-5)
│ └── VarLogo.tsx # inline SVG wordmark fallback
│
├── lib/
│ ├── experience.ts # ExperiencePhase enum
│ ├── animation.ts # camera timings, crossfade duration
│ ├── propertyLocations.ts # static lat/lng + airport lookup (13 props)
│ ├── presets.ts # buildTripFromProperty, fallback config
│ ├── tripSchema.ts # Zod TripConfig schema
│ ├── tripImportExport.ts # JSON import / download
│ ├── tripAdapters.ts # TripConfig ↔ Itinerary / globe Trip
│ ├── itineraryData.ts # client-side itinerary types
│ ├── tripData.ts # client-side globe Trip types
│ ├── map.ts # Mapbox style constants, route colors
│ ├── recap.ts # local recap narrative builder
│ ├── ids.ts # stop ID helpers
│ └── backend/
│ ├── supabase.ts # service-role client
│ ├── openai.ts # OpenAI client
│ ├── database.ts # typed accessors
│ ├── insights.ts # Stage 1: coverage/staleness vector
│ ├── questions.ts # Stage 2: gap + verification Qs
│ ├── review-synthesis.ts # Stage 3: title + body + ratings
│ ├── halflife.ts # recency decay weighting
│ ├── sentiment.ts
│ ├── translation.ts
│ ├── typing-analysis.ts
│ └── types.ts
│
├── public/
│ ├── logo.png # navy VAR wordmark (transparent)
│ └── var-logo.svg # fallback wordmark
│
├── styles/
│ └── globals.css # Tailwind layers + dark body default
│
├── tailwind.config.ts # brand.* color tokens
├── next.config.ts
├── tsconfig.json
└── package.json
All routes are Next.js App Router Route Handlers. Service-role keys never leave the server.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/properties |
List properties joined with propertyLocations.ts |
| GET | /api/properties/[id]/insights |
Stage 1 coverage + staleness vector |
| GET | /api/properties/[id]/questions |
Stage 2 gap + verification questions (with reasoning) |
| GET | /api/properties/[id]/reviews |
Recent reviews (half-life weighted) |
| POST | /api/reviews/submit |
Stage 3 synthesis + persist to property_reviews |
| POST | /api/reviews/analyze-typing |
Typing cadence analysis |
{
"eg_property_id": "abc...",
"gap_question": "...",
"gap_answer": "...",
"verification_question": "...",
"verification_answer": "...",
"verification_type": "yes_no" | "multiple_choice" | "short_answer"
}{
"ok": true,
"synthesized": {
"review_title": "...",
"review_body": "...",
"inferred_ratings": { "cleanliness": 4, "staff": 5, ... }
}
}VAR's palette is codified as Tailwind brand.* tokens in tailwind.config.ts so there are no inline hex codes scattered across components.
| Token | Value | Used for |
|---|---|---|
brand-yellow |
#FBCC33 |
Expedia primary yellow — landing bg, accents |
brand-yellow-light |
#FDD85D |
Hover states on yellow buttons |
brand-yellow-dark |
#E5B800 |
Pressed / border variants |
brand-yellow-soft |
#FFF3C2 |
Soft highlight panels on yellow |
brand-navy |
#1A1F3A |
Matches the logo.png navy |
brand-navy-light |
#2A2F55 |
Hover on the Start Trip button |
brand-navy-dark |
#0F1223 |
Pressed variants |
brand-dark |
#191A1F |
Body default (globe + map phases) |
brand-dark-card |
#222328 |
Summary card surface |
brand-dark-surface |
#2A2B31 |
Inner surfaces on dark |
Phase-dependent palettes.
- Setup phase (landing): yellow background, navy content, white translucent card. The navy logo reads at maximum contrast here.
- Globe / map phases: dark
#191A1Fbase — Mapbox dark-v11 and the blue-marble Earth texture look wrong on anything lighter. Start Trip is the deliberate "takeoff moment" from yellow to dark. - Summary phase: dark card with yellow accents, same contrast as the review card on the map.
Every substantive slide in the kickoff deck is addressed by a concrete subsystem. A full written mapping is in VAR_Hackathon_Report.pdf (at the repo root). Highlights:
| Kickoff requirement | VAR implementation |
|---|---|
| Identify missing / outdated property information | Stage 1 coverage vector + half-life decay (insights.ts, halflife.ts) |
| Generate 1–2 personalized, targeted questions | Stage 2 gap + verification with deterministic type rotation (questions.ts) |
| Support voice and text input | ReviewCard.tsx wires the Web Speech API |
| Enrich the property knowledge base | Stage 3 synthesized review written via insertReview() |
| Clear reasoning — why this, why now | Stage 2 returns an explicit reasoning field, shown in UI |
| Low-friction interaction | Exactly 2 questions per stop, one dropdown landing, no forms |
| "Complexity alone does not win" | 2 API routes, 1 DB, 1 model, flat architecture |
| Criterion | VAR's answer |
|---|---|
| Innovation & Creativity | Review reframed as the closing beat of a travel animation |
| Technical Implementation | Real Supabase + real OpenAI + Zod end-to-end, half-life decay, verified IDs |
| UX & Design | Yellow Expedia landing, voice/text, 2 Qs, cinematic crossfades |
| Opportunity & Impact | Schema already production-shaped; swap-in ready |
| Feasibility & Scalability | 1 Postgres + 1 key + 1 Next.js app; stateless per request; cheap model |
| Presentation | Entire flow fits a 3–4 min real-time demo with no cuts |
Add a new property. Append a row to lib/propertyLocations.ts keyed on the eg_property_id from Supabase, with city/lat/lng and the nearest airport (IATA + name + coordinates). The /api/properties inner-join lookup will pick it up automatically; it will appear in the dropdown on the next page load.
Tune the flight pacing. Edit SCENE_TIMINGS and CAMERA in lib/animation.ts. Both outbound and return flights read from the same timing table. GLOBE_TO_MAP_CROSSFADE_MS controls the duration of both phase transitions.
Change the gap-detection prompt. Edit the system prompt in lib/backend/insights.ts. Keep the response JSON schema — downstream stages depend on the field names.
Add a new verification type. Extend the VerificationType union in lib/backend/types.ts, add a case to the rotation selector, and update ReviewCard.tsx to render the new input.
Swap the model. Change the model name in lib/backend/openai.ts or pass it explicitly per call. All three stages use strict JSON mode, so any model that supports response_format: { type: "json_object" } will work.
Logo shows up with a white box around it.
The transparent PNG is getting flattened by Next's image optimizer. TripSetupPanel.tsx uses a plain <img> tag (not next/image) to serve /logo.png byte-for-byte, bypassing the optimizer. If you re-introduce next/image for this asset, the white matte will come back.
Mapbox shows a blank gray canvas.
NEXT_PUBLIC_MAPBOX_TOKEN is missing or invalid. Get a public token at https://account.mapbox.com/access-tokens/ and add it to .env.local.
Dropdown is empty / says "Loading destinations…" forever.
Your Supabase service key or URL is wrong, or the network request to /api/properties is failing. Check the server logs in the terminal running npm run dev. The component falls back to FALLBACK_PROPERTIES from lib/presets.ts if the fetch fails, so if you see dummy entries, it's silently falling back.
OpenAI calls 401 / 429.
Key is invalid, rate-limited, or out of credits. All three AI stages share the same key from OPENAI_API_KEY. gpt-4o-mini is intentionally chosen for cost — each full trip (3 LLM calls per stop) costs fractions of a cent.
Globe shows a black void.
react-globe.gl loads Earth textures from unpkg.com/three-globe. If unpkg is rate-limiting you or you're offline, swap the URLs in components/GlobeExperience.tsx for locally-hosted textures in public/.
npm run build fails on the plain <img> tag.
There's an eslint-disable-next-line @next/next/no-img-element above the tag in TripSetupPanel.tsx. Don't remove it — it's there specifically to bypass the optimizer for the transparent logo.
MIT License · Built for the 2026 Wharton AI & Analytics Hack-AI-Thon, presented by Expedia.
Cinematic globe → Mapbox city walk → in-memory reviews → deterministic local recap (no LLM). Milestone 3 adds a setup / preflight panel (edit trip, import/export JSON, zod validation), richer globe + map camera beats, visual overlays, and replay / reset-to-Rome-demo.
npm install
cp .env.example .env.local
# Set NEXT_PUBLIC_MAPBOX_TOKEN for the map
npm run dev| Area | Location |
|---|---|
Default Rome + Paris/Tokyo presets (coordinates, questions, optional per-stop camera) |
lib/presets.ts |
| Zod trip schema (exactly 3 questions per stop, unique ids, lat/lng bounds) | lib/tripSchema.ts |
| JSON import/export | lib/tripImportExport.ts |
| Recap templates & “future you” tips (third answers) | lib/recap.ts |
| Globe phase timings & POV beats | lib/animation.ts, components/GlobeExperience.tsx |
| Map route glow, default flyTo, pause before review card | lib/map.ts, lib/animation.ts (PRE_STOP_REVIEW_DELAY_MS), DestinationMapExperience.tsx |
| Setup UI | components/TripSetupPanel.tsx |
| Recap UI | components/RecapHighlightsPanel.tsx, TripSummaryCard.tsx |
| Vignette / grain overlay | components/CinematicOverlay.tsx |
Matches TripConfig in lib/tripSchema.ts: flight.origin/destination (name, lat, lng) and stops[] with id, name, lat, lng, type, questions (length 3), optional camera (zoom, pitch, bearing, durationMs). Use Export trip JSON from setup to generate a file you can re-import.
Next.js (App Router), TypeScript, Tailwind, react-globe.gl, Mapbox GL, Framer Motion, Zod.
origin2/main
{ "gap_question": "...", // targets a missing / stale topic "verification_question": "...", // short, structured confirmation "verification_type": "yes_no" | "multiple_choice" | "short_answer", "reasoning": "why this gap, why now, for this property" }