# Landing & Lead Capture (Main)

**Portal:** Main (public) · **Routes:** `landing.funnel` (`/{slug}`), `landing.video` (`/{slug}/video`), `landing.slot` (`/{funnel}/{slot}`), `register`, `auth.magic` — the root `/` (`landing`) renders the **site home** (`Main\SiteController@home`), **not** a funnel

## What it does
The public landing pages — the entry point for Facebook/Instagram ad traffic. Each [funnel/program](/docs/modules_handbook/manage/events/funnels/readMe.md) has its own landing at `/{slug}` (and each slot a single-CTA landing at `/{funnel}/{slot}` that registers the visitor for the **next upcoming session**); the root `/` is the **site home**, never a funnel. Visitors click a CTA button that opens a **modal registration form** (Name, Phone + country prefix, Email). On submit it:
1. Creates a **Non-Member** user account (random unknown password — sign-in is passwordless).
2. Records a **funnel registration** ([`lead_funnels`](/docs/modules_handbook/manage/leads/readMe.md)) for that person, always bucketed **source = Funnel** (2026-07 consolidation), with the ad attribution carried on the URL (utm_*, fbclid, `campaign_id`/`adset_id`/`ad_id`/`placement` — Meta URL dynamic parameters) — reusing the existing **[Lead](/docs/modules_handbook/manage/leads/readMe.md)** for a returning visitor, or creating one (auto-granted its free AI credits — see the [AI](/docs/modules_handbook/shared/ai/readMe.md) module).
3. Redirects to the shared **`/thank-you`** page (2026-07-25 — it replaced the in-place success modal): confirmed seat + confetti, the **session's date & time**, a **Join our VIP WhatsApp group** invite (the funnel's own `whatsapp_group_link` when it has one, else the shared `WHATSAPP_COMMUNITY_LINK` via `config('whatsapp.community_link')`), and a **"your bonus is on its way to `{phone}`"** panel with a **wrong-number correction** form and a contact-admin escape hatch. **Nothing is verified on this page** — the funnel's WhatsApp welcome carries the bonus *and* a `{{login_link}}` which both proves the phone and (when it is safe to) signs the person in. *History: that third card was a click-to-send **dual-OTP verify & sign in** step (shared `ContactVerification` against `auth/register/start` / `auth/register/verify`) until 2026-07-28 — it was replaced because the WhatsApp welcome already proves the number, so asking for two codes only bled conversions. The full dual-OTP flow still lives at `/register`.* Someone who never taps the welcome signs in from `/login` any time.

Registration is **frictionless**: a returning person (matched **by email only** — see the security note below) **reuses** their account + lead and simply gets a **new funnel registration** + a fresh WhatsApp welcome — registering for another funnel, or the same one again, is never rejected. A sign-in credential only rides that welcome when **no email-holding account existed beforehand** (`RegisterLeadAction`'s `allowAutoLogin`) and it is going to that account's **own stored phone**, so reusing an account can never grant access to anyone else. A person is **one account + one lead, with many funnel registrations** (each with its own attribution — see the [Leads](/docs/modules_handbook/manage/leads/readMe.md) module).

> **⚠️ SECURITY — register matches by EMAIL ONLY (never phone).** This is a public, unauthenticated endpoint that then hands out a **working sign-in credential** (today the welcome's `{{login_link}}`; until 2026-07-25 a mailed magic link), so the account is keyed on the email. It must **never** resolve or enrich an account via the **unverified typed phone**: doing so let an attacker type a *victim's* phone + their *own* email and be handed a link straight into the victim's account (account takeover). The system-wide tolerant phone-merge (see the [Leads › Identity resolution](/docs/modules_handbook/manage/leads/readMe.md) section) is deliberately NOT applied here — only on admin / background-ingestion paths. Do not re-add phone matching to `RegisterLeadAction::resolveAccount`.

The lead then taps the WhatsApp welcome's link and lands signed-in in the user portal (or, when the account could be claimed by somebody else, proves its email at `/verify-email` first).

## How it works

### Per-funnel Vue landings (folder per funnel, resolved by slug)
- Each funnel's landing is a **Vue component**, not a DB-driven template or an uploaded file. `LandingController@index($slug)` resolves the funnel by slug (404 if it isn't an active funnel) then delegates to a protected **`renderLanding()`** helper, which gathers up to **9** upcoming events for the funnel + the funnel's active **slots** (each with its nearest upcoming session, for the "jump to a class" links) + the URL tracking params (utm_*, fbclid, campaign/adset/ad ids, placement, landing_url) + the funnel uuid + **all active funnels** (`{name,slug,description}`, for the generic Default fallback's browse list), and renders the single Inertia page **`Pages/Landing.vue`**.
- **`Pages/Landing.vue` is a thin wrapper**: it `import.meta.glob`s `./Landings/**/*.vue` and renders **`Landings/{slug}/Index.vue`** (one folder per funnel, resolved by the **exact slug**), falling back to **`Landings/Default.vue`** for any funnel without its own folder. So `bootcamp` → `Landings/bootcamp/Index.vue`, `sutera-klcc` → `Landings/sutera-klcc/Index.vue`.
- **The root `/` is never a funnel** — it renders the **site home** (`SiteController@home`, a separate module). A funnel landing is only ever reached by its own slug (`/{slug}`), and a slot landing by `/{funnel}/{slot}`; there is no "one active funnel → redirect root to it" behaviour and no default funnel.
- **`Landings/Default.vue` is the generic fallback design** for a specific funnel with no `Index.vue` of its own — a register CTA for that funnel plus the other active programs to browse (driven by the active-funnel list the controller passes). It is no longer served at the root as a directory.
- **Scaffolding a funnel's design** (the design is source code → must be Vite-built): `php artisan funnel:landing {slug}` writes a starter `Landings/{slug}/Index.vue`. `FunnelsController@store` runs it **automatically in `local`** (`app()->isLocal()`) so creating a funnel locally drops a starter file (Vite HMR picks it up). In **production** it's skipped — a new funnel works via the `Landings/Default.vue` fallback until a dev commits a custom `Index.vue` + rebuilds.
- Each design is hand-built markup (hero, sections, CTAs) that drops in the shared **`<LeadCaptureModal>`** behind its "Register" buttons (`v-model` to an `open` ref flipped from any CTA).

### Per-slot landings (`/{funnel}/{slot}` — one CTA, auto-picked session)
- `LandingController@slot` resolves the active funnel + active slot by slug, then **auto-picks the single session** to register for: the **earliest** session of that slot whose derived **`live_status` is still Upcoming** (a Live or Past occurrence is skipped, bounded by `scheduled_date >= today` and eager-loading `webinar` so the status read is accurate). So a slot that repeats weekly always funnels the visitor into the **next** round; the visitor **never** picks a session and there is **no `?session=` deep-link**.
- **Gating:** a slot with **no** upcoming session **404s** for the public URL — **unless** `?preview` is present (`$request->boolean('preview')`), which renders the design for an admin with the CTA disabled (so a dead/empty slot is never publicly indexed). The picked session's uuid is passed as **`tracking.event`**, which the modal posts to `/register` (the `event` branch → registers for exactly that session; see below).
- **`Pages/SlotLanding.vue`** is the wrapper (mirrors `Landing.vue`): `import.meta.glob`s `./Landings/**/*.vue` and renders a bespoke **`Landings/{funnel}/{slot}/Index.vue`** if one exists, else the generic **`Landings/DefaultSlot.vue`**. It passes the single `session` + `preview` through. Creating a slot locally auto-scaffolds a starter design (`php artisan slot:landing`, mirroring `funnel:landing`).
- **Empty-slug guard (open issue).** Both slugs default to `''` and an empty one is **logged + 404s**. `{funnel}/{slot}` is a catch-all that receives every two-segment URL on the site, and production was seen dispatching here with **zero route parameters bound** — an `ArgumentCountError` 500, ~9×/day on 2026-07-16, in a bot-scan-like pattern. Route definition, route cache and the controller signature were all verified correct, so the triggering URL is **still unidentified**; the guard logs `url` / `request_uri` / `user_agent` / `ip` under `landing.slot dispatched without route parameters` so the next occurrence identifies it. Grep that message before re-investigating.

