# Events · Funnels (Manage)

**Portal:** Manage · **Routes:** `manage.events.funnels.*` (incl. `og-banner.{store,destroy}`), `manage.events.series.*`, `manage.events.sessions.{store,move,link-webinar}`, `manage.events.weekly.leads`, `manage.events.webinar.*`, `manage.events.registrations.*` (incl. `join-link`), + the shared single-session `manage.events.{show,update,cancel,destroy}` · **Nav:** **Funnels** — a flat entry under the sidebar's **Sales & Marketing** section (`resources/js/Layouts/ManageLayout.vue`, label `Funnels`, href `/manage/events/funnels`). It is **one nav entry fronting four pages**: the page itself mounts [`Components/FunnelMarketingTabs.vue`](/resources/js/Components/FunnelMarketingTabs.vue), a `HubTabs` config whose main tabs are **Dashboard** (`/manage/events/dashboard` — the business waterfall, see below) / **Funnel** / **Meta Ads** (→ Mapping · Setting → Account / Ads) / **AI Video** — so the sidebar entry's `prefixes` deliberately span `/manage/events`, `/manage/facebook`, `/manage/marketing/campaign-mapping`, `/manage/marketing/ads` and `/manage/video` (GUIDELINES §15). The Dashboard tab sits FIRST in the strip so its exact prefix resolves before Funnel's broad `/manage/events` (the §15 prefix trap).

**Permissions** (2026-08-11, tightened 2026-08-12): the events route group admits **either** view surface — `view-events` (Full View) or `view-events-reports` (Reports Only, held by the **Marketing** role with `view-marketing`; see the [Roles](/docs/modules_handbook/manage/people/roles/readMe.md) handbook). Reports-only holders get the Dashboard, the funnels list, a funnel's **Sessions** tab, a session's **Summary / Ads** tabs and a VSL funnel's own **Summary / Ads** (its report tabs, on the group gate like a session's). ⚠️ **The PERSON ROSTERS are gated on a different axis** — lead visibility (`Permission::leadLevels()`), because they name customers more richly than the Leads index does: the session **Registrations** tab (payload withheld, not just the tab), a VSL funnel's **Leads** tab (`vslLeads` sent as an EMPTY ARRAY — null would make the page render as a webinar funnel, Sessions and Slots over tables that can never have a row), the buyer names inside Summary's *What sold* (`buyer_rows` stripped; the item, the buyer COUNT and the money all stay) — **and three siblings that ship the same class of data past their own consumers' gates**, each found by asking *who actually reads this prop* rather than *which tab is hidden*: the **`webinar`** prop (full-view only — once a webinar is live or ended `transformWebinar()` attaches `activity_feed`, a join/leave log carrying every attendee's NAME and lead uuid: the roster by another route), **`vslCallers`** (staff name + email + phone; keyed on `$seesPeople`, never on `$vslLeads === null`, which asks a different question and stopped even being a proxy for it once `[]` became a third state) and **`projectOptions`** (the project catalogue, read only by a `manage-events` modal, so it follows that gate on both the index and the show page). ⚠️ **A fixture that cannot reach the leak proves nothing about it**: the first version of this suite asserted "no people" against a session with **no webinar** and against empty project / staff pools, so every one of those assertions passed whether the gate existed or not. The tests now seed a real ended webinar, a project and a sales-execution holder, and each guard is mutation-verified. Both Show pages filter their tab strips on `can('view-events')` for the machinery and on the leads levels for the rosters, and the full-view-only reads (`series.*` incl. posters, `weekly.leads`, `funnels.group-status`, `attendance.export`, `check-in.scan-page`) pin `view-events` on their own routes. Every write already carries `manage-events`, so reports-only is view-only by construction — the Summary/Ads window+budget editors, and the roster's Lead-modal / WhatsApp-inbox jumps, hide behind their target permissions.

> Sibling doc: the funnel's WhatsApp automation lives in [Events · Funnel WhatsApp](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md). There is **no** separate "occasional / one-off events" module — every session comes from a slot; the standalone Occasional module was removed (its members-only gating moved onto the slot, see below). Each Slots-tab row also carries a **Posters** action opening a poster-management drawer — no new page or sidebar entry, per GUIDELINES §15 — detailed in the sibling [Events · Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md) doc.

## What it does
A **funnel** is a named marketing **program** (e.g. *Bootcamp*, *Sutera KLCC*) fronted by a public landing page at `/{slug}`. It runs a **weekly sharing program**: each **slot** is a recurring session template, and a **session** is a slot scheduled on a **specific date**. Each session can back a real **Zoom webinar** that registered leads join for attendance tracking. Admins also record **registrations** (which leads joined each session) and attendance.

Funnels are **optional and plural**: there is **no "default" funnel** and **zero funnels is a legal state** — the public root `/` renders the site home, never a funnel (see [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md)). **Any** funnel can be deleted (there is no undeletable default). The demo **Bootcamp** funnel (slug `bootcamp`) is seeded by `FunnelsSeeder`. Slots are ordered **per-funnel** by their **`sort_order`** (their stable creation order) — a slot has **no day of week**; the day is chosen per session, when it is scheduled on a date. There are **no D1/D2/D3 labels** — slots are shown by their **title**.

Default slots on the seeded Bootcamp funnel (in slot order):
- *AI Prompt Class for Investment* (8:00pm–10:00pm)
- *AI 买房训练营 Masterclass*
- *Grade A 房产深度分析*

## The model — funnel + slots + sessions + registrations + webinar
- **`EventFunnel`** — a **program** (`name`, unique `slug`, `description`, `is_active`, `whatsapp_group_link`). Owns `series()` / `events()` / `leads()` / `leadFunnels()` / `whatsappMessages()`, plus `media()` (morphMany) + the `ogBanner()` helper behind the Landing tab's social-share image. `EventSeries` and `Event` both carry a nullable `event_funnel_id`; a `Lead` relates to a funnel only through the `lead_funnels` pivot (`leadFunnels()` / `leads()`), not a column of its own — the lead's `event_funnel_id` (and the rest of its attribution) was moved off the `leads` table onto `lead_funnels`. There is no `is_default` flag and no `default()` — funnels are optional and plural. `whatsapp_group_link` (the funnel's WhatsApp group invite link, set on the funnel form) feeds the `{{whatsapp_group_link}}` token in the funnel's WhatsApp automation — see [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md).
- **`EventSeries`** — the **slot template** (per funnel): default times + `mode` (Zoom / Physical), `default_zoom_link` / `default_location`, **`auto_webinar`** (default on), a unique-within-funnel `slug` (its public slot landing), `sort_order`, `is_active`, and a **`visibility`** (Public default / **Members-only** + the gating `memberships()` — its sessions inherit both). A slot has **no `day_of_week`** — the column was dropped 2026-07-13; a slot is a *template*, and the day only exists once it is scheduled onto a date as a session. Slots are ordered by **`sort_order`** (`nextSortOrderFor($funnelId)`, `withTrashed max + 1` — its stable creation order); there are **no D1/D2/D3 labels** (the `dLabels()` helper was removed) — slots are shown by their **title**. A slot also carries a **`registrations()`** hasManyThrough (slot → sessions → registrations) for the rolled-up Registered / Attended totals on the Slots tab.
- **`Event`** — one **session occurrence**, linked to a **slot** (`series()`); always `type` Weekly. A session is simply a **slot copied onto a date** — it inherits the slot's title / mode / times / link **and its members-only `visibility` + memberships** at creation and keeps those copies thereafter. `webinar()` is its 1:1 `ZoomWebinar`. There is no "round" / week a session belongs to.
- **`EventRegistration`** — a **lead joined to a session** (`event_id` + `lead_id`, unique together), with attendance `status`, `registered_at` / `attended_at`, the per-lead `zoom_registrant_id` / `zoom_join_url`, a **`source`** (`SOURCE_LANDING` / `SOURCE_ADMIN` / `SOURCE_WALK_IN` / `SOURCE_ZOOM` / `SOURCE_IMPORT` + the `SOURCES` metadata array) recording how the registration was created, and — since migration `2026_07_24_000004` — a **Meta ad snapshot** (`campaign_id` / `adset_id` / `ad_id` / `placement`, captured from the landing URL **at join time**). The snapshot is what makes **session-level CPL** possible: evergreen ads at a slot landing feed a *different* session each week, so per-funnel first-touch attribution (`lead_funnels`) can never answer "which ad paid for THIS session's leads" — the moment-of-join row can. First-touch per (event, lead): a re-join never overwrites.
- **`ZoomWebinar`** — the Zoom webinar backing a Zoom-mode session (1:1 on `event_id`). Full lifecycle is in the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md).

## How it works

### The Dashboard tab — the business waterfall (`/manage/events/dashboard`)
The strip's first tab is management's one-look answer to "is the funnel working?": **`Manage\Events\DashboardController@index`** (`Manage/Events/Dashboard.vue`, route `manage.events.dashboard` — a literal declared before any `{id}` route) renders, for **one funnel or all** over a picked range (7 / 30 / 90 days / all time), the numbers built by **[`App\Services\Marketing\FunnelDashboardService`](/app/Services/Marketing/FunnelDashboardService.php)**:

- **The conversion waterfall** — ad spend → **`meta_leads`** (Meta's OWN reported lead count from Ads Manager, synced per ad per day — see the [marketing doc](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md)) → landing leads (`lead_funnels`) → **in the WhatsApp group** → session registrations → attended → **bought membership** (`member_subscriptions`) → **property bookings** (`bookings`, cancelled excluded, SPA-signed split out). Meta's count and ours sit side by side ON PURPOSE — Meta counts inside its attribution window, we count real CRM registrations, and reconciling the two against Ads Manager is exactly the question this tab answers. A later stage can legitimately exceed an earlier one (an adopted webinar's imported registrants never passed through the landing page).
- **The cohort rule** (same as Ad Return, deliberately): the cohort is **landing-page registrations only** (`lead_funnels.marketing_source = FUNNEL`) — bulk-imported / other-source people attached to the funnel are excluded from EVERY figure and reported aside as `other_leads` ("+N imported/other (excluded)" on the leads stage), because they are not the traffic the ad spend bought: 808 imported rows once landed on one day and read as an impossible lead spike against RM 0 spend, and their memberships/revenue inflated ROAS with money the ads never earned. The range then selects the cohort by `registered_at`; membership / booking / revenue are **lifetime** for those people — a July lead who buys in September still counts for July, so a past range's outcomes keep rising. ROAS / profit / cost-per-member all follow.
- **Which spend belongs to the funnel:** campaigns **observed** on the scope's landing registrations (the self-reported `campaign_id` URL param) plus campaigns **tied** to the funnel on Campaign Mapping — summed from the local `meta_ad_insights` store, zero Graph calls on the request. A campaign that spent but brought no lead and was never tied is invisible; the page footnote says so.
- **KPI cards** — Spend / CPL / **Cost per attendee** / Show-up rate (past sessions only — an upcoming session's registrants haven't had the chance to attend) / Cost per member / Revenue / ROAS.
- **WhatsApp-group step is lazy**: it reuses the existing per-funnel `group-status` endpoint client-side (the roster needs the bridge, and the dashboard must never block on it) — funnels with no `whatsapp_group_link` show "—".
- **Per-session table** (regs / attended / show-up % / CTA / CPL via `SessionAdInsightsService::cplForEvents`), a **daily leads-vs-spend trend** (two single-series panels sharing the x-axis — never a dual-axis chart), a **Next session readiness card** (regs so far + personal-join-link clicks — the strongest turn-up signal before the day), and a **data-health strip** (spend last synced, campaigns tracked, WhatsApp sends failed/skipped in range).

Covered by `tests/Feature/Event/FunnelDashboardTest.php` (waterfall counts, scope isolation, the cohort rule, cancelled bookings excluded, range fallback, join-click card).

### Funnels (programs) & the hub
The sidebar **Events → Funnels** opens `FunnelsController@index` (`Funnels/Index.vue`) — every funnel as a card with its landing link + counts (active slots / sessions / leads) and CRUD via `FunnelFormModal`. **Any** funnel can be deleted — there is no default to protect. A card opens the **hub** `FunnelsController@show` (`Funnels/Show.vue`): a `PageHeader` + identity card + `ShowTabs` — **Sessions** / **Slots** / **Automation** / **Group** (only when the funnel has a `whatsapp_group_link`) / **Landing**. The identity card's header carries **two labelled counts** (there is **no** Active-slots/Past/Live/Upcoming stat row and **no Leads tab**): **"On sessions"** (`registrants_count` — distinct people registered on the sessions the funnel holds TODAY, via `registrantCountsByFunnel`, one grouped `COUNT(DISTINCT lead_id)` with an explicit `events.deleted_at` guard since the join bypasses SoftDeletes) beside the **Leads** button (`lead_funnels` → the Leads list filtered to this funnel). Two numbers on purpose: session registrations **travel with a moved session** — and an adopted webinar's imports arrive as registrations only — while `lead_funnels` deliberately stays credited to the source funnel (its ad attribution is never rewritten, see *Move a session* below). A funnel that received a moved 885-registrant session therefore honestly reads **"0 Leads · 885 On sessions"** instead of a bare 0 that contradicts its own session table. The **index** goes further (product decision 2026-08-03): its column is **Registered** (`registrants_count`, sortable via a correlated subquery carrying the same soft-delete guard) and there is **no Leads column at all** — the list-level number now tallies 100% with each hub's Sessions tab, and landing LEADS live on the hub header / the Leads list / the Dashboard instead. Covered by `tests/Feature/Event/FunnelRegistrantCountTest.php`.

**Group tab — who's in the funnel's WhatsApp group.** `FunnelsController@groupStatus` (`GET funnels/{id}/group-status`, JSON, lazy-loaded by `Partials/Tabs/GroupTab.vue`) resolves the funnel's invite link into a group JID via the Baileys bridge (`BridgeGateway::groupInviteInfo` → Baileys `groupGetInviteInfo`, no join; cached 7 days per code) and matches it against a **synced** `WhatsappGroup` — the roster (`whatsapp_group_participants`) only exists when a **connected QR number is a member of the group**. A plain group link checks that group alone; a **community** invite resolves to the community *parent*, whose own roster WhatsApp limits to the **admins** — `groupStatus` therefore **unions the parent + every synced linked group** (the announcement group carries the full membership; sub-groups add anyone synced there; `community_group_id` self-reference), deduped to one entry per person (keep-first, so the parent's admin role label wins), and the tab shows a `Community` badge + linked-group count. Every funnel registrant is then flagged in/out of the group by **tolerant phone match** (`PhoneNumber::canonicalDigits` — `0123…` = `+60…`), with honest degradations: leads without a phone, roster members hiding their number (`@lid` privacy identities), and the statuses `no_link` / `bad_link` / `unresolvable` (bridge down or revoked link) / `not_synced` (join the group with the connected number). The tab also lists the reverse — **in the group but never registered** — linking each to an existing CRM lead when their phone resolves to one (batch, read-only; nothing is created). See the [WhatsApp group handbook](/docs/modules_handbook/manage/messages/whatsapp/group.md) for the roster sync itself. The **Automation** tab configures everything that goes out around the funnel's events — WhatsApp, email, SMS rules (a funnel-level welcome when a lead registers + **per-slot** timed reminders/follow-ups) and the per-slot Zoom-native email toggles — full docs in [Funnel Automation](/docs/modules_handbook/manage/events/funnel-automation/readMe.md) and [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md). The hub **URL carries the funnel**, so every tab is funnel-scoped *without a switcher*. The single-session **detail page** keeps its own route but its back-link returns into the owning funnel's hub (`?tab=sessions`); the per-slot leads page returns to `?tab=slots`.

### Video Sales Letter funnels — a funnel with no sessions (2026-08-11)

A funnel carries a **`type`** (`event_funnels.type`, migration `2026_08_10_210000`; `EventFunnel::TYPE_WEBINAR` / `TYPE_VIDEO_SALES_LETTER` + `TYPES`, `isVideoSalesLetter()`, picked on the funnel form and shown as a badge beside Active/Paused). The type is not cosmetic — it decides what the hub Show page is **about**. A VSL sells "register → watch the 40-minute analysis → book a 1-on-1" (landing side: [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md), the `/{slug}/video` step), so it has **no slots and no sessions**, and leaving those tabs mounted would read as a broken page. `Funnels/Show.vue` therefore **swaps the tab list** for a VSL: **Summary / Ads / Leads / Automation / Landing** — deliberately mirroring the session Show page's Summary | Ads | roster order, so the two report-shaped pages read the same way (Summary and Ads carry spend, so they are full-view only, like the Dashboard; a reports-only holder gets Leads).

- **Leads tab** (`VslLeadsTab.vue`, the `vslLeads`/`vslStats` props on `show()`) — ONE row per person stitching the facts side by side: opted in (`lead_funnels`), watch progress (`funnel_video_views` — `FunnelVideoView`, incl. the 5-minute unlock), and the 1-on-1 request (`consultation_requests` — `ConsultationRequest`) with its **WhatsApp opened / not opened** verdict. Stitched in PHP, not joined: views and consultations can carry a NULL `lead_id` (someone who opened the video URL without registering — keyed by the shared `visitor_key`), and a join would drop exactly them. Rows order by **intent** (booked call first, then deepest watchers); the **WhatsApp opened / not opened** verdict stays a per-row column fact.

  **Export (`GET {id}/vsl-leads/export` → `FunnelsController@exportVslLeads` → [`VslLeadsExport`](/app/Exports/VslLeadsExport.php)).** The shared `ExportMenu` + `ExportsResource` pattern — see the [Export handbook](/docs/modules_handbook/shared/export/readMe.md) — with two things specific to this roster. **It is UNCAPPED**: `vslLeads()` takes a `?int $limit` and the page passes 500 while the export passes `null`, because a download that stopped at 500 of 1,800 people looks exactly like a complete one. And because this tab filters **in the browser**, the menu forwards its chips + search as `params` and the server re-applies them through [`VslRosterFilter`](/src/Event/Support/VslRosterFilter.php), the PHP twin of `useVslRosterFilters.js`, pinned case-for-case by [`VslRosterFilterTest`](/tests/Unit/Event/VslRosterFilterTest.php) — the only place in the app where a predicate is written twice, and only because both sides read the same row arrays. Gated on `Permission::leadLevels()`, NOT on the events group: `vsl-summary` / `vsl-ads` are aggregates and deliberately open to the wider Marketing view, while the roster is the one thing that role must not read — and a file outlives the session. The booking-form column carries the customer's own act (`whatsapp_opened_at`), never the consultation row's existence, so the file cannot restate the 已约-reads-93 bug in a spreadsheet.
  - **It is the session Registrations roster's table, at funnel scope** (2026-08-12) — the shared `DataTable` with `dense` + `stickyHeader` + `stickyFirst` + `stickyActions`, so the two lead-shaped tables read identically and an admin never meets two layouts for one kind of row. The **opted-in stamp sits UNDER the row #** (the Leads-index `#index` pattern — a whole column said no more; an unregistered viewer reads *未登记* there instead), the **Lead** cell is name + WhatsApp-linked phone + mail-linked email with the shared **`QualityTagChips`** Agent/Fake quality tags (`resources/js/Components/Leads/QualityTagChips.vue` — see the [leads handbook](/docs/modules_handbook/manage/leads/readMe.md) for the shared resolver behind it) (but **no verified/unverified shields**, unlike the session roster — product call 2026-08-12: a VSL registration is never asked to confirm a code, so every row would carry the same amber warning and it would only add noise), **Source / Ad is ONE cell** (the source badge with the campaign → ad set → ad chain beneath, the full chain on hover, and the ad name linking to Meta's rendered preview — an ad chain only ever has content on a landing row, so the two were one origin story), and the enrichment read is **two BANDS**: **FROM THEIR VISIT** (Location, Device — measured off the `ip_address` / `user_agent` the person arrived with) and **AI GUESS** (Occupation, Income, Hometown — a Gemini reading of a web search, muted italics). One band would let a guess borrow a measurement's credibility. Ad names + preview links resolve in ONE batch through the shared [`MetaAdLabelResolver`](/app/Services/Marketing/MetaAdLabelResolver.php) (`forRegistrations` — `lead_funnels` carries the same `campaign_id`/`adset_id`/`ad_id` columns a registration does), and the insight scalars come from the shared **`Concerns\BuildsLeadInsight`** trait — extracted from `EventsController` the same day so this roster and the session roster cannot drift on what a "Location" or an "Income" cell means.
  - **Deleting a funnel needs its NAME TYPED, and takes its slots + sessions with it** (2026-08-12). On that date a live funnel carrying 94 leads with paid traffic pointed at it was deleted by one stray click; its landing went 404 and the ads kept spending on a dead page. The row action now opens `Partials/DeleteFunnelModal.vue`, which states what goes (the funnel, its **N slots** — `slots_count`, the TOTAL, **not** the `active_slots_count` the rest of the page shows, because the cascade takes inactive slots too and a confirmation that under-counts is not one — and its **N sessions**) and what stays (**N leads** with their contact records, ad attribution, video progress and bookings) and holds the button until the funnel's exact name is typed. ⚠️ The same check is enforced server-side in `Funnels\DeleteRequest` — a disabled button is a hint, and a destructive route protected only by a hint is unprotected. `EventFunnelRepository::delete()` now cascades to `events` and `event_series` in one transaction (the old "kept for history" left slots and sessions reachable only from a hub that had just vanished, while the reminder scanner went on matching them). Everything is SOFT: `onlyTrashed()->restore()` on the three tables brings a mistake back. ⚠️ Zoom webinars are NOT torn down by this path (that is a fleet of external calls inside a transaction) — the modal says so when the funnel has sessions. Pinned by `test_deleting_a_funnel_needs_its_name_typed_and_takes_its_slots_and_sessions`.
