Immersive 360° place-based learning.
Live at wanderlust.witus.online. Part of the WitUS ecosystem: cross-linked with CentenarianOS Academy (Wanderlust preview blocks embed inside Academy lessons) and Fly.WitUS (BVC drone footage pushes into Wanderlust via a shared Cloudinary tenant). Operated by B4C LLC / AwesomeWebStore.com. Built by Brand Anthony McDonald.
Every Wanderlust course is anchored to a real location captured in 360° photo, 360° video, and drone footage. One person with a camera, a drone, and a laptop can publish a full multi-media course. Learners stand inside the place — a museum gallery, a trail, a workshop, a reef — then read, watch, and answer quizzes built on top of the footage. No AI-generated content, no stock imagery, no fabricated voices.
The course library is fed by BAM's field-content capture trips. The flagship is MUCHO Museo del Chocolate in Mexico City; the 2026-06 West Africa trip feeds a Ghana course (see ../../witus/plans/travel/ for trip context).
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript, strict |
| Styling | Tailwind CSS v4 + @tailwindcss/typography |
| Database | Neon Postgres via Drizzle ORM (neon-serverless driver) |
| Auth | Better Auth — password with self-service reset at /forgot-password, magic link, email OTP, 2FA, and "Sign in with WitUS" OIDC. No passkey sign-in: the plugin is registered but no enrollment UI exists, so the button was removed (docs/INFRA.md). |
| Media | Cloudinary (primary; R2 fallback documented in docs/INFRA.md) |
| 360° viewer | Photo Sphere Viewer (core + markers + video + virtual-tour + equirectangular-video-adapter) |
| Markdown | marked + sanitize-html |
| Payments | Stripe Checkout + webhook |
| Mailgun | |
| PDFs | pdf-lib (completion certificates) |
| i18n | Native Next.js [lang] routing — EN + ES |
| Offline | Serwist service worker + IndexedDB progress outbox |
| Testing | Vitest (pure logic) + Playwright + axe-core + pa11y-ci |
| Error monitoring | Better Stack via the @sentry/nextjs SDK. Inert until a DSN is set |
| Hosting | Vercel |
- Media library: signed Cloudinary uploads for image, audio, standard video, 360° photo, 360° video, drone video, transcripts, support attachments. Tags, soft + hard delete, reference blocker.
- Destinations + scenes: real places, 360° vantage points, click-to-place hotspots and scene links for navigable tours. Bulk scene creation turns a tour's assigned 360° files into one scene each in a single step. A list-based Connections page edits the whole scene graph from one keyboard-accessible screen — reverse links by default, plus orphan / dead-end / unreachable badges.
- Upload in context: the uploader is embedded on the New scene page (narrowed to the 360 kinds) and in the destination media library. Files uploaded inside a destination are assigned to it automatically, the New scene form claims the file it just uploaded once Cloudinary reports ready, and the destination page offers to run
bulkCreateScenesover any new 360 files. A first upload to a destination with no scenes cannot be assigned by design (no_scene_at_destination); the UI reports it as a next step rather than an error. - Media pickers: every media list (destination library, hero/profile/pin/arrow pickers, panorama and poster pickers, the main library) is collapsed or paginated through one shared
media-picker-chromehook, with a "this tour" scope where a destination is in context. - Tour map: per-destination floor plan (uploaded image or built-in grid/blank template) with normalized scene pins; visitors get a you-are-here mini-map (PSV MapPlugin) that jumps scenes on tap. Deterministic auto-arrange lays scenes out from the connection graph. Pins are placed by click, by percent field, or by arrow buttons/arrow keys with optimistic movement and a debounced write; placement never touches
scene_links. - Ambient audio per scene:
scenes.audio_media_idgives each scene a looping sound bed that crossfades as the visitor walks (src/components/virtual-tour/use-ambient-audio.ts). Sound is off until the visitor presses the in-viewer toggle, which is both the user gesture browsers require for playback and the control WCAG 1.4.2 requires for audio running past three seconds. The toggle only renders when the tour has sound. Distinct from a hotspot'saudioUrl, which is a clip the visitor triggers by clicking a marker. - Per-link arrival heading:
scene_links.arrival_yaw/pitchset which way the camera faces on arrival for each route in, so the same room entered from two doors leaves you facing two different ways. Falls back to the scene's start view when unset. - Hunts (GPS + game mechanics): a hunt is an ordered path through a destination's scenes where each stop opens freely, on a typed answer, on collected keys, or on the visitor physically arriving (
scenes.geo_lat/geo_lng, haversine, radius widened by the device's own reported accuracy). Keys are one primitive covering easter eggs, maze doors and clue chains (grants_key/requires_keyson hotspots, links and stops). Authoring reaches only hunt stops today: the viewer honours keys on hotspots and links and the publish checks already expect them, butsrc/lib/actions/hotspots.tshas no key fields and the hotspot editor has no input for them, so stop-to-stop chains are the only key mechanic a creator can switch on from the interface. Authoring is list-based and keyboard-operable with health checks that block publishing on an unfinishable hunt (a key nothing grants, an unplaced geo stop, an answer stop with no answers). Location never leaves the browser: proximity is compared on-device and only "this stop opened" is sent, so no visitor latitude/longitude is transmitted or stored anywhere. A remote fallback is on by default so a hunt is never completable only on foot. Hunts also carry a stop map: inline SVG projected from the scenes' coordinates, showing the route, the visitor's position, a scale bar, and a distance-and-direction readout for the next stop. It has no basemap on purpose (seesrc/lib/hunt-map.ts) so it renders with no network, which is the state a hunt is actually played in; everything it draws is also stated in text for screen-reader parity. Hotspot finds persist inhunt_hotspot_finds, so a key earned by finding something hidden survives a reload. Logic insrc/lib/hunts.tsandsrc/lib/hunt-map.ts(58 unit tests); seeplans/future/16-hunt-builder-and-geo-map-layer.mdfor what is still outstanding, including PSV viewer wiring for hidden hotspots and locked links. - Formatted descriptions: destination and course descriptions accept a deliberately narrow markdown subset (bold, italic, links, lists, line breaks) rendered server-side through
sanitize-htmlwith a closed allowlist. Cards and<meta>tags use a stripped plain-text projection. Seesrc/lib/description-markdown-core.ts; runpnpm check:sanitizerafter changing it. - Courses + lessons + blocks: six block types:
text,photo_360,video,video_360,quiz,virtual_tour. - Learner flow: catalog, course detail, free enrollment, Stripe checkout for paid courses, lesson player, resume-across-devices, PDF certificate on 100% completion.
- i18n: translation overlay via
course/lesson/block_translations; EN default, ES wired; CSV-driven translator templates viapnpm db:gen-template+ in-app translation editor. - Publish gate:
submitCourseForReviewenforces transcripts on video, ready-state on 360° media, non-empty lessons. Admin approval inbox at/admin/courses. - Support chat (status: beta): threaded learner-to-admin conversations with Mailgun notifications on both sides, an unread-reply badge on the Get help button, and a confirm-or-dispute resolution loop. See Known issues.
- Analytics (PostHog): client-side capture into the shared WitUS project, gated on
NEXT_PUBLIC_POSTHOG_KEY. Autocapture and session replay off in code; memory-only persistence, so no cookie and no consent banner. Event names are a typed map insrc/lib/analytics/events.ts. - Error monitoring (Better Stack): server, edge, and browser crash reports through the
@sentry/nextjsSDK into Better Stack, which ingests the Sentry protocol. Gated onSENTRY_DSN/NEXT_PUBLIC_SENTRY_DSN: with no DSN the SDK is never initialised, so nothing is collected and nothing is sent. Tracing and session replay are off (tracesSampleRate: 0, replays 0) andsendDefaultPii: false. Every event passes through thebeforeSendscrubber insrc/lib/sentry-scrub.ts, which strips reset tokens,?k=tour preview tokens, session cookies,Authorizationheaders,DATABASE_URLpasswords, vendor keys, and learner emails from messages, URLs, query strings, breadcrumbs, tags,extra, andcontexts. Covered bypnpm test. - Health check: public
GET/HEAD/api/healththat really runsselect 1against Neon. Uptime monitors point here, not at/. See Health check. - Accessibility: WCAG 2.1 AA runtime publish gate + axe-playwright + pa11y-ci on public pages on every PR. 2D fallback link on every 360° block.
- Offline: service worker, shell precache, learner-route cache, Cloudinary image cache, IndexedDB outbox with auto-replay on reconnect. The per-course "Save for offline" toggle has shipped (
toggleCourseOfflineEnabled, wired on the course detail page); remaining work is online/offline UI polish, see plan 05. - Tour discovery globe: rotatable 3D globe on
/[lang]/tours, pinned from destination lat/lng, pin colour driven by the destination's tour type (/admin/tour-types). - Video tours: a destination with a YouTube URL plays it on the public tour page;
youtubeis also a lesson block type. - Private preview links: share a not-yet-public tour via a rotatable capability token (
/tours/<slug>?k=…); constant-time checked, noindex, one click kills all sent copies. - Cross-tour linking: hotspots can link to another creator's tour, opt-in per account and per destination, with a preview card and a next-tour CTA.
- Tour stop rail: a persistent "where am I / what's left" strip under every multi-scene tour — current stop marked, visited stops ticked, an honest
Stop 3 of 9 · 6 leftcounter, and a tap-to-jump so a dead-end scene can never strand a visitor. Ordered by the creator'sscenes.order_indexwhere set, otherwise by walking the scene-link graph from the start scene (walkOrderFromStart), never by row order. - Peak scene: a creator marks the high point of a tour (
destinations.peak_scene_id). Shared links open there, and the link-preview image is built from it. - Sharing: a learner-facing share control on every public tour — OS share sheet where available, clipboard otherwise. Fires
tour_shared { surface: "public" }. - Auth: password (with reset), magic link, email OTP, 2FA, and "Sign in with WitUS" OIDC when
WITUS_OIDC_CLIENT_IDis set. That same flag turns on the silent "Continue as <name>" check (the sign-in page asks the IdP in parallel with the form and only relabels the button; a blocked or slow answer is invisible) and global sign-out (signing out ends the shared IdP session, so it signs you out of every WitUS app in the browser — the button reads "Sign out of WitUS"). Both are dark without the client id. Design notes insrc/lib/silent-sso.ts. - Public docs at
/[lang]/docs/{creator,admin,embed-tours,transcripts,capture-kit}rendering the guides indocs/.capture-kitis the recommended-equipment guide handed to institutional partners. - Help Center at
/[lang]/help: searchable, task-recipe help articles for partner staff (upload media, organize media by tour, hotspots, publish and embed, report a bug), each with numbered steps and a video walkthrough slot. Content registry insrc/lib/help-articles.ts.
- Support threads cannot carry attachments. The
support_messages.attachmentscolumn exists but nothing writes to it, so screenshots and screen recordings have to be hosted elsewhere and linked in the message body. (The two bugs previously listed here,plans/bugs/07-sign-in.mdand08-server-error.md, are both resolved — they were the sameserverExternalPackageswebsocket crash.) - Help Center walkthrough videos are unrecorded. All 19 articles show a "Video coming soon" placeholder; the narration scripts are written and readable in-app under each article.
- MUCHO ES translation is empty. Source column in
scripts/seed-data/mucho.es.csvis populated; value column awaits a human translator. EN-only launch is acceptable per 2026-04-19 decision. - Privacy / terms are drafts. Stubs at
/privacyand/termscarry an amber "pending legal review" banner. Counsel-reviewed text required before public launch.
pnpm install
cp .env.local.example .env.local # fill Neon, Better Auth, Cloudinary, Stripe, Mailgun
pnpm db:migrate
[email protected] pnpm db:seed
pnpm devOpen http://localhost:3100. This app pins 3100 everywhere — pnpm dev, pnpm start:local, Playwright, and pa11y — precisely because several of these apps run side by side and port 3000 is whichever one started first.
pnpm start is deliberately left portless so the host's PORT wins in production; use pnpm start:local when you want a production build on 3100.
For authoring + admin workflows, see docs/CREATOR_GUIDE.md and docs/ADMIN_GUIDE.md.
wanderlust-app/
├── src/
│ ├── app/
│ │ ├── [lang]/
│ │ │ ├── layout.tsx # header, footer, FAB
│ │ │ ├── page.tsx # landing
│ │ │ ├── courses/ # learner catalog + course detail
│ │ │ ├── learn/ # lesson player
│ │ │ ├── creator/ # media / destinations / courses authoring
│ │ │ ├── admin/ # users / courses review / support inbox
│ │ │ ├── docs/ # public creator + admin guides
│ │ │ ├── accessibility, privacy, terms, how-it-works, support
│ │ │ ├── sign-in, sign-up
│ │ │ └── dictionaries/ # EN + ES
│ │ ├── api/ # auth, media signing, webhooks, offline-sync, health
│ │ └── sw.ts # Serwist service-worker source
│ ├── components/ # blocks, virtual-tour, media, layout, support, offline
│ ├── db/
│ │ ├── schema/ # auth, courses, media, scenes, commerce, translations, support, reviews
│ │ ├── queries/ # typed Drizzle queries
│ │ └── migrations/
│ ├── lib/ # actions, cloudinary, stripe, mailer, publish-gates, translate, offline-outbox
│ ├── instrumentation.ts # Sentry register() per runtime + onRequestError
│ └── instrumentation-client.ts # browser Sentry init + router-transition hook
├── scripts/ # migrate, seed-mucho, gen-translation-template, promote-user
├── docs/ # CREATOR_GUIDE, ADMIN_GUIDE, INFRA, CLOUDINARY_*, a11y-critical-pages
├── plans/ # numbered plan files + bugs + ecosystem references
├── tests/a11y/ # Playwright + axe + pa11y-ci
├── src/**/*.test.ts # Vitest unit tests, co-located (pnpm test)
├── sentry.server.config.ts # + sentry.edge.config.ts (DSN-guarded SDK init)
└── public/ # static assets, sw.js build output
Wanderlust is one of eight WitUS-ecosystem products. Ecosystem-level conventions (shared Cloudinary tenant, per-app folder prefix, cross-app hand-offs) are in docs/CLOUDINARY_FOLDER_CONVENTION.md. Cross-app integrations (BVC footage from Fly.WitUS, Academy preview blocks from CentenarianOS) are scoped in plans/04-phase-2-roadmap.md Theme C.
The contract every commit agrees to — launch gates, content policy, git workflow, code patterns — lives in STYLE_GUIDE.md. Read it before contributing.
GET /api/health (and HEAD /api/health) is the endpoint external uptime monitors should watch. Point Better Stack at /api/health, not at /. The homepage can answer 200 straight from the Vercel CDN while the database is unreachable, so a green check on / proves only that the CDN is alive. This route opens a real connection and runs select 1, so a 200 means the app process and Neon are both answering right now.
| Condition | Status | Body |
|---|---|---|
| App and database both answering | 200 |
{"ok":true,"checks":{"db":"ok"}} |
| Query failed, or env/client construction threw, or the probe exceeded 4s | 503 |
{"ok":false,"error":"database_unreachable"} |
Notes for whoever wires the monitor, and for whoever edits src/app/api/health/route.ts next:
- Public and unauthenticated by design, and it leaks nothing: no version, no environment values, no row counts, no user data, and never the driver's error text (Neon and
pgboth put the connection string in their messages). Thecatchis written with no binding, so the error object is unreachable by construction; the body is a fixed literal and the log line is a constant string. Do not "improve" it by loggingerr.message, which moves the leak from the response into the log sink. - The DB client is imported dynamically inside the
try. A top-level import would turn a badDATABASE_URLinto a module-evaluation 500 whose stack trace can quote the connection string. Dynamic import keeps it an ordinary503. - 4-second timeout via
Promise.race, so a hung connection reports down instead of leaving the monitor waiting. - Never cached at any layer:
dynamic = "force-dynamic",revalidate = 0,Cache-Control: no-store, and an explicitNetworkOnlyentry at the top ofBYPASS_PATHSinsrc/app/sw.ts. That last one matters: Serwist'sdefaultCacheends in a catch-all matchingpathname.startsWith("/api/")onGETwithNetworkFirstand a 24-hour max age, so without an earlier match an installed PWA client could replay a day-old{"ok":true}after the database went down.
Error monitoring (Better Stack via the @sentry/nextjs SDK, DSN-guarded, beforeSend scrubber)
is documented under Features and stays there. This section covers the rest: tracing
and the deployment E2E gate.
Traces go to Honeycomb over OTLP via @vercel/otel (src/otel.config.ts, loaded from
src/instrumentation.ts before the Sentry configs — whoever registers the global tracer
provider first wins, and Sentry is told to stand down via skipOpenTelemetrySetup in
sentry.server.config.ts). Service name is wanderlust.
- Inert until the key is set.
HONEYCOMB_INGEST_API_KEY_SECRET(fallbackHONEYCOMB_API_KEY). With neither set, registration is skipped entirely — same DSN-guard pattern as the Sentry init. /api/healthspans are dropped at the sampler. Uptime monitors probe it around the clock (see Health check), and those requests must not spend Honeycomb's free-tier event budget. Everything else is recorded unsampled.
Two Playwright configs, two gates — they are deliberately separate:
playwright.config.tsowns the local suites (tests/a11y,tests/e2e) and spawns a dev server on:3100;.github/workflows/a11y.ymlruns the a11y half on every PR. This is the pre-existing gate described under Features.playwright.deploy.config.tsownse2e/and never spawns a server — it always points at an already-running app..github/workflows/e2e.ymlruns it ondeployment_status, testing the real Vercel deployment URL (preview → full suite, production →@smokeonly), so that job needs no secrets, database, or env. The deploy suite runs desktop plus a 360px mobile project, and covered pages must pass an axe check with zero serious or critical violations — minor/moderate findings are reported but don't gate. Fix the page, not the gate.
Local runs of the deploy suite:
PLAYWRIGHT_BASE_URL=<url> pnpm exec playwright test --config playwright.deploy.config.ts —
locally it drives installed Chrome via channel: "chrome" (Playwright's bundled chromium doesn't
support macOS 13); CI uses the bundled browser. If the Vercel project enables Deployment
Protection, set the project's "Protection Bypass for Automation" secret as the
VERCEL_AUTOMATION_BYPASS_SECRET Actions secret; public previews need nothing.
Every request the deploy suite makes carries x-witus-origin-test: playwright-synthetic
(an extraHTTPHeaders entry in playwright.deploy.config.ts). The OTel layer surfaces it as the
witus.origin_test span attribute (attributesFromHeaders in src/otel.config.ts), so
Honeycomb queries can include or exclude synthetic traffic. Absent header = attribute absent =
real user; queries about real users exclude the attribute. The local suite doesn't send the header
— it only ever talks to its own self-spawned dev server, which reports no telemetry.
Vercel. Pushes to main trigger production deploys. Env vars (Neon, Better Auth, Cloudinary, Stripe, Mailgun, ADMIN_NOTIFY_EMAIL) must be set for the Production scope — see docs/INFRA.md for the full table.
npx vercel --prodProprietary B4C LLC / AwesomeWebStore.com.