# Area Tutorials (`Src\AreaGuide`)

**Portals:** Manage (the builder) + Main user portal (the player) · **Routes:** `manage.area-tutorials.*`,
`main.portal.area-tutorials.show` · **Nav:** Manage: Channel → **Portal**, the *Area Tutorials* hub tab
(beside Courses — portal content, not a setting); Main: no nav entry of its own — a tutorial is reached
from its area's story inside the [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) tab of
the Learning Hub, and its page carries a back link to that area · **Gated by:** Manage
`view-area-tutorials` / `manage-area-tutorials`; Main `['auth','main','contact.verified']` + the Area
Guide's TEMPORARY lock (`features.area_guide_locked`) · **Added:** 2026-08-30.

## What it does

A **guided drive along one corridor of an Area Guide area** — the pilot is Old Klang Road, Scott Garden
→ Southbank → Mid Valley — told twice on one screen:

- **Left, the map.** A Mapbox Standard 3D street map with the route drawn on it. A pin drives along the
  real road from stop to stop while the camera follows, then settles on the building. Each stop is a
  condo from the **Subsale Database** (`catalog_projects` — median psf with its 25–75 range, median price,
  rental yield, completion year, developer, transaction count *and the period it covers*, asking
  listings), a landmark from the **market catalysts** registry, or a free pin with the admin's own copy.
- **Right, the story.** A stepper (one screen per step, `?step=` in the URL): intro → one **investor
  card** per stop → a **summary table** of every stop (the thing an investor screenshots) → the team's
  **walkthrough video** (Vimeo), chaptered to the same stops: as the video plays the pin drives to
  whichever stop the footage is at, and clicking a stop seeks the video.

The reason it is a *corridor* and not an *address*: an investor weighing Old Klang Road is comparing
Scott Garden against Southbank against Petalz — psf, yield, completion year, which end of the road.
The buildings differ far more than the street does, so the comparison IS the content; the animation is
how the comparison is told in order.

## How it works

- **Data.** `area_tutorials` (one per corridor; `area_key` names the Area Guide area, which is still
  static JS in `resources/js/utils/areaGuide/*.js`, so it is a string key by design) and
  `area_tutorial_stops` (ordered; `kind` = project / landmark / note; `catalog_project_id` /
  `market_catalyst_id` nullable; `video_at_seconds` nullable). **A tutorial never stores a price** —
  `AreaTutorialPresenter::project()` reads the catalogue at render time, so the card cannot go stale.
- **The route is fetched ONCE and cached.** `AreaTutorialsController@fetchRoute` is the only caller of
  `MapboxDirectionsService` (Mapbox Directions, billed per request): start → every stop in order → end,
  as a GeoJSON LineString in `route_geojson` + distance + duration. Every stop write that changes the
  waypoints (add / move / delete / reorder) **clears the cache** in the repository, and the builder says
  so; editing copy or a timestamp does not. Moving either end in `update()` clears it too. No member
  request can ever reach the API.
- **Publish is refused** without ≥ 1 stop and a fetched route — a pin needs a line to drive along.
- **The player** (`Pages/Main/Portal/AreaTutorial/Show.vue`) mounts
  [`AreaTutorialMap.vue`](/resources/js/Components/AreaGuide/AreaTutorialMap.vue) ONCE and drives it by
  `activeIndex` (-1 = overview, 0…n-1 = a stop). The drive is client-side geometry over the cached line
  ([`tutorialRoute.js`](/resources/js/utils/areaGuide/tutorialRoute.js): each stop is projected onto the
  route — a building sits beside the road, not on it — and the pin interpolates between those distances
  at a clamped speed; reduced-motion readers get the jump). Without a route (an admin previewing an
  unrouted draft) the pin flies point to point.
- **Video ↔ map sync.** The Vimeo Player SDK's `timeupdate` picks the last stop whose
  `video_at_seconds ≤ currentTime`; a chapter list under the video seeks. Watch engagement rides the
  existing `/track/video` beacon (`TrackVideoRequest::WATCHABLES['area_tutorial']`, published tutorials
  only) and shows on the builder.
- **Workflow the timestamps assume:** the director builds the stops here FIRST (this page is the shot
  list); the crew shoots in that order; after the edit, someone opens *Video & timestamps* and keys in
  where each stop appears ("Use current time" reads the player). Until then the stops simply have no
  chapter.
- **Members' gate.** A member gets published tutorials only; an admin may open a draft (the builder's
  *Preview* button) and sees a draft banner. Both go through the Area Guide's lock: while it is on, a
  member is redirected to the coming-soon tab, exactly as the guide itself behaves.
- **AI episode settings survive the one-click rerun.** The Script tab's Setup saves its choices to
  `episode_config`; `episodeConfigFromRequest` fills any setting ABSENT from the request from that
  saved config before the defaults. The header's *AI write episode* button posts no editors — without
  this fallback it silently reset a 4-editor debate to the default Gemini + Claude pair (2026-08-31).