- **A third band, ENGAGEMENTS, after AI GUESS** (2026-08-13) — the same five CRM columns the Leads index and the Property Match roster carry (Message / Zoom Meeting / Zoom Webinar / Phone Call / Portal), fed by the shared `BuildsLeadEngagementStats` trait so a person's numbers cannot differ between screens. Its header IS a switch (`#band-engagements`), defaulting to **SINCE REGISTER**: a VSL asks *"did this funnel lead anywhere?"*, while lifetime totals answer *"who is this person"* — which the Leads index already answers, and which makes a customer of a year look like a hot new lead. Both windows ship in the row, so the flip is pure client state.
    - ⚠️ **The window opens at an INSTANT, not a midnight.** `leadEngagementStatsSince()` used to round every cutoff down to the start of its day; it now joins the page's cutoffs as a derived table and compares timestamps exactly. The old rounding only ever over-counted, and it over-counted precisely the cohort the switch exists to separate: an existing list reached by a WhatsApp blast at 09:00 who registers at 20:00 had that blast credited as a RESULT of the registration it actually caused. Pinned by `EngagementWindowCutoffTest`. *(Property Match's since-figures dropped slightly as a result — it was never entitled to the pre-submission slice.)*
    - **The cutoff is a LADDER**: their registration on this funnel wins whenever there is one (that is the ask); else their **first** watch (`min` across devices — the table is unique on (funnel, visitor_key), so one person on a phone and a laptop has two rows, and taking the later one would open the window after most of what it counts); else their first enquiry. The scope is named in every cell's tooltip.
    - ⚠️ **NULL is not ZERO.** A row with no CRM lead behind it (an anonymous booking) and a row whose lead is outside the viewer's `LeadVisibility` both get a **null** window, rendered as a dash with its own tooltip. Five zeros would read as *"never contacted"* — the worst thing to print beside the highest-value row on the page. The ROSTER stays funnel-wide; only the FIGURES are lead-scoped, because these five columns are a CRM dossier and handing a `view-leads-own` closer everyone else's would be a disclosure the rest of the system refuses.
    - The five columns must stay **one uninterrupted block**: `DataTable` coalesces only consecutive same-group columns and renders the band slot per segment, so a foreign column wedged among them yields two ENGAGEMENTS headers and two toggles wired to one ref. Every cell reads through `stat()` / `has()` from **`composables/useEngagementBand.js`** (shared with Property Match, which adopted it in the same change) — a cell reaching for `row.x` would keep showing since-figures while its neighbours showed lifetime ones, with nothing looking wrong.
- **Caller rotations — who owns each person, and when** (`funnel_caller_rotations` + `_members`, 2026-08-13). A button on the **Caller column's own header** opens `Partials/CallerRotationModal.vue`. A funnel runs **TWO queues**, because nurturing somebody who only registered and closing somebody who asked for a call are different jobs usually done by different people:
    - **`KIND_LEAD`** — people who have not filled the 1-on-1 form. Assigned when they REGISTER, and **no alert is sent**: a funnel takes ~99 registrations for a handful of bookings, so a phone buzz per registration is a hundred messages a day for the one that matters, and `events.vsl_registration` already alerts on every registration. The assignment is visible on the roster, which is where that work is done.
    - **`KIND_BOOKING`** — people who have. Assigned when they submit the form, and the assignee **is** told (`events.vsl_caller_assigned`, `sendToUsers`) — nobody typed their name, so that alert is the only thing that tells them the lead is theirs.
    - ⚠️ **SEPARATE CURSORS.** Sharing one would let the registration queue's traffic advance the booking queue's turn, so who took the day's one real booking would depend on how many strangers registered that morning. Pinned by `test_the_two_queues_do_not_advance_each_other`.
- ⚠️ **EACH STAGE'S OWNER LIVES ON THAT STAGE'S OWN ROW.** The nurture owner is `lead_funnels.assigned_admin_id` (added 2026-08-13); the closing owner is `consultation_requests.assigned_admin_id`. Assigning at registration through the enquiry table would have to MINT a stub — the shape that made 已约 1-1 read 93 for a funnel nobody had messaged, and one that still SHADOWS the customer's real booking on the merged roster whatever the counting does. The roster shows the **closing owner once one exists**, else the nurture owner, so **the hand-off between the two teams is structural, not an overwrite**: nothing is mutated, a later fact simply outranks an earlier one, and each team keeps its own record of who they worked. Pinned by `test_booking_hands_the_person_to_the_closing_team`.
- ⚠️ **THE POOL IS A SEQUENCE, NOT A SET.** *"Shawn, Shawn, Shawn, KeXin"* is how an admin gives Shawn three turns in four — repetition IS the weighting control. That is why the members table carries **no unique (rotation, admin) index** and the repository does **not** de-duplicate; the modal shows a `×3` chip so the weighting is readable rather than counted by eye.
- ⚠️ **THE ARRANGED ORDER MUST SURVIVE THE SAVE.** Mapping the picker's uuids with `whereIn(...)->pluck('id')` returns DATABASE order, and the repository uses the array index as the turn `position` — so the one control the modal exists to offer would be silently discarded while the rotation kept cycling happily. The controller maps **in request order**; `test_the_arranged_turn_order_survives_the_http_save` goes through HTTP on purpose, because seeding `member_ids` directly is exactly what hid it.
- **Fairness is a monotonic counter x modulo over the LIVE-eligible list**, claimed under `lockForUpdate()` in the same transaction as the row write, through one shared `claim()` both queues use. Never a stored position pointer (out of bounds the moment the pool shrinks, then silently assigns nobody); never least-loaded (same lock, no gain, and lifetime counts hand a new member every lead until they catch up). Members are re-checked against role + `sales-execution` + active status **at claim time**, filtered BEFORE the modulo, so a resigned member is skipped **without consuming a turn**. An empty or fully-ineligible pool assigns nobody and deliberately does **not** fall back to "any admin". The cursor **survives an edit** — zeroing it on every save would hand the first admin every lead.
- ⚠️ **THE ROTATION FIRES FROM THE CONTROLLERS, NEVER FROM `ConsultationRequestRepository::create()`.** The admin roster calls that same method whenever a colleague picks a Caller by hand for somebody who never booked; hooked there, every manual pick would claim a turn, buzz a DIFFERENT admin about a lead that is not theirs, and then be overwritten one line later by the name the admin actually chose, with nothing erroring. `Main\ConsultationController::considerCallerRotation()` and `Main\LandingController::considerNurtureRotation()` are the only two publishers.
- **Forward-only, structurally**: saving writes configuration and nothing else — no sweep, no backfill — and each claim refuses a row that already has an owner. Only a genuinely NEW registration claims a turn (`lead_funnels` is `firstOrCreate`d, so a returning visitor is not a new row). `activated_at` is the on-screen proof.
- ⚠️ `caller_uuid` on the inline picker was tightened from `exists:users,uuid` to the assignable pool in the same change: every customer and every lead is a `users` row too, so the old rule would have accepted a CUSTOMER as a colleague's Caller. Both doors are pinned (`test_a_customer_cannot_be_put_in_the_pool`, `test_a_customer_cannot_be_set_as_a_caller_on_the_roster`).