### The post-registration VIDEO step (`/{slug}/video` — optional, per funnel)
- A funnel whose offer is "register → watch the analysis → talk to us" can own a **third page after the thank-you**: `landing.video` → `LandingController@video`. It resolves the active funnel by slug (404 otherwise) and renders **`Pages/FunnelVideo.vue`**, which mirrors `Landing.vue`: it `import.meta.glob`s `./Landings/**/Video.vue` and renders **`Landings/{slug}/Video.vue`**.
- **There is deliberately NO generic fallback design** (no `DefaultVideo.vue`). A video step only means anything if the funnel has a video; a shared "watch our video" page with no video is worse than not having the URL. Whether a design exists is a **build-time** fact the server cannot see, so `FunnelVideo.vue` `router.replace`s a design-less funnel back to `/{slug}` (replace, not visit — the back button must not walk into the dead URL again).
- **Registered viewers only** (2026-08-12). The URL is guessable, and an unregistered direct visitor used to get the whole presentation while appearing in the VSL roster as an anonymous *未登记 · direct* row nobody can follow up with. The registration IS the price of the video, so `LandingController@video` bounces anyone without the session payload their own registration planted (`register()` → the `thank_you` key, **funnel-matched** — one funnel's registration never unlocks another funnel's video) back to `/{slug}` to pay it. Signed-in **admins pass** (they QA the page without polluting the lead list) — and `VideoProgressController` records **attributable viewers only** (2026-08-12, tightened same-day from an admin-only skip): a heartbeat whose `resolveLeadId()` comes back null — an admin QA'ing, a colleague not signed in as themself, or a real viewer whose **session expired mid-watch** (the tab left open past the session lifetime mints a fresh empty session + fresh visitor_key) — stores nothing and just echoes, so no anonymous *未登记* row can ever be minted and staff minutes never fold into the funnel's watched / unlocked cards. The player is unaffected (it records, it does not gate); a returning registrant whose session expired is now recognised by the `funnel_visitor` cookie below, and failing that simply re-registers — 30 seconds, and the identity gate merges them onto their own lead. Pinned by `test_the_video_page_is_for_registered_viewers_only`.
- **THE 5-MINUTE WATCH GATE IS GONE (2026-08-12, product decision).** Booking is open from the first second, the player's scrubber is handed back to the viewer, and playback RESUMES where they stopped. Three consequences worth knowing before anyone "restores" the old behaviour:
  - **The bar is real, and it is the whole point.** A `<input type="range">` in the control bar, the played side painted gold via a `--played` custom property (`accent-color` cannot colour the two sides differently). Two things about it are load-bearing rather than styling: it commits on **`change`, not `input`**, so one drag is ONE seek instead of thirty; and a seek's ORIGIN cannot be read in either `seeking` or `seeked`, because the HTML seek algorithm fires a `timeupdate` first and the clock already holds the destination — so `prevPos` trails the position by one tick and is frozen while a seek is in flight. Get either wrong and the counters read zero (or thirty) while looking perfectly plausible. The resume seek is flagged and not counted: the player made it, not the viewer.
  - **Seeking is measured, not prevented.** The clamp that pinned playback to `furthest_seconds` is gone; instead every jump ≥1s is counted (`seek_forward_count` / `seek_back_count`) along with a per-rate `speed_changes` tally, so *did they study it or skim it* is answerable on the roster. Counters travel as **DELTAS since the last heartbeat** and are ADDED server-side — a client-sent total would let a refresh reset the tally or a replayed request inflate it.
  - **`last_position_seconds` is a NEW fact, not a rename of `furthest_seconds`.** With seeking, a viewer who jumps to minute 30 then drags back to 5 has a furthest of 30 and a position of 5; only the position can resume them. `resumeSeconds` on the video page reads the position, clamped short of the end so nobody resumes onto a finished video. ⚠️ It takes the **LATEST row, never `max()`** — the table is unique on (funnel, `visitor_key`), so one person holds a row per browser, and the deepest position across them is not where they last were (watch to minute 30 on the laptop, drag back to 5 on the phone, and `max()` sends the phone to 30). The client MAY OMIT the field, and the server then leaves the stored value alone — sending 0 for "I don't know yet" is how a tab closed before `loadedmetadata` erases the very resume point this exists for.
  - **`unlocked_at` survives with a new meaning, and is now DERIVED SERVER-SIDE** (`FunnelVideoView::ENGAGED_SECONDS`, stamped when `watched_seconds` first crosses 5 minutes). It used to be the gate, so the player owned the fact; it is now simply watch depth, which the server already holds — and a number the manage roster reports should not be one a browser can misstate. The roster's card is relabelled **看满 5 分钟 · Watched 5+ min**; the 已解锁 / 看完 row tags are gone. ⚠️ `completed_at` is a DIFFERENT fact (they reached the end) and feeds the funnels index's *finished* column — it was deliberately NOT removed with the gate.
- **The heartbeat's validation lives in `Main\StoreVideoProgressRequest`** (GUIDELINES §8), not inline in the controller — and every rule in it is a CLAMP, because the endpoint is public and unauthenticated while what it writes is the admin roster's watch figures. `speed_delta`'s **keys** are allow-listed against `FunnelVideoView::SPEED_RATES` (the column is merged additively and never pruned, so one invented key lives in the row for good), and `last_position_seconds` is genuinely OPTIONAL so a client that cannot yet say where it is omits the key rather than sending 0.
- **Remembering a device — `funnel_visitor`** (encrypted cookie, 30 days, `Concerns\ResolvesFunnelVisitor`). A 2-hour session cannot recognise someone returning tomorrow, so a returning registrant was being bounced back to the form and recorded as an anonymous viewer. ⚠️ **Its powers are deliberately tiny, and the reason is not obvious**: Laravel's cookie encryption stops FORGERY, but forgery is not the attack — the capture form resolves an account by EMAIL ALONE, so anyone who types `victim@example.com` can *ask the server* for a signed long-lived assertion that they are the victim. If that could rehydrate `thank_you`, the chain completes (updatePhone → welcome re-sent to the attacker's number → `{{login_link}}` → signed in). So the cookie may do exactly two things, both harmless to hand a stranger: **re-open the video page of a funnel that person actually registered for** (checked against `lead_funnels`, so one funnel's cookie never unlocks another's) and **attribute the watch heartbeat**. It must never prefill contact details, authorise an account write, or mint a login. Pinned by `test_a_remembered_device_resumes_the_video_and_records_interactions` and `test_the_video_page_is_for_registered_viewers_only`.
- **A remembered visitor's CTA skips the form** (`startBooking()` in the cochrane landing; `knownVisitor` is a BOOLEAN prop and never carries contact details, for the disclosure reason above). It must not re-register: `lead_funnels` is unique per (lead, funnel) and holds FIRST-touch attribution, so a second submit adds nothing — while `Lead` / `CompleteRegistration` would fire again, and Meta's dedup window is only ~48h, so a return visit days later is counted as a SECOND conversion, inflating lead volume and deflating cost-per-lead. The decision is taken BEFORE the modal opens, because `LeadCaptureModal` fires a `ViewContent` the instant it becomes visible.
- **The floating WhatsApp booking assistant** (2026-08-12, `Landings/cochrane/Partials/ChatBooking.vue`) — a chat-shaped second route to the same booking, in WhatsApp's own **dark-mode** palette (`#0B141A` / `#1F2C34` / `#005C4B` / `#25D366`: recognisably WhatsApp, yet part of this navy page rather than a white third-party widget pasted onto it, which is what a booking panel must never look like). Two states around the watch gate:
  - It opens ITSELF once, ~9s after playback starts — early enough to land while attention is high, late enough not to talk over the opening — and only while playing. There is no longer a *locked* state (the countdown ring, the live `mm:ss` and the FAB's countdown all went with the 5-minute gate on 2026-08-12): the panel opens straight into the conversation, because a booking channel that makes an interested viewer wait is just a slower way of losing them.
  - **The conversation** — the booking form asked as a chat: one question per turn with WhatsApp-style quick-reply buttons, each answer echoed back as an outgoing bubble, a typing beat between turns, identity CONFIRMED rather than re-asked (it is pre-filled from the registration), the optional note skippable, then a summary card and the hand-off button.
  - ⚠️ **It is a VIEW, never a second write path.** It receives the parent's `useForm` object and fills its fields (§14's fields-component pattern), then EMITS `submit` so the page's own `submit()` runs — the one that saves BEFORE pointing the claimed tab at `wa.me`. A chat bot with its own `window.open` would reintroduce exactly the bug that ordering exists to fix. It hides while the player is expanded (a panel floating over a full-viewport video is an obstruction), and the form modal remains as the alternative route.