- **The map stage and the editors rerun separately.** Setup's *Editors only* button (`plan` with
  `editors_only: true` → `EpisodeDebate::startEditorsRun`) reruns drafts → debate → final cut on the
  LAST run's stop briefs (read from the run's STOP cells in the DB, so no cache dependency), keeping
  the same `run_key`; a full rerun re-analyses every stop through the engine + writer first — slow and
  billed per stop. Refused while the map stage still has pending stops (the last stop's `advance()`
  would dispatch a second draft set). A failed/skipped draft or critique cell reruns ALONE via *Run
  this model again* on its card (`POST {id}/cells/{cellId}/retry` → `EpisodeDebate::retryCell`) — the
  stage-advanced cache locks are cleared so the stage re-pauses for review when the retry lands, and a
  draft retry deletes any critiques (they critiqued the old draft set).
- **The station layer's master list is aggregated in CHUNKS.** `masterStations()` scans every Klang
  Valley catalogue project's `amenities.train` (~10k rows and growing) via `toBase()->chunkById(1000)`
  — plucking through the Eloquent accessor held every decoded array at once and blew mod_php's 128M
  limit, so `stations-in-bounds` 500'd and the map silently lost all stations (the frontend swallows a
  non-OK response). Cached 1h; the 2D Leaflet fallback map has no station layer at all.

## Related files

**Backend**
- Models: [`src/AreaGuide/AreaTutorial.php`](/src/AreaGuide/AreaTutorial.php) (`STATUSES`, `waypoints()`),
  [`src/AreaGuide/AreaTutorialStop.php`](/src/AreaGuide/AreaTutorialStop.php) (`KINDS`)
- Repositories: [`AreaTutorialRepository`](/src/AreaGuide/Repositories/AreaTutorialRepository.php)
  (`create` / `update` / `storeRoute` / `clearRoute` / `publish` / `unpublish` / `delete`),
  [`AreaTutorialStopRepository`](/src/AreaGuide/Repositories/AreaTutorialStopRepository.php)
  (`create` / `update` / `delete` / `reorder` — each invalidates the route)
- Service: [`MapboxDirectionsService`](/src/AreaGuide/Services/MapboxDirectionsService.php) (`route(waypoints)`, ≤ 25 points)
- Presenter: [`AreaTutorialPresenter`](/src/AreaGuide/Support/AreaTutorialPresenter.php) (`card` / `full` / `stop` / `project` / `catalyst`, `vimeoEmbedUrl`)
- Controllers: [`Manage/AreaGuide/AreaTutorialsController`](/app/Http/Controllers/Manage/AreaGuide/AreaTutorialsController.php)
  (index / store / show / update / destroy / publish / unpublish / fetchRoute / projectOptions / catalystOptions),
  [`Manage/AreaGuide/AreaTutorialStopsController`](/app/Http/Controllers/Manage/AreaGuide/AreaTutorialStopsController.php),
  [`Main/Portal/AreaTutorialsController`](/app/Http/Controllers/Main/Portal/AreaTutorialsController.php) (show);
  `Main/Portal/CoursesController@index` supplies `areaTutorials` (published cards keyed by area) to the Learning Hub
- Form Requests: `app/Http/Requests/Manage/AreaGuide/AreaTutorials/{QueryRequest,StoreRequest,UpdateRequest}.php`,
  `app/Http/Requests/Manage/AreaGuide/AreaTutorialStops/{StoreRequest,UpdateRequest}.php` (reorder reuses `Manage/Lms/ReorderRequest`);
  `Main/Portal/TrackVideoRequest` registers the `area_tutorial` watchable
- Permissions: `Permission::VIEW_AREA_TUTORIALS` / `MANAGE_AREA_TUTORIALS` (group *Area Tutorials*)

**Frontend**
- Shared: [`Components/AreaGuide/AreaTutorialMap.vue`](/resources/js/Components/AreaGuide/AreaTutorialMap.vue),
  [`utils/areaGuide/tutorialRoute.js`](/resources/js/utils/areaGuide/tutorialRoute.js),
  [`utils/areaGuide/areaOptions.js`](/resources/js/utils/areaGuide/areaOptions.js) (the area pick-list from `countries.js`)
- Manage: `Pages/Manage/AreaGuide/AreaTutorials/{Index,Show}.vue`,
  `Partials/{AreaTutorialForm,AreaTutorialFormModal,StopFormModal}.vue`, `Partials/Tabs/{StopsTab,VideoTab}.vue`;
  the hub tab in `Components/Portal/PortalEngagementTabs.vue`; `/manage/area-tutorials` in `ManageLayout.vue`'s Portal `prefixes`
- Main: `Pages/Main/Portal/AreaTutorial/Show.vue` + `Partials/{StopCard,SummaryTable,VideoPanel}.vue`;
  `Components/AreaGuide/AreaGuidePanel.vue` (the *Drive the area* list in an area's story, from the `tutorials` prop);
  `Pages/Main/Portal/Lms/Index.vue` passes `areaTutorials` through

**Migrations**
- `2026_08_30_150000_create_area_tutorials_table.php`, `2026_08_30_150001_create_area_tutorial_stops_table.php`

**Seeders** — none. Permissions come from `RolesSeeder` (run it after deploying: new constants 500 until it does). The
Old Klang Road pilot was created through the builder as a draft, not seeded.

**Routes** — `routes/web.php` (`area-tutorials` prefix; `options/*` literals before `{id}`; `{id}/route` is the
Directions call), `routes/main.php` (`property/academy/area-tutorials/{id}`, declared before `{course}`)

**Tests** — `tests/Feature/Manage/AreaGuide/AreaTutorialsTest.php`
