# Dashboard (Main / User portal)

**Portal:** Main · **Route:** `main.dashboard` (`GET /dashboard`, `auth`) · **Nav:** the portal home after sign-in (and, since 2026-08-26, the only entry point to **Property Concierge** — see below)

## What it does
The landing screen of the user portal, shown to every signed-in main-portal user. It renders one of
two faces, chosen by whether the user currently holds an **active membership**:

- **Member** — a greeting + cards for each membership they hold (tier, price, benefits) + a quick link
  to their courses.
- **Non-member** — a greeting + a hardcoded onboarding journey (register a webinar, join WhatsApp,
  log in, pick a membership).

…and, **since 2026-08-26, a PERSONAL half under both of them** (`Components/Dashboard/PersonalDashboard.vue`),
on the user's instruction that the page should "not only serve as guideline how to do, it is a
personalized dashboard". It answers *what is mine, and what was I doing*: the wealth plan, the
sessions booked with them, the lesson they stopped halfway through, their last analyses and AI
threads, and Property Concierge. It renders for members AND non-members — a non-member who has run
one analysis still owns something — and each block hides itself when it has nothing to say, so a
brand-new account sees only the two blocks that work with no data (start a plan, ask the concierge).

### The hero: guidance first, in one band
The setup checklist sits at the TOP (2026-08-26, third pass — *"normally they would have this at
top to guide the user first"*), which is the SaaS convention and reverses the second pass. What
makes it survivable is that it is now **one band inside `DashboardHero.vue`** — a progress ring
plus four chips, ~320px — rather than the 650px card of full-width rows it began as. Guidance
leads; the member's own data still starts inside the fold. **The whole hero band removes itself
once all four steps are done**, so a settled member gets a greeting and nothing else.

A checklist with four equal actions is a to-do list, not guidance, so exactly one — the first
unfinished step — is promoted to a solid button beside the ring. Its label is the ACTION
("Choose membership"), not the chip's status text, or the same words appear twice side by side.

### Three fixes from reading the live page (2026-08-26, fourth pass)
- **A section that vanishes reads as broken, not as empty.** "What's next" hid itself when nothing
  was upcoming, and a member whose only webinar had finished an hour earlier asked *"why no
  upcoming event?"*. It always renders now, with an empty state that says what would appear there.
  ⚠️ Consequence: its partner in the two-column row can never be alone, so `span()` must be passed
  "is the partner ON SCREEN", not "does the partner have content" — getting that wrong put the two
  cards on separate rows.
- **A completed step must not print a stale date.** The hero read "Webinar booked · Wed 26 Aug ·
  8:00pm", which says *you have a session coming up* even when that session ended an hour ago. The
  step is complete either way, so the payload carries `is_upcoming` and the chip falls back to
  "Registered".
- **Contrast on the dark hero.** The done chips used `text-slate-500` on `navy-950` — **3.8:1**,
  which fails for small text and was reported as "not clear". Done and to-do are told apart by the
  tick and the ring now, not by dimming the words; both sit at 7:1 or better. Measure against the
  real background rather than trusting a shade name.
- **Profile gained a "Back to dashboard" link.** It is reached from the sidebar FOOTER and from the
  hero's "Choose membership" CTA, so no nav row is lit while you are on it (§15).

### Type scale and visualisation
The first cut used 11–12px labels and 14px headings and read as cramped on the portal's
most-visited page (*"why the font size is so small"*). Section titles are 16px, body 14px, figures
24–30px, and the greeting 36px. The wealth card **visualises** rather than lists: one stacked bar
puts equity against debt inside the portfolio's value, drawn from the same two stored columns —
no new maths, and still no projection. Colour is unchanged brand blue / navy / emerald.

> A card that spans a full row must be laid out as a ROW. "Pick up where you left off" takes the
> whole width whenever it is alone in its grid row, and as a stacked block that simply moved the
> emptiness from beside the card to inside it.

### Order is the point (measured, not felt)
The first cut of this appended the personal blocks UNDER whichever face won, which measured at
**1211px on a 1000px fold**: an account with a wealth plan, three analyses and a half-watched
lesson opened on a six-step card telling it to log in. The page now composes in value order —
**greeting → what's yours → what the account gives you / still needs** — and membership status no
longer decides whether a member's own data is above the fold. After: wealth plan at **217px**, all
six blocks inside the fold, page height **2119px → 1496px**.

Consequences worth keeping:
- **The greeting and the events list are the PAGE's**, not each face's. Both faces carried their
  own copy, which is part of what pushed everything down.
- **A two-column row where one side may be missing uses `span()`** — a lone survivor takes the
  whole row. Without it the surviving card kept half the grid and left a ~500px void (measured).
- **The onboarding checklist is a compact strip at the BOTTOM, with four steps that can all
  actually complete.** Two of the original six could never fail: "Log in to system" was only ever
  shown to someone signed in, and "Remember the Webinar Date" was a checkbox stored nowhere, so it
  reset on reload. The remaining four each have a real signal — the last two (talked to the AI,
  chose a membership) were permanently unticked for everyone until the dashboard started passing
  those answers in. The whole strip hides once they are all done.
- **`last_message_preview` is Markdown** (it is the raw message body truncated), so the controller
  strips it for display — the live page was printing literal `**` at the reader. Stripping happens
  on read, not on write: the stored preview must stay a faithful copy.

> 🐞 **Never gate a section on a raw prop the child re-filters.** "What's next" hosts
> `UpcomingEvents`, which drops sessions once they END. Gating the section on `events.length`
> rendered an empty card ten minutes after a webinar finished. The child now reports its
> post-clock count (`@count`) and the section is `v-show`n from that — `v-if` would unmount the
> child and it could never report.

### Rules the widget reads live by
A dashboard is the most-hit page in the portal, so each widget is ONE indexed, `limit`ed query on
named columns (whole page measured at ~220 ms against live data). Three constraints are easy to
break by accident and are commented at each call site in the controller:

- **Never select a JSON blob.** `wealth_plans.state` and `property_analyses.result` are wide; the
  analyses table carries a migration comment recording a real *"Out of sort memory"* incident from
  selecting whole rows.
- **Never touch the catalogue connection.** `Appointment::toCalendarArray()` and
  `PropertyAnalysis::catalogProject()` both resolve across it — a tile must not pay a
  cross-database read per row.
- **A portal user may have no lead row yet** (it is created lazily on first write), so every widget
  hangs off `$user->lead?->id` and renders empty rather than 500ing.

### What each widget may honestly claim
- **Wealth plan** — the PROMOTED summary columns only (`property_count`, `total_mv`, `total_loan`,
  `target_age`), plus net equity, which is a subtraction of two stored columns. It states what the
  member OWNS and never whether they are on track: the verdict and the trajectory are computed
  client-side from `state`, and a second server-side answer to "am I on track" would eventually
  disagree with the plan page.
- **Upcoming sessions** — the member's own `appointments` + `zoom_meetings`, i.e. what an admin
  booked against their lead on Manage → Calendar. ⚠️ That calendar is **not** a broadcast events
  feed: rows are agent-owned and targeted by `lead_id`, which is exactly why "define it there and it
  shows up here" works. ⚠️ `scheduled_at` is stored as **Asia/KL wall time, not UTC** — comparing or
  converting it as UTC moves every appointment by eight hours. An appointment has no title column;
  its label is its type.
- **Continue watching** — TWO stores, because neither answers both halves:
  `lms_lesson_progress` knows WHICH lesson was last opened (no position column),
  `video_watch_events` is append-only milestones (no per-lesson rollup). ⚠️ The lesson player
  accepts **no seek parameter**, so the card says *"you were 62% in"* rather than *"resume at 62%"* —
  it reports how far they got and never promises to start there.
- **Latest analyses / AI threads** — stored columns + the denormalised
  `last_message_preview` (never a join onto the messages table for one line). A thread with a
  `playbook_key` links into Investment Prompt, not the chat, because that is where its intake and
  follow-up chips live.
- **Property Concierge** — services read from `ConciergeRequest::SERVICES` so the card can never
  drift from what the request form offers, plus a count of the member's live requests so the card
  says *track* rather than *start* when there is something in flight.

It is also the portal's **one always-reachable page**: a customer whose email **or phone** is not yet
proven is confined here by the `contact.verified` middleware (see below), and meets the **Extra Bonus
overlay**.

Both faces also show a shared **"Upcoming events & webinars"** list — every upcoming funnel session.
This is the member-portal half of the events members-only gating (a session inherits its **slot's**
visibility — see [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md)): a member
who qualifies sees the session's full details; everyone else sees a locked **"Exclusive to {Membership}
members"** preview with a *View memberships* link.

## The Extra Bonus overlay (contact verification gate)

Signing in only ever proves **ONE** contact key: the funnel flow signs a lead in on their **phone**
(the WhatsApp welcome's `{{login_link}}` is network proof of the number), an emailed code signs them
in on their **email** — see the
[login handbook](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md). The
**other** key is proven **here**, framed as a reward rather than a chore.

- **The guard** — [`EnsureContactIsVerified`](/app/Http/Middleware/EnsureContactIsVerified.php), alias
  `contact.verified`, stacked as `['auth', 'main', 'contact.verified']` on the portal route group in
  [`routes/main.php`](/routes/main.php). Every portal page bounces a customer with an unproven email
  **or phone** back to `/dashboard`. **Exempt:** staff (`isManageUser()`, same rule as the `main` hold,
  so an admin previewing the portal is never trapped) and the checkout gateway's `return` / `cancel`
  URLs (never intercept a payment mid-flight). `/dashboard`, `/profile` and `logout` sit outside the
  group by construction — which is load-bearing: the profile endpoints are exactly what frees a held
  customer.
- **Why BOTH keys (2026-08-07).** Each key is a sign-in route on its own — a code is sent **to** it —
  so an unproven key is an unclosed door. An email-first customer whose phone was captured by a form /
  import / admin edit and never proven is precisely the account a stranger's mistyped-or-borrowed
  number could later be used against, the same reasoning that already held the email. `hasVerifiedContact()`
  is the one predicate, and a **MISSING key counts as unproven** (the overlay asks them to add it), so
  deleting a number can never become a way *out* of the gate.
- **Two predicates, two consumers.** `HandleInertiaRequests` shares
  `auth.user.needs_email_verification` **and** `needs_phone_verification` (plus `phone` for display),
  each computed with the *identical* rule the middleware's matching half uses — so the overlay is shown
  to exactly the people the guard holds, and agrees with it about *which* key is outstanding.
- **The overlay** — [`Components/Main/VerifyContactOverlay.vue`](/resources/js/Components/Main/VerifyContactOverlay.vue),
  mounted unconditionally in `Dashboard.vue` (it renders itself only while held). It shows **only the
  key(s) actually outstanding**, and how many there are decides the shape:
  - **BOTH outstanding → the DUAL step.** One click sends **both codes at once** and both are entered
    together in one submit — the same shape `/register`'s [`ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue)
    already uses, so the two surfaces feel like one product. (An earlier build collected them one at a
    time; that was reversed — the "two boxes invite pasting the wrong code" worry has a working
    counter-example in this very codebase, and a second round trip is a real cost to every registrant.)
  - **ONE outstanding → that key's own single-code flow.** The combined send deliberately skips a key
    that is already proven: an SMS costs money, and a code for a proven key proves nothing.
  - Either way, four steps per key: the pitch → code(s) sent → *"填错了？"* → code sent to a **new** one.
    **"Change" is always a single-key sub-flow**, even out of the dual step, because the change
    endpoints prove the NEW value on its own.
  - The phone side adds a **SMS / WhatsApp channel picker** (shown only when
    `WhatsappTemplate::otpLoginReady()`, passed as the `whatsappAvailable` page prop — never offer a
    channel that would silently fall back), and an account with **no phone at all** opens straight on
    the change form with no way back (a "返回" would land on a pitch offering to send a code nowhere).
- **The dual endpoints** — `POST main.profile.contact.verify.codes` (`throttle:6,1`) sends to every
  outstanding key; `PUT main.profile.contact.verify` proves them in one submit, **judging each
  independently and stamping a correct code even when its sibling is wrong** (the codes are separate
  proofs, each send cost something, and re-doing a code that was already right is pure waste). Which
  keys are outstanding is read from the **account**, never the request, so the client cannot declare
  itself verified; [`ConfirmContactVerificationRequest`](/app/Http/Requests/Profile/ConfirmContactVerificationRequest.php)
  makes each code required only while its key is unproven. A **partial delivery failure** is a success
  with a `warning` flash naming the failed side — collapsing it into an error would hide a code that
  *did* arrive behind a dead end.
- Everything underneath reuses the profile module's endpoints wholesale (`main.profile.{email,phone}.verify.code`
  / `.verify`, and `main.profile.{email,phone}.code` / `main.profile.email` + `main.profile.phone.update`
  for a change) via one shared `consumeVerifyCode()`, so there is no second OTP implementation —
  including their **reject-on-duplicate** rules (an email or number already on another account is
  refused, never merged: the customer has proven only one key). The phone change additionally re-checks
  ownership **at commit time**, since minutes pass between issuing the code and confirming it.
- **The hold is total, and that takes three things** — a `v-if` alone was not enough. The backdrop is
  **`<Teleport to="body">`**'d, because the portal sidebar is `fixed z-40` and an overlay nested inside
  `<main>` renders *underneath* it (that bug shipped: the sidebar stayed clickable). It sits at **z-50**
  — the shared `Modal` layer, above the sidebar and below `FlashToast` (z-60) so its own confirmations
  stay readable — and it **locks `body` scroll** while up, so the page behind cannot be scrolled either.
- **Success is celebrated, not swallowed.** Verifying is the last thing between this person and the
  portal, so the overlay swaps to a green *"恭喜您, {name}!"* panel with `Confetti` and a **领取我的
  Extra Bonus** button that visits `/courses`. It is keyed on the **transition** out of the hold
  (`held` true → false, i.e. BOTH keys proven), so somebody who was already verified never sees it on a
  plain page load — and finishing the *first* of two keys advances the overlay instead of celebrating
  early. That panel is dismissible — they are verified now, so nothing should trap them.
- **Blast radius:** the guard applies to **all** users, not just new ones (product decision
  2026-07-28), so existing customers with an unproven key meet the overlay once and then never again.
  When the phone half was added (2026-08-07) the production snapshot held **zero** accounts in the
  newly-caught state (email proven + phone not), so it landed as pure forward defence rather than
  locking out a live cohort.
- Pinned by [PortalContactGateTest](/tests/Feature/Auth/PortalContactGateTest.php): each key holds
  independently, a missing phone still holds, staff pass, `/dashboard` stays reachable, the shared
  props agree with the guard, and a held customer can reach the profile endpoints that free them.

## How it works
- [`Main\DashboardController@index`](/app/Http/Controllers/Main/DashboardController.php) reads the
  user's active membership ids once (`$user->activeMemberships()` — the live list from the lead's
  active subscriptions, see [Login foundation §5](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md)),
  then returns three props via `Inertia::render('Dashboard')`: `isMember`, `memberships` (full detail
  for the member cards), and `events`.
- **Events query** — `Event::upcoming()` (scheduled, date ≥ today) over `TYPE_WEEKLY` (funnel
  sessions), grouped by slot and ordered soonest-first, each group mapped by `eventGroupCard()`.
- **Server-side gating (the important bit)** — `eventCard()` calls
  [`Event::isAccessibleTo($memberIds)`](/src/Event/Event.php). When the user **can't** access a
  members-only event the card carries only `accessible: false` + a `lock_reason`, and its
  `description` / `location` / `zoom_link` are set to **null** — the gated detail never reaches the
  client. PUBLIC sessions (the default) are accessible to everyone; a session is members-only when its slot is. `lock_reason` reads
  `"Exclusive to {names} members"`, or `"Exclusive to members"` when the event gates on *any* active
  member (empty pivot).
- **Frontend** — [`Pages/Dashboard.vue`](/resources/js/Pages/Dashboard.vue) picks `MemberDashboard`
  vs `NonMemberDashboard` on `isMember` and passes `events` to both; each renders the shared
  [`Components/Dashboard/UpcomingEvents.vue`](/resources/js/Components/Dashboard/UpcomingEvents.vue),
  which draws a full card for accessible events (description, location / Zoom *Join link*) and a
  locked card (lock badge + membership CTA → `/profile?tab=membership`) otherwise.

## Related files

**Backend**
- [app/Http/Controllers/Main/DashboardController.php](/app/Http/Controllers/Main/DashboardController.php) — builds `isMember` / `memberships` / `events`; `upcomingEvents()` + `eventCard()` + `lockReason()` do the gating and withhold gated detail.
- [src/Event/Event.php](/src/Event/Event.php) — `scopeUpcoming()` + `isAccessibleTo()` + `VISIBILITY_*` (shared with the Manage events module).

**Frontend (Vue)**
- [resources/js/Pages/Dashboard.vue](/resources/js/Pages/Dashboard.vue) — member vs non-member switch; passes `events` down.
- [resources/js/Components/Dashboard/MemberDashboard.vue](/resources/js/Components/Dashboard/MemberDashboard.vue) · [NonMemberDashboard.vue](/resources/js/Components/Dashboard/NonMemberDashboard.vue) — the two faces.
- [resources/js/Components/Dashboard/UpcomingEvents.vue](/resources/js/Components/Dashboard/UpcomingEvents.vue) — the shared upcoming-events list (full card vs locked "Exclusive to {Membership} members" preview).

**Routes**
- [routes/main.php](/routes/main.php) — `main.dashboard` (`GET /dashboard`, `auth`).

## Related modules
- [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) — where the sessions + their per-slot members-only gating are created.
- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — how the signed-in user's membership(s) are read.
- [Memberships](/docs/modules_handbook/manage/membership/memberships/readMe.md) — the tiers an event can be gated to.
