Skip to content

Latest commit

 

History

604 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Wanderlust

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.

About

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).

Tech Stack

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
Email 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

Features

  • 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 bulkCreateScenes over 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-chrome hook, 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_id gives 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's audioUrl, which is a clip the visitor triggers by clicking a marker.
  • Per-link arrival heading: scene_links.arrival_yaw/pitch set 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_keys on 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, but src/lib/actions/hotspots.ts has 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 (see src/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 in hunt_hotspot_finds, so a key earned by finding something hidden survives a reload. Logic in src/lib/hunts.ts and src/lib/hunt-map.ts (58 unit tests); see plans/future/16-hunt-builder-and-geo-map-layer.md for 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-html with a closed allowlist. Cards and <meta> tags use a stripped plain-text projection. See src/lib/description-markdown-core.ts; run pnpm check:sanitizer after 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 via pnpm db:gen-template + in-app translation editor.
  • Publish gate: submitCourseForReview enforces 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 in src/lib/analytics/events.ts.
  • Error monitoring (Better Stack): server, edge, and browser crash reports through the @sentry/nextjs SDK into Better Stack, which ingests the Sentry protocol. Gated on SENTRY_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) and sendDefaultPii: false. Every event passes through the beforeSend scrubber in src/lib/sentry-scrub.ts, which strips reset tokens, ?k= tour preview tokens, session cookies, Authorization headers, DATABASE_URL passwords, vendor keys, and learner emails from messages, URLs, query strings, breadcrumbs, tags, extra, and contexts. Covered by pnpm test.
  • Health check: public GET/HEAD /api/health that really runs select 1 against 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; youtube is 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 left counter, and a tap-to-jump so a dead-end scene can never strand a visitor. Ordered by the creator's scenes.order_index where 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_ID is 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 in src/lib/silent-sso.ts.
  • Public docs at /[lang]/docs/{creator,admin,embed-tours,transcripts,capture-kit} rendering the guides in docs/. capture-kit is 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 in src/lib/help-articles.ts.

Known issues

  • Support threads cannot carry attachments. The support_messages.attachments column 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.md and 08-server-error.md, are both resolved — they were the same serverExternalPackages websocket 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.csv is populated; value column awaits a human translator. EN-only launch is acceptable per 2026-04-19 decision.
  • Privacy / terms are drafts. Stubs at /privacy and /terms carry an amber "pending legal review" banner. Counsel-reviewed text required before public launch.

Quick Start

pnpm install
cp .env.local.example .env.local   # fill Neon, Better Auth, Cloudinary, Stripe, Mailgun
pnpm db:migrate
[email protected] pnpm db:seed
pnpm dev

Open 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.

Project Structure

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

Ecosystem position

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.

Style guide

The contract every commit agrees to — launch gates, content policy, git workflow, code patterns — lives in STYLE_GUIDE.md. Read it before contributing.

Health check

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 pg both put the connection string in their messages). The catch is 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 logging err.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 bad DATABASE_URL into a module-evaluation 500 whose stack trace can quote the connection string. Dynamic import keeps it an ordinary 503.
  • 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 explicit NetworkOnly entry at the top of BYPASS_PATHS in src/app/sw.ts. That last one matters: Serwist's defaultCache ends in a catch-all matching pathname.startsWith("/api/") on GET with NetworkFirst and 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.

Observability & E2E

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.

Distributed tracing

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 (fallback HONEYCOMB_API_KEY). With neither set, registration is skipped entirely — same DSN-guard pattern as the Sentry init.
  • /api/health spans 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.

E2E + accessibility CI (deployment gate)

Two Playwright configs, two gates — they are deliberately separate:

  • playwright.config.ts owns the local suites (tests/a11y, tests/e2e) and spawns a dev server on :3100; .github/workflows/a11y.yml runs the a11y half on every PR. This is the pre-existing gate described under Features.
  • playwright.deploy.config.ts owns e2e/ and never spawns a server — it always points at an already-running app. .github/workflows/e2e.yml runs it on deployment_status, testing the real Vercel deployment URL (preview → full suite, production → @smoke only), 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.

Synthetic traffic tag

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.

Deployment

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 --prod

License

Proprietary B4C LLC / AwesomeWebStore.com.

About

A learning platform where every lesson begins with standing inside a real place.

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages