# 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) — or, where `SITE_CINEMATIC_HOME=true` (wk since 2026-10-03), at the frozen [Cinematic site](/docs/modules_handbook/main/cinematic-site/readMe.md) view, with the Inertia home kept at `/home` + `/{country}/home`, `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).

## hk-data (funnel id 10, redesigned 2026-09-23)

`/hk-data` — HK investors → Malaysian property, free LIVE online webinar, **Cheng Wai Kit as speaker**,
Traditional Chinese. Funnel id 10 ("HK 數據講座 · 五個數字決定買不買", TYPE_WEBINAR — not in
`video_steps`, registrants land on /thank-you). Design: `Pages/Landings/hk-data/Index.vue`.

**The slot `/hk-data/live-demo` shows the SAME design** (2026-09-26) through the thin wrapper
`Pages/Landings/hk-data/live-demo/Index.vue` — the cochrane-webinar pattern: the slot's one
auto-picked `session` becomes a one-item `events` list and `tracking.event` already carries its
uuid, so a sign-up there enrols in exactly that session (and its ad snapshot → per-session CPL).
Before this file existed the slot fell back to the blank `DefaultSlot.vue` while the ads pointed at
it. A new slot on this funnel needs its own wrapper the same way — the design is resolved by
`{funnel}/{slot}` path, never inherited.

**The thank-you page and the capture modal wear hk-data's LIGHT palette** (2026-09-28) via
`Pages/Landings/hk-data/theme.js` — the first light theme, so the contract in `Landings/theme.js`
gained three optional knobs a dark theme never needed: `glow` and `vignette` (the backdrop's accent
light and edge darkening — black corners on white read as dirt; defaults keep every dark funnel
unchanged) and `dangerText` / `dangerBorder` (the form's error colours, whose defaults are pale
tones for a dark panel). The session card also stopped printing `(Asia/Kuala_Lumpur)` after the time.

**A design's own `<style>` block is linked in the `<head>`** (2026-09-26). That CSS is bundled into
the page's chunk (`landings-*.css`), not `app.css`, and the chunk used to load only once `app.js`
lazily imported the page — so the SSR html painted UNSTYLED first (giant SVGs, bare text) on
`/hk-data` and its slot, the design that leans hardest on its own CSS. `app.blade.php` now passes
`resources/js/Pages/{component}.vue` to `@vite` (guarded by `file_exists`), the standard
Laravel + Inertia setup, so every page's chunk CSS is a render-blocking `<link>`.

**History (all 2026-09-23).** The earlier "five numbers" design is in git history up to `257c0c418`.
The founder then supplied a standalone page — v5 `propertylab-landing-evidence.html`
(`/file/c21f42f9-…`), superseded the same day by the HD v6 `propertylab-landing-final-hd.html`
(`/file/4976c37b-4cc1-42ee-aca9-8113bea37a60`) — which was first ported **1:1**. He then asked for it
to be improved as a conversion page; **what ships is that improved version**. The 1:1 v6 port is kept
OUTSIDE git at `storage/app/landing-backups/hk-data-v6-port/Index.vue` (this box only) in case he
wants it back.

**What the improvement changed, and why** (the design language is still v6's):
- **The hero continues the AD's story — keep them matched.** The founder's Meta ads open on the
  abandoned projects in Melaka and Forest City "where a lot of HK people got trapped", so the hero
  picks that fear up in Cantonese — **with generic terms only**: "爛尾盤、鬼城盤，好多香港人一買就被困。
  下一個，唔好係你。" ⚠️ **Never name a project on this page.** A 2026-09-24 draft said "森林城市、馬六甲
  爛尾盤"; calling a named project 爛尾 is a factual accusation its developer can contest (Forest City
  is widely reported as a low-occupancy "ghost town", not as abandoned), and it is commercial
  promotion, not reporting. Ad copy that names projects should go past a Malaysian lawyer first.
  The insider answering the fear: **"前工程師，在馬來西亞持有 15 間物業"** (the founder's own wording;
  the older approved copy said only "個人持有 15 間房產"). The pain cards (鬼城 / 保證回報 / 沒有人接 /
  看不到現場), the first "你會學到" item (避開下一個「鬼城盤」) and an FAQ for people who already bought
  and are stuck follow the same line. Change the ad angle → change these together.
- **The hero shows the real speaker — in Hong Kong.** v6 led with an AI-generated model captioned
  "非講者相片". Since 2026-09-24 the hero is the founder presenting to a Hong Kong audience at the
  Sheraton Hong Kong (`hk-seminar-sheraton-room.webp`: full height of the uploaded photo
  `/file/b9e36c9d-…`, from the slide to the right edge, so every seated listener is in frame and the
  empty rows on the left are not). The caption — "香港小班講座現場 · 喜來登酒店", name, role — sits in a
  light gradient over the CEILING; a first cut put it at the bottom, over the audience, and the page
  showed three listeners. Proof he has done this in Hong Kong, which a studio portrait cannot give.
  ⚠️ **Never edit the photo to show a bigger crowd** (asked for 2026-09-24, declined): it is shown as
  the real event, so added listeners would be a fabricated record — misleading advertising in HK and
  MY, and fatal to a page whose pitch is "numbers, not sales talk". The small audience is framed
  honestly instead: "小班" (a small class, more room for questions). The speaker section keeps
  the office portrait `cochrane/waikit.jpg` (900×1125), so the page shows two different photos.
- **Only numbers the company has already published.** 15 properties and the RM280,000 Cochrane exit
  are the approved speaker facts (verbatim from the old landing); the three student quotes (Ming Lee,
  Soon Liang, Dylan Ngan) are the approved ones, verbatim; RM500,000 is the Cradle letter on the page.
  ⚠️ The catalogue's "1,300+ projects" is **deliberately not used**: of 1,336 published projects only
  **66 are Malaysian** (319 HK, 951 UAE) — on a Malaysia page it would mislead.
- **First-person voice.** The supplied copy described the company as "主辦方" (a third party) ~15 times
  and carried production notes (pixel sizes, a second grant's amount); both are gone, and the inline
  disclaimers collapse into one line on the demo plus the footer notice. The "並非香港《東方日報》"
  clarification stays — it helps the reader.
- **It is sold as an AI showcase, not a property talk** (founder, 2026-09-24). The hero pill reads
  "免費網上直播 · PropertyLab AI 現場示範" and the lede opens "這不是一般的樓盤講座"; the PropertyLab AI
  section (`#propertylab`, nav "AI 示範") sits right after the problem. Order: problem (4 pains) →
  **the AI answer** → 這場講座，你會學到 (the showcase is item 01) → speaker (+ record) → students →
  4-step framework → life after purchase ("你人在香港，我們在吉隆坡" + the HK buyer card; the office
  name Sunway Velocity is only a sub-label — it means nothing to a Hong Kong reader, the KL team does) → documents → press →
  FAQ (incl. "PropertyLab AI 是甚麼？") → closing band (the showcase is its first tick). The AI
  section's four features are things the platform really does — supply within 3 km (the same finder
  `LandingSupplyController` uses), rent basis, monthly cash flow (loan, interest, maintenance,
  vacancy — `LandingDemoController`'s arithmetic), side-by-side comparison; the dashboard under them
  still shows SAMPLE cases and says so. Swapping it for the live `LiveDemo.vue` data would make the
  showcase claim stronger.
- **The session drives everything time-related** — date, time (+ 香港時間), a live countdown in the
  hero card and the closing band, the phone dock's line. With no session the page says 即將公布 and
  shows no countdown: never an invented date or a fake deadline.
- **The capture form speaks Chinese** (`locale="zh-Hant"`, below).

**Before running ads (not code):** the funnel has **no session, no series and no welcome message**
configured (checked 2026-09-23). The page promises "直播連結經 WhatsApp 發送"; until a session and a
funnel welcome exist on Manage → Events → Funnels, a registrant receives nothing after /thank-you.
The funnel's name/description (which drive the OG card and meta description) still describe the old
five-numbers talk. Also still to confirm with the founder: the FAQ answer to 會唔會推銷 ("最後會簡單介紹
我們團隊可以提供的服務"), the webinar's **language** (not stated anywhere), and the Cradle gloss
("馬來西亞政府支持的科技創業資助機構").

**The design system (2026-09-24, "standardized and premium")** — the second `<style>` block, `p-*`:
- **Type:** Noto Sans TC (loaded in the page's `<Head>` from Google Fonts, 400–700) with **Inter** first
  in the stack (loaded site-wide by `app.blade.php`) so figures and Latin render in Inter and Chinese
  falls through to Noto. One scale: `.p-display` (hero, clamp 32–52px) · `.p-h2` (28–40) · `.p-h3`
  (18) · `.p-lede` (18) · `.p-body` (16) · `.p-eyebrow` (13, tracked) · `.p-footnote` (13). Weights
  400/500/600/700 only. Numbers use Inter + `tabular-nums`.
- **Layout:** one container (`.p-container`, 1180px + 32px gutters; 20px on phones), sections
  `.p-section` (112px / 72px), alternating white / `--bg-soft`. Section header = `.p-section-head`
  (eyebrow + H2 + lede; `--center` / `--split`).
- **Components:** `.p-card` (20px radius, 1px `--line`, soft shadow), `.p-btn` (`--primary` /
  `--ghost` / `--light`; `--sm` / `--lg` / `--xl`), `.p-link`, `.p-icon` (`--warn` for pains),
  `.p-tag`, `.p-zoom`. Colour tokens live on `.hkd` (`--ink`, `--text`, `--muted`, `--blue`, `--navy`…).
- **Header** is sticky on desktop (the CTA stays one click away); on phones it is static and the
  bottom sign-up bar (`.p-dock`) takes over once the hero scrolls away.
- **Phones (audited 2026-09-24 at 360 / 390 / 414 px):** no horizontal scroll, hero CTA visible
  without scrolling, every dialog fits, tap targets ≥ 44px (the demo's thumbnail button is 40px),
  no page text under 12px (the demo's chart labels are SVG and render ~13px). Keep these:
  - **Noto Sans TC loads on ≥ 900px only** (`media` on the `<link>`, one variable file set
    `wght@400..700`). The stack puts `PingFang TC` before it, so iPhones and Macs use PingFang and
    Android falls through to its system Noto Sans CJK (the same design). Loading it everywhere cost
    phones **45 files / ~3 MB** — more than the rest of the page.
  - **Images are sized for where they show:** the press montage on the page is
    `media-montage-800.webp` / `-1400q.webp` via `srcset` (the supplied 1.3 MB file only opens in the
    evidence viewer); the demo thumbnail is `sample-residence-240.webp`. A full phone visit is
    ~1.6 MB (was 5.9 MB), most of it the site-wide JS.
  - **Capture-form inputs are 16px below 640px** (`LeadCaptureForm` / `PhoneInput`, all funnels) —
    iOS Safari zooms the page into any field under 16px.
- **Removed on purpose:** v6's viewport-scaled `--u` sizing for the page (it made 1920px screens
  huge and 1000px ones tiny), the AI-generated city/skyline/KL-tower images, the rotated
  handwritten note and italic numerals — premium came from restraint, the real photo and the
  documents. `--u` survives ONLY inside `.p-demo`, where v6's dashboard is sized as a screen.
- The FIRST `<style>` block is v6's CSS filtered down to what is still v6's (dashboard, dialogs,
  evidence viewer). `.hkd dialog.modal{margin:auto}` sits in the design-system block.

How the port works — keep these when editing:
- **The supplied stylesheet is namespaced under `.hkd`** (every selector prefixed; `:root` / `html` /
  `body` → `.hkd`; keyframes → `hkd-*`) in an unscoped `<style>`, so none of it survives an Inertia
  visit into /thank-you or the capture modal. After the design pass only the dashboard / dialog /
  evidence-viewer rules of it remain (first block); the page itself is the `p-*` system above. The 1:1 port matched the supplied
  file pixel for pixel with Tailwind's preflight loaded (v6 re-checked at 1440 / 1920 / 390) — with
  TWO exceptions, both restored after the `.modal::backdrop` rule: preflight's `* { margin: 0 }`
  removes the browser's `dialog:modal { margin: auto }` (every `<dialog>` opened top-left), and its
  `h1–h6 { font-weight: inherit }` un-bolds the viewer's transcript `<h3>`. Both only showed with a
  dialog OPEN.
- **Porting a new supplied version:** the file went through an HTML serialiser that lowercases SVG
  attributes — `viewbox` / `lineargradient` must go back to `viewBox` / `linearGradient`, or every
  icon renders blank.
- **Registration is the shared `LeadCaptureModal`.** The supplied sign-up dialog was a preview that
  submitted nothing (phone optional, a consent checkbox); every CTA (`[data-open-registration]`,
  `a[href="#registration"]`, `/hk-data#registration` deep links) opens the shared modal — phone
  REQUIRED, `default-country="HK"`, `locale="zh-Hant"`.
- **Footer notices** (`LEGAL`): `privacy` states what the registration is used for, because **this
  site publishes no formal privacy policy** — swap it for a link when one exists. Contact is
  `hello@propertylab.tech` (`config('company.email')`, the only address the company publishes).
- **Images** live in `public/main/images/hk-data/`: `hk-seminar-sheraton-room.webp` (hero, 106 KB),
  `malaysia-digital-cert.png`, `cradle-letter.png`, `media-montage.webp` (the 1.3 MB original, viewer
  only) + `media-montage-800.webp` / `media-montage-1400q.webp` (on the page), and
  `sample-residence-240.webp` (inside the demo). The speaker photo is `cochrane/waikit.jpg`. Supplied
  files carry a **`-hd` suffix on purpose** — Cloudflare caches images by URL, so re-using an old
  name keeps serving the old file from a warm edge; give any replacement a new name.
- **Session date** = `events[0]` in Hong Kong time (`10月8日（四）`, `20:00 – 21:30`), else 即將公布.

**New shared prop this page introduced — `locale` on `LeadCaptureModal` / `LeadCaptureForm`**
(`'en'` default, every existing form unchanged; `'zh-Hant'`): labels, placeholders, validation
messages, the submitting state and `PhoneInput`'s country search (`searchPlaceholder` /
`noMatchText` props) all switch language; `phoneHintFor(iso, locale)` / `validatePhone(digits,
fallback, locale)` in `utils/phoneCountries.js` carry the Chinese phone messages (tested). Before it,
the HK landings showed an English form ("Full name", "8 digits, starting with…") on an all-Chinese
page. Pass it from any Traditional-Chinese landing (`hk-webinar` does not yet).

**Left behind by the redesign:** `Landings/hk-data/LiveDemo.vue` and the two public endpoints it read
(`GET /hk-data/demo` → `LandingDemoController`, `GET /hk-data/supply` → `LandingSupplyController`,
both whitelisted to `hk-data`) are no longer used by any page. They still answer; delete them, or
mount the live demo back into the concept dashboard, when that decision is made.