- ⚠️ **The withheld banner reports PEOPLE we cannot contact — never rows that are not people.** An anonymous `funnel_video_views` row with **0 watched seconds** (a page that opened and reported nothing) is skipped before the completeness filter ever sees it. Counted as incomplete, it showed in the banner as a lead we had failed to capture, so an admin hunted for somebody who does not exist and merged identities trying to clear it — which could never work, and a withheld row is unreachable from the roster's own Remove action, so there was no way out of the UI either. An anonymous viewer who genuinely WATCHED is still counted (real audience, nobody to call). Pinned by `test_an_empty_anonymous_view_is_neither_shown_nor_reported_as_withheld`.
- **Every row on the roster is CONTACTABLE — guaranteed** (2026-08-12). A row missing any of **name / email / phone** cannot be worked (nobody to greet, nothing to mail, no number to call), so it never reaches the table. Two steps, in this order:
  1. **Rescue a withheld phone first.** A registration whose typed number already belongs to ANOTHER account is created with `phone = null` — an unverified typed phone must never be grafted onto a second account (`RegisterLeadAction`'s takeover rule). The number is not lost: the same registration files a pending `verified_identity_pairs` row carrying it, because two accounts sharing a number IS a duplicate to merge (in practice, the same person re-registering under a second email). The roster reads that back (one query, read-only, never writes identity) and flags the row `phone_from_duplicate` — without it a contactable person reads as uncontactable and the caller gets a dash.
  2. **Withhold what is still incomplete, and SAY SO.** The count ships as the `vslHiddenRows` prop and renders as a slate note above the table. A table that silently drops rows is how a paid-for lead disappears — the omission has to be visible to be trustworthy. ⚠️ A name that is merely the account's EMAIL counts as missing: `vslLeadIdentity` falls back to it when the profile has no name, which looks filled but renders the same address twice across two columns.
  Pinned by `test_the_roster_only_ever_shows_rows_with_a_name_email_and_phone` (complete shows · rescued-from-pair shows and is flagged · no-phone and no-name are hidden AND counted).
- **A fourth band, MESSAGES — what the automation actually sent them** (2026-08-13). One column per WhatsApp rule in scope (the funnel welcome + every after-registration follow-up), one status icon per cell, the reason and timestamp on hover — the **session Registrations roster's own pattern and colour language**, so an admin never meets two ways of reading "delivered". Built by `FunnelsController::buildVslMessagesProp()` (`vslMessages` prop) as the funnel-scoped twin of `EventsController::buildMessagesProp`, and it rides the roster's own lead-visibility gate because whether we messaged someone is a fact ABOUT that person. Above the table: **N reached · N read · N not for them · N problems**, the last a click-to-filter — the pre-call check of who did NOT get their message.
  - ⚠️ **A STAND-DOWN IS NOT A PROBLEM, and is not coloured like one.** A conditional rule that was not for this person (`condition_not_met`) or that skipped them because they had already booked (`already_booked`) did exactly the right thing. Those cells render **neutral grey** rather than the amber every other skip gets, and are counted apart as *not for them*. Colouring them as failures would paint a correctly-working funnel amber and send a caller hunting for a fault that is not there — the same false alarm `SKIP_ALREADY_POSTED` was excluded for. The set is `FunnelWhatsappSend::NON_PROBLEM_SKIPS`, read by the roster, `ruleSendStats()` and `attachProblemReasons()` alike, so a new no-op cannot be added in one place and forgotten in the others.
  - **Each column's header says WHO the rule was for** (its condition chips, on hover). Without that, a column that is grey for two thirds of the roster reads as a broken rule rather than a deliberately targeted one. Session-timed rules are excluded from the band outright — a VSL has no sessions, so their column could only ever be a row of dashes — and an **anonymous** row (watched without registering) shows a dash, since no ledger row can exist for somebody with no lead.
  - The rules themselves are authored on the **Automation** tab, which swaps its five-step session timeline for a **two-step VSL journey** (① when they register → ② a set time after they register, narrowed by watch depth + booking state). Full mechanics — the partitioning buckets, the 3-hour catch-up band, the never-retroactive guard and the de-dupe key — in [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md) → *After they REGISTER*.
- **Row actions: discussion + eye + trash.** The **discussion** button is the shared [`Components/LeadDiscussionButton.vue`](/resources/js/Components/LeadDiscussionButton.vue) (2026-08-12) — ONE component now mounted by every lead-shaped table (this roster, Sales project leads via `Sales/EngagementTable`, Property Match), so the affordance, tooltip and badge read identically instead of each table hand-rolling its own. It opens the shared `LeadDetailModal` straight into a NAMED topic (`openLeadDiscussion` — find-or-create by title), and the topic is the funnel's **linked project** name when one is set, else the funnel's own name: a VSL remark and a Sales remark about the same deal then land in **ONE thread** instead of two nobody cross-reads. The violet **badge** is that topic's comment count, so an admin sees a thread has history before spending a click; counts come from the shared **`Concerns\BuildsDiscussionCounts`** trait — one grouped query per page, keyed `(lead, topic-title)` so a lead's Property Match chatter never inflates the badge on their project row, and soft-deleted comments/threads stop counting. Anonymous rows have no lead and therefore no thread. **⚠️ 2026-08-12 — deleting a topic or a comment from this roster used to delete the FUNNEL.** The modal writes over axios, `back()` answered 302, and a browser replays a DELETE it follows — straight onto this page's own URL, which has a `DELETE {id}` route. Fixed app-wide by [`PreventRedirectMethodReplay`](/app/Http/Middleware/PreventRedirectMethodReplay.php) (302 → 303 on every write); the full account is in the [Leads handbook](/docs/modules_handbook/manage/leads/readMe.md). Nothing about *this* page was wrong — but any Show page with a `DELETE {id}` route was standing in the same line of fire.
- **Link the funnel to a project** — `event_funnels.project_id` (migration `2026_08_12_300001`, run locally), a `ComboBox` on the funnel form shown for **VSL funnels only** (a webinar funnel's remarks flow through its sessions). It is what gives the roster's discussion button its topic; unlinked funnels fall back to the funnel name, and unlinking is a legal edit.
- **Row actions: eye + trash.** The eye opens the shared read-only [`LeadDetailModal`](/resources/js/Components/LeadDetailModal.vue) in place via `useLeadModal().openLead(uuid)` — mounted once in `ManageLayout`, so the admin reads the whole CRM record without losing this table's search, filters or scroll. It is gated on the LEADS permissions (`view-leads-*`), not this page's: a reports-only Marketing user can read the roster but has no lead surface to land in, so the affordance hides instead of 403ing. Anonymous rows have no lead and show no eye.
- **The filter is SIX COMPOSING GROUPS** (`composables/useVslRosterFilters.js`, 2026-08-12; status 2026-08-14; closer + the status row's promotion 2026-08-15; **Quality 2026-08-18**) — they AND together, so *"the people Kexin owns, who watched past halfway, that we have not reached yet"* is one read instead of three exports. Every chip label is **English** (`All`, `Unassigned`, the group names) except the Chinese-only options nobody asked to translate — a dual-language chip (`全部 / All`) spends a third of a crowded row saying one word twice:
    1. **Form** — All / **已填 1-1 预约 Form** / **已填 + Scheduled** / **已填 + 还没 Scheduled** / **没填** (reworked 2026-08-15). `form` / `noform` partition the roster on one question — did the customer submit the 1-on-1 booking form (`whatsapp_opened_at`) — and the two SUB-options split `form` into the work queue: of the people who asked, who have we pinned a time with and who is still waiting on us. ⚠️ The pair reads **`appointment_at`, the fact**, not the Scheduled STATUS: a status is an admin's label and lags the moment somebody sets a time and forgets to change it. ⚠️ They sum to `form`, which sums with `noform` to All — so this group's options are deliberately NOT mutually exclusive; counting stays honest only because every count is derived from the option's own predicate and nothing assumes exclusivity. ⚠️ The sub-options are scoped to form-fillers on purpose: an appointment an admin fixed by phone for somebody who never touched the form appears in NEITHER, which is the whole difference between "who did we pin down" and "who has a time in the diary".
    2. **Watch** — All / **没看 video** / **看不到一半** / **看超过一半** (`HALFWAY = 50`). These three DO partition everyone, and a test asserts it: `completion` is null whenever the player never reported a duration, and a null that matched no bucket would leave a cohort unreachable from any filter while All still counted it. A null counts as UNDER half — over-crediting attention is the error that costs a call.
    3. **Status** — promoted on 2026-08-15 out of the chip rows into **its own row above them**, where the ten statuses render GROUPED (Total · 未处理 | Contact | Interest | Zoom | Outcome) so the shape of the pipeline reads left to right instead of as ten flat chips. ⚠️ ONE ROW ALWAYS — it scrolls horizontally rather than wrapping, because the grouping IS the information and a line break invents a fifth group; every child is `shrink-0` and the type is small so a laptop fits the whole pipeline. Groups, counts and selection all come from the same tables the select and the predicates use (`FOLLOW_UP_GROUPS` / `FOLLOW_UPS` / the status filter group), so the row cannot drift from them. 未处理 is explicit so untouched (and anonymous) rows land in a bucket and stay reachable.
        - **Every status owns a hue, and ONE table (`STATUS_TONES`) paints all three surfaces** — the chip, the per-row dropdown and the read-only chip — so the cell an admin just set matches the chip they filtered by. Colour says WHICH status; the VARIANT says which control (`chip` tint / `on` solid = the one being filtered by / `field` for the select's border+fill). ⚠️ Full literal class strings, never `bg-${color}-50`: Tailwind scans source for finished class names, so an interpolated one is never generated and the chip renders unstyled with nothing failing. ⚠️ The ten hues are deliberately far apart — `Attended Zoom` moved emerald→**teal** and `Closed` teal→**violet** on 2026-08-15 because emerald/green/teal sat side by side and read as one colour. **Zero-count chips dim to `opacity-50`** (hover restores): ten fully-lit hues where nine are empty reads as noise rather than as a pipeline, and the shape of where people actually are is the point of the row.
    4. **Caller** — All / one per assigned admin / **Unassigned**. The unassigned option is NAMED, not implied: those people are reachable only by eye otherwise, and they are the likeliest to be dropped.
    5. **Closer** — All / one per assigned closer / **Unassigned** (2026-08-15). Reads the row's OWN `closer` fact, never derived from the caller columns, so the two compose instead of shadowing each other.
    6. **Quality** (2026-08-18, the lead-quality-tags-and-filters plan's Phase 5) — **`全部 All`** / **Agent** / **Fake** / **Clean**, one-tap triage over the SAME `agent_state`/`fake_state` (Phase 2's `Src\Lead\Support\LeadQuality` resolver) the row's own tag chips render and the Leads index filters on. **Agent** folds `agent_linked` into the same bucket as a confirmed `agent` — this is coarse one-tap triage, not the Leads index drawer's four-way taxonomy, which stays the precise tool for that distinction. **Fake** matches `fake_state === 'fake'` regardless of source (an AI-suspected fake and an admin-confirmed one are the same bucket; only the tag's own label — "Fake?" vs "Fake" — carries that difference, via `LeadQuality::fakeLabel()`/`QualityTagMeta.js`'s `fakeLabel()`). **Clean** is neither flagged — an ADMIN-CLEARED row (`not_agent`/`genuine` with source `admin`) lands here exactly like a never-judged one; the bucket answers "anything to worry about", not "who decided". The matching predicate is `matchesQualityOption(insight, option)` in `resources/js/Components/Leads/QualityTagMeta.js` — the ONE shared definition of the four buckets, also used by the session **Registrations** roster's own quality filter (`resources/js/Pages/Manage/Events/Partials/Tabs/RegistrationsTab.vue`), so the two rosters can never disagree on what counts as Agent/Fake/Clean even though they present it differently: **that roster keeps its EXISTING `FilterDrawer`/`ActiveFilterChips` idiom (Status / Source / Campaign / Zoom / date range already live there) rather than growing a second, competing filter mechanism for one more dimension** — `quality` is a single-select `FilterDrawer` dimension with its own `counts` map (walked the same way `source`/`campaign` already are), while this VSL roster stays all-pills. ⚠️ Because this roster's export re-applies the chips server-side, the group is ALSO in `exportParams` and in [`VslRosterFilter::matchesQuality()`](/src/Event/Support/VslRosterFilter.php) (pinned by `VslRosterFilterTest::test_quality_buckets_mirror_the_composable`) — add a bucket in one place and the file silently stops matching the screen. One filter idiom per surface, by QA decision (2026-08-18) — see the [leads handbook](/docs/modules_handbook/manage/leads/readMe.md) for the shared resolver both idioms read.
  The other five groups render as chip rows UNDER the status row, in the order Form / Watch / Quality / Caller / Closer; the component picks them **by key** (`chipGroups`), never by index — an index silently re-points at a different dimension the day a group is added, and the reader would then be filtering by something other than the label. **Counts are FACETED** — each option shows what it would leave GIVEN the other groups' selections, so a chip never promises rows a second filter has already removed; with several groups live at once that is the normal case, not the edge case. Every option is declared once as `{ key, label, hint, match }` and the counting, the filtering and the markup all walk that one table — `useVslRosterFilters.test.js` pins *every option's count equals the rows clicking it actually yields*, across seven filter combinations, because a card that says 12 and opens a table of 40 is not a visible bug, it is a number an admin plans a day around. A **显示 N / M + Clear all filters** line appears whenever anything is narrowing, so a short table never reads as missing data. The amber 没发-WhatsApp banner is gone (product call 2026-08-12), and so is the per-row **WhatsApp column** (2026-08-13) — the fact it showed now lives only in `consultation_requests.whatsapp_opened_at`, which is what the 已约 1-1 count keys on; in its place sits a **Caller chips** row, one chip per admin anyone is assigned to (name + count, computed from the same rows), so a caller clicks their own name and reads their worklist — it composes with the card filter, both apply. *(A seventh card, **看满 5 分钟**, was removed the same week — once the gate was gone it measured watch depth, which the % + minutes in the 影片进度 column already say per row. The Summary tab's waterfall keeps it as a stage, where a drop-off between steps is the point.)*
- ⚠️ **已约 1-1 COUNTS `whatsapp_opened_at`, NOT the existence of a `consultation_requests` row** (2026-08-12) — and the difference is not academic. `scheduleVslLead()` MINTS a row the first time an admin assigns a caller to somebody who never enquired, because the appointment and the caller need somewhere to live; counting rows therefore counted **our own follow-up work as customer demand**. Live Cochrane read **93 booked** for a funnel nobody had messaged — 93 being exactly the three callers' 31 + 31 + 31. `whatsapp_opened_at` is stamped **only** by the public booking form, at the moment the hand-off tab to wa.me was granted, so it is the customer's own act and the admin path can never set it. The rule is applied in **three separate queries** that must stay in step — the roster's Form chips (`VslLeadsTab`), `vslStats()` (hub + Summary waterfall) and `vslIndexStats()` (the funnels index's *booked 1-1*) — and `vslIndexStats` counts **distinct people** for the same reason `watched` does, or one person booking from two devices out-counts the funnel's leads. Since 2026-08-15 the roster group is simply **已填 / 没填** on this one rule; the appointment question it used to carry (已定时间 / 未定时间, which deliberately did NOT follow the rule — an admin who fixed a slot by phone has an appointment) now lives in the Appointment column and the **Scheduled** status. Pinned by `test_a_caller_assignment_is_not_counted_as_a_booking` and `test_one_person_booking_twice_counts_once_on_the_index`.
- **Inline scheduling: 预约时间 + Caller** (2026-08-12, columns beside 1-1 预约 — asked / agreed / who-owns-it read as one story). `consultation_requests` gained **`appointment_at`** (the EXACT date-time a colleague agreed on WhatsApp — distinct from the `preferred_date`/`preferred_slot` band the visitor typed, which is a preference, not a booking) and **`assigned_admin_id`** (migration `2026_08_12_200001`, run locally). The roster edits both inline on **EVERY row, booked or not** (product call 2026-08-12 — an appointment arranged directly on WhatsApp is still an appointment): a **`ComboBox`** over the `vslCallers` prop (the shared `assignableManagerOptions` pool, filtered locally) → `PUT funnels/{id}/vsl-schedule` (`manage-events`, `Funnels\VslConsultationRequest`, funnel-scoped 404) — an **UPSERT**: an existing enquiry is addressed by `consultation_uuid`; a row that never filled the booking form is keyed by `lead_uuid` / the anonymous `visitor_key`, reuses the person's newest enquiry when one exists, and otherwise gets a minimal one created on save (identity seeded from the account). Only a row with no identity at all cannot be pinned. Writes land in `ConsultationRequestRepository::update()`, which also keeps **`status` in step**: setting an appointment advances a fresh enquiry to SCHEDULED, clearing it steps SCHEDULED back to CONTACTED, CLOSED is left alone — so the status column never contradicts the appointment beside it. ⚠️ The endpoint replaces BOTH fields per call; the tab therefore always sends the row's current pair with one side patched (sending only the changed field would silently null the other). 
- **Remove a person** (2026-08-11) — each row's Trash action → `DELETE funnels/{id}/vsl-leads` (`manage.events.funnels.vsl-leads.destroy`, `manage-events`, `Funnels\VslLeadRemoveRequest`: a `lead_uuid` OR the anonymous `visitor_key`, exactly one) → `EventFunnelRepository::removeVslPerson()`: deletes the person's `lead_funnels` + `funnel_video_views` rows and **soft-deletes** their `consultation_requests`, in one transaction — the **Lead/account itself is never touched** (it may belong to other funnels). ⚠️ Removing a registered row also removes its ad attribution, so the funnel's lead count drops and its **CPL rises** — the `ConfirmModal` says so. VSL funnels only (404 otherwise): a webinar funnel's people are managed per session.
- ⚠️ **The topic is the project's CANONICAL name** — `EventFunnel::discussionTopic()`, ONE definition read by both the rows' button and the badge count. It was `$funnel->project?->name`, the RAW column: a catalogue-linked project owns none of its own facts and `projects.name` is a stale compatibility cache that is never read back, so a catalogue rename would have had a VSL row opening a SECOND thread under the old name while the Sales pages kept writing to the canonical one — two threads for one deal, each side's badge counting only its half. Made a method, not an expression repeated twice, because the two silently diverging is exactly how the promise broke. `project.catalogProject` is eager-loaded where it is read. ⚠️ The original test could not catch this: its fixture is a CUSTOM project, where `canonicalName() === name`. Pinned by `VslFunnelManageTest::test_the_topic_follows_the_catalogue_name_not_the_stale_projects_column`, whose fixture is catalogue-LINKED with a deliberately different stale name.
- **The three columns after assigning a caller** (2026-08-12). Assigning a caller CREATES an enquiry, and that one side effect used to light up two neighbouring columns with things that were not true, so each now answers one question honestly:
    - **`1-1 预约` shows two different facts, because there are two kinds of row.** A visitor who filled the booking form ASKED for a band, and that band is their words — it stays exactly as it was (`2026-08-14 · 晚上 8:00 – 9:30`, emerald). A row with no form behind it — a caller assigned by hand, which CREATES the enquiry — has no band to show and used to read *New*, the enquiry's stored `status`, which says nothing about whether anyone has a time in their diary. That case now reads the STATE instead: **Pending** (amber) until an exact time exists beside it, **Scheduled** (emerald) once `appointment_at` is set.
    - ⚠️ **Inline edits never cost the admin their filters.** Every roster write (appointment, caller, status, the booking/backfill/rotation modals) submits with `preserveState: true` + `preserveScroll: true`: the props refresh, the DataTable re-renders, and the four filter groups / search / band toggle survive, because they are component state and a remount wipes them. A `preserveState: false` here once made every status change reset the page — the exact opposite of an inline edit.
- **The follow-up STATUS column** (`lead_funnels.follow_up_status`, 2026-08-14; reworked 2026-08-15 into a GROUPED table of ten): the dropdown renders four `<optgroup>`s — 联系 · Contact (NPU / Called), 意向 · Interest (Scheduled / Keen, not scheduled / Not keen), Zoom (Attended Zoom / Not Attended Zoom), 结果 · Outcome (Closed / Converted / Lost) — with null = 未处理 as the placeholder, not a status. The names, hints and colours live ONLY in `LeadFunnel::FOLLOW_UPS`; the DISPLAY ORDER lives in **`FOLLOW_UP_GROUPS`**, an ordered ARRAY of `{name, statuses}`, because the keyed map's integer keys re-sort numerically in JS — both ride `vslMeta`, and the select's optgroups and the filter chips' order derive from the groups table. ⚠️ Values are storage, never display order: renames keep their number (3 Done Zoom → Attended Zoom, 5 Not so keen → Not keen); **4 (Priority) is RETIRED** — the 2026-08-15 closer migration nulls surviving rows back to untouched, and the value must never be reused. A select for managers, a chip for readers; saved via `PUT {id}/vsl-status`. On the REGISTRATION row like the nurture caller, because a status must be settable on people who never filled the booking form — a stub enquiry minted to hold it would shadow a real booking. Anonymous rows have no registration row and show a dash. The array deliberately has no zero entry, so "untouched" can never be confused with a real status.
- **The Appointment column is the shared cell, not a date input** (2026-08-20). It used to be a bare `datetime-local` writing `consultation_requests.appointment_at`, which recorded a time and nothing else: no invitation to send, no entry on anyone's calendar. It now mounts [`Components/AppointmentCell.vue`](/resources/js/Components/AppointmentCell.vue) + [`useLeadAppointment`](/resources/js/composables/useLeadAppointment.js) — the SAME cell, calendar form modal and detail modal the [Property Match roster](/docs/modules_handbook/manage/engagement/sales-projects.md#the-appointment-column-shared-with-the-vsl-roster-2026-08-20) uses, over the SAME `ResolvesLeadAppointments` resolver — so a booking made here is the very object the calendar page manages, Copy Invitation included, and the same lead reads identically on both pages.
  - ⚠️ **SAVING WRITES TWICE, ON PURPOSE.** The calendar row is one fact; the enquiry's own `appointment_at` is another — *"a time a colleague agreed, however it was arranged"* — and it is what the **已填 + Scheduled / 还没 Scheduled** chips, the follow-up status and `VslLeadsExport` all read. Booking from this column without stamping it would leave four surfaces saying the person has no time set while the row plainly shows one. `AppointmentFormModal`'s `saved` event carries `{ scheduled_at }` for exactly this; the tab feeds it back through the existing `vsl-schedule` upsert.
  - ⚠️ **AND ON RESCHEDULE, CANCEL AND DELETE — `saved` alone is a bug.** Those three happen inside `EventDetailModal`, not the form, so a mirror that only listened to `saved` went stale the moment an appointment moved or was called off. Worse than stale: `VslLeadState::bookedLeadIds()` reads `appointment_at` to decide whether the *"要不要约个 1-1?"* nurture message is SUPPRESSED, so a cancelled appointment silently dropped that person from both the worklist and the automated re-invite — and with the inline date input gone, nothing in the UI could repair it. `EventDetailModal` therefore emits **`changed`** (`{ scheduled_at }`, null when the event is gone) and the tab mirrors that too. Clearing the column is also what steps the enquiry's status back out of SCHEDULED.
  - ⚠️ **`@edit-appointment` must be wired** where the cell is mounted, or the detail modal's Edit button closes and does nothing.
  - ⚠️ **`appointment_at` was NOT retired**, and must not be. It is not a duplicate of the calendar row: it is also settable from `VslBookingDetailsModal` for a time agreed on WhatsApp with nothing on any calendar, and four surfaces read it. Retiring it means rewriting all four.
  - The Show page gained `zoomScheduling` / `appointmentTypes` / `appointmentStatuses` props, all null for a webinar funnel — the same metadata Property Match sends, because it is the same modal.
- **`php artisan vsl:backfill-scheduled-status [funnel] [--dry-run]`** (2026-08-15) — the one-off that marks everyone who already had an appointment as **Scheduled**, for the history that pre-dates the status column. ⚠️ **IT ONLY EVER MOVES PEOPLE FORWARD**: the promotable set is untouched / NPU / Called — the states BEFORE Scheduled — so a row already at Attended Zoom, Converted or Lost is left exactly as it is. An appointment does not un-attend a Zoom, and a backfill that walked people backwards would destroy the record it exists to complete. Rows it declines to touch are COUNTED in the output table (`Left as-is`), never silently skipped. Idempotent and re-runnable; writes through `LeadFunnelRepository::update()` like every other status write, so there is no second lane. An unknown funnel argument FAILS rather than falling back to "every funnel". Pinned by `VslBackfillScheduledStatusTest`.
- **The CLOSER column** (`lead_funnels.closer_admin_id`, 2026-08-15, beside Caller): who runs the 1-1 Zoom and closes — a DIFFERENT colleague from the Caller who dials and books, so it is its own column, its own filter row and its own write lane (`PUT {id}/vsl-closer`, `VslCloserRequest` — same assignable-pool rule as the Caller, because every customer is a `users` row and `exists:users` would accept one). Manual pick only — no rotation queue feeds it. On the registration row like the status (settable whether or not they ever booked); anonymous rows show a dash. The roster payload ships it as `closer` `{uuid, name}`, resolved in the same batched id→User read as the nurture callers.
- **Two roster notes were REMOVED on 2026-08-15** (product call — "not important"): the 这批人一共看了 N 分钟影片 total under the filters, and the N 条记录资料不全 hidden-rows banner. The completeness FILTER itself is unchanged — incomplete rows are still withheld server-side, and `vslHiddenRows` / `vslStats` are still shipped (tests pin the definitions); only the two banners went.
- **Add record — backfilling a person who registered before tracking** (2026-08-14, the button beside the search box; `POST {id}/vsl-leads`, `VslLeadStoreRequest`). All three contact keys are REQUIRED because the completeness filter withholds any row missing one — a modal that saved into the hidden pile would recreate the exact confusion it exists to end. Identity goes through `LeadRepository::firstOrCreateForIdentity`, so an email/phone already on file ATTACHES to the existing person, never a duplicate. `registered_at` is backdatable (the `attach()` whitelist gained the key; the public path still stamps now()); watch minutes land under a `backfill:{lead uuid}` visitor key that can never collide with a real session; and a backfilled booking carries NO `whatsapp_opened_at`, so 已约 1-1 deliberately does not move — that count is the customer's own act.
- **The 1-1 预约 Form cell is two states, both clickable** (2026-08-14; column renamed 2026-08-15 — the header now says FORM, because the cell reports what they filled in, not when they will be seen): the date+slot they asked for, or **Pending** — no more "Scheduled" (the appointment column already says it) and no more "—". Clicking opens `Partials/VslBookingDetailsModal.vue`, which edits the booking ANSWERS (date/slot/budget/goal/timeline/notes — an answer taken over the phone is still an answer) through the same `vsl-schedule` upsert. ⚠️ The answer fields are PRESENT-ONLY on the server while appointment/caller keep replace-semantics: the modal always sends the whole set (current appointment included), and the inline editors send none of the answers — get this backwards and an inline date change silently wipes what the customer typed. The repository's `data_only` whitelist gained the six keys in the same change (the classic trap: a whitelisted update() drops new columns silently). Pinned by `test_booking_answers_survive_an_inline_appointment_edit`.
- **The actions column can add a row's lead to the linked project's pipeline** (2026-08-14, reworked 2026-08-15; button order is discussion → add → remove): a `FolderPlus` that opens the shared `Components/Sales/AddLeadToProjectModal.vue` with **both sides locked** — its new optional `lead` prop (`{ uuid, name }`) hides the search/create steps, `projectUuid` is the funnel's link — leaving what the admin actually decides: **which stage the pipeline opens at** (`Engagement::STATUSES` select), plus the unit fields when that stage is a booking. Same modal, same endpoint (`POST /manage/sales-projects/{uuid}/leads`), so the same GroupScope + LeadVisibility gates apply and `open()` stays idempotent (re-adding never restages a live deal; the flash says which happened) — no second write lane. The modal's tables ride `vslMeta` (`engagementStatuses` / `leadSources` / `defaultLeadSource` = `SOURCE_FUNNEL`) — the SAME constants the Sales pages ship, so the two surfaces cannot disagree. Already-in rows render a green `FolderCheck` (title carries the stage) matched by **uuid**, not name — `pipelinesForLeads()` gained `project_uuid` for this, because the pipelines payload shows `canonicalName()` while the funnel link ships the raw `name`. Gated on the endpoint's OWN permission (`manage-leads`), hidden without a linked project or a real lead. Filters survive the save (useForm's non-GET default is `preserveState`). Pinned by `test_roster_pipelines_carry_the_project_uuid_the_add_button_matches_on`.
- **The Lead cell's phone button is CLICK-TO-CALL with the recording-machine prompt** (2026-08-13, before the WhatsApp icon — the shared `Components/CallLogButton.vue`, also mounted on the Lead page and the quick-view modal, so it is ONE habit everywhere). It writes the same `agent_call_events` intent the Sales Projects lead cell does (`POST manage/leads/{uuid}/call-clicks`), so the doway recording that follows **auto-pairs with this lead** on the next poll (same agent + started inside the click window; the event carries the lead's phone, so a recording that ever gains digits can only pair with the SAME number). Unlike Sales Projects it does **not** dial `tel:` — the agent dials on the recorder handset — so a success toast is the receipt. The pairing lands on Phone Call as a HIGH-confidence suggestion for one-click Confirm, never a silent attribution — see the [call-history handbook](/docs/modules_handbook/manage/call-history/readMe.md) for why (agent+time is a guess without a shared call id, and the auto-link behaviour was deliberately withdrawn 2026-07-17). Pinned end-to-end by `test_a_roster_click_pairs_the_next_recording_with_the_lead`.
- ⚠️ **The `WhatsApp` COLUMN was removed on 2026-08-13** (product call: the roster was too wide, and the fact it carried is already the one 已约 1-1 counts). It used to report the visitor's own tap of the hand-off button, scoped to enquiries the visitor actually submitted so an admin-created booking did not read as a warning about a button that person was never shown. The fact itself is untouched in the database, so a column or a filter option can be brought back without a migration; nothing else reads it on screen today.
    - **A `?` opens what they answered.** The three-answer line (`RM500k – RM700k · 收租 · 1 – 3 个月`) reads as a glance, not a record — alone, *1 – 3 个月* could be a budget, a timeline, or how long they have been looking. It moved into a modal behind a **clipboard** icon (`ClipboardList` — this opens a form somebody filled in, a thing to READ; a `?` would read as a question about the chip beside it), built from **`ConsultationRequest::answerCards()`** (`answers` in the payload): one `{question, answer}` pair per field, the questions quoted **verbatim from the video page's own form labels** (方便的日期 / 方便的时段 / 预算范围 / 你的目标 / 打算什么时候入手), unanswered fields omitted. `summary()` is untouched and still serves everywhere else.

- **Summary tab** (`VslSummaryTab.vue` ← `GET funnels/{id}/vsl-summary`, JSON, lazy, on the events group's own gate) — the same shared **`Components/FunnelMetrics/`** set the Dashboard and the session Summary mount (`KpiCards` + `ConversionWaterfall` + `LeadsSpendTrend`), fed funnel-scoped numbers, **all time** (a VSL runs continuously — there is no session window to scope by). The payload **composes existing definitions**: spend/CPL/trend/revenue from `FunnelDashboardService::build($funnel, null, null)` (identical to the Dashboard scoped to this funnel) + the VSL stages from the same `vslStats` the Leads tab renders — so neither neighbour can disagree with it. The waterfall walks the VSL's OWN journey: Meta-reported leads → opted in (+N imported/other excluded) → pressed play → 看满 5 分钟 → **预约 1-1 · Messaged us**; the KPI row adds **Cost / booking** (spend ÷ requested), the number a VSL campaign lives or dies by. ⚠️ Both the stage and that KPI read the **customer-initiated** booking count — see the 已约 1-1 note above — so a caller assignment can never flatter the cost per booking.
- **Ads tab** (`VslAdsTab.vue` ← `GET funnels/{id}/vsl-ads?grain=`, JSON, lazy, on the events group's own gate) — the session Ads tab's design at funnel scope: the same **Campaign | Ad set | Ad** grain switch (the Traffics vocabulary), the headline tiles (leads-from-ads x/y · spend · **Funnel CPL** = spend ÷ ALL landing leads, the same division the Summary and Dashboard make — tiles stay **campaign-grain** whatever the table shows), and two banded column families: **This funnel** (leads at that grain from `lead_funnels` ids, + the row CPL) vs **whole window** (the object's entire synced history from `meta_ad_insights` — spend / impressions / CTR from the sums / Meta leads / synced day range), plus the status badge + (ad grain) Meta's rendered **preview link** via `MetaAdLabelResolver`. Backend is `FunnelDashboardService::campaignBreakdown($funnel, $grain)`; Campaign-Mapping ties join at campaign grain only. **It is deliberately its own component, not the session `AdsTab.vue`**: that component is welded to session-only machinery (promotion-window card, day-by-day allocation audit) a funnel with no sessions cannot feed — what is shared is the vocabulary and the reading rules. The row CPL divides the object's whole spend by the leads it brought THIS funnel — accurate while the object is dedicated to the funnel, an overstatement if it also feeds other landings, and the card says so. Untagged/organic registrations are counted beside the table, never divided into any CPL.
- **Staff phone alert** — a VSL registration fires the **`events.vsl_registration`** [Notify](/docs/modules_handbook/shared/notify/readMe.md) event (`LandingController::considerVslNotify`, webinar funnels no-op): the registrant is on the video page *right now* and the next step is a booked call, so a fast personal follow-up is the point. Throttled **per person** (5 min — coalesces the same human double-submitting; a funnel-wide window would mute a real second registrant). Best-effort: the Notifier never throws at a registration.

**The funnels INDEX row swaps too** (2026-08-12). A VSL has no slots/sessions/session-registrants, so its row would read 0 · 0/0/0 · 0 — three numbers that look broken beside a live funnel. `FunnelsController::vslIndexStats()` (batched grouped queries — never per-funnel) gives each VSL row its own journey instead: the **Slots** cell is a dash, the **Sessions** cell shows **watching** (pressed play, not finished) · **finished** (`completed_at`) · **booked 1-1**, and the **Registered** cell shows the funnel's **landing leads** with the **ads · organic** split beneath (by whether the registration carried a `campaign_id` — the same split the hub's Ads tab makes; the total matches the hub header's Leads count). The name cell carries the amber type chip so a reader knows why this row reads differently. Webinar rows are untouched; sorting is unchanged (a VSL row simply sorts as 0 on the session-shaped columns).

Covered by `tests/Feature/Event/VslFunnelManageTest.php` (remove keeps the Lead, anonymous remove by visitor_key, the 404s on a webinar funnel, the summary/ads payloads incl. the grain re-slice, the index row's journey numbers, and register-survives-the-notify).

### Sessions — slot + date (the Sessions tab)
A session is just a **slot + a date** — created two ways: **Add session** (pick a slot + date; the app then creates everything, including the Zoom webinar when the slot opts in) or **Link Zoom webinar** (adopt a webinar that already exists on the Zoom account — see below). The hub's **Sessions** tab (`SessionsTab.vue`) lists the funnel's sessions (recent past + upcoming, in one place — **no Upcoming | Past toggle**; a **Past / Live / Upcoming** summary sits above the table) in the shared **`DataTable`** (non-paginated / client-side mode) with a client-side search box (title / status); columns are **Session** (mode icon + title), **Date**, **Time** (12-hour), **Status** (the derived `live_status` badge, a pulsing dot while Live) and three per-session engagement counts — **Registered** (all registrations), **Attended** (registrations marked Attended) and **CTA** (WhatsApp CTA touches captured while the session's webinar was live) — and each row's **actions** are **View** (opens the session detail page), **Edit** (the shared `EventFormModal` — title / date / time / mode / status; rescheduling re-syncs the webinar's schedule) and **Delete** (a `ConfirmModal` → `EventsController@destroy`, which tears the Zoom webinar down at Zoom + **emails every registrant a cancellation**, then soft-deletes the session). `transformSession` therefore also returns the full editable fields (`description` / `zoom_link` / `location` / `email_tickets` / `has_webinar`). The counts are `withCount` subqueries in `show()` (`registrations_count` / `attended_count` / `cta_count`, the last via the new `Event::ctaCaptures()` relation) so the list stays N+1-free. **Add session** (`AddSlotSessionModal`) lets an admin pick a **slot + date**; it posts to **`POST manage/events/sessions`** (`manage.events.sessions.store` → `SessionsController@store`, validated by `Sessions\StoreRequest`). The controller loads the posted slot and calls **`EventRepository::createFromSlot($slot, $date)`** — the slot → session field map — so the session **inherits the slot's** title / description / mode / times / link, and **its funnel is taken from the slot** (never the posted value alone). When the slot's `auto_webinar` is on, its Zoom webinar is auto-created (`CreateSessionWebinarAction::dispatchForGenerated([$event])`, a clean no-op when Zoom is unconfigured — see *Webinars* below).

Dedupe is **one session per `(event_series_id, scheduled_date)`, spanning soft-deletes** (a deleted session still blocks re-adding the same slot on the same day); the date is `after_or_equal:today` and **capped ~26 weeks ahead** as a fat-finger guard, and the slot must belong to the funnel and be **active** (a paused slot can't be scheduled). The same slot **may** appear more than once in a week on different dates — the date is the admin's to pick, with **no week/round constraint**.

**Move a session to another funnel — FINISHED sessions only.** A past session's row carries a fourth action (`MoveSessionModal.vue` → **`POST manage/events/{id}/move`**, `manage.events.sessions.move` → `EventsController@move`, validated by `Sessions\MoveRequest`). The restriction is the point: a session that has not run is still wired into live machinery — the reminder scanner matches on **both** `event_funnel_id` **and** `event_series_id` (`RunFunnelReminders::candidateSessions`), the landing page still sells it, and Zoom is still admitting registrants — so moving it mid-flight would stop the reminders matching and attach *new* Zoom registrants to the destination while everyone who signed up earlier stays on the source. A **cancelled** session is refused too (`live_status` is Cancelled, not Past — there is nothing to preserve), as is moving into the session's own funnel.

The write is one call, **`EventRepository::moveToFunnel($event, $funnel, $slot = null)`**, and it exists because a session's funnel is stored **twice**: `events.event_funnel_id` is a denormalised copy of its slot's funnel, and different code trusts different ones — the reminder scanner reads both, while `ImportWebinarRegistrants`, `WebinarAttendeeMatcher`, `EnrollLeadInFunnelSessionsAction` and the hub's session counts read the column directly. Writing one without the other does not error; it silently splits the numbers and stops the automation. Both are therefore set together in one transaction.

A session cannot hang off a funnel alone, so the **destination slot** is the real question — and the common case (a funnel with no slots yet, as the modal's disabled second option shows) has no existing slot to offer. So the default is **clone this session's slot into the destination**: same title / times / mode / visibility (+ its gating memberships), created **inactive** so it holds the history without generating new sessions, with its **slug regenerated** — `event_series.slug` is unique per funnel, so copying it verbatim would collide with a same-named slot already there and break the public `/{funnel}/{slot}` URL. The clone happens inside the same transaction, so a failure cannot strand an empty slot. The alternative is picking an existing slot in the destination, which `MoveRequest` checks really belongs to it (and is not soft-deleted).

**Registrations follow; leads do not.** `event_registrations` key on `event_id`, never on a funnel, so the whole roster and its attendance move for free. `lead_funnels` is deliberately left alone: it is a per-funnel registration row carrying that funnel's **own first-touch ad attribution** (utm / fbclid / ad_id / campaign_id / landing_url), unique on `(lead_id, event_funnel_id)` — those people really did arrive through the source funnel's landing page and ads, so rewriting it would falsify the attribution history (and CPL) and collide for anyone already in the destination. The modal says so, and the flash message repeats it. The destination funnel's `moveTargets` prop (every *other* funnel + its slots, active or not) comes from `FunnelsController::moveTargets`. Covered by `tests/Feature/Event/SessionMoveTest.php`. **The UI then shows both truths, labelled** — the hub header pairs `leads_count` with `registrants_count` ("On sessions"), and the index's people column is **Registered** (`registrants_count`) outright, so a funnel that received a moved session reads "0 Leads · 885 On sessions" rather than two numbers silently disagreeing (see *Funnels (programs) & the hub* above).

A new session starts **empty** — leads are **not** auto-enrolled. A lead lands on a session only via **their own registration** (landing / slot landing) or when an admin adds them on the session's **Registrations** tab.

**Link Zoom webinar — adopt a webinar scheduled in Zoom's own portal.** The Sessions tab's second button (`LinkWebinarModal.vue`) lists the account's linkable upcoming webinars (`GET manage/events/sessions/linkable-webinars` → `SessionsController@linkable`: every host via `listUserWebinars`, minus already-linked + past ones); the admin picks a **webinar + a slot** and `POST manage/events/sessions/link-webinar` (`SessionsController@linkWebinar`, `Sessions\LinkWebinarRequest`) hands off to **`LinkSessionWebinarAction`** (the mirror of `CreateSessionWebinarAction`), which: creates the **session on the webinar's own date/time** (converted to `app.user_timezone`; same one-session-per-`(slot, date)` dedupe as Add session), **binds the webinar** (`ZoomWebinarRepository::adoptForEvent` — reuses an eventless discovered row or creates one, `SOURCE_DISCOVERED` + Upcoming; a webinar already backing another session is refused), and queues **`ImportWebinarRegistrants`**: each Zoom registrant is resolved through **`WebinarRegistrantLinker`** → the shared [`LeadLinker`](/docs/modules_handbook/shared/lead-linking/readMe.md) (match the existing person by email, else mint account + lead; a form-typed phone is `TRUST_UNVERIFIED` — enrich-only; staff and unusable emails skipped), enrolled with **`EventRegistration::SOURCE_ZOOM`** ("Zoom form") and their **original** `zoom_registrant_id` + `zoom_join_url` stored — so `SyncWebinarRegistrants` (which only processes NULL-registrant rows) never re-registers them, they keep the join link Zoom already emailed, and attendance reconcile matches exactly. **New sign-ups keep arriving** — a linked webinar's Zoom registration page stays live, so the import is **not** one-shot: **`zoom:import-registrants`** (scheduled **every 5 minutes**, `withoutOverlapping`) re-queues `ImportWebinarRegistrants` for every webinar that is **bound to a session**, **still Upcoming/Live**, and **collects registrants** (`registration_url` — adopted *and* app-created alike, since a lead can register on Zoom's own page for either), skipping a stale Upcoming row whose *ended* webhook never arrived (`start_time` older than 6h). The job is idempotent **and `ShouldBeUnique`** (`uniqueFor` 600s, keyed on the webinar), so the 5-minute cadence never duplicates a person nor piles up hundreds of jobs while the queue worker is down. Without this sweep a post-link registrant would be invisible until they *attended* (reconcile recovers attendees as walk-ins) — so they would miss the funnel's WhatsApp reminders and never appear on the no-show list. An **adopted webinar stays managed at Zoom**: `CreateSessionWebinarAction::sync`/`teardown` skip the Zoom calls for `SOURCE_DISCOVERED` rows (`is_discovered`), so editing/cancelling/deleting the session here **never** edits or deletes the real webinar at Zoom (only the local binding row is removed). Everything downstream — webhooks/live status, attendance, engagement, per-slot WhatsApp reminders — works as for an app-created webinar.

### Slots — the session templates (+ auto-webinar)
Managed on the hub's **Slots** tab (`SlotsTab` → `SeriesController` writes via `SeriesFormModal`/`SeriesForm`), rendered in the shared **`DataTable`** (non-paginated) in **slot order** (`sort_order`) — columns **Slot** (title + mode, **no D-label**; a small **Members only** and/or **Inactive** tag sits beside the title only when it applies) / **Time** (12-hour) / **Upcoming** / **Registered** / **Attended** (rolled up across the slot's sessions), with per-row actions (Leads / Edit / Delete) and an **`#expand`** row for the public-page + design-file info. There is **no separate Visibility or Status column** (surfaced as the inline tags) and **no per-row active toggle** — a slot's active flag is set on the slot form (`SeriesForm`). Full CRUD: **create** (`sort_order` auto-assigned by `EventSeries::nextSortOrderFor($funnelId)` — `withTrashed max + 1`), **edit** (title / **slug** / description / start & end time / mode / Zoom-link or location / **auto_webinar** / **visibility** — there is **no day-of-week field**; the day is picked per session), **active toggle** (a paused slot can't be scheduled — `Sessions\StoreRequest` rejects it), **soft-delete**. The **`slug` is admin-editable** on the slot form (auto-suggested from the title until touched, pre-filled on edit): it drives the slot's **public URL** `/{funnel}/{slot}` **and its landing design path** `resources/js/Pages/Landings/{funnel}/{slot}/Index.vue`, and the form previews both live. `Series\StoreRequest` validates it URL-safe + unique within the funnel (spanning soft-deletes, via `withValidator`, ignoring the slot itself on edit); left blank it is derived from the title (`EventSeries::generateSlug`). Changing a slug on an existing slot moves its public URL + expected design path, so a bespoke design folder must be renamed to match. `SeriesController@store`/`update` map input explicitly and null the field that doesn't apply to the mode. Edits/deletes affect **future sessions only** — already-created sessions keep their copied values (the card shows `upcoming_sessions_count`). Each slot also fronts a **public slot landing** at `/{funnel}/{slot}` (its info + upcoming dated sessions a visitor can register for — see the Main [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) doc).

**Auto-create Zoom webinar (`auto_webinar`, default on).** For a Zoom-mode slot with the toggle on, every session added from it auto-creates a real Zoom webinar (host = the account's licensed webinar host) instead of just copying a static `default_zoom_link` — so the manual link field is hidden in the slot form, and the generated join link wins. Validation only requires a manual link when auto_webinar is **off**. The card shows an "Auto-creates Zoom webinar" line.

**Members-only slots (`visibility`, default Public).** A slot is either **Public** (default) or **Members only**, and a members-only slot ties to specific **memberships** (empty set = *any active member*) — the same gating LMS courses use, chosen on the slot form (`SeriesController` resolves the picked membership uuids → ids and syncs the `event_series_membership` pivot). Every session generated from the slot **inherits** that visibility and copies the slot's memberships into its own `event_membership` at creation (`EventRepository::createFromSlot`), so the member-portal Dashboard (`Event::isAccessibleTo`) shows qualifying members the session's full details and everyone else a locked *"Exclusive to {Membership} members"* preview (see [Dashboard](/docs/modules_handbook/main/dashboard/readMe.md)). Editing a slot's visibility affects **future sessions only** — already-created sessions keep the visibility they inherited. The slot card shows a **"Members only"** line.

**A members-only slot is invisible to the public registration surface, and that is enforced SERVER-SIDE, not just in the UI.** The landing form is anonymous, so nothing posted through it can prove membership — and enrolment is not a passive row: it makes Zoom email a personal join link, mails a QR check-in ticket for a physical session, and feeds the person into the funnel's WhatsApp reminders, i.e. exactly the access the member Dashboard deliberately withholds. Four gates, all in `LandingController` / `EnrollLeadInFunnelSessionsAction`: (1) the funnel-wide auto-enrol takes **public sessions only** (`->where('visibility', Event::VISIBILITY_PUBLIC)`), so registering on the funnel landing never sweeps a gated session in; (2) `register()` **404s** a posted `event` uuid whose session is members-only (`event` is validated only as an existing uuid, so without this any visitor knowing the uuid would be registered on it); (3) the slot landing `/{funnel}/{slot}` **404s** for a members-only slot; (4) the funnel landing lists **public** sessions and slots only, so a gated session's description and venue are never published. Members reach these through the member portal / an admin enrolment instead. Covered by `tests/Feature/Event/MembersOnlyPublicGateTest.php`. (This replaces the removed standalone Occasional module, which used to be the only place a members-only event could be created.)

### Sessions (the `Event`)
A single session's detail page (`EventsController@show` → `Events/Show.vue`) opens on **Registrations** and has **Check-in** (physical-mode only) / **Webinar** (Zoom-mode only) / **Attendance** (once a webinar exists) / **Engagement** (after the webinar ends) / **CTA** (Zoom sessions, or whenever any CTA touch already arrived) / **Ads** tabs, plus Edit / reschedule / cancel / delete. **There is no Details tab (removed 2026-08-03)** — the identity header card carries all of its facts instead: slot + funnel link in the subtitle (no type label — every session is Weekly), then a meta row (date / time / mode / location / Zoom link) and the description, so the tab strip opens straight on the work (an unknown `?tab=` — e.g. an old `?tab=details` or `?tab=messages` bookmark — falls back to the first tab). **There is no Messages tab either (merged into Registrations the same day)** — the funnel-WhatsApp delivery analytics live on the roster: a per-rule chip column, headline reached/read counts and a *problems-only* filter (detail in [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md)). The **CTA** tab lists the WhatsApp CTA touches (`ctaCaptures`) that landed while this session's webinar was live. For a Zoom-mode session these actions also keep the **bound webinar** in step — see *Webinars* below. The **Attendance** tab (live/final roster, no-show list + CSV/Excel export, unmatched participants — `attendance` prop via the shared `LoadsWebinarAttendance` trait) is detailed in the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md).
- **Reschedule is unconstrained** — a session can be moved to **any** date (the shared session `UpdateRequest` validates `scheduled_date` as just `required|date`); there is no week/round to stay inside, so there is no week-bounds guard.
- **Delete** — `EventsController@destroy` first tears down the session's Zoom webinar (deletes it **at Zoom** + locally, via `CreateSessionWebinarAction::teardown()`) so a deleted session never leaves a live webinar, then soft-deletes the session and **redirects to the funnel's Sessions tab** (the deleted session's own page would 404).

**Live status (derived, not stored).** Besides the stored `status` (Scheduled / Completed / Cancelled), a session exposes a **computed `live_status`** accessor — **Upcoming → Live → Past** (+ **Cancelled**) — with matching `live_status_label` / `live_status_color`. It drives the badge on the session detail header (`Events/Show.vue`) and the Sessions-tab chip (a pulsing dot while **Live**), and is what the slot landing's auto-pick reads. It's resolved fresh on read: a **cancelled** session is Cancelled and a **completed** one is Past; otherwise, for a **Zoom** session the **bound webinar wins** — `webinar.started` → **Live**, `webinar.ended` → **Past** — so an over-running webinar stays Live past its scheduled end and a webinar that ends early flips to Past immediately (the `webinar.ended` webhook also marks the session **Completed** — see *Webinars* below). Only `webinar.ended` (→ Past) and a **Live** webinar (→ Live) let the webinar win: with no webinar (physical, or Zoom before the webinar exists) — **and equally when the bound webinar is still Creating or Upcoming** — it falls back to the **clock**: `now ≥ start` → Live, `now ≥ end` → Past, else Upcoming (start/end parsed in `app.user_timezone`). That third case is deliberate: a session whose `webinar.started` webhook never arrives must not hang on "Upcoming" forever.

`is_upcoming` (Upcoming **or** Live) is **not** an `Event` accessor — it is a field `FunnelsController::transformSession()` adds to each session row, and on the Events pages its only consumer is the WhatsApp welcome-**backfill** modal's "include past sessions" filter. The Sessions tab has no Upcoming/Past toggle; it orders upcoming/live ahead of past and filters client-side.

### Webinars (per session)
A Zoom-mode session backs at most one `ZoomWebinar`. From the Webinar tab you can **rename** it, **delete** it, or **open it in Zoom** as host (`WebinarsController@update` / `@destroy` / `@start`). By default the webinar's **name follows the session title** — editing the session re-syncs title + schedule + agenda to Zoom (`CreateSessionWebinarAction::sync`); renaming the webinar on its tab sets **`topic_custom`**, which pins that name so later session renames leave it alone (a *Use session title* link reverts it). So editing the session re-syncs the webinar at Zoom, and **cancelling / deleting the session — or switching it to physical — deletes the webinar at Zoom** (`teardown`), so no inactive session ever leaves a live webinar. Creation is the shared **`CreateSessionWebinarAction`** (Zoom-API-then-persist → queue registrant sync), reached two ways: the session's **Webinar tab** (`WebinarsController@store`, a one-click create — nothing to fill in) and **auto-on-add** (the slot toggle → queued `CreateSessionWebinar` job); a FAILED attempt has a **retry** (`WebinarsController@retry`). The tab is **state-aware** — creating / failed, **Upcoming** (links + passcode + registrant progress), **Live** (current attendee count), **Ended** (attendance summary + the cloud-recording replay) — and polls while in flight. Enrolled leads are added as registrants (unique join links) for attendance tracking. **Full webinar + attendance detail lives in the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md).**

### Attendance & engagement (after the webinar)
When Zoom fires `webinar.ended`, two queued jobs finalize the session and populate its last two tabs: **`ReconcileWebinarAttendance`** (final Attended / No-show verdict + minutes → the **Attendance** tab) and **`SyncWebinarResponses`** (poll / quiz / Q&A results → the **Engagement** tab). The full flow — the reconcile/matcher internals, the canonical `zoom_polls` model, and the type-aware engagement rendering — lives in the **[Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md)**.

### Registrations (admin-assigned)
Leads are joined to sessions by admins. `EventRegistrationRepository::join()` is idempotent on `(event, lead)` and tags the registration's **`source`** (defaulting to `SOURCE_LANDING`; `EnrollLeadInFunnelSessionsAction` passes LANDING, admin adds via `RegistrationsController@store` pass `SOURCE_ADMIN`, reconcile / door walk-ins pass `SOURCE_WALK_IN`, an adopted webinar's imports `SOURCE_ZOOM`, and the guest-list importer `SOURCE_IMPORT`); `markStatus()` stamps `attended_at` when marking Attended; `remove()` un-joins. Managed two ways: the per-slot **D-page** (`WeeklyController@leads` → `Weekly/Leads.vue`) rolls up a slot's leads; and **any** session's own **Registrations** tab (`Events/Show.vue`) manages just that session's attendees.

**Adding people to a session (un-parked 2026-09-03 for physical guest lists — the button had been parked since 2026-08-03).** The Registrations tab's toolbar carries two ways in, both for any mode of session:
- **Add lead** (`Partials/AddRegistrationModal.vue`) mounts the shared **`LeadComboBox`** — search the CRM, or create the person on the spot through its hatch (`/manage/leads/resolve`, i.e. `LeadLinker`, so a typed-in person we already hold is MATCHED, never duplicated) — and posts `{event: uuid, lead_uuid}` to `RegistrationsController@store`, so a hand-added registrant earns exactly what a landing one does (the QR ticket when the session emails them, the activity-trail line, the Zoom registrant sync). The tab's former hand-rolled typeahead is gone.
- **Import CSV** (needs `manage-events` **and** `manage-leads` — the file mints people) mounts the shared **`ImportCsvModal`** in its **`register`** mode (`name` / `email` / `phone`, any alias, any order), against `RegistrationsImportController` (`POST manage/events/{id}/registrations/import/preview` → dry run; `POST …/import` → confirm; names `manage.events.registrations.import-preview` / `.import`). The PEOPLE half is the same **`BulkMemberImportAction`** in leads mode that the Leads page uses — same identity gate, same "an import never edits a contact detail" rule, same conflict / needs-review outcomes (a flagged row is ticked to register AS-IS, or skipped; the conflict-merge tick is there too) — and `apply()` now returns each row's **`lead_uuid`** so the controller can `join()` every resolved lead as `SOURCE_IMPORT` without a second identity pass. The preview additionally re-stamps an EXISTING / NEEDS_REVIEW row whose lead is already on this roster as **`already_registered`** (`BulkMemberImportAction::OUTCOME_ALREADY_REGISTERED`, filed under "already in the system"), and the confirm leaves such rows untouched (original source and status kept). Each NEW registration gets the ticket email (when the session's `email_tickets` is on) and a trail line; one `SyncSessionWebinarRegistrantsAction::forEvent` runs afterwards for a Zoom session. ⚠️ Ticket emails are sent synchronously per row, so a large physical import with ticket emails on is slow — `parseImport` already lifts the PHP time limit. Pinned by `tests/Feature/Manage/Events/RegistrationsImportTest.php`.
- **Ticket QR** (physical sessions only) — every roster row carries a **QR** action opening `Partials/TicketQrModal.vue`: the registration's door QR (the SAME code the public ticket page renders — the registration uuid — so a laptop turned round at the desk scans like a phone) plus the signed **ticket link** to copy into WhatsApp or open. The row's `ticket_url` is `EventRegistration::ticketUrl()`, attached in `transformRegistration` only for a physical parent (the rows are handed their `event` via `setRelation` in `transformRegistrations`, so it is never a per-row query); Zoom rows carry `null`. Adding a lead to a **Zoom-mode** session also **registers them on the Zoom webinar** (queued `SyncWebinarRegistrants` via the shared `SyncSessionWebinarRegistrantsAction`), so Zoom sends their unique join link + reminders — the same registrant sync every landing registration runs (see the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md)).

The **Registrations tab** (`Partials/Tabs/RegistrationsTab.vue`) renders in the shared `DataTable` and has **two views keyed on whether the session is finalized**. *Not finalized* (upcoming / live, or physical before check-in closes) → the plain registrant table (**Lead** / the CRM-context columns / **Source / Ad** / editable status + remove; the registered date-time sits **under the row #**, Leads-index style). The CRM-context columns mirror the Sales → Property Match view row-for-row: **Member** (active tier chip) / **Pipelines** (project + engagement status chips, linking to the lead's Pipeline tab) / **Property** (Booked / Converted / Lost count) / the banded **ENGAGEMENTS** group (**Message** in/out WhatsApp · **Zoom Meeting** · **Zoom Webinar** · **Phone Call** · **Portal**) — batched per session by the shared `BuildsLeadEngagementStats` trait (`app/Http/Controllers/Concerns/`, merged into each row by `transformRegistration`), a handful of grouped whereIn queries over the session's lead ids showing the same numbers as the Leads index. Per-rule **MESSAGES** band columns (Welcome / 1d / 30m …) additionally appear when the funnel has automations, and a **Zoom** column when the session is Zoom-mode. *Finalized* → a row of **clickable summary filter cards** that filter that same table: **Registered** (all, the default) · **Attended** · **No-show** · **Unknown people**. "Finalized" means the **Zoom webinar has ended** (`attendance.is_ended`) for a Zoom session, or — for a **physical** session — the session is Past **and** every registration is resolved (check-in was closed, so none are left on `Registered`). **Unknown people** = the **unmatched Zoom attendees** (people who joined the webinar but matched no registered lead, from `attendance.unmatched`); the card shows for a finalized **Zoom** session only (never physical), and its table lists the raw Zoom name / email / joined / minutes. The Add lead / Import CSV / status / remove management stays available in both views, and every row carries a **view-lead** (eye) action opening the lead's detail page (`/manage/leads/{uuid}`). The counts (Attended / No-show) are derived client-side from each registration's `is_attended` / `is_no_show`; the tab receives the same `attendance` prop the Attendance tab uses (threaded from `Events/Show.vue`) — no new backend. **Export (2026-09-01)** — an `ExportMenu` beside Filters downloads the roster as .xlsx / .csv (`GET manage/events/{id}/registrations/export` → `EventsController@exportRegistrations` → [`EventRegistrationsExport`](/app/Exports/EventRegistrationsExport.php)). ⚠️ **This roster filters in the BROWSER**, so — like the VSL Leads tab and unlike every other list in the app — the menu forwards the drawer, the campaign buttons, the problems toggle and the search as `params`, and [`Src\Event\Support\RegistrationRosterFilter`](/src/Event/Support/RegistrationRosterFilter.php) (the PHP twin of the tab's own `visibleRegistrations`, pinned case-for-case by [`RegistrationRosterFilterTest`](/tests/Unit/Event/RegistrationRosterFilterTest.php)) re-applies them over the SAME `transformRegistrations()` rows the page renders — there is no second query. The file flattens what a spreadsheet cannot show: the MESSAGES band becomes one column per rule under its FULL label + scope (`Welcome (Whole funnel)`, not the table's `1d` chip), the AI GUESS band is spelled into each heading, the Zoom chip splits into its state and the reason from its tooltip, and an AI call that never rang a phone (quiet hours / budget / blocklist) says so rather than reading as a customer refusing to talk. ⚠️ **That Zoom state is now the model's** — `EventRegistration::zoomState()` resolves the precedence (registered > exhausted > held > syncing; a held row still has zero attempts, so testing *syncing* first reports every paused registration as in-flight) and `ZOOM_STATES` carries the wording, both shipped on the row as `zoom_state` / `zoom_state_label`. The roster's column, its chips, its filter drawer and the export all quote them; before 2026-09-01 the rule and the four labels were written out separately in each of those places. Pinned by `ZoomPushRetryLoopTest::test_zoom_state_resolves_in_precedence_order`. The Zoom / MESSAGES / AI CALL columns appear on the same three conditions the table applies them. **A filtered file says so in its name** (`registrations-{session}-filtered-{stamp}`) — download the whole roster and then one campaign's slice and the two files otherwise look alike, and a slice mistaken for everything is the failure the whole endpoint exists to avoid. It is gated on **lead visibility, not `view-events`** — the roster's own gate, since an export is a file the admin keeps — and the MESSAGES columns additionally ride `$fullView`, so a reports-only reader would get neither them nor the problems filter they drive. See the [Export handbook](/docs/modules_handbook/shared/export/readMe.md).

### Check-in (physical sessions)
**There is no Check-in tab (product call 2026-09-03).** A physical session's door lives **inside the Registrations tab** — the separate `CheckInTab.vue` (a second roster with its own counters, search and walk-in modal) was deleted the same day, so ONE roster serves the desk and the reports instead of two lists of the same people that had to be kept in step. On a physical session the tab therefore differs from a Zoom session's in four places, all keyed on `event.is_physical_mode` (+ `manage-events` for the writes — `isDoor` in the Vue): (1) the **Status column is on from the start** (`showStatusColumn`) and **always editable** (`canEditStatus` — a physical roster has no machine writing attendance, unlike a Zoom roster, which stays locked until the webinar's reconcile has run) — the **dropdown is the only control**: an admin checks someone in by picking *Attended* (→ the existing `RegistrationsController@status`). A per-row "Check in" button shipped first and was **pulled the same day at the user's request** (the dropdown already does it; the second control cluttered the cell — do not bring it back); (2) the **summary filter cards** (Registered / **Awaiting** / Attended / No-show) show throughout, not only once finalized — they are the door's live counters and filter the table; (3) the toolbar carries **Open scanner** and **Close attendance**; (3a) the **lead's name is a link** (underlined on hover) opening the shared `LeadDetailModal` — the same thing the row's eye action does, since at a desk the name is what the finger lands on — and inside that modal the **Account manager strip has a Change button** (`manage-leads` + the lead's `can_assign`), mounting the shared `AssignLeadManagerModal` in its `host="modal"` mode: the save is an in-place Inertia visit (`preserveState` + `keepOpenDuringVisit()`, the same transport as the modal's Edit), so THIS roster re-renders with the new manager underneath while the modal stays open, and `@saved` re-reads the modal's own lead row (`?section=lead`); the picker's options ride `quick()`'s `assignable_admins`; (3b) an **Account manager** column sits right beside the Lead (the lead's ONE manager, `leads.assigned_admin_id`, shipped on the row as `account_manager` `{uuid, name}` via a `registrations.lead.assignedAdmin.profile` eager-load — so the door can hand an arrival to whoever owns them), with a matching **Account manager** multi-select in the filter drawer (options + counts derived from the roster like Source; a `none` bucket = "No account manager", so the arrivals nobody owns yet are one pick) — mirrored in [`RegistrationRosterFilter::matchesManager()`](/src/Event/Support/RegistrationRosterFilter.php) and forwarded in `exportParams`, and the export carries an **Account Manager** column on the same physical-only condition (`EventRegistrationsExport`'s `$showAccountManager`), so the file matches the screen; (4) **Add lead** doubles as the **walk-in**: `AddRegistrationModal` shows a *"They're at the door — check them in now"* tick on a physical session, which routes the SAME `LeadComboBox` pick to the walk-in endpoint `CheckInController@walkIn` (`POST manage/events/{uuid}/check-in/walk-in` → `EventRegistrationRepository::walkIn()`: register `SOURCE_WALK_IN` + mark Attended in one transaction, idempotent on `(event, lead)`, original source preserved) instead of the plain registration store. ⚠️ That endpoint **no longer creates leads of its own** (2026-09-03): the old door form's private name/email/phone branch and the controller's `resolveNewLead` copy of the Leads create logic were removed — a second lead writer is how one human becomes two records — so a body without `lead_uuid` is a validation error, and someone brand new is created by the picker's own hatch (`/manage/leads/resolve`, the identity gate) before the walk-in is posted; a door admin who lacks `manage-leads` sees no hatch (the picker hides it, since the endpoint would 403). **Open scanner** opens the full-screen `CheckInScan.vue` page (`CheckInController@scanPage`) **in a new tab** (a plain `<a target="_blank">`, not an Inertia `Link` — the scanner runs on the door device with the camera held open while the roster stays on the desk) which decodes the camera feed with `jsQR` (pure JS — any browser, laptop webcam included; degrades to a notice + manual entry when the page is not on a secure origin or the camera is refused) and POSTs each decoded QR to `CheckInController@scan` — a JSON, event-scoped, idempotent endpoint (`checked_in` / `already` — no `attended_at` re-stamp / `not_found`), whose payload carries the registrant's **name, email, phone and account manager** (`registrantContact()`). A successful scan is made unmissable on the door device (2026-09-03): a **success chime** (synthesised with the Web Audio API — no audio asset to ship; `already` gets a single neutral tone, `not_found` a low buzz), a **confetti burst** (the shared `Components/Confetti.vue`) and a full-screen card greeting the person by name with their email + phone, so the door can eyeball that the ticket is theirs — with the **account manager highlighted** as the brightest element on the card (a white pill: *Account manager — NAME*; "No account manager yet" when nobody owns them), because "who do I hand them to" is the one question after the name. The viewfinder is as large as the screen allows while staying square (`max-w-[min(92vw,72vh)]`, reticle 70% of it) — a small one had people holding the phone too far away; the reticle is only a guide, jsQR reads the whole frame. **Close attendance** (`CheckInController@close` → `EventRegistrationRepository::closeAttendance()`) bulk-marks every still-Registered lead a **no-show** in one UPDATE (physical-only). The scanner page's back link returns to `?tab=registrations`. Ticket delivery is via the `{{ticket_link}}` reminder token (see [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md)), the `email_tickets` email, or the roster's per-row **Ticket QR** modal (copy the signed link / show the QR) — the QR encodes only the registration uuid; the public ticket page is signed.

### Public landing (per-funnel + per-slot)
Each funnel has a public landing at **`/{slug}`** → `Main\LandingController@index` shows only that funnel's upcoming events and tags the captured lead with the funnel (hidden `funnel` uuid → `RegisterLeadAction` writes `event_funnel_id`). Each slot additionally has a **slot landing** at **`/{funnel}/{slot}`** (`LandingController@slot`). The slot landing **does not show a session list** — the backend **auto-picks the one session** to register for (the earliest whose `live_status` is still **Upcoming** — a live or past occurrence is skipped so the page always funnels the visitor into the **next round**), and the design shows a single **Register** button that captures the lead for exactly that session (its uuid rides in `tracking.event`; the visitor never chooses). A slot with **no upcoming session** returns **404** for the public URL — unless `?preview` is present, which lets an admin view the design with a disabled CTA (so a dead slot is never indexed). The **root `/` is never a funnel** — it renders the public site home (see the Main [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) doc). The admin **Leads** list gets a Funnel column + filter. Every landing registration stamps **`marketing_source` = Funnel** (2026-07 consolidation). The hub's **Landing tab** carries **two cards**. The first is the **"Meta ad URL" copy card** — the Website-URL template (with Meta `{{campaign.id}}`/`{{adset.id}}`/`{{ad.id}}` dynamic parameters) to paste on ads in Ads Manager so each registration records exactly which ad brought the lead — and each slot's **expand row on the Slots tab** has the same for the slot's own URL (a slot ad's registrations land on the slot's *next upcoming session*). A campaign can additionally be tied to the funnel on [Campaign Mapping](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md). ⚠️ **Unsubstituted macros are guarded server-side** (`RegisterLeadAction::sanitizeAdTracking`, 2026-08-03): Meta only fills the `{{…}}` parameters when the ad is actually served, so anyone opening the template URL directly posts the literal `{{campaign.id}}` strings — which used to be stored as a real zero-spend "campaign" that deflated every CPL it touched. A tracking value still containing `{{`/`}}` is stored as NULL (the registration itself succeeds); migration `2026_08_03_100001` retro-cleaned the rows written before the guard. Pinned by `tests/Feature/Main/LandingAttributionTest.php`.

The Landing tab's **second card is the Social share image** — the funnel's **OpenGraph banner**, i.e. the picture that shows when someone pastes the landing link into WhatsApp or Facebook. Upload / replace auto-saves on pick; remove sits behind a `ConfirmModal`. It is `FunnelsController@storeOgBanner` / `@destroyOgBanner` (`POST` / `DELETE manage/events/funnels/{id}/og-banner`, names `manage.events.funnels.og-banner.{store,destroy}`, behind `manage-events`), validated by `Funnels\OgBannerRequest`, stored through `MediaService` in the funnel's **`og_banner`** collection and read back via `EventFunnel::ogBanner()` (which is why the model carries a `media()` morphMany). The page receives it as `og_banner_url`; it is served publicly by `Main\OgImageController` at `/og-image/{uuid}`, and **slot landings inherit their funnel's banner**. Full detail in the [SEO handbook](/docs/modules_handbook/shared/seo/readMe.md). The Session Show page's **Ads tab** then answers **"what did this session's leads cost?"**: a lazy JSON endpoint (`GET manage/events/{id}/ad-insights` → `EventsController@adInsights` → `App\Services\Marketing\SessionAdInsightsService`) breaks the session's landing registrations down per campaign and allocates each campaign's **daily spend proportionally** across the sessions its registrations landed on that day (an evergreen ad feeding several sessions never double-counts; unclaimed days are reported as `window_spend`), yielding per-campaign and total **CPL**. Spend is read from the LOCAL **`meta_ad_insights`** store — **one row per ad per day**, summed up to campaign grain by `SessionAdInsightsService::campaignDaySpend()`. The hourly **`meta:sync-ad-insights`** command refreshes a trailing 7-day window (Meta *restates* the past: spend settles and actions keep landing for ~72h) for **every ad in every active account**, and that single grain is what CPL here and the merged **Traffics** page (Campaign Performance + Ad Return, one page since 2026-08-03) read — so no two screens can quote a different spend for the same campaign. **The Ads tab's on-demand write-through backfill is gone (2026-07-29)**: it wrote into the now-retired campaign-grain `meta_campaign_spends` table, and since the sync no longer only covers campaigns that had registrations, a missing day means *the sync has not run*, not *nobody ever asked about this campaign*. So `forEvent()` returns whatever partial stored days it has, and only when it has none at all does it fail the campaign with `No stored ad spend for these days yet — run 'meta:sync-ad-insights'.` (This replaced `meta_campaign_spends` + the hourly `meta:sync-ad-spend`, which is unscheduled — see the RETIRED note in `app/Console/Kernel.php`.) The funnel hub's **Sessions tab shows each session's CPL column** — but since 2026-08-12 it shows the **REPORT CPL**, not the allocated one: `report_cpl` / `report_leads` on `transformSession` via **`SessionAdInsightsService::reportCplForEvents()`**, which is *the promotion window's whole spend ÷ every LANDING registration inside it*. ⚠️ **That is deliberately the SAME method the session Summary tab reads**, so the list's column and the tab's card cannot become two numbers — pinned by `SessionSummaryTest::test_the_report_cpl_is_one_definition_shared_with_the_sessions_list`, which is mutation-verified against the realistic drift (a denominator that stops filtering on source). The row is also built to be *checkable*: Ad spend, Registered-split-by-source and CPL sit next to each other, and the CPL cell prints its own divisor (`÷ N landing`) — the reader can do the division on screen instead of trusting it. The allocated `cplForEvents()` figure stays in the payload as `ad_cpl` because the **Funnel Dashboard** still reads it. ⚠️ **The two therefore disagree by design**: the Dashboard's session rows show the allocated CPL, this list shows the report CPL, and the same word labels both. Strictly DB-only, zero Graph calls on the list; a session shows "—" until its spend is synced. Fails soft per campaign. The CPL column header and the Ads-tab heading both carry a **"?" explainer** — the shared bilingual (English + 中文) `resources/js/Components/CplInfoButton.vue`, which walks through the `Spend ÷ Leads` formula, the Meta-spend-vs-CRM-leads split, and the daily-proportional allocation with a worked example (dropped in via the new per-column `header-{key}` slot on the shared `DataTable`).

### The Summary tab — the marketer's Telegram report, computed (2026-08-11)

The session Show page now opens on **Summary** (tab order: **Summary | Ads | Registrations | …**): the daily hand-written Telegram report (period · spend · leads · daily counts · CPL vs target with an SG/MY split · budget pacing · exclusions · geography) rebuilt from data, with a **Copy for Telegram** button that renders the whole thing as text — including the SAME grouped geography the tab shows (the marketer's old hand-made region buckets are retired at her own request; `config marketing.my_regions` went with them). The tab is built from the **shared `Components/FunnelMetrics/` set** (2026-08-11): `KpiCards` + `ConversionWaterfall` (both extracted from the Dashboard — width/percentage maths live in the component, once) plus the Dashboard's own `LeadsSpendTrend`, so **the Dashboard reads a RANGE of sessions and the Summary reads ONE session through identical components** — same cards, same waterfall, same chart, two scopes. Payload: `GET {id}/summary` (JSON, lazy, read-only) → [`SessionSummaryService`](/app/Services/Marketing/SessionSummaryService.php), which **composes existing definitions rather than minting new ones**: window + spend from `promotionSpendForEvents()` (which now also returns its per-day series + campaign ids, additively), revenue from `revenueForEvents()`, monthly pacing from `FunnelDashboardService::globalMonthSpend()` (the one definition of the shared pool's utilisation), quality from lead enrichment.

- **The report CPL is its own, labelled figure**: `window spend ÷ window leads` — the plain division the hand-written report always used. It is NOT the allocated Session CPL (different window, different question); the tab shows both, each labelled, with the allocated one linked to the Ads tab.
- **SG/MY split**: leads bucket by **phone country code** (config `marketing.phone_countries` — the number they typed beats an IP that travels); spend buckets by each **adset's targeting countries** (`meta_ad_settings.targeting.geo_locations.countries`) over the same campaigns + days as the headline spend, so the buckets sum back to it. A multi-country adset lands in **Mixed** — shown, never divided into a CPL.
- **Geography — two groupings of one dataset.** The TAB renders `geo_grouped` (2026-08-11): a single-column list with **Malaysia as one section** whose rows are the raw GeoLite2 **states** that actually have records (KL and Selangor separate — nothing hides in an "Other MY" bucket; residuals pin last), then the other countries by display name (config `marketing.country_names`). The TELEGRAM text keeps `geo` — the marketer's flat region buckets (config `marketing.my_regions`) — because that report is her format; same totals, two groupings. ⚠️ **A +60 phone browsing from a foreign IP is *Abroad · {Country}* / *MY (abroad)*, never a Malaysian state** — phone decides WHO is Malaysian, the IP's own `geo_country_code` decides WHERE they were, and before that guard real production data listed "Hong Kong" and "Central Singapore" as MY areas (5 of 32 leads on the snapshot are Malaysians abroad — itself a useful sales signal). A MY phone with no IP fix at all shows as *Area unknown* / *MY (area unknown)* and shrinks as `leads:enrich` catches up. Pinned by `test_a_my_phone_browsing_abroad_is_not_a_malaysian_state`.
- **Quality counts annotate, never subtract** (product decision): *wrong number* = `phone_is_valid=false` or `wa_exists=false` (the enrichment probes that already exist), *possible duplicate* = pending `VerifiedIdentityPair`s touching the window's leads, *not enriched yet*. Lead totals and CPL are never reduced, so this tab cannot disagree with any other lead count.
- **Budgets are GLOBAL** (product decision 2026-08-11 — an earlier same-day draft put per-funnel columns on `event_funnels`, reverted before commit, so the committed history only ever knows the pool): `marketing.monthly_ads_budget` + `marketing.target_cpl` (`Setting::MARKETING_*`) are ONE pool shared by every funnel / slot / session, edited on the **Dashboard's budget card** (`POST dashboard/budgets` → `DashboardController@updateBudgets`, `manage-events`; clearing a field `SettingRepository::forget()`s its row so readers fall back honestly). **Weekly is derived monthly ÷ 4, never stored.** Utilisation = **ALL synced ad spend this month** (`FunnelDashboardService::globalMonthSpend()` — every campaign in every account, tied or not, deliberately broader than the Dashboard's scope-filtered spend KPI; the two sit side by side, labelled) vs the pool, with the calendar pace (`day/daysInMonth`) as the tick. `events.promo_budget` survives as THIS push's override (edited on the Summary tab, same promotion-window endpoint); null ⇒ derived as weekly × the window's **full** length.

Covered by `tests/Feature/Marketing/SessionSummaryTest.php` (window maths incl. the pre-registration spend day, the SG/MY split summing back to the headline, geo buckets, quality-annotates-never-subtracts, the budget derivation chain + override, the Telegram text carrying the same numbers, the endpoint).

### What a session cost to ADVERTISE (2026-08-11)

Distinct from CPL, and it has to be. CPL allocates a **day's** spend across the sessions whose registrations landed on it, so its window is derived from registrations and can be nothing else — which means a day the ads ran and nobody signed up has no share to give, and is invisible. On the production snapshot the 12 Aug session's registrations span 31 Jul → 3 Aug while its campaign also spent **RM 213.61 on 30 Jul**: real money, aimed at that session, missing from every figure.

So there is a second number over a second window — [`SessionPromotionWindow`](/src/Event/Support/SessionPromotionWindow.php) + `SessionAdInsightsService::promotionSpendForEvents()` — shown on the session **Ads tab**, as an **Ad spend** column on the funnel **Sessions tab**, and on the **Dashboard**'s session table.

**The window is derived, not typed.** The slot landing auto-picks the earliest session whose `live_status` is still **Upcoming** (`Main\LandingController::slot()`), so an evergreen slot ad points at exactly one session at a time and the handovers are knowable.

⚠️ **The boundary is the previous session's START, not its end.** `live_status` flips Upcoming → Live at `now >= start`, and the landing skips anything not Upcoming — so a 20:00 session hands the landing to the next one at 20:00, two hours before its own webinar is over. Hence **datetimes**: a date-only boundary misplaces every evening session by most of a day.

⚠️ **Whole days, and a changeover day is never split.** `meta_ad_insights` stores spend per DAY, so an edge falling mid-day cannot be applied exactly. Rather than pro-rate by hour — which would assume Meta spends evenly through a day, and it does not — **the day goes whole to whichever session owned more than half of it**. For 20:00 sessions that gives the 12 Aug session 30 Jul → 12 Aug (recovering the RM 213.61), and `test_no_day_is_ever_claimed_by_two_sessions` pins that adjacent windows never overlap. The rule is computed from the hour, not hardcoded for evenings: a 09:00 session hands the day the other way.

**Overrides** (`events.promo_starts_at` / `promo_ends_at`, migration `2026_08_11_100001`, `POST {id}/promotion-window`) exist for what the schedule cannot know — an ad paused early, a late boost, a flight off the slot's rhythm. Null in **both** restores the derived window, so the control is reversible; a set window renders a *Manually set* chip.

> ⚠️ **This total is never divided into anything, and it is not a replacement window for CPL.** Feeding an arbitrary range into the CPL maths does not widen it (a day with no registrations still contributes nothing) but **narrowing it silently lowers CPL** — days drop out of the numerator while the lead count stays put, with nothing on screen to say so. Two windows, two questions, no crossover. It also only covers campaigns this session's own registrations named: an ad that ran and brought nobody is unknowable from here, which is the honest limit of self-reported attribution and is stated on the card. Covered by `tests/Feature/Marketing/SessionPromotionWindowTest.php`.

### What a session SOLD (2026-08-10)

The Ads tab answers what a session's leads **cost**; this answers what it **earned**. Two new joins, each doing what it is good at.

**Which session sold it** — [`App\Services\Marketing\PurchaseSessionAttributor`](/app/Services/Marketing/PurchaseSessionAttributor.php), called from [`PurchaseFulfiller::fulfill()`](/app/Services/Payment/PurchaseFulfiller.php) beside `ReportPurchaseToMetaAction` (same contract: every gate inside, writes only its own marker columns, never throws). The rule, in order:

1. the buyer **tapped that session's CTA link** before paying → that session (`EVENT_ATTRIBUTION_CTA`). A tap is the customer answering a pitch, at a known moment, in a known room — proof, so it wins outright;
2. else the **last session they ATTENDED** before the money arrived (`EVENT_ATTRIBUTION_ATTENDED`);
3. else **no session** — a real answer, not a gap.

**Attended, never registered.** A funnel-landing sign-up auto-enrols a person into *every* upcoming session at once, so "registered" would hand the sale to a webinar they never opened. `attended_at` is COALESCEd with the session's date, because a hand-marked walk-in or an older imported roster may only have the latter — excluding those would quietly drop exactly the people an admin reconciled by hand.

**Stamped once, never recomputed** (`purchase_histories.event_id` / `event_attribution` / `event_attributed_at`, migration `2026_08_10_200004`). A Zoom attendance import landing next week would otherwise move money between sessions and restate a figure already reported to management. `event_attributed_at` is what separates "decided: nothing to credit" from "never examined" — the only thing a backfill can key on. Catch-up + repair: **`purchases:backfill-sessions`** (unscheduled by design; `--force` re-decides and asks first).

⚠️ **The CTA signal needs the touch ledger, not the capture table.** `whatsapp_cta_captures` is `UNIQUE(cta_link_id, contact_id)` because it answers "which CTA first brought this person in" — so a returning customer tapping the same slot's link at *next* week's session writes no row at all, and that person is precisely who session revenue is about. Hence **`whatsapp_cta_touches`** (migration `2026_08_10_200003`): append-only, no unique key, one row per tap carrying the live session. Both are written together in `WhatsappCtaLinkRepository::recordCapture()`.

**Reading it** — [`SessionAdInsightsService::revenueForEvents()`](/app/Services/Marketing/SessionAdInsightsService.php), shaped like `cplForEvents()` so one implementation can feed the Ads tab, the Sessions tab and the Dashboard. **Grouped by currency from day one** (two neighbouring services still sum `total_amount` blind — harmless only while every row is MYR, and it stops being harmless silently), ACTIVE only, and Eloquent rather than raw SQL precisely so the SoftDeletes scope reaches it: deleting a payment is a routine correction and must leave the session's revenue immediately. Revenue is split into an **offer hit** (arrived through the payment link the session's frozen `event_offers` snapshot names, falling back to matching the payable) and the rest — **cross-sell counts as the session's revenue** (product decision), it is just reported apart so "did the pitch work?" keeps its own answer.

> ⚠️ **This is NOT a ROAS and must never be labelled one.** The site has exactly one ROAS definition, shared by Traffics and the Funnel Dashboard: spend in range → the leads it bought → everything those leads have ever paid, credited to the ad that brought them in. This is a different question against a different denominator — a session's *allocated fraction* of the spend, which excludes days that produced no registration — so it reads higher and the two are not comparable. The Ads tab says so on the card. Covered by `tests/Feature/Marketing/PurchaseSessionAttributionTest.php`.

**Where the money now SHOWS (2026-08-12)** — the totals grew three surfaces, all reading the same attribution:

- **Summary tab → "What sold"** (`sold_items` on the summary payload — `SessionAdInsightsService::soldItemsForEvent()`): items grouped on the payable, each **labelled with the ledger's frozen `title`** (survives renames/deletes, the reason the ledger freezes it), tagged **pitched vs cross-sell** against the `event_offers` snapshot, amounts as per-currency lists, and expandable **buyer rows** — name (→ Lead modal only for holders of a leads permission; the Marketing role's names stay plain text), the attribution chip from `PurchaseHistory::EVENT_ATTRIBUTIONS` (never re-typed in JS), amount, date. **No gateway ids or references** — that detail belongs to the Payments module's `view-sales` screens. The **Telegram text** carries the same line (`sold: Gold membership ×1 (MYR 3,000.00) · …`).
- **Sessions tab → "Sold" column** (`sold_buyers` + `sold_revenue` on `transformSession`, batched via `revenueForEvents()` like the CPL column beside it): per-currency amount + buyer count per row, so which session sold and which sold nothing is one scan.
- **Slots tab → "Lifetime" line** on the *Offered during the webinar* card (`slotSales` prop, keyed by slot uuid — `slotSalesForFunnel()`, full-view only like the tab): every purchase credited to the slot's sessions, rolled up — buyers are `COUNT(DISTINCT lead)` per slot (a second grouped query, so a two-currency buyer is never counted twice), and the `events` join guards `deleted_at` by hand (a join bypasses the model scope). Definition and result on one card: the offers say what the slot sells, this line says what it has sold.

All three pinned by the itemise/roll-up tests in `PurchaseSessionAttributionTest` + the sold-items endpoint test in `SessionSummaryTest`.

### Lists & writes
- **Per-slot leads** → `WeeklyLeadsQueryRequest`. The Sessions / Slots tabs aren't server-paginated (a handful each) — the hub passes them as plain arrays; **both** render in the shared `DataTable` in **non-paginated (client-side)** mode (`:paginated="false"`, data is just `{ data: [...] }`). The Slots tab's table uses per-row **actions** (Leads / Edit / Delete) and an **`#expand`** row for each slot's public-page + design-file info. The **Sessions tab** shows a **Total + Past / Live / Upcoming** breakdown across all the funnel's sessions (`funnel.session_counts`, derived server-side from each session's webinar-aware `live_status`; Total = the sum), and the Sessions tab's count badge is that total; the funnel **Index**'s Sessions column shows the same 3-way split per funnel. All session/slot **times render in 12-hour format** (`formatClockRange` in `utils/datetime.js`). **The Sessions row reads left to right as one sentence about the push** (2026-08-12): **Session** (name with its date + time on the line beneath — the two former Date and Time columns folded in, because a session's clock is part of naming it, not a fact to compare down a column) → **Status** → **Ad spend** (the window stacked one date per line, `since` / `to` / `until`) → **Registered** (split `N landing · N other`, with attendance on its own line — only the landing half can carry an ad cost, and naming the split is what makes the next column checkable) → **CPL** (÷ that landing figure, printing its own divisor) → **CTA** → **Sold** → **ROAS**. **ROAS here is SESSION-SCOPED** — MYR credited to the session ÷ its promotion spend, i.e. the same arithmetic the Summary tab labels *Return ×* — and its tooltip says outright that it is **not** the Traffics ROAS, which follows the whole ad cohort for as long as they keep buying. MYR only, because the spend is MYR; dividing a mixed-currency total by ringgit yields a ratio true in no currency. The row's View action is an **icon-only eye**, like every other row-action gutter in Manage. The hub header no longer embeds a leads list — its **Leads** button links to `/manage/leads?funnel={uuid}`; `show()` therefore no longer returns a `leads` prop.
- All writes go through a repository inside `DB::transaction`; controllers map input explicitly and return `Inertia::render` / `back()`. Mode-aware saves null the field that doesn't apply.

> There are **no** `Create.vue` / `Edit.vue` pages and **no** flat `/manage/events` index — create/edit happen in modals on the lists, per GUIDELINES §14.

## Slot → the five-day training's demo project (2026-09-02)
A slot (event series) may name a **Demo project** (`event_series.demo_catalog_project_uuid`, the slot
form's last field; options = publicly listed catalogue projects, federated). A member who enrols in
the DMAIC road's five-day training carries the slot of their latest registration as their cohort, and
their Wealth Plan opens with that project in Step 4 (`Src\Journey\Support\DemoUnit`). Blank → the
program default (`TRAINING_DEMO_PROJECT_UUID`) → a synthetic dual-key from the member's D card.

## Related files

**Backend — Models** (`src/Event/`)
- [EventFunnel.php](/src/Event/EventFunnel.php) — program (name, slug, **description**, is_active, whatsapp_group_link; `series`/`events`/`leads`/`leadFunnels`/`whatsappMessages` + `media()` / `ogBanner()` for the OpenGraph share banner — see [SEO](/docs/modules_handbook/shared/seo/readMe.md)). No `is_default` / `default()`.
- [EventSeries.php](/src/Event/EventSeries.php) — slot template (times, mode, `auto_webinar`, `slug`, `sort_order`, **`visibility` / `VISIBILITIES` / `memberships()`** — per-slot members-only gating, **`registrations()`** hasManyThrough for rolled-up Registered/Attended counts, `MODES`, `nextSortOrderFor()`). No `day_of_week`, no `DAYS`, and **no `dLabels()`** — the D1/D2/D3 concept was removed, slots show by title.
- [Event.php](/src/Event/Event.php) — session occurrence (TYPES — **Weekly only**, MODES, STATUSES, **VISIBILITY + `memberships()` + `isAccessibleTo()`** inherited from the slot, `scopeUpcoming`, `series`/`registrations`/`webinar`/`ctaCaptures`). No `TYPE_OCCASIONAL` and no `cycle()`.
- [EventRegistration.php](/src/Event/EventRegistration.php) — a lead's join + attendance + per-lead Zoom registrant columns + `source` (`SOURCE_LANDING` / `SOURCE_ADMIN` / `SOURCE_WALK_IN` / `SOURCE_ZOOM` — "Zoom form", an adopted webinar's imported registrant — / `SOURCES`).

**Backend — Repositories** (`src/Event/Repositories/`)
- [EventFunnelRepository.php](/src/Event/Repositories/EventFunnelRepository.php) — funnel create/update/setActive/delete (any funnel is deletable — no default).
- [EventRepository.php](/src/Event/Repositories/EventRepository.php) — `createFromSlot($slot, $date)` (the slot → session field map used by every "Add session"; **inherits the slot's `visibility` + copies its memberships**) + single session `create()` / `update()` / `cancel()` / `complete()`.
- [EventSeriesRepository.php](/src/Event/Repositories/EventSeriesRepository.php) — slot create (auto `sort_order`) / update / setActive / delete (**+ syncs the `event_series_membership` pivot when `membership_ids` is provided**).
- [EventRegistrationRepository.php](/src/Event/Repositories/EventRegistrationRepository.php) — `join()` (idempotent on `(event, lead)`; tags `source`, default LANDING), `markStatus()`, `remove()`, `setWebinarRegistrant()`.

**Backend — Controllers** (`app/Http/Controllers/Manage/Events/`)
- [FunnelsController.php](/app/Http/Controllers/Manage/Events/FunnelsController.php) — funnels: index / show / store / update / toggle / destroy / **groupStatus** (the Group tab's JSON: invite-code → JID via the bridge, roster vs registrants tolerant match, non-registrant members) / **storeOgBanner** + **destroyOgBanner** (the Landing tab's social-share image). `show` returns a **flat** `sessions` array from `transformSession` (upcoming + the last 90 days, oldest→newest, with `registrations_count` / `attended_count` / `cta_count` subqueries and the per-session CPL — nothing is grouped by month), the `session_counts` Past/Live/Upcoming split, the slot form's `slotVisibilities` + active `memberships` options, and `moveTargets` (every *other* funnel + its slots) for the move modal. It no longer returns a `leads` prop.
- [DashboardController.php](/app/Http/Controllers/Manage/Events/DashboardController.php) — the **Dashboard tab** (`manage.events.dashboard`): owns `RANGES` (7/30/90 days + all time), resolves the `?funnel=` scope, delegates every number to [`App\Services\Marketing\FunnelDashboardService`](/app/Services/Marketing/FunnelDashboardService.php) (waterfall / KPIs / per-session rows / trend / next-session / health — see *The Dashboard tab* above).
- [SessionsController.php](/app/Http/Controllers/Manage/Events/SessionsController.php) — **store**: add a session for a slot on a date (`createFromSlot` + auto-webinar; no auto-enrol) · **linkable** (JSON: the account's linkable upcoming Zoom webinars) · **linkWebinar** (thin: resolves the slot, delegates to `LinkSessionWebinarAction`, flashes).
- [SeriesController.php](/app/Http/Controllers/Manage/Events/SeriesController.php) — slots: index (→ redirect) / store / update / toggle / destroy (store & update map **`visibility`** + resolve the picked membership uuids → ids).
- [WeeklyController.php](/app/Http/Controllers/Manage/Events/WeeklyController.php) — `leads({series uuid})`: a slot's joined-leads list.
- [EventsController.php](/app/Http/Controllers/Manage/Events/EventsController.php) — single session: show (incl. the `attendance` prop via `LoadsWebinarAttendance` + the post-webinar `engagement` prop via `buildEngagementProp`) / update (unconstrained reschedule) / cancel / **destroy** (webinar teardown + redirect to the hub's Sessions tab) / **exportRegistrations** (`ExportsResource`; the Registrations roster as .xlsx / .csv — the same `transformRegistrations()` rows the tab renders, narrowed by `RegistrationRosterFilter` to the browser-side selection the menu forwarded, gated on lead visibility rather than `view-events`). Visibility is a slot-level setting, so a session edit never offers a visibility picker (`allowVisibility` is always false).
- [WebinarAttendanceController.php](/app/Http/Controllers/Manage/Events/WebinarAttendanceController.php) — `exportNoShows` (no-show CSV/Excel; `ExportsResource` + `LoadsWebinarAttendance`). See the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md).
- [WebinarsController.php](/app/Http/Controllers/Manage/Events/WebinarsController.php) — session webinar store / **update (rename)** / **destroy (delete)** / retry / **start (open in Zoom as host)** (delegates to `CreateSessionWebinarAction` + `ZoomServerService`).
- [RegistrationsController.php](/app/Http/Controllers/Manage/Events/RegistrationsController.php) — registrations store / status / destroy / **joinLink** (`GET manage/events/registrations/{id}/join-link`, JSON, `manage-events`): returns the registrant's short `/zoom/{token}` personal join link, **minting the token on first use** — which is why it is a write-gated route despite being a GET. Refused with 422 when the lead is not a Zoom registrant. Behind the copy-link button beside the **On Zoom** chip in the Registrations tab's Zoom column; the token itself is documented in the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md).
- [RegistrationsImportController.php](/app/Http/Controllers/Manage/Events/RegistrationsImportController.php) — the session guest-list importer (2026-09-03): `preview` (dry run, JSON, + the `already_registered` re-stamp) / `import` (`POST manage/events/{id}/registrations/import[/preview]`, `manage-events` AND `manage-leads`). People through the shared `BulkMemberImportAction` (leads mode, via the `ParsesMemberImport` trait like the Leads / Members importers), then `EventRegistrationRepository::join()` per resolved lead as `SOURCE_IMPORT`, ticket + trail line per NEW registration, one webinar registrant sync at the end. See *Adding people to a session* above.
- Shared trait [Concerns/ResolvesFunnel.php](/app/Http/Controllers/Concerns/ResolvesFunnel.php) — `resolveFunnel()` returns the posted `funnel` uuid's funnel or **null** (STRICT — no default/first-funnel fallback); callers require `funnel` and `abort` on null.

**Backend — Webinar action / job** (see the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md) for the rest)
- [app/Actions/CreateSessionWebinarAction.php](/app/Actions/CreateSessionWebinarAction.php) — `prepare` / `fulfill` / `dispatchForGenerated` / `sync` (re-sync topic+schedule on session edit) / `teardown` (delete at Zoom on cancel/delete).
- [app/Actions/LinkSessionWebinarAction.php](/app/Actions/LinkSessionWebinarAction.php) — **the inverse**: adopt an existing Zoom webinar as a session (`execute($slot, $webinarId)`). Fetches the webinar, raises the Zoom-dependent rules (must be upcoming; no `(slot, date)` clash) as field errors, creates the session + binds the webinar in **one transaction** (a concurrent link rolls the loser's session back too, so no orphan), then queues `ImportWebinarRegistrants`.
- [app/Jobs/Zoom/CreateSessionWebinar.php](/app/Jobs/Zoom/CreateSessionWebinar.php) · [SyncWebinarRecording.php](/app/Jobs/Zoom/SyncWebinarRecording.php) · [SyncWebinarRegistrants.php](/app/Jobs/Zoom/SyncWebinarRegistrants.php).

**Backend — Form Requests** (`app/Http/Requests/Manage/Events/`)
- [Funnels/StoreRequest.php](/app/Http/Requests/Manage/Events/Funnels/StoreRequest.php) + [Funnels/UpdateRequest.php](/app/Http/Requests/Manage/Events/Funnels/UpdateRequest.php) — funnel create/edit (URL-safe unique slug; `RESERVED_SLUGS`; `description` nullable) · [Funnels/OgBannerRequest.php](/app/Http/Requests/Manage/Events/Funnels/OgBannerRequest.php) — the Landing tab's social-share image: required `image`, `jpg/jpeg/png/webp`, **max 2 MB**, **min 1200×630** (Meta/WhatsApp crop the preview to that ratio).
- [Series/StoreRequest.php](/app/Http/Requests/Manage/Events/Series/StoreRequest.php) + [Series/UpdateRequest.php](/app/Http/Requests/Manage/Events/Series/UpdateRequest.php) — slot create/edit: required `funnel` (no default), URL-safe **`slug`** unique within the funnel (`withValidator`, spanning soft-deletes, self-ignored on edit; `UpdateRequest` drops the `funnel` rule), `auto_webinar`, **`visibility` + `membership_uuids`** (members-only gating); manual Zoom link required only when auto is off.
- [Sessions/StoreRequest.php](/app/Http/Requests/Manage/Events/Sessions/StoreRequest.php) — **Add session**: funnel/slot/date, slot-belongs-to-funnel + slot-is-active + one-session-per-`(slot, date)` dedupe (spanning soft-deletes), date `after_or_equal:today` and capped ~26 weeks ahead.
- [Sessions/LinkWebinarRequest.php](/app/Http/Requests/Manage/Events/Sessions/LinkWebinarRequest.php) — **Link Zoom webinar**: funnel/slot/numeric `webinar_id`; slot-belongs-to-funnel + active, and a pre-Zoom guard that the webinar is not already linked; `webinarId()` exposes the id reduced to digits. The date comes from the webinar, so the upcoming-check and the `(slot, date)` dedupe can only run after the Zoom lookup — `LinkSessionWebinarAction` raises those.
- [Sessions/MoveRequest.php](/app/Http/Requests/Manage/Events/Sessions/MoveRequest.php) — **Move a session to another funnel**: destination funnel required and must differ from the session's own; the session must be **Past** (a not-yet-run or Cancelled session is refused); an optional destination slot must really belong to that funnel and not be soft-deleted (omit it and the source slot is cloned instead).
- [UpdateRequest.php](/app/Http/Requests/Manage/Events/UpdateRequest.php) — session edit (unconstrained reschedule — no week-bounds guard).
- [Webinar/StoreRequest.php](/app/Http/Requests/Manage/Events/Webinar/StoreRequest.php) — only `requires_registration` (topic + schedule come from the session).
- [WeeklyLeadsQueryRequest.php](/app/Http/Requests/Manage/Events/WeeklyLeadsQueryRequest.php) · [RegistrationStoreRequest.php](/app/Http/Requests/Manage/Events/RegistrationStoreRequest.php) · [RegistrationStatusRequest.php](/app/Http/Requests/Manage/Events/RegistrationStatusRequest.php) · [RegistrationImportRequest.php](/app/Http/Requests/Manage/Events/RegistrationImportRequest.php) (the guest-list file + `merge_conflicts` + `review_confirmed`, mirroring the Leads / Members import requests) · [CheckIn/WalkInRequest.php](/app/Http/Requests/Manage/Events/CheckIn/WalkInRequest.php) (`lead_uuid` only since 2026-09-03) · [CheckIn/ScanRequest.php](/app/Http/Requests/Manage/Events/CheckIn/ScanRequest.php).

**Frontend (Vue)** (`resources/js/Pages/Manage/Events/`)
- [Dashboard.vue](/resources/js/Pages/Manage/Events/Dashboard.vue) — the **Dashboard tab**: scope + range controls, KPI cards, the conversion waterfall (single-hue bars, group step lazy-loaded from `group-status`), the two-panel leads-vs-spend daily trend, the sessions-in-range `DataTable`, the next-session readiness card and the data-health strip.
- [Funnels/Index.vue](/resources/js/Pages/Manage/Events/Funnels/Index.vue) — funnels overview (card per funnel + CRUD).
- [Funnels/Show.vue](/resources/js/Pages/Manage/Events/Funnels/Show.vue) — the hub (`PageHeader` + stats + `ShowTabs`).
- [Funnels/Partials/FunnelFormModal.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/FunnelFormModal.vue) — funnel create/edit.
- [Funnels/Partials/Tabs/SessionsTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/SessionsTab.vue) — the session list rendered with the shared `DataTable` (non-paginated, client-side search; no Upcoming|Past toggle) + **Add session** (slot + date). Its Past / Live / Upcoming chips are the shared **`SessionLiveCounts`** in clickable mode — a click-to-filter status control (click again, or the empty-state's "show all statuses", to clear).
- [Partials/SessionLiveCounts.vue](/resources/js/Pages/Manage/Events/Partials/SessionLiveCounts.vue) — the ONE Past / Live / Upcoming breakdown component (2026-08-03): `md` = segmented pill group (the hub — optionally `clickable` as a `v-model` status filter), `sm` = the compact dot-count-label row the index's Sessions column uses. Zero counts render dimmed; the Live segment pulses only while something is live.
- [Funnels/Partials/Tabs/SlotsTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/SlotsTab.vue) — the slots list in the shared `DataTable`. Five columns — **Slot / Time / Upcoming / Registered / Attended** — plus per-row actions (Leads / Edit / Delete) and an expand-row with the public-page + design-file info. There is **no Visibility and no Status column**: *Members only* and *Inactive* are inline tags inside the Slot cell. Passes the `visibilities` / `memberships` options to the slot form. Since 2026-08-10 it also receives **`slotOffers`** and shows **what the slot SELLS** — a chip row inside the Slot cell (product + price, dimmed when paused, plus an amber *No CTA link* flag when an active offer can capture nothing) and a fuller block in the expand row beside the Meta ad URL, which is the deliberate pairing: the ad URL is what the money goes **out** on, the offer is what it is meant to come **back** on. **Read-only** — every write still goes through the Automation tab, so there is exactly one create/edit path; the block's *Manage offers* link deep-links to `?tab=automation&scope={slot uuid}`.
- [Funnels/Partials/Tabs/LandingTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/LandingTab.vue). (The funnel-hub `LeadsTab.vue` was removed — the header's Leads button goes to the Leads list filtered to the funnel.)
- [Series/Partials/SeriesForm.vue + SeriesFormModal.vue](/resources/js/Pages/Manage/Events/Series/Partials/) — slot form (incl. the **editable `slug`** with a live URL + design-path preview, the auto-webinar toggle, and the **members-only visibility picker** — Public / Members + a membership checklist; `SeriesFormModal` auto-suggests the slug from the title until touched).
- [Weekly/Leads.vue](/resources/js/Pages/Manage/Events/Weekly/Leads.vue) + [Weekly/Partials/AddRegistrationModal.vue](/resources/js/Pages/Manage/Events/Weekly/Partials/AddRegistrationModal.vue) — a slot's joined-leads list + add-lead.
- [Show.vue](/resources/js/Pages/Manage/Events/Show.vue) — single session detail. The identity header carries the session facts (slot + funnel link, date / time / mode / location / Zoom link, description — the removed Details tab's content); `ShowTabs` then mounts **seven** tabs, conditionally: Registrations / **Ads** / Check-in (physical only) / Webinar (Zoom only) / Attendance (once a webinar exists) / Engagement (after it ends) / CTA. **Summary sits FIRST and Ads SECOND since 2026-08-11** — what a session cost and sold is read as often as its roster, and while it was last its position slid around with the session's state (a physical session has no Webinar tab, an unfinished one no Engagement tab), so it was never in the same place twice. (The Messages tab merged into Registrations 2026-08-03.)
- [Partials/EventForm.vue + EventFormModal.vue](/resources/js/Pages/Manage/Events/Partials/) — the single-session **Show edit** form/modal (**edit-only** — sessions are created from a slot, so there is no create path here).
- [Partials/AddSlotSessionModal.vue](/resources/js/Pages/Manage/Events/Partials/AddSlotSessionModal.vue) — the **slot + date** picker (posts to `/manage/events/sessions`, any date, no week bounds); used by the Sessions tab.
- [Partials/LinkWebinarModal.vue](/resources/js/Pages/Manage/Events/Partials/LinkWebinarModal.vue) — the **Link Zoom webinar** picker (fetches the account's linkable upcoming webinars, radio-pick one + a slot; posts to `/manage/events/sessions/link-webinar`); used by the Sessions tab.
- [Partials/Tabs/RegistrationsTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/RegistrationsTab.vue) — the registrant `DataTable`, Leads-index-style since 2026-08-03: the lead cell shows name + WhatsApp-linked phone + mail-linked email (with unverified shields), and the roster carries a dedicated **Zoom** column (On Zoom / Syncing… / **Paused** / Failed + the join-link-click signal; only on Zoom sessions — *Paused* is the slate state for a push the sweep has deliberately stopped retrying, waiting on a missing phone/email or Zoom's 00:00 UTC daily reset, and it is a filter option in the drawer too) and — under a tinted **MESSAGES band** (the Leads-index engagements pattern, native `DataTable` `groups`) — **one column per WhatsApp rule** (header = the rule: Welcome / 1d / 30m; cell = a single status icon, reason + timestamp on hover; only when the funnel has automations), plus headline reached/read counts and a **problems-only** toggle above the table. The roster also answers **which ad brought each person**: the ad details live **inside the Source / Ad column** (merged 2026-08-04 — an Ad column only ever had content on Landing-page rows, so the two were one origin story): the source badge with **campaign / ad set / ad** stacked compactly beneath it, the full campaign → ad set → ad → placement chain on hover, and the **ad name is a new-tab link to Meta's rendered preview** (a plain `<a>` — external target; `meta_ad_settings.preview_link`, batch-fetched by the shared [`MetaAdLabelResolver`](/app/Services/Marketing/MetaAdLabelResolver.php), plain text until the settings sync covers the ad); the **Registered** column moved to a compact stamp **under the row #** (the Leads-index `#index` pattern, full date-time on hover); the **copy-personal-join-link button lives in the Zoom column beside the On Zoom chip** (moved from the actions gutter 2026-08-04 — the control sits with the state it belongs to) — names resolved server-side in ONE batch by that same resolver (`meta_ad_refs` first, then the `meta_ad_settings` / `meta_campaigns` fallbacks, a fixed handful of queries for any roster size — extracted from this controller 2026-08-10 so the roster and the Ads tab can never disagree about what an ad is called) — and the drawer gains an **Ad campaign** multi-select, which ALSO surfaces inline as one-click campaign buttons beside the roster search ("All ads" + one button per campaign with its count, busiest first — the same `applied.campaign` the drawer owns, so the buttons, drawer, chips and Filters badge can never disagree). **The expand row is gone (2026-08-11)** — its SHORT facts are columns now, and they are banded into two families on purpose: **FROM THEIR VISIT** (Location, Device — read off the `ip_address` / `user_agent` the person actually arrived with, via GeoLite2 + device-detector) and **AI GUESS** (Occupation, Income, Hometown — a Gemini reading of a web search, with income leaning on a DOSM *state-level* median that is an area prior and never a household fact). One band would let a guess borrow a measurement's credibility; the amber band and the muted italic cells are what keep them apart. The scalars ride the existing row payload via the shared **`Concerns\BuildsLeadInsight`** trait's `registrantInsight()` (extracted from this controller 2026-08-12 so the VSL funnel's Leads roster reads the same definition) behind a `registrations.lead.enrichment` eager-load — one query for the roster, not one per row. The PROSE (profile summary, WhatsApp about/avatar, discovered socials) is deliberately NOT inlined: it would add hundreds of bytes to each of ~900 rows and reads properly only on the lead's own Identity tab, which the row's view-lead action opens. `RegistrantInsightPanel.vue`, `RegistrationsController@insight` and its route were **DELETED together (2026-08-12)**, having been dormant with no caller since the expand row became columns. ⚠️ The trigger was not tidiness: the route carried no gate of its own, so a URL nobody was maintaining still answered with a person's occupation / income guesses, geography and device to anyone the events group admits. **Dead code that still answers is not dead** — it is an unowned endpoint, and the safe half-measure (leave it, nothing calls it) is exactly what let it outlive its own UI. The roster mounts with the `DataTable`'s opt-in **`stickyHeader`** (added 2026-08-03: the table becomes its own `maxHeight` scroll area, since a plain `overflow-x-auto` wrapper swallows vertical `position:sticky`), so a 900-person list scrolls with the headers in view — plus, since 2026-08-11, **`dense` + `stickyFirst` + `stickyActions`** (the Leads-index setup): `text-xs` throughout, and the # and Lead columns pinned to the left edge with the component's own right-hand divider, so the person stays identifiable however far the now-much-wider table is scrolled. **Column order** is Lead → Source / Ad → Zoom (min-width so the chip + copy-link breathe) → **Status right beside Zoom once the session is under way** (link-then-turn-up is one story; before that the column hides, see below) → the per-rule Messages → the two insight bands → ENGAGEMENTS → the CRM context (Member / Pipelines / Property): origin, outreach and who-they-are read as one uninterrupted sweep — those are what an admin scans while the session is still ahead — and the standing CRM relationship, which is not about THIS registration, sits last. **Status is conditional on a Zoom session** — it only appears once `live_status` is no longer `upcoming`, because before that every row reads the same "Registered" and the column is an invitation to hand-edit a value the reconcile is about to overwrite (see `canEditStatus`); **on a physical session it is on from the start and always editable**, because that roster IS the door (see *Check-in (physical sessions)*). The **Add lead** button is back (un-parked 2026-09-03 on the shared `LeadComboBox` in [Partials/AddRegistrationModal.vue](/resources/js/Pages/Manage/Events/Partials/AddRegistrationModal.vue), beside an **Import CSV** button mounting the shared `ImportCsvModal` in `register` mode, and — on a physical session — a per-row **Ticket QR** action opening [Partials/TicketQrModal.vue](/resources/js/Pages/Manage/Events/Partials/TicketQrModal.vue); see *Adding people to a session* above), and, on a Zoom session, the **status select only unlocks once the session is past** — before that attendance is machine-owned (Zoom's reconcile) and the badge's tooltip says so (a physical session's select is always live — and it is the only per-row attendance control). The toolbar is the Leads-index pattern applied CLIENT-SIDE (the roster is fully loaded, so no server round-trips): a roster **search** whose matches highlight in the lead cell (shared `HighlightText`), the **Add lead** / **Import CSV** buttons, and a **Filters** button opening the shared **`FilterDrawer`** (status / source-with-counts / Zoom-state / **quality** dimensions + a "Registered date" range via the new `dateTitle` prop) with **`ActiveFilterChips`** underneath — **quality** (2026-08-18) is a single-select `全部 All`/Agent/Fake/Clean dimension carrying its own `counts` map (walked the roster the same way source/campaign already are; `FilterDrawer.vue`'s single-select pills gained an opt-in count badge for it), matched via the shared `matchesQualityOption()` predicate in `QualityTagMeta.js` — the SAME one `useVslRosterFilters.js`'s pill-based Quality group uses on the VSL funnel roster, so the two never disagree on what counts as Agent/Fake/Clean despite the different idiom (this roster's existing drawer, not a pill row — decided in QA rather than growing a second filter mechanism here) — the finalized summary cards write the same `status` the drawer owns, so the two controls never disagree. A plain list before the session is finalized, then clickable **Registered / Attended / No-show / Unknown people** filter cards (Unknown = unmatched Zoom attendees). The toolbar's **`ExportMenu`** forwards that whole client-side selection through its `params` prop (`exportParams`), which is the only reason the download can match the screen — see the Registrations tab paragraph above · [WebinarTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/WebinarTab.vue) — the session's bound webinar (creating/failed/Upcoming/Live/Ended; one-click create, no edit/cancel modal) · [AttendanceTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/AttendanceTab.vue) — live/final roster + no-show list/export + unmatched (detail in the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md)) · [EngagementTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/EngagementTab.vue) — a thin wrapper (resync button + `engagement` prop) around the shared [Components/WebinarEngagement/](/resources/js/Components/WebinarEngagement/) set ([WebinarEngagementPanel.vue](/resources/js/Components/WebinarEngagement/WebinarEngagementPanel.vue) → `EngagementSummary` / `PollResults` + `PollQuestionCard` / `QaResults` / `ChatResults` — those four only; `AttendanceRoster` belongs to the **Attendance** tab, not this panel) — post-webinar poll / quiz / Q&A results, rendered by question type (detail in the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md)) · (`CheckInTab.vue` and `CheckInWalkInModal.vue` were **deleted 2026-09-03** — the physical door folded into the Registrations tab, see *Check-in (physical sessions)* above) · [CtaTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/CtaTab.vue) (WhatsApp CTA touches captured while the webinar was live) · [AdsTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/AdsTab.vue) — session ad performance at a **Campaign | Ad set | Ad** grain switch (2026-08-10, `?grain=`): the shared `DataTable` with two banded column families — **This session** (leads / allocated spend / CPL) and **whole window** (the object's own spend / impressions / CTR / Meta-reported leads) — an ad-grain name that links to Meta's rendered preview, a Running/Paused badge, a *small sample* flag under 5 leads, and an `#expand` carrying the day-by-day allocation audit plus the creative. The headline tiles deliberately stay **campaign-grain** (they come from `cplForEvents()`, the same call the Sessions tab and Dashboard make) while the table re-queries per grain — the three levels do not sum to one another, so it is a switch and never a tree; full reasoning in the [marketing handbook](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md). (`DetailsTab.vue` and `MessagesTab.vue` were deleted 2026-08-03 — the header card and the Registrations roster absorbed them.)
- [Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — the sidebar's single **Funnels** entry (→ `/manage/events/funnels`), under the **Sales & Marketing** section · [Components/FunnelMarketingTabs.vue](/resources/js/Components/FunnelMarketingTabs.vue) — the hub strip that entry fronts (Funnel / Meta Ads / AI Video).

**Migrations / Seeders** (`database/`)
- `2020_05_25_042759_create_event_funnels_table.php` → `…_042766_create_event_registrations_table.php` (funnels / series / events / sort_order / short_label / registrations).
- [2026_06_25_000001_add_auto_webinar_to_event_series_table.php](/database/migrations/2026_06_25_000001_add_auto_webinar_to_event_series_table.php) — the slot `auto_webinar` flag.
- [2026_06_30_000001_add_source_to_event_registrations_table.php](/database/migrations/2026_06_30_000001_add_source_to_event_registrations_table.php) — `event_registrations.source` (registration provenance: LANDING / ADMIN / WALK_IN).
- [2026_07_02_000040_add_whatsapp_group_link_to_event_funnels_table.php](/database/migrations/2026_07_02_000040_add_whatsapp_group_link_to_event_funnels_table.php) — the funnel `whatsapp_group_link` (the `{{whatsapp_group_link}}` automation token).
- [2026_07_13_000001_drop_rounds_and_default_funnel.php](/database/migrations/2026_07_13_000001_drop_rounds_and_default_funnel.php) — **drops** the `event_cycles` table + `events.event_cycle_id` (rounds removed) **and** `event_funnels.is_default` (default funnel removed). `down()` restores the schema only, not the data.
- [2026_07_13_000002_drop_day_of_week_from_event_series_table.php](/database/migrations/2026_07_13_000002_drop_day_of_week_from_event_series_table.php) — **drops** `event_series.day_of_week`: a slot is a template with no weekday; slots are ordered by `sort_order`.
- [tests/Feature/Event/MembersOnlyPublicGateTest.php](/tests/Feature/Event/MembersOnlyPublicGateTest.php) — the public surface never reaches a members-only session: funnel-wide enrol skips it, a posted gated uuid 404s, its slot landing 404s, and a public slot landing still works.
- [2026_07_23_000001_add_visibility_to_event_series_and_memberships.php](/database/migrations/2026_07_23_000001_add_visibility_to_event_series_and_memberships.php) — **per-slot members-only gating**: adds `event_series.visibility` (default Public) + creates the `event_series_membership` pivot (mirrors `event_membership`). Sessions inherit both at creation. This replaced the removed standalone Occasional module.
- Zoom-webinar tables (incl. `zoom_webinar_attendances.match_method`): see the [Zoom handbook](/docs/modules_handbook/manage/zoom/readMe.md).
- Seeders: [FunnelsSeeder.php](/database/seeds/FunnelsSeeder.php) (the demo funnels, incl. **Bootcamp**) · [EventSeriesSeeder.php](/database/seeds/EventSeriesSeeder.php) (the 3 Bootcamp slots + a few weeks of concrete sessions created directly via `EventRepository::createFromSlot`) · [EventRegistrationsSeeder.php](/database/seeds/EventRegistrationsSeeder.php). (The old `EventFunnelSeeder` is deleted — the Bootcamp funnel now comes from `FunnelsSeeder`.)

**Routes**
- [routes/web.php](/routes/web.php) — `manage.events.*`: **`dashboard`** (`GET dashboard`, the Funnel Dashboard — a literal declared first so it isn't read as a uuid), `funnels.*` (CRUD + `{id}/group-status` + **`{id}/og-banner`** store/destroy), `series.*` (index → redirect + CRUD), **`sessions.store`** (`POST sessions`, the literal declared **before** `{id}` so it isn't read as a uuid) + **`sessions.linkable-webinars`** / **`sessions.link-webinar`**, **`sessions.move`** (`POST {id}/move` → `EventsController@move`), `weekly.leads`, `{id}/ad-insights` (the Ads tab's lazy JSON), `{id}/webinar.*` (store / update / destroy / retry / start + Phase 7 `responses/sync`), `attendance.export` (no-show export — declared **before** `{id}` per §14), `registrations.*` (store / status / destroy + **`registrations.join-link`**), `{id}/check-in.*`, single-session `show/update/cancel/destroy`.
- [routes/main.php](/routes/main.php) — root `/` is the **site home** (`Main\SiteController@home`, never a funnel) + `landing.funnel` (`/{slug}`) + `landing.slot` (`/{funnel}/{slot}`) + funnel-/session-aware `register`.