- **The booking form requires name + phone + EMAIL** (2026-08-12 — `StoreConsultationRequest`; the modal gained an Email input, pre-filled from the registration so the typical visitor never types it twice). The submit saves the enquiry to `consultation_requests` **before** the WhatsApp hand-off is attempted, so a blocked popup / an abandoned WhatsApp still leaves a complete, chaseable booking on the VSL Leads roster (the 没发 WhatsApp list).
- It is **public and unauthenticated** — a lead is never signed in at this point in the funnel — but **`noindex`** (`Seo::noindex()`), because it is a step inside a flow rather than a page anyone should land on cold from search.
- The controller passes a **`salesWaUrl`** prop: a `wa.me` deep link to `config('services.whatsapp.sales')` pre-filled with the funnel's name. This step's job is to book a human, and there is no in-app booking surface for it.
- ⚠️ **`video` is a RESERVED slot slug.** The route is a two-segment literal declared *before* the `{funnel}/{slot}` catch-all, so it wins — a slot slugged `video` would resolve here and its own landing would be unreachable. `Manage\Events\Series\StoreRequest::RESERVED_SLUGS` rejects it at slot creation (and at edit, via `UpdateRequest`), which is the only place an admin ever sees the reason. Any future literal `/{slug}/…` route must be added to that list in the same commit.
- Live consumer: **`cochrane`** (`Landings/cochrane/Video.vue` — the 40-minute Cochrane/Maluri project analysis, self-hosted at `/main/videos/zen-40mins.mp4` and set in the component's `VIDEO` block). It is a *different* file from the 1-minute teaser on the `/cochrane` landing (`cochrane-teaser.mp4`).
- **Every `<video>` on a VSL funnel carries the same four attributes** — `@contextmenu.prevent`, `controlslist="nodownload noremoteplayback"`, `disablepictureinpicture`, `playsinline`. The right-click menu's *"Open video in new tab"*, the native download button and Picture-in-Picture each hand the raw file to the browser's own player. This is not download protection (the `src` is plain HTML and an extension reads it anyway) — it is closing the accidental exits, which is why the teaser and the client clips carry them too. ⚠️ **What those four attributes are for CHANGED with the gate.** They used to protect unskippable watch time; since the player is seekable (2026-08-12) the remaining reason is narrower but still real — the browser's own player **reports no progress**, so a viewer who leaves through one of those exits vanishes from the roster mid-watch. The webkit CSS that hid the native **timeline and seek buttons** was deleted with the gate (keeping it would have half-removed it — our scrubber working while a native one stayed invisible); only the **download** button is still suppressed, and only on `video.gated`.
- **Autoplay with sound is bought by the visitor's OWN click, and cannot be faked.** Browsers honour only a gesture whose `isTrusted` is true; `dispatchEvent(new MouseEvent('click'))` is false by specification and unlocks nothing. What works here is the flow itself: a visitor reaches the video page by pressing a CTA on the landing, and Inertia navigates without a document reload, so that real gesture still counts and the video plays with sound. Only someone who typed the URL directly arrives ungestured — they get muted playback plus a one-tap 开启声音 chip, which is the honest fallback.
- **The landing teaser auto-plays on scroll-into-view, sound-first** (2026-08-12, `Landings/cochrane/Index.vue` — TEASER AUTOPLAY block): unmuted `play()` is tried first (succeeds whenever the visitor has already tapped anything on the page — browsers treat sound-on autoplay as a permission, not a setting), and when vetoed it degrades to muted autoplay + a **点击开启声音** chip whose tap is the gesture that legalises audio. It pauses on scroll-out (half-heard audio from a section the reader left is how you lose them) and never re-autoplays over a visitor's own pause.
- **…and a real `poster` frame-grab** (2026-08-12). `preload="metadata"` does NOT promise a visible first frame — iOS Safari renders a blank box until play is pressed — so every player (main VSL, teaser, the four client clips) points at an ffmpeg frame-grab in `public/main/images/cochrane/posters/`. The posters are **committed** (unlike the gitignored videos), so a fresh deploy has thumbnails even before the videos are re-copied; regenerate the grab (`ffmpeg -ss <t> -i <video> -frames:v 1 -vf "scale='min(1280,iw)':-2" -q:v 4 <poster>.jpg`) whenever a video is replaced, and pick a frame with the presenter on screen rather than an establishing shot.
- **A funnel opts IN by slug** — `config/funnels.php` `video_steps` (env `FUNNEL_VIDEO_STEPS`, default `cochrane`). `register()` redirects a listed funnel to `/{slug}/video` and everyone else to `/thank-you`, carrying the same `thank_you` session payload either way so the browser `Lead` + `CompleteRegistration` pixels still fire wherever the visitor lands. Listing a slug with no `Video.vue` sends its registrants to a page that bounces them straight back — which reads as the registration having done nothing.

### Two offers, one argument: the `cochrane` / `cochrane-webinar` pair
- `Landings/cochrane-webinar/WebinarLanding.vue` is a deliberate near-copy of `Landings/cochrane/Index.vue` — same sections in the same order, same comparison table, same trust block, same per-project catches, same shared `/main/images/cochrane/*` assets. **Only the OFFER differs:** `/cochrane` registers you to watch the 40-minute VSL (`video_steps`); `/cochrane-webinar` registers you for the LIVE webinar and goes to `/thank-you`.
- It is a second page rather than a prop because **half the copy on a landing IS the offer** — the step strip, every CTA, the countdown, the FAQ, the exit modal and the capture-modal headings all change with it. The upside of keeping them otherwise identical is that the same ad set can be split between "watch now" and "attend live" and compared honestly, since nothing else moved.
- **The webinar's date is data, not copy.** The page reads the `session` its wrapper resolved (the nearest upcoming public one) and derives every date, time, weekday, duration, venue and the countdown from it; a hard-coded `FALLBACK` covers the window before an admin creates the session. So a reschedule happens in Manage → Events, not in the component. Times are pinned to **+08:00** and rendered by shifting the instant — a bare `new Date('… 20:00')` is parsed in the *visitor's* zone and puts the countdown hours out for anyone abroad.
- It passes `tracking.event` (the session uuid) to `<LeadCaptureModal>` the way a slot landing does, so a registrant is enrolled in **that** session — ticket, reminders, Zoom sync, and the date on the thank-you page — instead of only the funnel.
- **Two URLs, ONE design.** The webinar is reachable at both `/cochrane-webinar` (funnel) and `/cochrane-webinar/cochrane-live` (slot), and the page lives in exactly one file — [`Landings/cochrane-webinar/WebinarLanding.vue`](/resources/js/Pages/Landings/cochrane-webinar/WebinarLanding.vue). The two `Index.vue` files are ~20-line wrappers that normalise their props into its contract (`session` / `tracking` / `preview`). This is the one place in the module where a slot design is NOT a standalone page, and the reason is length: a 1,100-line landing copied twice does not stay copied — one copy gets the price correction and the other keeps selling last month's numbers. *(Its slot was initially left inactive so the second URL would 404 and not compete in the sitemap; it was activated 2026-08-19 once both routes rendered the same page, which removes the duplicate-CONTENT problem but not the duplicate-URL one — see below.)*
  - The wrappers exist because the two routes are handed different shapes: the funnel landing gets an `events` **array** and no preview flag; the slot landing gets ONE auto-picked `session`, a `preview` flag, and a **`tracking.event` already filled in** by `LandingController@slot`. `captureTracking` prefers that existing `event` and falls back to `session.uuid`, so both routes enrol the lead in the same session.
  - `preview` is honoured: every CTA on the page routes through a single `openForm()` guard rather than flipping `showForm` itself, and a red banner says the dates are the fallback. A dozen buttons each carrying their own `:disabled` is a dozen chances to forget one — and the forgotten one hands an admin a registration form for a session that does not exist.
  - The slot design deliberately **ignores** `poster` / `posterWidth` / `posterHeight`. This page opens with the comparison table, which IS the ad's promise; a slot poster above it pushes the one thing the visitor came for below the fold.
  - ⚠️ **Both URLs are now in the sitemap with the same content.** Point ads at ONE of them (today: `/cochrane-webinar`) and treat the other as the in-product link. If search ever splits them, the fix is a canonical on the slot route, not a second design.
  - Covered by [`Landings/cochrane-webinar/WebinarLanding.test.js`](/resources/js/Pages/Landings/cochrane-webinar/WebinarLanding.test.js): both wrappers mount the real page — dates derived from an arbitrary session, the no-session fallback, the `tracking.event` precedence, and the inert-preview case.

### Every Meta event a VSL funnel sends (and when)
The table below is the whole set for the `/{slug}` → `/{slug}/video` → booking journey. **★ = reported from both halves** (browser Pixel + Conversions API, sharing one `event_id` so Meta counts one conversion); the rest are browser-only, and each has a reason.

| Event | Fires when | Browser | CAPI |
|---|---|---|---|
| `PageView` | any public page loads — base code, then `app.js` on every SPA navigation | ✅ | — |
| `ViewContent` | the capture modal OPENS (`LeadCaptureModal.vue`) — the strongest pre-conversion intent a landing page has | ✅ | — |
| `SubmitApplication` | the submit button is PRESSED (`LeadCaptureForm.submit()`) | ✅ | — |
| **`Lead`** ★ | the video page mounts after a completed registration | ✅ | ✅ |
| **`CompleteRegistration`** ★ | same moment, own `…-cr` id, plus `status: new\|returning` | ✅ | ✅ |
| **`Schedule`** ★ | a consultation form is stored successfully | ✅ | ✅ |

- **`SubmitApplication` has no server half on purpose.** The server only ever hears about submissions that SUCCEED, and that is exactly the population this event exists to look past — the gap between it and `Lead` IS the submit drop-off. It also carries no `event_id`, since there is nothing to deduplicate against.
- **`Schedule` is the event a VSL campaign should be optimised on** — the whole page exists to reach it — which is why it gained a CAPI half (`ConsultationController::reportScheduleToMeta`). Optimising on a browser-only signal quietly teaches Meta to find the subset of buyers who *allow tracking*; the ones behind an ad blocker book calls too.
- ⚠️ **`Schedule` must never carry `value`.** Meta reads `value` as money, so watch time sent there would be reported as revenue and inflate the ROAS shown on the Ad Return page — from a step where nobody has paid anything. Watch time and the WhatsApp-opened flag travel in `custom_properties` instead. Locked by `ConsultationCapiTest`.
- ⚠️ There used to be a **second `ViewContent`** here, fired when watch time crossed the 5-minute gate (`content_name: cochrane-video-unlock`). It went with the gate on 2026-08-12 — there is no unlock moment to report. Watching is still recorded, in `funnel_video_views`, which is where that fact belongs: the server's copy of it is the DB, not Meta.

### Meta ads → landing attribution (the ad URL contract)
- To know **which campaign / ad set / ad** brought a registering lead, the ad's **Website URL** must carry Meta **URL dynamic parameters** — the param names match what the landing + `RegisterRequest` + `lead_funnels` already accept, so no backend mapping is involved:
  `https://{APP_URL}/{funnel-slug}?utm_source=meta&utm_medium=paid_social&utm_campaign={{campaign.name}}&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}&placement={{placement}}`
- The **Funnel Show → Landing tab** has a ready-made **"Meta ad URL" copy button** for exactly this template, and each slot's **Slots-tab expand row** has the slot-level equivalent (`/{funnel}/{slot}` — for ads promoting one specific class). Meta replaces the `{{…}}` at click time; both landings forward the values into the registration's `lead_funnels` row **and** stamp the same ids as a per-join snapshot on every **`event_registrations`** row created (slot landing → the auto-picked session; funnel landing → all upcoming sessions) — the basis of the Session Show **Ads tab**'s session-level CPL (see the [Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) doc).
- After registration, **`ResolveMetaAdRefsJob`** resolves the raw ids into names (campaign / adset / ad + the owning **ad account**) via one Graph call into the **`meta_ad_refs`** lookup — shown as "Name (id)" on the Lead → Attribution tab. See the [Marketing](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) module.
- A campaign can additionally be **tied to the funnel** on the [Campaign Mapping](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) page (XOR with a project tie) so its landing registrations group under the funnel for reporting.

### The shared capture modal
- **`Components/LeadCaptureModal.vue`** holds ONLY the capture form — the success screen is the `/thank-you` page now. Designs customise via props: `title` / `subtitle` / `button`; **`successTitle` / `returningTitle` / `whatsapp` / `loginUrl` / `loginNote` are LEGACY no-ops** (accepted so old designs compile, ignored).
- **`LeadCaptureForm.vue`** owns the form state (incl. the hidden tracking + funnel uuid), light client-side validation, and the **Inertia `POST /register`** submit — no `preserveState`, because the server redirects away to `/thank-you`. The phone uses the shared **`PhoneInput`** country-prefix (submitted `phone` is the stored `<dial><national>` form, e.g. `60123456789`).
- **The modal is themed by the SAME `Landings/{slug}/theme.js` as the thank-you page**, so a dark landing no longer opens a white form. `LeadCaptureModal` resolves `captureVarsForSlug(funnel.slug)` and, when the funnel has a theme, hands `Modal` an optional **`panelStyle`** — the panel's own paint plus the **`--lc-*`** custom properties (`surface` / `text` / `muted` / `border` / `field` / `hover` / `accent` / `accent-text` / `accent-hover` / `accent-shadow` / `ring` / `ring-width` / `danger-*`). The header row, `LeadCaptureForm` and the input row of `PhoneInput` read those variables in their scoped CSS.
  - **Every rule is written `var(--lc-…, <today's colour>)`.** A funnel with no `theme.js` sets no variables, so the fallbacks render the original light form byte-for-byte — which is what keeps the **9 admin/portal forms that also use `PhoneInput`** untouched. Do not "simplify" those fallbacks away.
  - Only `--lc-accent*` is derived from the theme's one `accent` (hover = 88 % toward black, shadow = 30 % alpha), so a funnel still declares a single accent colour. Validation stays **red** on every theme (an accent-tinted error is not an error) and `PhoneInput`'s **dropdown stays light** — a white menu opening from a dark field is the readable, expected pattern.

### The shared thank-you page (`/thank-you`)
- **One page, every funnel.** `LandingController@register` puts a `thank_you` payload in the **session** (not a flash, so a refresh or back-navigation still renders it; the next registration overwrites it) and redirects to `thank-you`. `Main\ThankYouController@show` reads it and renders `Pages/ThankYou.vue`; a visitor with no payload (i.e. never registered) is sent to `/`.
- **Payload:** `{ new, email_masked, contact{email,phone}, funnel{name,slug}, session{scheduled_date,start_time,end_time,mode_label}, event_id, registration_event_id, user_uuid, whatsapp_phone, welcome_ctx{funnel_id,series_id} }`. Plus `new_account` and `account_had_email` (bools). Those are **server-side only** (never sent to the page as-is): `user_uuid` scopes the wrong-number correction to the visitor's own just-made registration, `account_had_email` decides whether a re-sent welcome may carry a sign-in credential, and `welcome_ctx` lets it re-dispatch the SAME welcome. `session` is null on a funnel-wide landing (which enrols every upcoming session, so there is no single one to show).
- **Content is identical everywhere; only the PALETTE changes.** A funnel skins the page by dropping **`resources/js/Pages/Landings/{slug}/theme.js`** (the same slug convention the landing designs use); `ThankYou.vue` globs those, merges the chosen one over `DEFAULT_THEME` and emits it as `--ty-*` CSS custom properties on its root. A partial theme is fine — a funnel that only wants a different accent writes one line. Contract + helper: [`resources/js/Pages/Landings/theme.js`](/resources/js/Pages/Landings/theme.js); example: `Landings/investment-masterclass/theme.js` (dark gold).
- **Sections:** confirmed-seat hero (+ `Confetti` — plays on **every** visit, first registration or not, product decision 2026-07-28; the headings still tell the two apart in words) → session date/time card (hidden on a funnel-wide landing, which has no single session) → **Step 1** VIP WhatsApp group card (4 perks + green CTA + the **auto-join countdown**, below) → **Step 2** "独家 Bonus 已发送到您的 WhatsApp" card: the number it went to, the **"Wrong number?"** correction form (`PhoneInput` → `POST /thank-you/phone`) and the **"Not receiving messages?"** wa.me link to the admin. `ThankYou.vue` imports exactly `Confetti`, `PhoneInput`, `trackPixel` and the theme helpers — there is **no** `ContactVerification` and no verify step here any more (removed 2026-07-28, see step 3 of *What it does*).
- **Auto-join (2026-08-19).** Joining the group is what this page is FOR, and a large share of registrants read it, mean to tap the button, and never do — so after **7 seconds** (`AUTO_JOIN_SECONDS`) the page navigates to the group itself. Five things about it are load-bearing:
  - **It is shown, not silent.** A countdown sits under the CTA (`N 秒后自动为您打开群组`) with a real **留在此页** button. A page that moves on its own without saying so reads as a hijack.
  - **`window.location.href`, never `window.open`.** A window opened with no user gesture behind it is popup-blocked — and a blocked popup is a redirect that silently does nothing. Same-tab also keeps the page in history, so `back` returns here, which is what makes the redirect recoverable at all. (The manual button still opens a new tab; tapping it cancels the countdown, or this tab would move too and lose the visitor's place.)
  - **Step 2 cancels it.** Opening the wrong-number form stops the clock for good. A mistyped phone is the one failure this flow cannot survive silently — the welcome carries the bonus *and* the sign-in link — so anyone actually correcting one must never be yanked away mid-edit.
  - **A funnel with no group link never starts one**, or the countdown would end on a navigation to `''`.
  - ⚠️ **The trade-off is real and deliberate:** with the redirect on, most visitors never read Step 2. If wrong-number corrections dry up, lengthen `AUTO_JOIN_SECONDS` or gate the countdown on funnels that run their own cohort group — do not make it silent. Covered by [`resources/js/Pages/ThankYou.autoJoin.test.js`](/resources/js/Pages/ThankYou.autoJoin.test.js) (countdown → navigate, no re-fire, both escapes, and the no-link case).
  - **The footer is commented out** in `ThankYou.vue` (`<!-- ===== FOOTER ===== Shawn Purposely Comment out this first -->`), so neither the "sign in from `/login`" line nor the "返回 {{ funnel.name }}" back-link renders today. The markup is kept in place, not deleted — it is a deliberate hold, so re-enabling it is uncommenting rather than rewriting.
- **Pixel.** The two **conversions** fire **here** (`onMounted`), not at submit: a redirect would race a fire-then-navigate, whereas this page is guaranteed to have loaded. One submit is reported as **`Lead`** *and* **`CompleteRegistration`** — in Meta's vocabulary it is both — each reusing the `event_id` its own Conversions API half carried. The two ids differ (`…` vs `…-cr`) because Meta dedupes on the **(event_name, event_id) pair**; sharing one id would collapse the two events into one. Three browser events belong to this flow in all: **`ViewContent`** when the capture modal opens (`LeadCaptureModal.vue`), **`SubmitApplication`** at the moment the button is pressed (`LeadCaptureForm.submit()` — browser-only and deliberately **without** an `event_id`, since the server only ever hears about submissions that succeed; the gap between it and `Lead` IS the submit drop-off), then `Lead` + `CompleteRegistration` here. See [Meta Pixel & CAPI](/docs/modules_handbook/manage/meta-ads/pixel/readMe.md).

### Backend capture (one path — Inertia `/register`)
- `LandingController@register` (rate-limited `throttle:10,1`) validates via `RegisterRequest` — phone→digits / lowercased email, **field format only** (no duplicate rejection) — and requires either a **`funnel`** or an **`event`** (a `required_without` pair: a funnel landing posts the funnel uuid, a slot landing posts the specific session's `event` uuid; there is **no** default funnel to attribute an unfunnelled lead to). It maps input explicitly and delegates to `RegisterLeadAction`; a posted `event` carries its own funnel (`event_funnel_id` from the session).
- **VSL staff alert** — after the Meta report, a registration on a **Video Sales Letter funnel** (`EventFunnel::isVideoSalesLetter()`) also fires the **`events.vsl_registration`** [Notify](/docs/modules_handbook/shared/notify/readMe.md) event (`LandingController::considerVslNotify`; webinar funnels are a no-op): the registrant is on the video page *right now* and the next step is a booked call, so a colleague's phone buzzes for a fast personal follow-up. Throttled **per person** (5 min — only coalesces the same human double-submitting; see the note in `config/notify.php`), best-effort — a Telegram hiccup can never break a registration (pinned in `tests/Feature/Event/VslFunnelManageTest.php`). The manage-side view of these people (watch progress, bookings, the per-row remove) is the funnel hub's VSL page — see [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md).
- `RegisterLeadAction` runs: **resolve the account** (existing **by email only** → else create a Non-Member user + profile with a `Str::random(40)` password — a phone already owned by another account is NOT stored on the new one; see the ⚠️ security note above on why phone is never a lookup key here) → ensure the person's single **lead** (`firstOrCreateForUser`) → **attach the funnel registration** (`LeadFunnelRepository::attach`, idempotent per funnel) with its attribution (`event_funnel_id` from the posted session's funnel or the submitted funnel — **no** default-funnel fallback; **`marketing_source` is always `SOURCE_FUNNEL`** — the 2026-07 consolidation removed the old utm_source→FMX/MKT/Organic sniffing; paid-vs-organic now lives in the row's own utm/ad columns) → **dispatch `ResolveMetaAdRefsJob`** when an `ad_id` arrived (resolves the campaign/adset/ad **names** + owning **ad account** into `meta_ad_refs` for the Lead → Attribution tab — see the [Marketing](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) module) → **auto-enrol** the lead (a slot-landing registration with an `event` joins **only that session** via `EventRegistrationRepository::join`; a funnel-landing registration enrols the lead in all upcoming funnel sessions via `EnrollLeadInFunnelSessionsAction` — best-effort) → **register the lead on the session's Zoom webinar** where one is live (see *Zoom webinar registration* below) → **record the WhatsApp marketing opt-in** → **fire the funnel's WhatsApp welcome** (best-effort, if the admin configured one — see [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md); it runs on **every** registration). It returns `{ lead, new_registration, new_account, account_had_email, email(masked) }` — the last two are what decide whether the WhatsApp welcome may carry an auto-login link. **No email is sent here any more** — the old `WelcomeSignInMail` magic link was removed 2026-07-25; the only credential this flow issues is the WhatsApp welcome's `{{login_link}}` (gated by `allowAutoLogin`), and `/login` works any time.
- **WhatsApp marketing consent (opt-in).** Consent is **implicit + always on** (2026-07-24: the visible checkbox was removed; `wa_opt_in: true` is a fixed value in the form object — Inertia serializes the form object, not DOM inputs). `RegisterLeadAction::recordWhatsappConsent` (best-effort — a WhatsApp hiccup never fails the sign-up) calls `WhatsappContactRepository::recordLandingOptIn`: normalise the phone to E.164, **find-or-create the WhatsApp contact and link it to this account** (`whatsapp_contacts.user_id`), then write a `CATEGORY_MARKETING` / `SOURCE_LANDING_FORM` consent in the [whatsapp_consents ledger](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md) with an IP / user-agent / landing-URL / timestamp **proof** trail (the Meta-defensible evidence a previously-restricted business needs). That contact then becomes reachable by a Marketing-category **[broadcast](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md)**; an inbound **STOP** reply opts them back out everywhere.
- The controller puts the `thank_you` payload in the session and **redirects to `/thank-you`** (see the section above) — it does **not** return `back()`, and there is no success modal on the landing any more (`RegistrationSuccessModal` was replaced 2026-07-27). What the visitor then sees:
  - **Hero** — confetti 🎉 (`Components/Confetti.vue`, every visit) + "You're registered!" (`registration.new === false` only changes the WORDING — 您已报名 ✓ vs 报名成功！) + the masked inbox.
  - **Step 1 · Join our WhatsApp group** — **the funnel's own `whatsapp_group_link` when it has one, else** the env-configured Community shared by everyone else (`WHATSAPP_COMMUNITY_LINK` → `config('whatsapp.community_link')`); either way it arrives as the `community` page prop, resolved in `ThankYouController@show`. Most funnels leave the column empty and share the one room — the exception is a **cohort** funnel (a dated webinar, e.g. `cochrane-webinar`), whose registrants belong in that cohort's group and whose reminders are posted into it, so sending them to the general Community is a dead end. `register()` carries the link in the `thank_you` payload's `funnel` array, because the page is rendered from the session by a controller that no longer has the funnel; a payload written before that key existed falls through to the Community. *History: this was ONE link for ALL funnels, deliberately (user decision 2026-07-25), until 2026-08-19 — the first funnel to run its own group made the shared room the wrong destination rather than a simplification.*
  - **Step 2 · Your bonus is on its way to `{phone}`** (replaced the dual-OTP verify step, 2026-07-28). No verification happens here any more — the **funnel WhatsApp welcome** carries the bonus *and* a `{{login_link}}` that signs the person in and proves their phone. The panel exists to make a **mistyped number** recoverable, which is the one failure this flow cannot survive silently:
    - It names the number the bonus actually went to (`whatsappPhone` = the account's stored phone, which for a returning account may differ from what was just typed).
    - **"Wrong number?"** posts `POST thank-you/phone` (`throttle:6,1`) → `ThankYouController@updatePhone`. It is **unauthenticated** and the number it writes is where a working sign-in credential gets delivered, so it is fenced FOUR ways — and **the first one is load-bearing**:
      1. Only the visitor's OWN session-scoped registration (`user_uuid`).
      2. Only a **main-portal account** — `if (! $user || ! $user->isMainUser()) return redirect('/')`, and `isMainUser()` is `hasRole([MEMBER, NON_MEMBER])`, so a staff/admin account can never be re-pointed from this public page even if its uuid ends up in a session.
      3. Only while the phone is still **unproven** (`profile->phone_verified_at === null`) — a verified phone is a live sign-in key and is never replaced from a public page.
      4. Never onto a number owned by another account (`LeadRepository::updateUnverifiedPortalPhone` → `phoneOwnedByAnother`).

      It deliberately DOES serve a pre-existing account, so a returning customer whose number changed does not need a support ticket. That is only safe because the re-sent welcome carries **no auto-sign-in link** when an email-holding account already existed (`account_had_email` in the payload → `allowAutoLogin`), so re-pointing the number can no longer re-point a credential with it. **These two halves must be changed together or not at all** — regression-pinned in [`tests/Feature/Auth/BlankPhoneTakeoverProbeTest.php`](/tests/Feature/Auth/BlankPhoneTakeoverProbeTest.php).

      On success it re-records the WhatsApp opt-in and re-dispatches the welcome to the new number, then rewrites the session payload so a refresh shows the corrected number. A **verified** phone still routes to the contact-admin escape hatch.
    - **"Not receiving messages?"** — a `wa.me` deep link to `config('services.whatsapp.sales')`, pre-filled with **the SESSION's name** (`session.title` = the event's own title, else its slot's). The funnel name is an internal label ("1. Trust Nurturing — Property Investment") and means nothing to the person writing in; it only stands in on a funnel-wide landing, which has no single session. Plain `<a>`: wa.me must leave the SPA.
  - **What happens when they tap the WhatsApp link** — the welcome's `{{login_link}}` always proves the phone; whether it opens a session depends on whether anyone else could claim the account. If not, they are signed in; if so, they prove the account's email at **`/verify-email`** first. Neither path dead-ends. See [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md).
  - Leaving the page = skip; the registration stands and `/login` works any time.
- `MagicLoginController` (`auth/sign-in/{user}`, `signed` middleware) is still very much on the landing path — it is what consumes the WhatsApp welcome's `{{login_link}}`, a 7-day `URL::temporarySignedRoute('auth.magic', …)` minted by `FunnelWhatsappComposer::loginLink()`. What changed in 2026-07 is only the **carrier**: no landing registration sends a sign-in link by *email* any more.
- Contact data → `users` + `user_profiles`; attribution → `lead_funnels` (see the [Leads](/docs/modules_handbook/manage/leads/readMe.md) module).

### Zoom webinar registration (the lead is added to Zoom, not just enrolled locally)
When a lead registers for a **Zoom-mode** session, we don't only write the local `event_registrations` row — the lead is **registered on the Zoom webinar itself** so Zoom sends them its own **registration confirmation + reminder emails** and a **unique per-lead join link** (and the attendance webhooks can match them back). This runs on **every** enrolment path, via the shared **`SyncSessionWebinarRegistrantsAction`** which queues the `SyncWebinarRegistrants` job for each enrolled session that has a **live (Upcoming / Live)** webinar:
- **Slot landing** (single session) → `RegisterLeadAction` (the `event` branch).
- **Funnel landing** (all upcoming sessions) → `EnrollLeadInFunnelSessionsAction`.
- **Admin adds a lead** to a session → `RegistrationsController@store`.
- **Webinar (re)created** for a session → `CreateSessionWebinarAction` (catches up everyone already enrolled).

The job is **idempotent** — it only registers rows still missing a `zoom_registrant_id` — so re-queuing on every registration is safe, and a lead who registered **before** the webinar existed is caught up when it's created. A session with **no** webinar (physical, or a Zoom session whose webinar isn't provisioned yet) is a clean no-op. `requires_registration` on the webinar must be on (Zoom returns a `registration_url`) for per-lead links to exist; otherwise there's nothing to sync.

> The step ③ "Attend the webinar" note on the success screen is informational — the actual per-lead Zoom link is delivered by **Zoom's own email** once this sync runs (plus the funnel's WhatsApp reminders if configured).

## Related files

**Backend — Controller**
- [app/Http/Controllers/Main/LandingController.php](/app/Http/Controllers/Main/LandingController.php) — `index($slug)` (renders the Inertia `Landing` page with funnel + events + slots + tracking; 404 on an unknown/inactive slug — never the root), `video($slug)` (the optional post-registration video step → `FunnelVideo`, noindex, with the sales `wa.me` link), `slot($funnel, $slot)` (renders the `SlotLanding` page for the **auto-picked next upcoming session**; its tracking now forwards the same `campaign_id`/`adset_id`/`ad_id`/`placement` ad params the funnel landing does), `register` (the Inertia capture flow → `RegisterLeadAction`; flashes the `registration` result). The root `/` is served by `Main\SiteController@home`, not this controller.
- [app/Http/Controllers/Main/ConsultationController.php](/app/Http/Controllers/Main/ConsultationController.php) — `store($slug)` for the video page's booking panel: records the enquiry, then reports the `Schedule` conversion. Its `resolveLeadId()` reads the visitor's OWN session only — never the typed phone (the account-takeover hole above).
- [app/Http/Controllers/Concerns/ReportsMetaConversions.php](/app/Http/Controllers/Concerns/ReportsMetaConversions.php) — the shared server half: `reportMetaConversion()` queues one `SendMetaCapiEventJob`, `metaIdentity()` assembles who/where, `metaClickId()` rebuilds `_fbc` from an `fbclid` when the cookie is absent. It is a **controller trait, not a service**, because the payload needs the visitor's own cookies, IP and user agent — a job or an observer has none of those. Used by `LandingController` (Lead + CompleteRegistration) and `ConsultationController` (Schedule).

**Backend — Form Request**
- [app/Http/Requests/Main/RegisterRequest.php](/app/Http/Requests/Main/RegisterRequest.php) — name / phone / email **format only** (no duplicate rejection; identity is resolved in `RegisterLeadAction`) + a **`funnel` OR `event`** requirement (`required_without` pair — no default funnel) + pass-through tracking fields.

**Backend — Action (orchestration)**
- [app/Actions/RegisterLeadAction.php](/app/Actions/RegisterLeadAction.php) — resolve account → ensure lead (`firstOrCreateForUser`) → attach funnel registration (incl. the `fbp` / `fbc` browser ids) → auto-enrol (single session or all upcoming) → register on the Zoom webinar → record WhatsApp opt-in → dispatch the funnel WhatsApp welcome with the `allowAutoLogin` decision → queue `EnrichLeadJob`. **Sends no email.** Returns `{ lead, new_registration, new_account, account_had_email, email(masked) }` — `account_had_email` is what lets the thank-you page's re-send apply the SAME auto-sign-in rule instead of re-deriving it.
- [app/Actions/EnrollLeadInFunnelSessionsAction.php](/app/Actions/EnrollLeadInFunnelSessionsAction.php) — the funnel-wide enrol (join every upcoming session + send its ticket + queue the Zoom registrant sync).
- [app/Actions/SyncSessionWebinarRegistrantsAction.php](/app/Actions/SyncSessionWebinarRegistrantsAction.php) — the shared "register the lead on the session's Zoom webinar" step (`forEvent` / `forEvents` → queues `SyncWebinarRegistrants` for each live webinar). Used by the slot-landing, funnel-landing and admin-add paths.
- [src/Whatsapp/Repositories/WhatsappContactRepository.php](/src/Whatsapp/Repositories/WhatsappContactRepository.php) — `recordLandingOptIn()` (find/create contact, link account, write the marketing consent + proof). Shared with the WhatsApp module's CSV contact import.

**Backend — shared props**
- [app/Http/Middleware/HandleInertiaRequests.php](/app/Http/Middleware/HandleInertiaRequests.php) — shares `flash.*` + the one-shot `registration` prop the modal reads after submit.

**Backend — Mail / Magic sign-in**
- ~~`app/Mail/WelcomeSignInMail.php`~~ — the landing's magic sign-in **email** until 2026-07-25, then an orphan with no call site; **deleted 2026-09-11** together with its `welcome-sign-in` / `welcome-sign-in-text` templates. For the shape of a self-contained HTML + text email with a single CTA, read [EventTicketMail](/app/Mail/EventTicketMail.php) / [LoginLinkMail](/app/Mail/LoginLinkMail.php) instead.
- [app/Http/Controllers/Auth/MagicLoginController.php](/app/Http/Controllers/Auth/MagicLoginController.php) — verifies the signed link, logs in main-portal users.

**Backend — Writes via repositories (shared)**
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — Non-Member user + profile.
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) + [src/Lead/Repositories/LeadFunnelRepository.php](/src/Lead/Repositories/LeadFunnelRepository.php) — the lead + its funnel registration.
- [src/People/UserProfile.php](/src/People/UserProfile.php) — `phone` mutator (stores digits, e.g. `60123456789`).

**Frontend (Vue)**
- [resources/js/Pages/Landing.vue](/resources/js/Pages/Landing.vue) — the wrapper that resolves the per-slug design (Default fallback).
- `resources/js/Pages/FunnelVideo.vue` — the post-registration video-step wrapper (`/{slug}/video`): resolves `Landings/{slug}/Video.vue`, and `router.replace`s back to `/{slug}` for a funnel with no video design. No generic fallback, by design.
- `resources/js/Pages/Landings/{slug}/Video.vue` — a funnel's video-step design (today: `cochrane`). Its `VIDEO` block at the top of `<script setup>` is the whole switch — point `src` at a file under `public/main/videos/` (+ optional `poster`). **YouTube is deliberately not an option**: its player reports its watch time to Google, not to `funnel_video_views`, and the roster (and every card built on it) would go blank. Self-hosted files are served by Apache, so byte-range requests work — which is what makes both buffering and the scrubber's mid-file jumps possible — but see the ⚠️ below on where those files actually live.
- `resources/js/Pages/SlotLanding.vue` — the shared slot landing wrapper (`/{funnel}/{slot}`): resolves a bespoke `Landings/{funnel}/{slot}/Index.vue` (none exist yet), else `Landings/DefaultSlot.vue`. Passes through the **single auto-picked `session`** + `preview` flag (no session list).
- `resources/js/Pages/Landings/DefaultSlot.vue` — the generic slot design: a slot's info + **one Register button** for the auto-picked next-upcoming session (its uuid is in `tracking.event`, which `<LeadCaptureModal>` posts). In `?preview` with no upcoming session the button is disabled ("preview only"). The visitor never chooses a session.
- `resources/js/Pages/Landings/{slug}/Index.vue` — per-funnel landing designs (e.g. `bootcamp/Index.vue` served at `/bootcamp`, `sutera-klcc/Index.vue` at `/sutera-klcc`). One folder per funnel; each drops in `<LeadCaptureModal>`. `Landings/Default.vue` is the **generic fallback design** for a funnel without its own `Index.vue` (a register CTA + the other active programs to browse). Scaffold a new one with `php artisan funnel:landing {slug}` ([app/Console/Commands/MakeFunnelLanding.php](/app/Console/Commands/MakeFunnelLanding.php)) — auto-run in `local` by `FunnelsController@store`.
- [resources/js/Components/LeadCaptureModal.vue](/resources/js/Components/LeadCaptureModal.vue) — the capture modal: per-funnel copy via props, per-funnel palette via `captureVarsForSlug` → `Modal`'s `panelStyle`, and the `ViewContent` pixel on open.
- [resources/js/Components/LeadCaptureForm.vue](/resources/js/Components/LeadCaptureForm.vue) — the bare form (client-side validation + Inertia `POST /register` → `/thank-you`); its colours are `--lc-*` variables with light fallbacks. On submit it **mints** the shared `event_id` (`newEventId()`, the id the server's CAPI half reuses) **and fires the `SubmitApplication` pixel** — browser-only, no `event_id`, safe to fire before the post because Inertia posts by XHR so the document never unloads.
- [resources/js/Components/Modal.vue](/resources/js/Components/Modal.vue) — the shared modal. Optional **`panelStyle`** is the theming escape hatch: an inline `background` beats the default `bg-white` class, so passing nothing changes nothing for its other consumers.
- [resources/js/Pages/ThankYou.vue](/resources/js/Pages/ThankYou.vue) — the shared post-registration page (themed per funnel): confetti + session date/time + the WhatsApp group card (the funnel's own `whatsapp_group_link`, else env `WHATSAPP_COMMUNITY_LINK`) + the **bonus-sent-to-WhatsApp** card (wrong-number `useForm({ phone })` → `POST /thank-you/phone`, plus the contact-admin `wa.me` link), and the browser `Lead` + `CompleteRegistration` pixels. It imports **no** `ContactVerification` — the dual-OTP verify step it used to host was removed 2026-07-28 and now exists only at `/register`; its footer block is commented out in place.
- [resources/js/Pages/Landings/theme.js](/resources/js/Pages/Landings/theme.js) — the ONE theme contract, shared by the thank-you page and the capture modal: `DEFAULT_THEME`, `themeVars` (`--ty-*`, the page), `themeForSlug` (**null** = this funnel has no theme) and `captureVarsForSlug` (`--lc-*`, the modal). The `import.meta.glob` of `./*/theme.js` lives here — those paths resolve relative to the file they appear in, so keeping it in one place is what makes both surfaces agree on what a funnel's theme is. A funnel overrides it with `Landings/{slug}/theme.js` (`investment-masterclass` dark gold; `cochrane` and `cochrane-webinar` navy + gold — the two share a palette because they are the same offer sold two ways).
- [app/Http/Controllers/Main/ThankYouController.php](/app/Http/Controllers/Main/ThankYouController.php) — reads the session `thank_you` payload; no payload ⇒ redirect home. Route `thank-you` in [`routes/main.php`](/routes/main.php), declared before the funnel catch-all.
- [resources/js/Components/PhoneInput.vue](/resources/js/Components/PhoneInput.vue) — the country-prefix phone field. Shared with admin/portal forms, so only its **input row** is `--lc-*`-driven (every fallback = the colour it always used) and its dropdown stays light.
- The admin **Landing tab** ([Funnels/Partials/Tabs/LandingTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/LandingTab.vue)) shows the public URL + which `Landings/{Slug}.vue` the funnel resolves to.

**Landing media (where the files live)**
- **Images are committed** — `public/main/images/{slug}/` (e.g. `cochrane/`: `cochrane-table.jpg`, `cochrane-table-full.jpg`, `waikit.jpg`, `zen-chua.jpg`, `press-feature.jpg`, ~1.8 MB total). Small enough that a clone gets them for free, which is why a landing's photos may be referenced without ceremony.
- ⚠️ **Videos are NOT committed** — `public/main/videos/` is in `.gitignore` (a ~140 MB set would bloat the repo permanently, and git keeps every version of a binary forever). Consequence: **a fresh checkout or clean deploy has an EMPTY `public/main/videos/`, so every landing video 404s** — nothing in the build fails, the player is just blank. Re-copy the files after a clean deploy. Cochrane's set: `zen-40mins.mp4` (the `/cochrane/video` main player), `cochrane-teaser.mp4` (the 1-minute teaser on `/cochrane`, ALSO a fallback `src` nowhere else), and four client clips — `tm-lucas.mp4`, `tm-ming-lee.mp4`, `tm-property-checking.mp4`, `tm-jb-event.mp4`.
- The **`upload` Media collection** (Manage → File Uploads, `/manage/uploads`) is where a second copy belongs — but note that page lists only **your own** uploads (`created_by`), so "I see nothing there" is not evidence the file is absent. Its ceiling is **500 MB** per file (`Manage\Uploads\Chunked\StartRequest::MAX_BYTES`, sent in pieces — see the [File Uploads](/docs/modules_handbook/manage/uploads/readMe.md) doc); anything larger has to be moved to the server by hand.
- A few landing images are **hot-linked to `investhink.ai`** (`propertylab-logo.png`, `md-status-cert.png`, `cradle-award-letter.png`) rather than served from this repo — if that host changes, the logo and certificates break with no local file to point at.

**Seeders**
- None for real registrations. Sample leads come from [database/seeds/LeadsSeeder.php](/database/seeds/LeadsSeeder.php).

**Routes**
- [routes/main.php](/routes/main.php) — `landing` (`GET /`) now points at `Main\SiteController@home` (the site home, behind `public.site`, **never** a funnel), `register` (`POST`, throttled `throttle:10,1`, requires `funnel` or `event`), `auth.magic` (`GET /auth/sign-in/{user}`, `signed` middleware); and — declared **last** — the funnel catch-all `landing.funnel` (`GET /{slug}`, constrained to `->where('slug', '[a-z0-9-]+')`) and then the slot catch-all `landing.slot` (`GET /{funnel}/{slot}`), so they 404 on unknown slugs rather than shadowing named routes. Reserved slugs (blocked at funnel creation) now also include `region` and `t`. Between the two catch-alls sits `landing.video` (`GET /{slug}/video`) — a literal second segment, which is why `video` is in turn reserved as a SLOT slug.

## hk-webinar (funnel #6, 2026-08-26)

`/hk-webinar` — HK investors → Malaysian property, LIVE Zoom webinar, **Wai Kit as speaker**.
Funnel id 9 ("6. HK Investors — Malaysia Webinar", TYPE_WEBINAR). Design:
`Pages/Landings/hk-webinar/Index.vue` (+ theme.js navy/gold), modelled section-for-section on the
user's reference (class.starcityglobal.com/offline) but ONLINE and in **Traditional Chinese**
(the Cochrane pages are Simplified — different audience). Session date reads `events[0]` with a
FALLBACK (2026-09-13 20:00 +08:00); the countdown and every copy line re-read from the
admin-created session. NOT in `config/funnels.php` `video_steps` — registrants land on
/thank-you, no VSL. Speaker facts are copied from the Cochrane trust block (one biography
source); quotes are the approved ai-bootcamp testimonials in Traditional characters.

**New shared props this page introduced (defaults unchanged for every other funnel):**
`LeadCaptureModal` / `LeadCaptureForm` now accept `defaultCountry` (threaded to `PhoneInput` —
this page passes `'HK'`, so the dial code opens on +852 instead of +60) and `aiCallLabel`
(the AI welcome-call checkbox sentence — Traditional here, the Simplified default elsewhere).
