# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

@AGENTS.md

## Commands

```bash
npm run dev      # start dev server (Turbopack) at http://localhost:3000
npm run build    # production build — runs full TypeScript type-checking (stricter than editor/incremental checks)
npm run start    # run the production build
npm run lint      # eslint (eslint-config-next core-web-vitals + typescript)
```

There is no test framework configured in this repo (no test script, no test files). Before handing off work, run `npx tsc --noEmit -p tsconfig.json` for a fast type check, but confirm with `npm run build` before considering a change done — it has caught type errors the incremental checker missed. `npm run build`'s route list also shows which pages are static (`○`) vs dynamic (`ƒ`) — worth checking after any change to `app/layout.tsx`, since reading a cookie or header there opts every page out of static prerendering.

Regenerating translated UI strings (after editing `app/i18n/messages/en.json`):

```bash
node --env-file=.env.local scripts/generate-i18n.mjs
```

## Architecture

This is the Next.js (App Router, v16, Turbopack, React 19, TypeScript strict) frontend for "AstroEye" (AstroTalk-style astrology app). It has effectively no business logic of its own — everything (kundli generation, kundli matching, horoscope, panchang, tarot, wallet, auth, profile, and the separate astrologer-facing app) is served by a separate Go backend at `https://api.astrology.togwe.com/api/...`. This app is a thin BFF + UI layer over it.

### API proxy pattern

Routes under `app/api/**` exist only to forward requests to the Go backend: read the incoming `Authorization` header, `fetch` the same payload to the corresponding `https://api.astrology.togwe.com/api/...` endpoint, and return the response (status + body) verbatim. Proxy shapes used depending on how the backend groups an endpoint family:
- **One dynamic route per family**: `app/api/horoscope/[period]/route.ts`, `app/api/panchang/[type]/route.ts`, `app/api/tarot/[type]/route.ts` — a single route handler validates the dynamic segment against an allow-list and forwards to `${API_BASE}/${segment}`.
- **One route folder per endpoint**: `app/api/kundali-match/{add,aggregate,ashtakoot}/route.ts`, `app/api/wallet/{offers,create-order}/route.ts`, and the entire `app/api/astrologer/**` tree (~40 routes, one per backend endpoint) — used when the endpoints don't share a clean path segment, or there are simply too many to justify a dynamic segment.
- **Multipart uploads** (`app/api/profile/upload/route.ts`, `app/api/astrologer/profile/document/route.ts`): parse the incoming request with `request.formData()` and re-append the file to a fresh `FormData` when forwarding — don't try to stream the raw body through, and don't set a `Content-Type` header manually (let `fetch` generate the multipart boundary).

Follow whichever pattern matches the endpoint family you're integrating. Never call `api.astrology.togwe.com` directly from client code — always go through an `app/api/**` proxy so the Authorization header and base URL stay server-side. The same server-only-secret principle applies to any third-party API key (see Google Places/Translate below): proxy through `app/api/**` rather than shipping a key to the client, unless the third party's SDK genuinely requires client-side loading.

### Feature module structure

Each major feature (Free Kundli, Kundli Matching, Panchang, Horoscope, Tarot, Wallet) follows the same layered structure:
- `app/lib/<feature>.ts` — typed request helpers that call the local `app/api/**` proxy, plus (for Free Kundli / Kundli Matching) localStorage-backed "saved items" caching, since the backend has no list endpoint for these.
- `app/api/<feature>/**/route.ts` — the proxy layer described above.
- `app/<feature>/**/page.tsx` — client components (`"use client"`), each importing its own dedicated `<feature>.css` (see Styling below), with its own auth-gate + loading boilerplate (see Auth below).
- `app/components/<feature>/` — shared presentational components reused across that feature's pages.

Panchang is the most factored example: `app/lib/usePanchangData.ts` (a hook) and `app/lib/panchangParse.ts` (pure field-extraction/formatting helpers) are shared by all 7 panchang pages (today/tomorrow-panchang, rahu-kaal, tithi, choghadiya, vaar, hora) via `app/components/panchang/*`. Reuse this hook for any new panchang-adjacent page rather than re-fetching/re-parsing. The Lagna Chart shown on Today/Tomorrow Panchang is a real chart image, not a placeholder — `getPanchangChartImageUrl` in `app/lib/panchang.ts` builds a URL to `GET /user/kundali/chart-image` (noon on the selected date/location, matching the `time: "12:00"` convention every other panchang request already uses) and renders it as a plain `<img src>`.

Horoscope has four periods (daily, tomorrow, weekly, yearly), each with a list page (`app/horoscope/<period>-horoscope/page.tsx`) and a per-sign detail page (`.../[zodiac]/page.tsx`); the four list pages were originally built by copy-pasting `daily-horoscope/page.tsx`, and more than once that left a page calling `getDailyHoroscope(...)` / redirecting to `/signin?redirect=/horoscope/daily-horoscope/` / linking its zodiac nav to `daily-horoscope` even though the page itself was titled "Weekly"/"Yearly" — when touching any of these, confirm the API call, sign-in redirect, and nav `type` prop actually match that page's own period rather than trusting the surrounding boilerplate. The detail pages share `ScoreBar` (segmented 5-pill bars, takes a `color` prop) and `HoroscopeSidebar` (period nav links + a "want a personal reading" CTA card) from `app/components/horoscope/`. Weekly's real response includes a narrative Personal/Money/Health/Love breakdown alongside the numeric scores; yearly's real response is confirmed live to be the `phase_1..4` quarterly structure (each phase has its own `score` plus nested per-category objects — `physique`, etc. — each with their own `score`/`prediction`), not the flat life-area-breakdown guess that was the other candidate — both detail pages still extract defensively and fall back to the plain score bars/`YearlyHoroscope` component if a response ever doesn't match, since the guess was never fully ruled out for every account.

- **`GET /user/horoscope/signs`** (new, confirmed live to need **no** Authorization header at all — every other horoscope endpoint requires one) returns the 12 zodiac signs with a real per-sign `image` (PNG URL) and `svg_image` (full inline SVG markup), but no date-range/element/slug — those still come from the local `zodiacSigns` table in `app/lib/zodiac.ts`. `getHoroscopeSigns()` (`app/lib/horoscope.ts`) + its own dedicated `app/api/horoscope/signs/route.ts` (a separate route, not folded into the `[period]` dynamic proxy, since it's GET/no-auth where every other horoscope call is POST/authenticated) fetch it; `app/lib/useZodiacSigns.ts`'s `useZodiacSigns()` hook merges the two by numeric id and is what the zodiac picker grid (`HoroscopeCard`), the sidebar strip (`ZodiacNavigation`), and all four per-sign detail pages' header icon now read from instead of the plain static symbol table.
  - **The sign's separate `image` PNG URL is deliberately unused — confirmed live that 9 of the 12 (everything except Aries/Aquarius/Pisces) 302-redirect to an HTML admin login page instead of serving the PNG** (a real backend storage/ACL bug: those nine are an older upload batch under a path that apparently needs an authenticated admin session; the three newer ones serve fine with a plain `200 image/png`). `svg_image` sidesteps this entirely, since it's markup embedded directly in the same JSON response rather than a separate image request that can 302/fail — `app/components/horoscope/ZodiacImage.tsx` renders it via `dangerouslySetInnerHTML` (trusted server data from this app's own backend, not user input), falling back to the local `zodiacSigns` symbol (♈ etc.) only if a sign is ever missing `svg_image` entirely. **Don't switch this back to the `image` field** — it's not a hypothetical risk, 75% of signs are confirmed broken on it right now.
  - **The real artwork renders in solid gold (`#FCD400`)** — the picker grid's circular badge (`.card-zodiac`) originally used a solid gold background to match, which made the icon completely invisible (gold-on-gold). Fixed by using the same light cream background (`#f5f1e9`) the small nav strip's `.zodiac-icon` already used, which is exactly what gives the gold artwork contrast. If any other gold-badge design is added for these icons, check contrast against `#FCD400`, don't assume a "brand gold" background reads fine.
  - Each of `HoroscopeCard`/`ZodiacNavigation`/the 4 detail pages independently calls `useZodiacSigns()`, so `GET /user/horoscope/signs` gets fetched multiple times per page load (confirmed live, harmless since it's free/no-auth/cacheable, but worth sharing via a context if this is ever revisited for efficiency — not done here since it wasn't asked for).
- **The four list pages (`app/horoscope/<period>-horoscope/page.tsx`) no longer fetch a horoscope for all 12 signs just to show a scored preview snippet per card** — confirmed this was real, unwanted API load the user explicitly asked to remove ("i want to call only horoscope sign api other data show static"). They now render a plain "Choose your sign" picker (heading + subtitle + a 6-column `HoroscopeCard` grid of icon/name/date-range only, no score, no prediction text, matching a supplied mockup) sourced purely from `useZodiacSigns()` — no `Promise.all` of `getDailyHoroscope()`/etc., no per-list-page auth gate (the old redirect-to-signin was only ever needed because of that removed per-sign fetch). **The per-sign detail pages are unchanged** — `/horoscope/daily-horoscope/aries` etc. still call the real single-zodiac API and require login, exactly as before; only the list/picker page stopped doing this for all 12 signs at once. `HoroscopeCard` lost its `horoscope` prop entirely as part of this — don't reintroduce a per-card API call without the user asking for it again.
- **The small top-of-page zodiac nav strip (`ZodiacNavigation`, shared by list and detail pages) still exists but list pages no longer render it** — it was a redundant second set of the same 12 signs sitting above the picker grid with no clear purpose once the grid itself became the primary picker (matching the supplied mockup, which shows no such strip). It's still rendered on the four detail pages, where it serves a real purpose (quick-jump between signs while viewing one, with the current one highlighted via `activeZodiac`) — don't remove it from there without being asked.
  - **Restyled per a supplied mockup**: a grid of icon cards (not the old single-row flex-wrap pill strip) — the active sign's card gets a solid gold fill with dark text (`.zodiac-item.active`), inactive cards are plain dark translucent cards, both using the same real SVG icon circle. Same underlying component/props (`type`, `activeZodiac`) — only `.zodiac-navigation`/`.zodiac-item`/`.zodiac-icon`'s CSS changed, not the JSX structure.
  - **Fixed a real "flash of the wrong icon on every load" bug, confirmed by a live user report with screenshots**: `ZodiacImage` used to fall back to the local `zodiacSigns` unicode symbol (♈ etc., in `.zodiac-icon`'s leftover purple `color: #7d45d7`) for the brief window before `useZodiacSigns()`'s `GET /user/horoscope/signs` call resolved, then swap to the real SVG the instant it did — so every single page load visibly flashed the plain symbol first. `useZodiacSigns()` already returned an `imagesLoaded` boolean that nothing consumed; `ZodiacImage` now takes a `loading` prop (threaded through from every call site — `HoroscopeCard`, `ZodiacNavigation`, all 4 detail pages) and renders a neutral placeholder circle (`.zodiac-svg-placeholder`) while loading, falling back to the unicode symbol only once loading has genuinely finished and a sign still has no `svg_image` (which doesn't happen for any of the current 12, so in practice the symbol path is dead code today — kept only as a genuine last-resort, not for the loading window).
- **`POST /user/horoscope/daily-moon`** and **`POST /user/horoscope/daily-nakshatra`** (new) — `getDailyMoonHoroscope()`/`getDailyNakshatraHoroscope()` in `app/lib/horoscope.ts`, added to the `[period]` proxy's allow-list. Confirmed live: both return the identical `{total_score, lucky_color, lucky_number, bot_response, ...}` shape as `daily`, just keyed by a moon-zodiac or a nakshatra id (1–27) instead of the birth-sign zodiac id — `daily-nakshatra`'s request body field is `nakshatra`, not `zodiac`. Neither has a UI entry point wired up yet (no nakshatra/moon-sign picker exists anywhere in this app) — only the lib/proxy layer was added, per the "don't invent a UI with nothing asking for its specific placement" rule; wire up a picker for either if a specific page/design for it is ever supplied.
- **`POST /user/horoscope/feedback`** (new, confirmed live: `{feedbacktype: "Great"|"Average"|"Poor", zodiac, horoscope_type, feedback?}` → `{data: null, message: "Feedback submitted", status: true}`) — `submitHoroscopeFeedback()` in `app/lib/horoscope.ts`, also added to the `[period]` allow-list. `app/components/horoscope/HoroscopeFeedback.tsx` is a small real widget (three emoji pills + an optional 1000-char textarea + submit) mounted at the bottom of all four per-sign detail pages, passing `horoscopeType: "daily"` for both the daily and tomorrow pages (tomorrow is just `daily` called with tomorrow's date under the hood, so its feedback is real `daily` feedback, not a distinct type) and `"weekly"`/`"yearly"` for those two. Verified live end-to-end (real submit, real "Feedback submitted" response, UI swaps to a "Thanks for your feedback!" confirmation).

Tarot mixes a static dataset with the API: `app/lib/tarotCards.ts` hardcodes the canonical 78-card deck (slugs, names, suits, keywords) so deck-browsing/navigation never depends on the network, while `app/lib/tarot.ts` + `app/lib/tarotParse.ts` fetch and parse the actual card meaning/artwork and reading results from the backend. `app/lib/tarotCards.ts`'s `drawRandomCards()` does the client-side random draw (name + card + direction) that's then sent to the reading endpoints — the backend doesn't do the drawing. Every reading page (one-card/three-card/love/yes-no — love and three-card both call the three-card endpoint, since astrotalk.com's "love reading" turned out to just be a themed three-card spread, not a distinct feature) follows the same `form → picking → revealed → predictions` step flow: `TarotCardPicker` renders a face-down fan of `TarotCardBack` cards (each instance independently falls through the `artwork`/`classic`/`dark`/`ghibli` style variants via `extractCardBackCandidates` — the API's own `artwork` style 404s for card-back images specifically), tapping one reveals it via `TarotCardVisual`, and only the final "I want to see predictions" click fires the real reading API call. `TarotCardVisual`'s real-artwork `<img>` defaults to `loading="lazy"`, which doesn't reliably start loading for small always-on-screen reveal/result images in this project's dev setup — pass its `eager` prop anywhere a real API image should always be visible (reveal steps, prediction rows, one-card/yes-no results); leave it lazy only for large lists like the 78-card deck grid.

### Astrologer App (`/astrologer/**`)

A second, parallel account type — astrologers registering and managing their own profile — layered on top of the same Go backend but under a completely separate API namespace (`/api/astrologer/...` vs `/api/user/...`) and its own auth:

- **Auth**: `astrologerToken` / `astrologerRefreshToken` / `astrologerUser` in `localStorage` (never the end-user `token` keys), managed by `app/lib/astrologer-token.ts`. Every astrologer page reads `astrologerToken` directly per the same direct-localStorage pattern described under Auth below.
- **Response envelope**: this API family reports success via a boolean `status` field, not `success` like the end-user API. `app/lib/astrologer.ts` (~700 lines, one function per endpoint) wraps every call in a `normalize()` helper that aliases `status` → `success` so callers can check `response.success` uniformly — except the reviews endpoint (see below), which breaks that assumption.
- **Registration wizard**: 10 steps (basic → languages → skills → photo → education → professional → experience → bank → documents → availability), each a page under `app/astrologer/profile/<step>/page.tsx`. `app/lib/astrologerSteps.ts`'s `STEP_ROUTES` map and `getNextStepRoute()` are the single source of truth for "where should this astrologer land" — driven entirely by the backend's `next_screen` field (`"create_profile"` / `"profile_under_review"` / anything else = approved), never a locally cached step, so logging out mid-wizard and back in always resumes at the correct step.
- **Register vs. edit are separate page files, not a shared page with a mode query param**: each step has two independent files — `app/astrologer/profile/<step>/page.tsx` (first-time registration: always full-screen wizard chrome, always the `save*` helper, redirects to `/astrologer/profile/progress`) and `app/astrologer/profile/edit/<step>/page.tsx` (post-approval editing: always dashboard-sidebar chrome, always the `update*` helper, redirects to `/astrologer/profile`). Both render through the shared `StepShell` component (`app/components/astrologer/StepShell.tsx`) but pass a hardcoded literal `mode="register"`/`mode="edit"` prop — `app/lib/astrologerSteps.ts`'s parallel `EDIT_STEP_ROUTES` map (mirrors `STEP_ROUTES`, same step numbers) is what the Profile hub and progress checklist link into for an already-approved astrologer. Only the edit-mode pages fetch `getAstrologerProfile()` to prefill from the astrologer's saved data (see below) — the register-mode pages always start blank.
- **Edit-mode prefill must be re-derived from `GET /astrologer/profile` per page, not assumed** — this endpoint's real field names for the repeatable-item steps don't match what their own save payloads expect, confirmed live against a fully-approved test account: `educations[]` uses a flat array with an `EducationType` ("UG"/"PG") discriminator and `specialization` (not the nested `ug`/`pg` keys or `field_of_study` the save payload uses); `professionals[]` has `occupation`/`current_company` (not `company_name`/`designation`); `experiences[]` has `is_current` and no `start_year`/`end_year` (not `currently_working`); `BankAccount[]` and `documents[]` map directly. `Availability` is a **single object**, not a 7-day array — the endpoint only ever exposes one saved day, so the edit/availability page can only prefill rates/hours/that one day/social links, not the full week.
- **After "Submit Profile"** (`app/astrologer/profile/progress/page.tsx`) the astrologer lands on a one-time Thank You screen (`app/astrologer/profile/thank-you/page.tsx`), then continues to `/astrologer/profile/review` as before. Shown once via a `sessionStorage` flag set on successful submit and consumed on first view (a `useRef` guard keeps React strict mode's double effect from consuming it early); opening the URL again redirects straight to the review page. The "Profile ID" shown is the astrologer's own `ID` from `GET /astrologer/profile` (no separate profile-code field is known), omitted if it can't be loaded.
- **Dashboard shell**: `/astrologer/dashboard`, `/astrologer/profile`, `/astrologer/wallet`, `/astrologer/requests`, and every step page in edit mode share `app/components/astrologer/DashboardSidebar.tsx` (Home/Requests/Wallet/Profile/Settings nav, with a mobile hamburger drawer) plus the `.astrologer-dashboard-shell` / `.astrologer-panel` / `.astrologer-stat-*` / `.astrologer-avail-*` CSS classes in `globals.css`. Reuse these classes for any new astrologer-dashboard-family page rather than inventing new ones.
- **The dashboard's own "Waiting now" panel (`app/astrologer/dashboard/page.tsx`) is now a second, real entry point into the same accept flow as `app/astrologer/requests/page.tsx`** — its Accept/Decline buttons call `acceptConsultation()`/`rejectConsultation()` directly (previously `console.log()`-only stubs) and Accept navigates to `/astrologer/consultation/[id]` on success, matching the requests page's `handleAccept`/`handleReject` exactly (same silent-dismiss error list, same "already gone" cleanup). Keep these two call sites' accept/reject logic in sync if either changes — they're independent copies, not a shared function, since the dashboard's version reads from `waitingRequests`/`astrologerToken` state instead of the requests page's own `items`/`tokenRef`.
- **Wallet** (`/astrologer/wallet`, `/astrologer/wallet/withdraw`, `/astrologer/wallet/settlement-history[/​[id]]`, `/astrologer/wallet/transaction/[id]`) breaks from the rest of the Astrologer App in two ways worth knowing before extending it: (1) these pages `fetch()` `/api/astrologer/wallet/**` directly inline rather than going through a typed helper in `app/lib/astrologer.ts` — only `getAstrologerWallet`/`getAstrologerWalletTransactions`/`getAstrologerWalletWithdrawHistory` exist there; withdraw and settlement-history have no corresponding lib functions, don't assume one exists before adding a new call. (2) There's no per-id settlement endpoint — `wallet/settlement-history/[id]/page.tsx` re-fetches the *entire* settlement-history list and finds the matching row client-side; `wallet/transaction/[id]/page.tsx` does the same against the withdraw-history list specifically for withdrawal-type transactions (`?type=WITHDRAWAL`). These pages also currently import plain `next/link`/`useRouter()` instead of `LocaleLink`/`useLocaleRouter` (see Multi-language UI) — a real, unfixed locale-dropping gap if this feature is reached while browsing in Hindi.
  - **For earnings-type transactions, `GET /api/astrologer/wallet/transaction/[id]` (the old per-id endpoint) is now confirmed broken/stale — it 400s `"transaction not found"` live for a transaction that genuinely exists** (verified with real id 68 against the real backend), evidently since consultations became the source of wallet earnings rows and this older endpoint's lookup wasn't kept in sync. It's also the *only* place customer name/session duration/settlement status ever lived from the frontend's perspective, and it never actually returned them (the page used to carry an honest "does not provide customer name..." disclaimer for exactly that reason). The real source for all of that is `GET /astrologer/wallet/transactions?type=ALL` (confirmed live: each row has `customerName`, `durationMinutes`, `transactionType` — `CHAT`/`AUDIO_CALL`/`VIDEO_CALL`/`ORDER`/etc. — `category`, `settlementStatus`, `settlementLabel`, `source`, `referenceNo`, `statusCode`, plus `transactionDate`/`transactionTime` after all), same shape `app/astrologer/wallet/page.tsx` already lists from. `wallet/transaction/[id]/page.tsx` now fetches both concurrently via `Promise.allSettled` and merges (list row wins for any field it provides, per-id fills the rest, e.g. `creditAmount`/`debitAmount`/`balanceAfter`) — the page still renders correctly even with the per-id call 400ing, since only the list result is required for the merge to succeed. Don't remove the per-id call outright — its extra fields aren't in the list row and it may not be broken for non-consultation-sourced transaction ids.
  - **`kindOf()`'s medium detection (CHAT/CALL/VIDEO/OTHER, drives the section title/icon) had a real bug found while live-testing this**: it let the page's own `?type=earning|withdrawal` routing param (which endpoint to call, not a medium) flow into the same generic string match as the real `transactionType` — `"earning"` matches none of VIDEO/CALL/CHAT, so every earnings-type transaction silently rendered as generic "OTHER" regardless of its real medium. Fixed by only using that param to force `WITHDRAWAL`, never letting it suppress a real `transactionType` read. If this page is touched again, keep the routing param and the display-medium detection separate — they look like the same string but aren't.
  - **`app/astrologer/wallet/page.tsx`'s Transaction History table has no Action column** — each `<tr>` is itself the click target (`role="link"`, `tabIndex`, `onClick`/`onKeyDown` calling `router.push()` to the same `wallet/transaction/[id]?type=earning|withdrawal` href the old per-row chevron button used), styled with a `.wallet-table-row` hover/focus state rather than a dedicated action cell. Follow this pattern (whole-row navigation, not a trailing icon column) for any other transaction-style table added to this page family, per the current design.
- **GET /astrologer/profile** returns PascalCase GORM field names (`Name`, `ExperienceYears`, `ChatRate`, a typo'd `countary`, nested `BankAccount`/`documents`/`skills[].Skill.name`) that differ from the snake_case the wizard-step *save* endpoints expect — every page that reads this response aliases both forms (e.g. `p.name ?? p.Name`). The reverse mismatch also happens: the real Postman body for `PUT /astrologer/profile/update` requires `working_platform` (bool), `working_platform_name`, and `learn_astrology` — fields with no obvious counterpart in the edit form until you check GET's `isWorkingOnAnotherPlatform`/`nameofplateform`/`learnAstrology` — so when a save endpoint's Postman example has fields the current form doesn't collect, check GET's response for the matching value before assuming they're optional.
- **Confirmed backend quirks** (verified live, not guessed):
  - Every boolean toggle field on the dashboard endpoints (`online-status`, `chat-status`, `call-status`, `busy-status`, `quick-dnd`) is tagged `binding:"required"` on the Go side, and Go's validator treats `false` as an unset zero value — so turning one of these **off** always 400s, while turning it on works. `isRequiredToggleOffError()` in `app/lib/astrologer.ts` detects this so the UI can revert the toggle with a clear message instead of a raw error.
  - There is no GET endpoint for offer settings (`PUT /astrologer/dashboard/offer` is write-only) — the Offers UI can set values but can never know or display the astrologer's actual saved state.
  - `POST /astrologer/reviews` (proxying the end-user `/user/getAstrologerUserReview`) returns a **numeric** `status` (200 / 400), not the boolean every other astrologer endpoint uses — `normalize()`'s generic `success: data.status` aliasing is actively wrong here (400 is truthy too), so `getAstrologerReviews` hand-codes `success: false` on the error path instead of trusting `normalize()`. The endpoint also 400s with a raw SQL scan error for any astrologer whose `experience` column is a decimal-looking string (e.g. `"8.0"`) — a real backend data bug, not something to retry around.
  - `astrologer_educations.astrologer_id` has been observed referencing `users(id)` instead of `astrologers(id)`, causing intermittent FK errors right after a fresh registration; `saveEducation`/`updateEducation` retry a couple of times via `withDbRaceRetry()` before giving up.
  - `PUT /astrologer/profile/update` and `PUT /astrologer/profile/update-availability` both currently fail for at least one real approved test account with a raw `Error 1054 (42S22): Unknown column 'displayName' in 'field list'` — reproduced directly against the backend with a minimal, known-good payload, so it's a backend schema-drift bug (the Go model references a DB column that doesn't exist on that row's table), not a frontend field-mapping issue. Don't try to "fix" this by changing the request payload.
  - `friendlyErrorMessage()`'s `looksLikeRawDbError()` filter exists because raw Go/SQL driver errors (`"Error 1452 (23000): ... foreign key constraint fails"`, `"sql: Scan error on column index 7, ..."`) get passed straight through as `message` on several endpoints — never surface these to a user; extend the regex there if a new raw-error shape turns up rather than adding a one-off check at the call site.

### The Postman collection has mislabeled entries — don't trust names, verify the actual URL

This has bitten integrations more than once: several requests in the collection have a correct-sounding `name` but a copy-pasted, wrong URL. Confirmed examples: "Wallet Transaction" and "Wallet Summery" both point at `/user/report/chat-history`; "User App Home" points at `/user/delete`; the Astrologer App's "Update Availability" request points at `/astrologer/profile/update-availability`, which 404s — the real endpoint is `/astrologer/update-availability` (no `profile/` segment, matching the naming of the other `update-*` endpoints); its "Update Busy Status" request reuses the exact URL and body shape of "Update Call Status" — the real busy-status endpoint is a distinct `/astrologer/dashboard/busy-status` taking `is_busy`, not `is_available`. When wiring up a new endpoint, check the request's actual `url`/`path` fields, not just its name, and treat any endpoint whose behavior doesn't match its name as suspect rather than assuming the app is misusing it.

**Update: a working user-side wallet-transactions endpoint has since been confirmed live** (the collection's own chat-history one is still broken, unrelated) — see the Wallet section below; the "honest empty state over fake wire-up" rule still applies anywhere else a Postman entry turns out to have no real counterpart (e.g. there's no GET for astrologer offer settings — see above).

### User-side wallet transactions (`/wallet`)

`app/wallet/page.tsx` now shows real data — confirmed live, `POST /user/wallet/transactions` (a GET 404s at the Apache level, the same "reads like a GET, is actually a POST" mislabeling this codebase has hit before) returns `{recordList, totalRecord, currentPage, limit, status}`, each row `{id, title, transactionNumber, transactionType, amount, isCredit, icon, createdAt, astrologerName?}`. `getWalletTransactions()` (`app/lib/wallet.ts`) + `app/api/wallet/transactions/route.ts` fetch it.

- **Neither a `type` nor a `transactionType` field in the request body actually filters server-side — confirmed live, passing either returned the identical unfiltered list.** All of the page's Call/Chat/Wallet Transactions/Payment Logs tabs are filtered **client-side** via `categorizeWalletTransaction()` (`app/lib/wallet.ts`): `isCredit: true` → Payment Logs (add-money/recharge); `transactionType` containing `CALL` → the Call tab; containing `CHAT` (this also catches a real `DEMO_CHAT` type, confirmed live) → the Chat tab; anything else (e.g. a real `HoroscopeView` type, confirmed live — a debit for viewing a horoscope) → the honest catch-all Wallet Transactions tab. If a new `transactionType` ever shows up that doesn't fit this, it falls into Wallet Transactions by default rather than being dropped.
- **The Header profile dropdown's "Wallet Transactions ₹0" figure was a literal hardcoded `₹0`, never wired to anything.** Fixed by having `Header.tsx` fetch its own fresh balance the same way `app/wallet/page.tsx` already did (`GET /user/profile` + `getWalletBalance()`) — the `user` object cached in `localStorage` is only a login-time snapshot and was never a valid source for a balance that changes on every recharge/consultation. `app/wallet/page.tsx` now also dispatches a `wallet-updated` window event after it loads a fresh balance, which `Header.tsx` listens for (alongside the pre-existing `profile-updated` event used for the avatar) — so visiting `/wallet` also refreshes the dropdown figure, not just its own page. Verified live against a real account whose actual balance is genuinely ₹0 (confirmed by the wallet page itself), so this reads correctly as a real fetched value rather than a coincidental match to the old hardcoded one — check with a non-zero-balance account if this is touched again, to see the fix visibly change the number.
- **The page's default sub-tab under "Wallet" was `"payment"` (Payment Logs), not `"wallet"` (Wallet Transactions)** — `activeHistory`'s initial `useState` value now matches the real default the Wallet Transactions tab should load into.
- **A "Wallet Transactions tab shows call/chat mixed in" report couldn't be reproduced against `categorizeWalletTransaction()` as written** — replayed the exact real `recordList` rows the report was based on (7 `HoroscopeView`, 9 `CHAT`, 4 `CALL`) directly through the function and confirmed the Wallet Transactions bucket contained only the 7 `HoroscopeView` ids, zero cross-contamination from Call/Chat. If this resurfaces, check whether it's a stale-bundle symptom first (this session has hit that more than once elsewhere) before assuming the categorization logic itself regressed.
- **Each row in `/wallet` is a real drill-down link now** (`/wallet/transaction/[id]?type=<transactionType>`, same "whole row is the click target" convention as the astrologer wallet transaction table), backed by a **separate** endpoint from the list — confirmed live: `POST /user/wallet/transaction/details` (not `/user/wallet/transactions`, despite the similar path) takes `{id, transactionType}` (both required — a real 400 `"id and transactionType are required"` without them) and returns one `{status, message, data}` object, not a list. `transactionType` must be the *exact* string the list row had (it's what the backend uses to pick which table to search — `user_chat_histories`/`user_call_histories`/`user_orders`/`wallettransaction` for anything else) — sending a different value, or an id belonging to another user, both come back as a real `404 "Transaction not found"`.
  - **Which nested objects come back non-null depends on `transactionType`, confirmed live for CHAT** (`astrologer` + `session` populated, `payment`/`product`/`address` all `null`): `CHAT`/`CALL` → `astrologer` (if linked) + `session` (medium/status/duration, always); `ORDER` → `product` (always) + `address` (if linked); `RECHARGE`/anything else → `astrologer` (if linked) + `payment` (if a matching payment row exists). `getWalletTransactionDetails()` (`app/lib/wallet.ts`) + `app/api/wallet/transaction/details/route.ts` fetch it; `app/wallet/transaction/[id]/page.tsx` renders whichever of the five nested objects are actually present, never assuming a fixed set for a given type.
  - **Only the CHAT shape was verified against a row this app's own test account genuinely owns** (a `CALL`-type id was tried first using an id from a *different* real user's data pasted into this conversation, and correctly 404'd — "a row is only found if it belongs to the user in the token" isn't just documentation, it's real) — CALL/ORDER/RECHARGE are wired to the same documented shape but unverified end-to-end; re-check field names live against a real row of one of those types before trusting them fully.

### Defensive API response parsing

The Go backend's JSON response shapes are not reliably documented (the Postman collection frequently has no sample response at all, and — see above — sometimes names the wrong endpoint entirely) and are often wrapped in nested envelopes, e.g. `{ status, message, data: { charged, data: { ...actual fields... } } }`, with the nesting depth varying by endpoint. Parsing code throughout the codebase unwraps nested `data` envelopes generically and checks multiple key aliases per field, because guessed field names have repeatedly turned out wrong once tested against a real response:
- `unwrapUntil` in `app/lib/panchangParse.ts` and `app/lib/tarotParse.ts`
- `getReport`/`getScore`/`getDoshas` in `app/kundli-matching/[id]/page.tsx`
- `getProfileImageUrl` in `app/lib/profileImage.ts` and `getWalletBalance` in `app/lib/wallet.ts` (alias-scan a user/profile object for a field whose real name isn't confirmed)
- `normalize()` in `app/lib/astrologer.ts` (aliases the whole family's `status` → `success`) — but don't assume every astrologer response follows it; the reviews endpoint's numeric `status` breaks that assumption (see Astrologer App above).

Confirmed (not guessed) examples of single-letter/typo field-name mismatches that silently dropped data rather than erroring — worth remembering as the shape of bug to watch for, since nothing throws: panchang choghadiya's real per-slot field is `muhurat` (not `muhurta`); hora's real list field is `horas` (plural, not `hora`/`hora_list`) with each item's planet under `hora` (singular, not `lord`/`planet`/`name`); and a tarot reading's `cards[0].reading.meaning` can hold a short unrelated value (observed literally `"No"`) while `cards[0].reading.description` holds the real narrative — `OVERVIEW_ALIASES` in `tarotParse.ts` is ordered to prefer `description` over `meaning` for exactly this reason. Each of `panchangParse.ts`'s row-normalizers and `extractOrientation`'s alias list exist because of a real mismatch like these, not defensive-by-default speculation.

When integrating a new endpoint, treat the Postman collection as a starting guess, not ground truth — verify against a real response before considering field mapping done. Some endpoints (e.g. tarot cards, `get-user-home`) don't actually validate the bearer token's contents and will return real data for any non-empty token, which is a fast way to check a response shape without real credentials; `GET /user/kundali/chart-image` (a Lagna/divisional chart image, used directly as `<img src>` in Free Kundli and Panchang) goes further and needs no Authorization header at all. Most others (`/user/profile`, wallet, horoscope, panchang) validate strictly and either 401 or return `{"message":"Invalid token","success":false}` for a bad token, so the token-leniency trick doesn't generalize — check each endpoint rather than assuming.

### Auth

Token/user are stored in `localStorage` (`token`, `refreshToken`, `user`). An `AuthContext`/`useAuth()` exists (`app/context/AuthContext.tsx`, wraps the app in `app/layout.tsx`), but most feature pages don't use it — they read `localStorage.getItem("token")` directly inside a `useEffect` and call `router.replace("/signin"...)` if it's missing, or if an API call comes back 401. Match this direct-localStorage pattern in new pages for consistency rather than switching them to `useAuth()`. There is no server-side auth middleware — all auth gating is client-side, per page. (The astrologer app has its own, entirely separate set of `astrologerToken`/`astrologerRefreshToken`/`astrologerUser` keys — see Astrologer App above; the two sessions never share state.)

Two separate `getAuthToken()` helpers exist: `app/lib/token.ts` (async, checks `token` only) and `app/lib/auth-token.ts` (sync, checks `token`/`authToken`/`access_token`). Newer code uses `auth-token.ts`.

**`app/lib/mask.ts`**'s `maskMobileNumber()`/`maskEmail()` are display-only (never touch the actual value used for API calls) and were already used correctly on the astrologer side's OTP screens (`app/astrologer/verify-mobile/page.tsx`) but were missing from every end-user OTP screen — `app/verify-mobile/page.tsx`, `app/verify-email/page.tsx`, `app/mobile-verification/page.tsx` (the social-login mobile step), and `app/auth/OtpForm.tsx` (the sign-in OTP step) all rendered the raw mobile number/email in plain text. Fixed by importing the same shared helper into all four instead of duplicating masking logic — confirmed live for `verify-mobile`/`verify-email` (`+********99`, `so*********@ex*********`); `mobile-verification` and the sign-in `OtpForm` reuse the identical already-proven function but weren't live-tested here since reaching their OTP step needs a real Google social-login session or sending a real login OTP SMS. If another OTP-display screen is added anywhere in the app, import from `mask.ts` rather than writing new masking logic inline.

The profile photo (`app/components/ProfileAvatar.tsx`, resolved via `app/lib/profileImage.ts`) falls back to a generated initials circle rather than a placeholder image when the user has none — don't reintroduce a static stock-photo default. When a profile field changes (e.g. after a photo upload in `EditProfileModal`), dispatch a `window.dispatchEvent(new Event("profile-updated"))` — `Header.tsx` listens for this to refresh the avatar it shows without a full reload, since it reads `user` from `localStorage` independently of whatever page triggered the update.

### Multi-language UI (`/hi/**`, etc.)

The site supports multiple UI languages without moving any route under an `app/[locale]/**` folder:

- **`proxy.ts`** at the repo root (Next 16 renamed the `middleware.js` file convention to `proxy.js` — the exported function must be named `proxy`, not `middleware`, or it silently never runs) rewrites a `/hi/...` request to the unprefixed route and stamps a `NEXT_LOCALE` cookie with the resolved locale on every request, including plain (unprefixed = English) ones. `app/i18n/locales.ts`'s `LOCALES` array is the only place that lists which prefixes exist.
- **`app/context/LanguageContext.tsx`**'s `useLanguage()` gives any client component `{ locale, t, setLocale }`. Critically, `locale` is derived reactively from `usePathname()`, not from a value passed down as a prop from the root layout's Server Component — client-side navigation (`router.push`) never re-runs that Server Component, so a prop-based locale would go stale the moment a user switched languages via the picker instead of a hard reload.
- **`app/components/LocaleLink.tsx`** is a drop-in replacement for `next/link`'s `Link` that auto-prefixes `href` with the current locale. Virtually every `<Link>` in the app (not just shared nav — auth flows, astrologer pages, horoscope detail pages, blog, tarot, dashboards) should import from here instead of `next/link`; a sitewide audit found 31 files still importing plain `next/link`, each one silently dropping a Hindi user back to English on click. When adding any new page or component with an internal link, import `Link` from `@/app/components/LocaleLink`, never `next/link`, by default.
- **`app/lib/useLocaleRouter.ts`** is the same fix for *programmatic* navigation — `LocaleLink` only patches `<Link>` clicks, so every `router.push()`/`router.replace()` call with a hardcoded path (redirects to `/signin`, the signup -> verify-email -> verify-mobile chain, the astrologer register -> verify-mobile -> verify-email -> profile/progress chain) had the identical bug and needed `useRouter()` swapped for `useLocaleRouter()` instead. This is easy to miss because it looks like a `<Link>`-only problem at first glance — check for hardcoded `router.push("/...")`/`router.replace("/...")` calls too whenever fixing a "kicks the user back to English" report, not just anchor tags. Nothing enforces this at the type/lint level, so it keeps regressing in brand-new code — the whole Wallet feature (see Astrologer App above) was added after this fix and still imports plain `next/link`/`useRouter()` throughout; check any newly-added page for this specifically rather than assuming the fix "took" repo-wide.
- **`app/i18n/messages/en.json`** is the source of truth for UI strings, organized as one top-level namespace per page/feature area (`nav`, `hero`, `home`, `auth`, `astrologerAuth`, `footer`, etc. — nest per-component/per-page under that, mirroring the JSX structure); the other locale files are *generated*, not hand-edited — run `node --env-file=.env.local scripts/generate-i18n.mjs` (needs `GOOGLE_TRANSLATE_API_KEY`) after changing English copy to regenerate them via the Google Cloud Translation API. The `t(key, fallback)` helper has no interpolation support, so a string with a dynamic value must be split into a static `t(...)` piece plus the raw JS variable/element (see `hero.subtitlePrefix` + `<span>{t("hero.verifiedCount")}</span>` + `hero.subtitleSuffix` in `Hero.tsx` for the pattern) — don't try to pass a template literal through `t()`.
- `scripts/generate-i18n.mjs` batches translation requests (`BATCH_SIZE`, currently 100) rather than sending the whole dictionary in one call — confirmed live, Google's v2 API rejects a single request over a few hundred text segments ("Too many text segments"), which will resurface as the dictionary grows past whatever `BATCH_SIZE` is currently set to. It also hardcodes an `OVERRIDES` map for terms the generic translation engine gets wrong for this domain (confirmed live: it returned literal untranslated "TAURUS" for "Taurus" and phonetic transliterations like "एआरआईएस" for "Aries" instead of the real Hindi rashi names) — add another entry there rather than accepting a bad machine translation for a well-known fixed term. `TARGET_LOCALES` must be kept in sync with `LOCALES` in `app/i18n/locales.ts` by hand — the script doesn't import it (a stale `["hi", "kn"]` here after `kn` was dropped from `LOCALES` regenerated a `kn.json` nothing served).
- **`toContentLang()`** in `app/i18n/locales.ts` exists because the astrology *content* APIs (horoscope, panchang, tarot, kundli/kundli-matching) are backed by a different third-party engine than the UI translations — confirmed live, it accepts `en`/`hi` but 400s on anything else. Any page that sends a `lang` field to one of these APIs should pass it through `toContentLang(locale)` first, so an unsupported UI locale falls back to English content instead of erroring, while the page chrome around it stays in the user's chosen language.
- Reading the locale cookie in the root layout (`app/layout.tsx`, an `async` Server Component using `await cookies()`) makes every route dynamically rendered (`ƒ`) instead of statically prerendered (`○`) — a deliberate trade-off for not restructuring into `app/[locale]/**`, visible in `npm run build`'s route list.

### Chat/Call consultations (`/chat-with-astrologer`, `/talk-to-astrologer`) — ring → accept → confirm, then real Agora chat/audio

Clicking "Chat"/"Call" on an `AstrologerCard` (`app/components/dashboard/` and `app/components/talk-to-astrologer/`, near-duplicate files) opens `StartConsultationModal.tsx`, which drives the real consultation lifecycle — confirmed against a **"Consultation API Handbook"** the user supplied directly (not the Postman collections; read it in full before touching this area again if it's ever resupplied, since it superseded an earlier, incompatible assumption this codebase was built against). **This is a ring/accept/confirm handshake, not instant billing**: `/consultation/start` only rings the astrologer; nothing is billed and no Agora channel exists until the astrologer accepts and the customer explicitly confirms via `/start-chat`. Verified live end-to-end against production, including real Agora RTM chat delivery — see Verification note below.

- **The public homepage's "13,000+ expert astrologers" teaser (`app/components/home/Astrologers.tsx`, rendered from `app/page.tsx`) is real, live data only for a logged-in visitor** — a genuine Chat + Call entry point (`StartConsultationModal`, same component the rest of this flow uses), matching the ring/accept/confirm lifecycle above exactly (verified live: clicking Chat on a genuinely-busy real astrologer surfaced the real precheck-blocked message, not a fake success).
  - **Anonymous visitors see the real top 3 astrologers** from the public `GET /user/astrologers/top?limit=3` (confirmed live to need no Authorization header; proxied by `app/api/astrologers/top/route.ts`, cached 60s) — mapped `chatRate`→charge, `totalOrders`→orders. Clicking anything on these cards (the card itself, or its Chat/Call buttons) does `router.push("/signin")` and never opens `StartConsultationModal`, per explicit request. The old hardcoded "Viehana/Shiva/Shivam" placeholders were removed; if the fetch fails the section simply doesn't render.
  - **Once logged in**, `POST /user/astrologers/list` (`getAstrologers()`) is used — confirmed live, it 400s "Token required"/"Invalid token" for no/bad auth, so it only ever runs when a real `token` is present in localStorage. That response has real `isOnline`/`chatRate`/`callRate`/`rating`, letting the section genuinely sort "online first, then rating" as asked. Caps at the top 3 and never fabricates a placeholder if the real fetch comes back empty (the section just doesn't render, per the "honest empty state" rule) — 
  - Each real (logged-in) card has separate **Chat** and **Call** buttons (`medium: "CHAT"` / `"AUDIO"`) sharing one `StartConsultationModal` instance toggled by state (keyed on `${id}-${medium}` so switching astrologer/medium always remounts fresh rather than reusing stale modal state).

- **`app/lib/consultation.ts`** (customer side) — full lifecycle: `precheckConsultation()`, `startConsultation()` (rings only), `getConsultationStatus()` (poll every 3s while REQUESTED), `startChatConsultation()` (billing begins here, Agora creds mint), `cancelConsultation()`, `tickConsultation()` (poll every **30s** — `tick_interval_seconds`, never `tick_timeout_seconds`, a separate field with a 4x ratio; polling at the timeout gets the session swept as abandoned and billed), `continueConsultationPaid()`/`extendConsultation()` (after warnings), `syncConsultationMessages()`, `endConsultation()`, `getActiveConsultation()`. Proxied at `app/api/user/consultation/{precheck,start,status,start-chat,cancel,tick,continue-paid,extend,messages/sync,end,active,agora-token}/route.ts`.
- **`app/lib/astrologer.ts`** (astrologer side, appended near the pre-existing `getConsultationRequests` stub) — `acceptConsultation()`, `rejectConsultation()`, `getAstrologerActiveConsultation()` (read-only, unlike the customer's `/active` — never bills/closes a session as a side effect, so it's safe to poll on a timer, unlike the customer version), `endAstrologerConsultation()`, `getAstrologerConsultationAgoraToken()`. Proxied at `app/api/astrologer/consultation/{requests,accept,reject,active,end,agora-token}/route.ts`.
- **`precheckConsultation()` replaced a client-side `rate × 5` guess** — confirmed live, the real minimum the server enforces frequently does *not* match a naive rate-times-minutes estimate (one real astrologer's actual required balance was ₹120 against a ₹175 client guess). `StartConsultationModal` calls `/precheck` first and shows the server's real `blocked_reason`/`blocked_message` (`ASTROLOGER_OFFLINE`, `ASTROLOGER_BUSY`, `INSUFFICIENT_BALANCE`, `MEDIUM_DISABLED`, `NO_RATE_CONFIGURED`, `SESSION_ALREADY_OPEN`, `SERVICE_NOT_CONFIGURED` — all confirmed live) rather than guessing; only `INSUFFICIENT_BALANCE` gets a "Proceed to wallet" button, since the others have no useful client action. `getAstrologers()`'s own `isOnline` field (used for the card's online/offline dot) can disagree with `/precheck`'s live check — confirmed live, a card showing "Online" still precheck-blocked with `ASTROLOGER_OFFLINE` — precheck is the authority, the card dot is just a fast visual hint. **`AstrologerCard.tsx` (both `dashboard/` and `talk-to-astrologer/`) now surfaces this live authority on the button itself instead of only inside the modal**: `StartConsultationModal` takes an `onUnavailable(reason)` prop and, specifically for `ASTROLOGER_BUSY`/`ASTROLOGER_OFFLINE` (the two reasons the card already has a visual home for), skips the "blocked" modal step entirely — closes immediately and calls `onUnavailable` with a toast instead. The card stores this in a `liveStatus` state that overrides the list's `isOnline`/`isBusy` fields once set, flipping the button to a disabled red-outline "Busy" pill or a muted gray-outline "Offline" pill without ever popping a dialog. Other blocked reasons (`INSUFFICIENT_BALANCE`, etc.) still use the modal, since those need an actionable message/button the card has no equivalent for.
- **`getWalletBalance()` in `app/lib/wallet.ts` was silently broken until this was wired up** — it aliased top-level fields like `Wallet`/`Balance`/`wallet_balance`, but the real `GET /user/profile` response nests it at `user.UserWallet.amount` (confirmed live). Check that shape first if wallet balance ever looks wrong elsewhere.
- **Never compute a ring/join countdown from a raw timestamp on the device clock** — confirmed live this drifts badly (an initial implementation computing `ring_expires_at − Date.now()` showed 649s instead of the real ~60s ring window). Always prefer the server's own pre-computed `seconds_to_expiry`/`seconds_to_join_end` fields when present, falling back to timestamp math only if they're absent.
- **`GET /astrologer/consultation/active` has no `has_active` field at all — confirmed live, this was the actual root cause of the astrologer's chat window never opening after a real accept.** Both "nothing active" and "something active" return `status:true` with the identical `message: "Active consultation"`; the only real signal is `data.consultation_id` being non-zero (`0` + `status:""` + all-zero fields when empty; a real id + populated fields otherwise — see a full captured pair of both shapes below). The customer-side `/user/consultation/active` genuinely does document and return `has_active`, and that assumption was carried over to the astrologer endpoint without checking — it never held. Fixed in both `app/astrologer/consultation/[id]/page.tsx` and `app/astrologer/requests/page.tsx` (`activeSession`/`isBusy` derivation) by checking `!!data?.consultation_id` instead. **If this endpoint is touched again, check `consultation_id`, never add back a `has_active` check.**
  ```json
  // no active session
  {"data":{"consultation_id":0,"consultation_no":"","status":"","astrologer_id":0,"astrologer_name":"","medium":"","is_free_chat":false,"free_minutes":0,"rate_per_minute":0,"wallet_balance":0},"message":"Active consultation","status":true}
  // ONGOING
  {"data":{"consultation_id":53,"status":"ONGOING","astrologer_id":1,"astrologer_name":"vikas","medium":"CHAT","elapsed_seconds":3937,"channel_name":"cs-51","agora":{...},...},"message":"Active consultation","status":true}
  ```
- **This same `/active` response also has no customer-name field at all** (`user_name`/`consultee_name`) — only `astrologer_name` (the astrologer's own). The name is only ever present in `POST /astrologer/consultation/accept`'s response. `acceptConsultation()` in `app/lib/astrologer.ts` now session-storages it (`consultation_${id}_customer_name`) the moment accept succeeds, and `app/astrologer/consultation/[id]/page.tsx` falls back to `getCachedConsultationCustomerName(consultationId)` when `/active` doesn't carry a name — without this the session header/avatar just showed a blank "?" for the whole call. This is why the fix lives inside `acceptConsultation()` itself rather than each of the four call sites (Orders page, dashboard "Waiting now", the notifications page, `NotificationBell`) — they all benefit automatically.
- **`app/astrologer/consultation/[id]/page.tsx`'s poll loop also had a stale-closure bug**, a separate issue from the `has_active` one above — its mount `useEffect` had deps `[consultationId]` only, so the `poll()` closure's reads of `phase` were frozen at whatever it was on mount ("loading") forever; a "no active session" read that should have set `"ENDED"` (session really over) instead always fell through to the `"loading"` branch and set `"NONE"`. Fixed by tracking phase in a `phaseRef` kept current via a separate `useEffect(() => { phaseRef.current = phase }, [phase])`, and reading `phaseRef.current` inside `poll()` instead — the customer-side `app/consultation/[id]/page.tsx` never had this bug because its equivalent polling effects already include `phase` in their deps array, recreating the closure fresh each transition.
- **All three of the above were confirmed and fixed via a real live end-to-end run** (real customer + real astrologer accounts, curl-driven `precheck`→`start`→UI-click-Accept→curl `start-chat`→verify in-browser): request appears in the Orders queue within one poll → Accept navigates to the session page and shows "waiting for the customer to join" → the moment the customer confirms, the astrologer's page flips to the real chat window with the correct customer name, live timer, and a working composer → End shows the confirmation modal and closes the session on both sides. This was the fix for the "astrologer dashboard not open chat window" report.
- **`app/lib/agora.ts`** wraps the Agora Web SDKs (`agora-rtm-sdk` **2.x** — pinned deliberately, the tokens this API mints are AccessToken2/"007…" format that RTM 1.x can't consume — plus `agora-rtc-sdk-ng` for audio). RTM is the control plane for every medium, not just chat text. Only `NEXT_PUBLIC_AGORA_APP_ID` lives in this repo (`.env.local`) — the App Certificate is a Go-backend-only secret used to mint tokens server-side and must never be added here.
  - **Both Agora SDKs must be loaded via a lazy `import()` inside the functions that use them, never a static top-level `import`** — confirmed live, both packages touch `window`/browser globals at module-evaluation time (typical of WebRTC packages), so a static import crashes with `"window is not defined"` the moment Next server-renders a page that pulls in `agora.ts`. This didn't show up in earlier client-side-navigation testing (Accept → `router.push(...)` never triggers SSR for the destination), only on a hard refresh or a direct URL load of `/consultation/[id]` or `/astrologer/consultation/[id]` — both of which real users (and this handbook's own recovery/resume flow) do routinely. `getAgoraAppId()` and the type-only imports (`IAgoraRTCClient`/`IMicrophoneAudioTrack`) stay static since they don't touch `window` and TS types are erased at compile time anyway.
- **`app/consultation/[id]/page.tsx`** (customer) and **`app/astrologer/consultation/[id]/page.tsx`** (astrologer) are the live session screens — a real chat message list + composer over RTM for `CHAT`, a mute/hangup call screen over RTC for `AUDIO`. Both `getActiveConsultation()`/`getAstrologerActiveConsultation()` branch on the real `status` (`REQUESTED`/`ACCEPTED`/`ONGOING`) to resume correctly rather than assuming a session is already live. Messages are buffered in memory and flushed via `messages/sync` every ~25 messages or 30s and once more on end (`is_final: true`) — a web tab has no equivalent to the handbook's mobile disk-persisted buffer, so this is the honest web equivalent, not a gap to "fix" by adding IndexedDB persistence unless asked.
- **`app/astrologer/requests/page.tsx` is now a tabbed "Orders" hub** (URL and `DashboardSidebar` nav item unchanged — only the nav *label* changed from "Requests" to "Orders", per a supplied mobile UI kit), with Request/Waiting/Chat/Call tabs and, inside Request, All/Chat/Call/Video sub-filter pills. It polls `getConsultationRequests()` (ring queue, confirmed real shape: `{items, count, poll_interval_seconds}`) and `getAstrologerActiveConsultation()` together every ~4s via `Promise.allSettled` — **not** `Promise.all`, since both of those functions reject (axios throws on any non-2xx) rather than resolving `{success:false}`, so `Promise.all` would let one endpoint's failure silently kill the other's result on every single poll; this exact failure mode was confirmed live (a fake/expired token produced an uncaught promise rejection that stopped the whole poll from updating anything, with no visible error until console.error was added). Accept routes to the astrologer session page, Reject dismisses; the same silent-dismiss accept-failure messages as before are still honored, not toasted.
  - **Only Request can ever show more than one row.** Waiting/Chat/Call each render the astrologer's *single* active session (from `getAstrologerActiveConsultation()`) if its `status`/`medium` matches that tab, else an honest empty state — **the supplied mockup's "Waiting"/"Chat" tabs showing 5+ simultaneous customers in a ranked queue don't match this backend** (`POST /astrologer/consultation/accept` marks the astrologer busy; only one `ACCEPTED`/`ONGOING` consultation can exist at a time). The Video sub-filter pill is rendered disabled with a tooltip rather than removed outright, since the mockup has it — there's no video medium wired anywhere in this app.
  - **The full-screen "Incoming call" alert is a real, intentional feature, not decorative** — it matches the handbook's own push-notification description of `CONSULTATION_REQUEST` ("astrologer → full-screen incoming sheet, over the lock screen"). It fires once per newly-seen `consultation_id` while the astrologer isn't already busy, and its own "Decline" button deliberately does **not** call `/reject` — it only dismisses the overlay (matching the mockup's own copy, "Declining keeps your availability on — the request returns to the waiting list"), leaving the request answerable from the Request tab's list underneath. Only the list's own Accept/Decline buttons touch the real API.
  - Countdowns on this page tick from the server's own `seconds_to_expiry` via a local 1-second interval, never recomputed from a raw timestamp (see the ring/join-countdown note above).
- `DashboardSidebar.tsx` self-polls `getAstrologerDashboard()`'s confirmed-real `pending_request_count` field for a badge on the Orders nav item.
- **Video medium, the mobile-style disk-persisted message buffer, and the admin panel endpoints are explicitly out of scope** — only `CHAT` and `AUDIO` are wired (the app has no video UI anywhere), and no admin UI exists in this app.
- **The astrologer session page (`app/astrologer/consultation/[id]/page.tsx`) was restyled against a supplied mobile UI kit** (local mockups: ringing queue, waiting list, per-call/chat screens) **but only the single-session parts of it** — an initials avatar ring, a live MM:SS timer, an End confirmation modal (`astrologer-modal-backdrop`/`astrologer-modal`, matching the "End chat?"/"End call?" pattern), and — only when the active-consultation response actually includes a `rate_per_minute` field (defensive; not confirmed present on every real response) — a live "₹X earned so far" line computed client-side from elapsed time × rate. **The mockup's "Waiting" and "Chat" tabs showing 5+ simultaneous customers in a ranked queue don't match this backend**: `POST /astrologer/consultation/accept` marks the astrologer busy, so only one `ACCEPTED`/`ONGOING` consultation can exist at a time (confirmed by the handbook's own wording, not guessed) — a literal multi-concurrent-session queue UI would have nothing real to bind to. Don't build that part of the mockup without a corresponding backend change; the visual language (dark/gold, avatar rings, big mono timers) was reused, the fake concurrency model was not.
- **A second pass restyled the `ONGOING` phase specifically to match a supplied desktop mockup** (breadcrumb → title/subtitle → two-column `consult-layout`: chat/call in the main column, a "Consultation Details" card + a customer card + a static "Important Information" tips box in a 320px right column). Everything in the right column is either already-known real state (medium/rate/live duration/live earned, the same customer name cached at accept time) or clearly-static copy — nothing new was invented. One thing from the mockup was deliberately **not** built, matching this codebase's "honest empty state over fake wire-up" rule: the mockup's "View Profile" link (no per-customer profile page exists to link to). **Update: real image/voice attachments were added in a later pass — see "Image/voice attachments (Agora Chat)" below** — the `messages/sync` API itself is still text-only (max 4000 chars), but attachments no longer ride that API at all, they use a second, separate Agora connection. The mockup's quick-reply chips (`QUICK_REPLIES` in the page, e.g. "Please share your birth details") **are** built as real, fully local canned phrases — clicking one appends it to the composer rather than auto-sending, so a misclick can't send on the astrologer's behalf; there's no API behind them, they're just starter text. Per-message avatars: the other party gets an initials circle, the astrologer's own messages get a generic gray person-icon circle (no astrologer self-photo is fetched here). Verified live end-to-end (real accept → real start-chat → quick-reply click → real send → visible in the message thread with the correct customer name and live earnings) — not just built to spec, actually exercised against production.
- **A third pass restyled the `ACCEPTED` ("waiting for the customer to join") phase to match a supplied "Connecting…" mockup** — an animated orbiting-dots ring (`consult-orbit`/`consult-orbit-ring`, pure CSS `@keyframes` rotation, no JS) around the avatar, a medium badge (💬/📞), a tinted info note, and a **Cancel** button. That button is real, not decorative: it reuses the same `showEndConfirm`/`handleEnd` flow already built for ending an `ONGOING` session, just with `phase === "ACCEPTED"`-conditional copy ("Cancel this request?" / "Cancel request", vs. "End chat?"/"End call?" for `ONGOING`) — there's no documented "un-accept" endpoint distinct from `/astrologer/consultation/end`, and reusing the existing one was the honest option rather than adding a button with nothing behind it. Verified read-only against a real live `ACCEPTED` session (a customer's own in-progress test, not one this session created) rather than risk cancelling someone else's active request just to screenshot it — the "?" avatar in that check is expected, not a bug: the name cache is per-tab sessionStorage, and that session was accepted from a different browser.
- **A fourth pass rebuilt the `ONGOING` + `AUDIO` call screen to match a supplied mobile "Call connected" mockup**, replacing the old bare Mute-button layout: the generic chat header card is hidden for calls (there's nothing to show there that the call screen itself doesn't already show bigger), and the call screen itself gets the avatar ring, an uppercase "CALL CONNECTED" pill with a phone icon, name, medium/rate line, the big green timer, live earnings, a 4-button row, and a full-width red "End call" bar. **Only Mute is real** (unchanged, wired to `toggleMic`) — **Speaker, Hold and Remedies are rendered `disabled` with an explanatory `title` tooltip**, not wired to anything: there's no browser API for toggling a call's output device the way a phone's speaker/earpiece switch does, no hold/resume endpoint documented anywhere, and no "remedies" feature exists in this app at all. This matches the same disabled-with-tooltip treatment already used for the Orders hub's Video pill — the visual layout matches the mockup, the fake capabilities don't. Not live-tested end-to-end (the only test wallet has ₹0 and audio calls, unlike the free-chat offer, have no free tier — `precheck` correctly blocked with `INSUFFICIENT_BALANCE`); verified via type-check/build only, reusing state (`userName`/`ratePerMinute`/`elapsedSeconds`/`earnedSoFar`/`muted`) and the `joinAgora`/RTC-join logic that's already proven live for the CHAT case above.
- **The customer-side `app/consultation/[id]/page.tsx` got the matching AUDIO call screen** (same `consult-*` CSS classes as the astrologer page above, reused as-is — they were never scoped to the astrologer dashboard shell). Confirmed live: the customer's own `/active`/`start-chat` responses (unlike the astrologer's) **do** carry `rate_per_minute` and `astrologer_image`, so those render for real here (no defensive-hide needed the way the astrologer side has to guard for a possibly-absent `rate_per_minute`). Same honest disabled-Speaker/Hold treatment as the astrologer side; there's no third "Remedies" button on the customer side since the supplied mockup for this page only showed Mute/Speaker/Hold.
  - **Fixed a real mislabeling bug found while doing this**: the shared chat/call header was rendering `₹{currentCharge}/min`, but `current_charge` (from `tickConsultation()`) is the *cumulative* amount charged so far, not a rate — confirmed against the handbook's own tick sample (`current_charge: 0` early in a session that's clearly not free). A `ratePerMinute` state was added, populated from `data.rate_per_minute` at both ONGOING entry points (`getActiveConsultation()` on resume and `startChatConsultation()`'s own response), and the header now shows the real rate while `currentCharge` is only ever used for "amount spent" framing (`₹{currentCharge} spent so far` on the new call screen).
  - **The `COMPLETED` receipt was rebuilt into two cards** ("{Chat/Call} Details": date/start time/end time/duration; "Charges Details": rate/total duration/total charge/wallet deducted) matching a supplied "Call Completed" mockup — `startedAt` (captured at the same two ONGOING entry points) supplies Start Time since the end receipt itself has no `started_at`, only `ended_at`; Date is derived from whichever of `startedAt`/`receipt.ended_at` is available. Still hides `platform_fee`/`astrologer_earning` and shows "Free — ₹0" for a free session, per the existing rule. Verified live end-to-end (real request → accept → start-chat → end): the receipt rendered with the real date/times/duration/rate, and the pre-existing star-rating review modal (unrelated, built earlier) still layers correctly on top of it, matching the mockup's own two-screen sequence (review first, receipt underneath).
  - **`remainingSeconds` only refreshes from the server every `TICK_POLL_MS` (30s)** — the call screen's big timer was reading straight from that state with no local countdown, so it visibly jumped in 30-second steps instead of ticking every second. Fixed with the same local-`setInterval` pattern already used for `ringSecondsLeft`/`joinSecondsLeft` (REQUESTED/ACCEPTED phases): a `phase === "ONGOING"`-gated 1-second interval decrements `remainingSeconds` locally, and the next real `runTick()` response re-syncs it to the authoritative server value. Apply the same pattern to any other "live" numeric field here that's only updated by a slow poll.
  - **A generic "End consultation" button (`.chat-end-wrapper`/`.chat-end-button`) sat below the medium-specific content and rendered unconditionally** — harmless for CHAT (it's the only end control there) but a real duplicate for AUDIO once the dedicated call screen got its own `.consult-end-call-btn` inside the card. Fixed by gating the generic one with `{medium !== "AUDIO" && (...)}`. If a third medium is ever added with its own embedded end button, gate this one against that medium too rather than assuming CHAT is the only exception.
- **Agora RTM error -10027** ("user ID already in use by another active RTM instance with the same app ID") fires reliably when resuming a session after a hard refresh, a crashed tab, or a reload mid-call — there's no reliable way to run an async `client.logout()` during page unload, so the previous instance is still considered "active" by Agora for a short window afterwards. `createAndLoginRtm()` in `app/lib/agora.ts` retries login with a 2.5s backoff (4 attempts) specifically on code `-10027`, and tracks the tab's one active RTM client (`activeRtmClient`) so a fast remount logs the old local instance out before creating a new one instead of leaving two live clients for the same account. Confirmed live on both the astrologer and customer sides for the same consultation.
- **The astrologer dashboard's own "Waiting now" panel now feeds into this same session page** (see the dashboard bullet under Astrologer App above) — accepting from either it or `app/astrologer/requests/page.tsx` lands here identically.
- **Post-consultation review**: once `app/consultation/[id]/page.tsx` reaches `COMPLETED`, it shows a star-rating + tag-picker + free-text modal (`showReview`/`reviewRating`/`selectedReviewTags`/`reviewText` state), submitting via `app/api/user/review/add/route.ts` → `POST /user/userReview/add` with `astrologerId`/`rating`/`review`/`isPublic`. There is no equivalent flow on the astrologer side.
- **A mid-session refresh used to lose the whole chat thread** on both sides — `messages` state only ever grew from live RTM events + locally-sent ones, so remounting on an already-`ONGOING` session (a real refresh, not just resuming from REQUESTED/ACCEPTED) always started from an empty array, even though the transcript was already durably persisted server-side via `messages/sync`. Fixed by reloading the durable transcript on resume:
  - **Customer side** (`app/consultation/[id]/page.tsx`): the existing `getConsultationMessages()` (`GET /user/consultation/messages`, already used by `consultation-history/[id]`) is now also called — via a new `loadPriorMessages()` — from the mount effect's ONGOING-resume branch (`getActiveConsultation()` returning `status: "ONGOING"`), merging results ahead of whatever's already in state (deduped by `clientMessageId`) rather than replacing it, so it's safe even if an RTM message already arrived before the fetch resolves.
  - **Astrologer side had no equivalent endpoint wired up at all** — confirmed live that `GET /astrologer/consultation/messages?consultation_id=&page=&limit=` genuinely mirrors the customer one exactly (same `{consultation_id, consultation_no, items, page, limit, total, total_pages}` shape), so `getAstrologerConsultationMessages()` (`app/lib/astrologer.ts`) + `app/api/astrologer/consultation/messages/route.ts` were added to match, and `app/astrologer/consultation/[id]/page.tsx`'s own `loadPriorMessages()` is called once per mount from the poll loop's ONGOING-detected branch (guarded by the same `joinedAgoraRef` flag that already gates the one-time `joinAgora()` call, so it isn't refetched on every 4s poll tick).
  - Verified live end-to-end (real consultation, real accept/start-chat, a message sent from each side, `messages/sync`'s 30s flush confirmed via direct API polling, then a real page reload on both `/consultation/[id]` and `/astrologer/consultation/[id]`): the previously-synced message survived the refresh on both sides instead of showing an empty thread.
  - **Found and fixed a related, more serious bug while verifying this live**: the astrologer page's poll loop, on detecting the session is no longer active (`!hasActive`, the branch that sets `phase: "ENDED"`), never called `flushMessages(true)` first — only the astrologer's own manual `handleEnd()` did. So if the *customer's* side ended the session (timeout, insufficient balance, a manual end on their end) while the astrologer had an unflushed message still batched in `pendingSyncRef` (the 30s interval hadn't ticked yet, and fewer than 25 messages were queued), that message was silently lost forever — confirmed live: sent an astrologer message, ended the session from the customer side within ~10s, and the message never appeared in `GET /astrologer/consultation/messages` until `flushMessages(true)` was added to that branch (mirroring the customer page's own `finishSession()`, which already flushed on both its manual and auto-detected end paths) — re-verified live afterward, same scenario, message persisted.
  - **A second, more common trigger for "chat resets on refresh" remained even after `loadPriorMessages()` above**: it can only restore what the server already has, and `messages/sync` only fires every 30s or every 25 messages — a message sent seconds before a refresh (the realistic "I just started chatting and reloaded" case a user actually hit) was still purely in-memory (`pendingSyncRef`) and never reached the server at all, so there was nothing for `loadPriorMessages()` to find. Fixed on both `app/consultation/[id]/page.tsx` and `app/astrologer/consultation/[id]/page.tsx` with a `pagehide`/`beforeunload` listener (registered while `phase === "ONGOING"`) that flushes any still-batched messages via `flushMessages(false, /* keepalive */ true)`. `syncConsultationMessages()` (`app/lib/consultation.ts`) now takes a 4th `keepalive` param forwarded straight into the `fetch` call's `RequestInit` — `keepalive: true` is what lets that request actually finish sending after the tab is already gone (a plain fetch gets aborted mid-flight on unload; `navigator.sendBeacon` can't carry the `Authorization` header this proxy requires, so keepalive-fetch is the only option that works here). Confirmed live: sent a message and refreshed within ~1 second (nowhere near the 30s timer), and the message was already durably persisted by the time the reloaded page re-fetched the transcript.
- **The astrologer page's own Duration/Earned-Amount timer (`elapsedSeconds`) also restarted from 00:00/₹0 on every refresh, for both CHAT and AUDIO** — separate from the message-loss bugs above, but reported the same way ("if call started, refresh restarts the timer from 0") and found while re-verifying those live. Its ticking `useEffect` unconditionally called `setElapsedSeconds(0)` the moment `phase` became `"ONGOING"`, which fires identically on a genuinely fresh accept *and* on resuming an already-minutes-long session after a reload — there was never a read of the real duration anywhere. Fixed by seeding `elapsedSeconds` from `GET /astrologer/consultation/active`'s real `elapsed_seconds` field (confirmed live present on the ONGOING shape — see the captured example earlier in this section) in the poll loop's ONGOING-detected branch, and removing the `setElapsedSeconds(0)` reset from the ticking effect so it continues forward from that seeded value instead of always restarting at 0. The customer-side equivalent (`remainingSeconds`, which counts *down*) never had this bug — it was already being seeded from `data.remaining_seconds` on resume. Verified live end-to-end (real chat session, messages sent from both sides, ~80s of real elapsed time, then a real refresh of the astrologer page): Duration read 01:27 and Earned Amount read ₹36 immediately after reload, continuing forward from the real value instead of resetting — the two chat messages survived the same refresh too, confirming all three fixes in this section work together correctly. AUDIO itself wasn't live-tested (the only test wallet still has ₹0 and audio calls have no free tier, same limitation noted elsewhere in this file), but the astrologer's call-screen timer reads the exact same `elapsedSeconds` state as the chat header, so the fix applies identically to both mediums.
- **The real root cause of "astrologer's own chat messages disappear on refresh" (as opposed to the customer's, which were already fine) was an auth bug, not a display bug — `app/astrologer/consultation/[id]/page.tsx` was durably syncing its own outgoing messages through the *customer*-side `syncConsultationMessages()` (`app/lib/consultation.ts`), which authenticates via `getAuthToken()` reading the end-user `token`/`authToken`/`access_token` localStorage keys — never `astrologerToken`.** This silently "worked" throughout this session's own testing only because the same browser tab happened to have both the customer's `token` and the astrologer's `astrologerToken` sitting in localStorage at once (both apps share one origin) — a real astrologer on their own separate device/browser has no end-user `token` key at all, so every one of their sync calls quietly failed with `"Please login first."` and their messages were never actually saved server-side, even though the RTM-delivered copy still rendered locally until the page reloaded. Confirmed exactly this way by a real user report with screenshots: after a refresh, the customer's two messages were still there but the astrologer's own reply was gone.
  - **Confirmed live that a real, separate `POST /astrologer/consultation/messages/sync` endpoint exists** and mirrors the customer one's request shape (`{consultation_id, is_final, messages}` — probed with an empty `messages` array and got back the same "min" validation error the customer endpoint gives, not a 404/401, proving the route is real). Fixed by adding `app/api/astrologer/consultation/messages/sync/route.ts` (proxy) and `syncAstrologerConsultationMessages()` (`app/lib/astrologer.ts`, takes the astrologer's own `token` explicitly like `getAstrologerConsultationMessages` already does), then switching `app/astrologer/consultation/[id]/page.tsx`'s `flushMessages()` to call it with `tokenRef.current` (the astrologer's token, already tracked in that component) instead of the customer-side function.
  - **`syncAstrologerConsultationMessages()` is a raw `fetch()`, not the `api.post()` axios instance every other function in `app/lib/astrologer.ts` uses** — axios 1.x's default browser (XHR) adapter has no `keepalive` concept, so a `keepalive` field passed into an axios request config is silently dropped rather than forwarded to the transport. Since this function needs to support the same `pagehide`/`beforeunload` keepalive-flush pattern as the customer side (see above), it has to bypass axios and call `fetch(..., {keepalive})` directly — don't "clean this up" to match the rest of the file's axios convention, it would silently break the unload-flush.
  - **Verified live with a browser session that had *only* `astrologerToken` in localStorage** (explicitly cleared `token` first, to actually reproduce a real separate-device astrologer session rather than this session's own convenient-but-unrealistic shared-tab setup): sent a message from the astrologer, refreshed, and the message was still there — then independently confirmed via a direct `GET /astrologer/consultation/messages` call that it was genuinely durably persisted server-side with `sender_type: "ASTROLOGER"`, not just surviving in local React state.
- **Consultation history (`/consultation-history`, `/consultation-history/[id]`) was removed from the customer side per explicit user request** — the pages, the `Header.tsx` dropdown link, `getConsultationHistory()`/`ConsultationHistoryItem` in `app/lib/consultation.ts`, and its exclusive proxy `app/api/user/consultation/history/route.ts` are all gone. **Don't rebuild this without being asked again.**
  - `getConsultationMessages()` in `app/lib/consultation.ts` and its proxy `app/api/user/consultation/messages/route.ts` were deliberately **kept** — despite living in the same file/section as the removed history feature, `getConsultationMessages()` is also what `app/consultation/[id]/page.tsx`'s `loadPriorMessages()` uses to restore the chat transcript when resuming an already-`ONGOING` session after a refresh (see the chat-persistence fixes earlier in this section) — removing it would have silently reintroduced that bug. If consultation history's removal is ever revisited, re-check this dependency before touching `getConsultationMessages()` again.
  - `/user/consultation/history` itself is still a real, working POST endpoint on the backend (confirmed live earlier — a GET 404s, POST with an empty body and the params in the query string returns real data) — it's just not called from this app anymore. Nothing was deleted on the backend side, only this frontend's integration of it.

#### Astrologer list pages (`/chat-with-astrologer`, `/talk-to-astrologer`)

`app/components/{dashboard,talk-to-astrologer}/Dashboard.tsx` (near-duplicates, as are their `AstrologerList`/`AstrologerCard`) re-fetch `getAstrologers()` every 60s (and on tab re-focus, skipped while hidden) so a newly-online astrologer appears without a manual refresh; the refresh is silent and keeps the current list on failure. `AstrologerCard`'s live precheck override (`liveStatusEntry`) is tagged with the list's `isOnline|isBusy` at the time it was observed and ignored once the list reports something different — without that, a card that once precheck-blocked as Offline would stay "Offline" forever after the astrologer came back. `AstrologerList`'s filter chips are built from the astrologers' own comma-separated `allSkill` (most common first; the old chips read a `category` field the API never returns, so only "All" ever showed), and search matches name, `primarySkill`/`allSkill` and `languageKnown`.

#### Waiting queue (busy astrologer) — customer + astrologer sides

Per the 3 Oct 2026 API change. A busy (`ASTROLOGER_BUSY`) or queue-reserved (`QUEUE_RESERVED`) astrologer no longer dead-ends: the customer joins a FIFO queue. Verified only against a mocked `queue/status`/`connect`/`start` (no real test token with a free account was available, and ringing a real astrologer to test was deliberately avoided) — the proxy routes were smoke-tested against the real backend (they reach it and return its auth error), but real response shapes for the astrologer waitlist were NOT confirmed live; `normalizeWaitlistEntry()` in `app/lib/astrologer.ts` reads row fields defensively for that reason.

- **Customer lib/proxies**: `app/api/user/consultation/queue/{join,status,connect,cancel}/route.ts` + `joinConsultationQueue`/`getConsultationQueueStatus`/`connectConsultationQueue`/`cancelConsultationQueue`/`extractQueueEntry` in `app/lib/consultation.ts` (status returns `{list:[…]}`, the other three return the entry directly — `extractQueueEntry` handles both). Statuses WAITING/NOTIFIED/CONNECTING hold a place; everything else is final. Drive UI off `next_action`, never guess from `status`.
- **Entry point**: `StartConsultationModal` no longer closes on `ASTROLOGER_BUSY`; it switches into `queueMode` (same consultee form, "Join Queue" button, `QUEUE_RESERVED` handled identically). Offline still closes silently. The three astrologer surfaces' busy button is now an enabled red-outline "Join Queue" (profile page: separate "Join Chat Queue"/"Join Call Queue" because the queue is per-medium). The consultee form is collected once at join and cached per queue id (`cacheQueueConsulteeDetails`) because the turn can arrive minutes later; the queue page falls back to the profile prefill when deep-linked from a push on a fresh tab.
- **`app/waiting-queue/[id]/page.tsx`** polls `queue/status?queue_id=` every `poll_interval_seconds` (5s), counts down locally from the server's `seconds_remaining` (never device-clock math). WAITING → position + Leave; NOTIFIED → Connect (one tap: `queue/connect`, then the page auto-calls the existing `/consultation/start` while `next_action === "START_CONSULTATION"`); once the entry carries a `consultation_id` (CONNECTING/CONNECTED) it `router.replace`s to `/consultation/[id]` — the normal ring → accept → confirm flow. `startFailedRef` stops the 5s poll from re-firing a failed `/start` forever (only "Try again" retries). Leaving a request that is already ringing must go through `/consultation/cancel` (the queue cancel refuses with "already in progress"), which the page does.
- **Push**: `QUEUE_TURN` / `QUEUE_EXPIRED` handled in both `public/firebase-messaging-sw.js` and `PushNotificationManager.tsx`. Note the backend's `data.screen` for these is a logical name (`"waiting_queue"`, `"astrologer_profile"`), not a path — `resolveNotificationUrl` now only honors `data.screen` when it starts with `/`, otherwise falls to the `type` table (`QUEUE_TURN` → `/waiting-queue/<queue_id>`; `QUEUE_EXPIRED` → `/chat-with-astrologer`, since the payload has no slug for the profile page).
- **Astrologer side**: Orders hub has a new **Queue** tab (`app/astrologer/requests/page.tsx`) listing `GET /astrologer/activity/waitlist` (proxy `app/api/astrologer/activity/waitlist/route.ts`) with a Remove button → `DELETE …/waitlist/:id` (`[id]/route.ts`; marks the entry REJECTED/REMOVED_BY_ASTROLOGER and hands the turn on). It is a different thing from the existing "Waiting" tab (the single accepted session waiting for its customer). `POST …/waitlist/add` has no UI (needs a customer id) and no proxy. Online/busy status semantics (login → ONLINE, accept → BUSY, end → ONLINE; `is_available` 0/1/2) are entirely backend-side — no frontend change was needed; the customer list's `isBusy` already distinguishes the states.

- **Live-QA'd on 3 Oct 2026 against the real backend (customer 213 + astrologer id 42)**: join → hand-over on session end → NOTIFIED → Connect → auto `/start` → ring → accept → ONGOING (call), astrologer Queue tab + Remove (final state shown on the customer page), Leave queue, 409 duplicate, 400 "available now" all behave as documented. Real shapes: queue entries match the doc exactly; the astrologer waitlist row is `{queueId, userId, name, gender, profileImage, waitingType, queuePosition, status, waitingSince}` inside `data.list` + `data.pagination` — the delete id is `queueId`. The unfiltered `queue/status` list omits CONNECTED. The backend lets a customer join an astrologer's queue **while that same customer's own session is ongoing**, but `precheck` then reports `SESSION_ALREADY_OPEN` (not busy), so the modal deliberately does not offer the queue in that case. Customers with an open session are redirected off the list pages to `/consultation/[id]`.
- **Backend bug found (not fixable here): a session created without a birth date can never be ended.** `/consultation/end` (customer and astrologer) and the sweeper all fail with raw `Error 1048: Column 'birthDate' cannot be null`; the session stays ONGOING (billing), the astrologer stays BUSY, and `GET /user/consultation/active` returns HTTP 500 for that customer until the row is repaired in the DB. Frontend mitigation: `StartConsultationModal` now requires a date of birth before ringing or joining the queue, and the queue page refuses to place its auto-`/start` without one. Don't remove these guards without the backend fix.

#### Image/voice attachments (Agora Chat) — a second, separate Agora connection from RTM/RTC

The real messaging API (`messages/sync`, RTM) is text-only — image and voice-message attachments ride **Agora Chat** instead, a completely separate Agora product from the RTC (audio)/RTM (text control-plane) already used everywhere else in this file. Confirmed live before building any of this: Agora Chat shares the **same unified App ID** as RTC/RTM (`NEXT_PUBLIC_AGORA_APP_ID`) — there's no second "Chat App Key" despite older Agora docs implying one; what IS genuinely separate is the App Certificate flow for minting Chat tokens.

- **The Go backend does not mint Agora Chat tokens** (it only mints RTC/RTM AccessToken2 tokens, at `/consultation/start-chat` etc.) — so, as a deliberate, narrow exception to "secrets live in the Go backend, never here," this repo mints Chat tokens itself, server-side only. `AGORA_APP_CERTIFICATE` (never `NEXT_PUBLIC_`-prefixed) plus `AGORA_CHAT_REST_HOST`/`AGORA_CHAT_ORG_NAME`/`AGORA_CHAT_APP_NAME` live in `.env.local` — the REST host/org/app values aren't shown anywhere in Agora's console UI; they were only discoverable from a real 401 error response's embedded request URL (`app/lib/agoraChatServer.ts` has the full story in its own comments).
- **`app/lib/agoraChatServer.ts`** (server-only, guarded by the `server-only` package so a stray client import fails the build) wraps the official `agora-token` npm package's `ChatTokenBuilder` plus a REST "create user" call. **Confirmed live: this Agora project has Open Registration turned off**, so a Chat username must be provisioned via REST (`ensureChatUserExists()`, idempotent — tolerates "already exists") before it can ever log in; skipping this gets a real `{"type":204,"message":"User not found"}` on login.
- **`app/api/agora/chat-token/route.ts`** mints a token for the caller, but — critically — **Chat usernames are scoped to the consultation, not the caller's real account id**: `cs-<consultationId>-user` / `cs-<consultationId>-astro`. This is deliberate, not arbitrary: `GET /astrologer/consultation/active` has no numeric customer-id field anywhere (confirmed — see the astrologer-side quirks above), so the astrologer side has no way to construct the customer's identity from a real account id even if it wanted to; the consultation id is the only identifier both sides can independently derive. The route verifies the caller actually owns that specific consultation by independently checking the real `/user/consultation/active` or `/astrologer/consultation/active` endpoint and comparing `consultation_id` — never trusts a client-supplied id on its own, since a Chat "user token" grants broad access to that whole Chat identity, not a single scoped channel the way an RTC token does.
- **`app/lib/agora.ts`** gained `loginAgoraChat()`/`logoutAgoraChat()`/`sendAgoraChatImage()`/`sendAgoraChatAudio()`/`onAgoraChatAttachment()`/`loadAgoraChatAttachmentHistory()`, following the file's existing lazy-`import()` convention (same `window is not defined` SSR risk as RTC/RTM above). `loginAgoraChat()` retries login a few times with backoff — confirmed live, a login attempt shortly after a previous session's disconnect can fail outright with a transient DNS/timeout error (`"get DNS failed"`, type 304), a different failure mode from RTM's `-10027` conflict but the same underlying "reconnecting too soon" root cause, and with no retry it permanently disabled attachments for the rest of that session.
- **A mid-session refresh used to silently drop every image/voice message sent before the refresh** — same category of bug as the RTM text-persistence fixes above, but Agora Chat has its own, completely separate durable history (`connection.getHistoryMessages()`), untouched by any of the `messages/sync`-based fixes. `loadAgoraChatAttachmentHistory()` is called once right after Chat login succeeds (both a fresh accept and an ONGOING resume) on both pages, merges by `clientMessageId`, and derives `senderType` by comparing each history item's `from` against the caller's own Chat username (`cs-<id>-user`/`cs-<id>-astro`) — confirmed live end-to-end by sending from both sides, then independently verifying via a raw SDK call that the data was genuinely durable in Agora's own storage the whole time the UI appeared to have "lost" it.
- **Real on-device voice recording** uses the browser's `MediaRecorder` API (separate feature from "voice typing" below — this one sends an actual audio file as a message). Composer UI: tap the mic to start/show a live "Recording... 0:04" bar, tap Send to upload+send via `sendAgoraChatImage`/`sendAgoraChatAudio` (the SDK handles the upload to Agora's own hosted storage itself — no separate upload step, confirmed live), tap Cancel to discard.
- **Image/voice message bubbles drop the normal colored chat-bubble background/padding** (`.chat-message-media` on the customer page, `.consult-msg-bubble.media` on the astrologer page) — the image/audio player is the message itself, not wrapped in a yellow/gray box like text, matching how real chat apps render media messages.
- **The "Attach file" (generic document) tile in both pages' attachment sheets was removed, not wired up** — only Photo and voice recording are real; generic file attachments were never requested and have no Agora Chat message type built for them here (Agora Chat does support a generic `type: "file"` message, same mechanism as image/audio, if this is ever asked for).

#### Voice typing (speech-to-text dictation) — unrelated to the attachment above, purely client-side

**`app/lib/useVoiceTyping.ts`** is a separate feature from the real voice-message recording above — it transcribes speech directly into the composer text box (editable before sending), never sends an audio file, and never leaves the browser. Built on the standard Web Speech API (`SpeechRecognition`/`webkitSpeechRecognition`) — no backend, no Agora. Genuinely unsupported in some browsers (confirmed: no Firefox support), so the composer's 🎙️ button checks `isSupported` and shows an honest error rather than silently doing nothing. Shared between both pages via this one hook since the logic has no page-specific coupling.

#### Kundli-in-chat (`app/components/consultation/KundliModal.tsx`)

The astrologer's chat header (and, since a later pass, the AUDIO call screen too) has a "🔯 Kundli" button that opens a real kundli for the current customer, reusing the same tab UI extracted from the Free Kundli detail page (`app/components/kundli/*`). **Confirmed live: `POST /user/kundali/add` accepts the astrologer's own `astrologerToken`, not just the end-user `token`** — there's no astrologer-specific kundli endpoint in the Postman collection, and this was the only way to make it work from the astrologer side. The birth details it's generated from come from the customer's own real chat message (an auto-sent "My details: ..." message at chat start, parsed back out via `parseAutoDetailsMessage()`) — not from any API field, since the backend never exposes a customer's birth details to the astrologer through any other response.

### Astrologer public profile (`/best-astrologer/[slug]`) — follow/unfollow + reviews

A separate, end-user-facing "view one astrologer's public profile" page (distinct from the Astrologer App's own dashboard/profile-edit pages above) — `AstrologerProfileClient.tsx` (~1500 lines) renders it and also owns the Chat/Call entry point for that one astrologer (reuses `StartConsultationModal`, see above).

- **No backend endpoint resolves a profile by slug.** `fetchAstrologer()` fetches the *entire* astrologer list (`app/api/astrologers/list` → `POST /user/astrologers/list`), client-side slugifies every `name` (`createSlug()`) to find the one matching the URL's `[slug]`, then makes a second call — `app/api/astrologers/profile` → `POST /user/astrologers/getAstrologerById` with that numeric id — to get the richer profile actually rendered on the page (the list response alone isn't detailed enough). Reviews are a third, separate call: `app/api/astrologers/reviews` → `POST /user/getAstrologerUserReview`. Don't assume a slug can be resolved server-side or in one round trip when extending this page.
- **Follow/unfollow**: `app/api/astrologers/follow/route.ts` → `POST /user/follower/add`; `app/api/astrologers/unfollow/route.ts` → `POST /user/follower/update` — **not** a `/remove` or `/delete` endpoint, despite the "unfollow" name (another instance of the "verify the actual proxied URL, not the name" rule described above for the Postman collection). Both flip `astrologer.isFollowing` optimistically on success rather than refetching the profile.

### In-app notifications (bell + list, both apps)

A separate feature from FCM push (below): a persistent, readable notification history served by the backend, added after the consultation rebuild. Seven endpoints are mounted **identically** on `/user/notifications/**` and `/astrologer/notifications/**` — one client (`app/lib/notifications.ts`) serves both, parameterized by an `audience: "user" | "astrologer"` that only changes which base path and which localStorage token get used: `GET /notifications?page=&limit=&status=&search=`, `GET /notifications/count`, `POST /notifications/seen`, `POST /notifications/read` (`{ids}` or `{all:true}`), `POST /notifications/delete` (`{ids}`), `DELETE /notifications/:id`, `POST /notifications/clear`. Envelope is `{status, message, data}` (boolean `status`), matching the astrologer-family convention.

- **`is_read`/`is_seen` vs `is_new` are three distinct things, not two names for one** — `is_read`/`is_seen` means the person opened *that* notification (`counts.unseen` is the badge count); `is_new` means it arrived since the screen was last opened server-side (`users.notifications_seen_at`; `counts.new` is the tab dot). `POST /notifications/seen` clears the dot **without** marking anything read — entries still render unread until individually tapped. Don't collapse these into one "read" concept without checking with backend first, since the count fields are computed independently.
- **`app/components/notifications/NotificationBell.tsx`** is the one shared bell + preview-dropdown component for both apps (audience/token/`notificationsHref`/`dark` props) — mounted in `Header.tsx`'s profile-icon cluster for the customer app. **It was originally placed in `DashboardSidebar.tsx`'s brand row** (next to the logo) but moved per user feedback — that spot was cramped (the sidebar column is only 220px wide). On the astrologer side it's now wired into each dashboard-family page's *own* topbar individually, since there's no shared topbar component (each page hand-rolls its own `astrologer-dashboard-topbar`/`astrologer-dashboard-actions` markup, or in Wallet's case its own `wallet-page-header`):
  - `app/astrologer/dashboard/page.tsx` — replaced a static non-functional 🔔 placeholder that already lived in its `astrologer-dashboard-actions` row.
  - `app/astrologer/requests/page.tsx` and `app/astrologer/profile/page.tsx` — their topbar previously had no actions row at all; one was added (`.astrologer-dashboard-topbar` is `display:flex; justify-content:space-between`, so a second child lands on the right automatically).
  - `app/astrologer/wallet/page.tsx` — this page is the documented outlier (own `wallet-page-header`, inline `<style jsx>` rather than a `.css` file); the bell was added as a sibling inside that header, with `display:flex; justify-content:space-between` added to `.wallet-page-header` in its own `<style jsx>` block.
  - The astrologer session pages (`app/astrologer/consultation/[id]/page.tsx`) still have no bell — their topbar is just a bare title, no icon row exists there yet.
  
  Each of these pages independently tracks its own `astrologerToken` state (set once in the page's existing auth-check `useEffect`, alongside the redirect-to-signin-if-missing check) purely to hand to this component — there's no shared "current astrologer token" context in this app, matching the established per-page direct-localStorage convention (see Auth below). It self-polls `/notifications/count` every 20s for the badge, and calls `/notifications/seen` (clearing the dot, not marking read) whenever the preview panel is opened.
- **`app/notifications/page.tsx`** (customer) and **`app/astrologer/notifications/page.tsx`** (astrologer, new) are the full paginated list pages — mark-one-read on click, "mark all as read", per-item remove, "clear all", and a checkbox multi-select + "Delete selected" bar (wired to `deleteNotifications()`, which the API already supported but nothing called until this pass). The customer page previously had hardcoded fake placeholder notifications; that's now replaced with real data end-to-end.
- **A chat/call *request* notification on the astrologer's list/dropdown renders as an interactive Accept/Reject card, not a plain row** — but this is deliberately **not** keyed off `notification_type`, since the exact numeric code for "new request" was never confirmed (see the Push notifications section's own numeric-code table, still marked unconfirmed). `app/astrologer/notifications/page.tsx` and `NotificationBell.tsx`'s compact dropdown (gated on `audience === "astrologer"`) both separately poll the same `getConsultationRequests()` ring queue the Orders hub uses (every 4s, and only while the dropdown is actually open for the bell — it's mounted once per astrologer page, so polling unconditionally would mean up to four redundant pollers running at once) and pass each notification to **`matchLiveConsultationRequest()`** (`app/lib/notifications.ts`) to find its still-open request, if any.
  - **That matcher tries `chat_request_id`/`call_request_id` against the live queue's `consultation_id`s first, but confirmed live that id alone doesn't reliably match** — a real "New chat request" notification for a customer with an actually-open request in the "Waiting now" panel still fell through to the plain row. It falls back to matching the request's `consultee_name`/`user_name` against the notification's own title+description text instead, since the backend embeds the customer's name verbatim there (e.g. "sohan togwe wants to start a chat with you") — this doesn't depend on the unconfirmed id field at all, only on a `REQUEST_NOTIFICATION_HINT` regex catching request-shaped copy. A `claimedIds` set (rebuilt fresh every render) stops one still-open request from being attached to more than one historical notification about the same customer, since the list can show several old "New chat request" rows for one person before only the newest is still actually live.
  - A match renders the real `waiting-request-card` UI (avatar, consultee birth details, live `seconds_to_expiry` countdown, Accept/Reject) with buttons calling `acceptConsultation()`/`rejectConsultation()` directly — Accept navigates to `/astrologer/consultation/[id]` on success, Reject actually rejects (not a local dismiss). No match (already resolved elsewhere, or a non-request notification) falls back to the plain read-only row.

### Push notifications (Firebase Cloud Messaging)

Both account types get web push via FCM. The Go backend triggers pushes server-side (Firebase Admin SDK) for chat/call requests, wallet credits, and astrologer approve/reject — there is no REST endpoint to "send" a notification from this app; the frontend's entire job is obtaining a token, registering it with the backend, and displaying incoming pushes.

- **`app/lib/firebase.ts`** already initializes the Firebase app singleton for `firebase/auth` (social login) — its `getFcmMessaging()` export (SSR-guarded, `isSupported()`-checked) is the shared entry point for messaging; don't create a second Firebase app instance.
- **`app/lib/pushNotifications.ts`**'s `requestFcmToken()` is account-type-agnostic and never throws (permission denied/unsupported browser both resolve to `null`) — it must never be allowed to break the login/signup flow that calls it.
- **Astrologer side is fully wired** (real backend endpoint exists): `updateAstrologerDeviceToken()` in `app/lib/astrologer.ts` POSTs to `/astrologer/device-token`, called fire-and-forget right after a real session token exists in both `app/astrologer/signin/page.tsx` and `app/astrologer/verify-email/page.tsx`. Confirmed live against the real backend — returns `{"message":"Device token updated successfully.","status":true}`.
- **End-user side is now also fully wired, but via a different mechanism than the astrologer side** — there's no standalone `/user/device-token`-style endpoint; instead, `POST /user/register`, `POST /user/verify-mobile-otp`, and (critically) `POST /user/verify-login-otp` **all** accept a `device_token` field directly in their existing body (confirmed live against real Postman requests/responses). This means every login re-sends a fresh token via `OtpForm.tsx`'s `verifyLoginOtp` call — an initial assumption that this was an unresolved gap (only fixable at registration time) was wrong and has been corrected; don't reintroduce that assumption. `RegisterForm.tsx` sends `device_name`/`ip_address`/`platform`/`device_token` (no `device_type` field here, unlike the astrologer side's `/register`/`/verify-login-otp`); `app/verify-mobile/page.tsx`'s `verifyMobileOtp()` call also passes one through (with `device_type`, since that field IS present on that specific endpoint). The **social-login** path (`AuthLanding.tsx` → `socialWebLogin()` → `/api/auth/social-login`) is a distinct endpoint whose device-token support is still unconfirmed — it only requests permission for UX consistency, not wired into that payload yet.
- **`public/firebase-messaging-sw.js`** is a static file (not a Next.js route) with the Firebase config **inlined as literals**, because a service worker can't read `process.env` at runtime — these are non-secret client config values already shipped in every page bundle, but if the Firebase project config ever changes, this file needs a matching manual update (nothing keeps it in sync automatically). It uses the **compat** SDK via `importScripts` — the modular SDK used elsewhere in the app doesn't work in a raw service-worker context.
- **The real `data.type` string values are confirmed** (Consultation API Handbook, section 06 — supersedes the earlier numeric-`notification_type` guess from the Postman `testing.json` reference folder): `CONSULTATION_REQUEST` (→astrologer, full-screen incoming sheet), `CONSULTATION_ACCEPTED`/`REJECTED`/`MISSED`/`NOT_JOINED` (→customer), `CONSULTATION_CANCELLED` (→both, dismiss the incoming sheet even while showing), `FREE_CHAT_ENDING`/`INSUFFICIENT_FUND` (→customer, in-session banner — do not leave the chat), `SESSION_ENDING_SOON` (→astrologer), `CONSULTATION_ENDED` (→both, tear down and show the receipt — **never call `/end` client-side for this one**, the backend already billed it). The handbook also says to route on `data.type` but **navigate on `data.screen`** if the backend sends one — `resolveNotificationUrl()` in both `public/firebase-messaging-sw.js` and `PushNotificationManager.tsx` checks `data.screen` first, falling back to the `type` table. What's still unconfirmed: whether a real payload actually includes `data.screen`/`data.consultation_id` and what they contain — no real push had been captured as of this writing; verify and correct the fallback routing (`/consultation/${consultation_id}` etc.) once one is.
- Testing this requires a **real browser with actual user interaction** — the Notification permission prompt cannot be granted by browser automation (Chrome deliberately blocks this), so `Notification.permission` reads `"denied"` in any automated/headless context without ever showing a prompt. Firebase Console's "New campaign" flow won't deliver either unless you use its **"Send test message"** link (targets one FCM token directly) — a full campaign targets by topic/analytics-audience and will silently show `0` sends against this integration, since it doesn't wire up Firebase Analytics app-instance tracking.

### Location / geocoding

`app/lib/places.ts`'s `searchPlaces()`/`getPlaceDetails()` call `app/api/places/{autocomplete,details}/route.ts`, which proxy Google's Places API server-side — the key (`GOOGLE_MAPS_API_KEY`) is never sent to the client, following the same proxy-secrets-server-side rule as the astrology backend. `app/components/PlaceAutocomplete.tsx` (an `AsyncSelect` with a hand-rolled debounce) is the shared UI, used by signup, Edit Profile, Free Kundli, and Kundli Matching's birth-place fields; it exposes an `onSelect(details)` callback (lat/lng/pincode/city/state/country) in addition to the plain label `onChange`, since resolving a picked suggestion to coordinates is a second async call, not something a synchronous local lookup can do anymore.

Two things worth knowing before touching this: the autocomplete proxy requests `types=geocode`, not the narrower `types=(cities)` — confirmed live, a whole city (e.g. "Mumbai") has no single `postal_code` in Google's data (it spans many), so only sufficiently specific results (a locality, sublocality, or address) actually carry one for the pincode-autofill feature to use. And there is no Time Zone API call — `app/lib/countryTimezones.ts` is a small hardcoded per-country UTC-offset table (same one-value-per-country precision the old dataset had, just not a 36MB bundled asset), so multi-timezone countries (US, Russia, etc.) can still resolve to the wrong offset; enabling the Google Time Zone API and calling it from `getPlaceDetails()` would be the real fix if per-city precision is ever needed.

**Pincode autofill has a fallback, gated on an API not yet enabled on this project's Google Cloud key.** Confirmed live: many small/rural localities (e.g. "Bagwara, Rajasthan") return a full Place Details result with no `postal_code` address component at all — not a bug in `PlaceAutocomplete`'s `onSelect` wiring (checked every call site: `StepTwo.tsx`, `EditProfileModal.tsx` both correctly no-op on an empty pincode rather than clearing a previously-typed value). `getPlaceDetails()` now falls back to `app/api/places/geocode/route.ts`, a reverse-geocode of the same lat/lng filtered to `result_type=postal_code`, which Google usually has even when the specific locality doesn't. **This fallback is currently a no-op in this environment** — confirmed live, the Geocoding API responds `REQUEST_DENIED: "This API is not activated on your API project"` for the same `GOOGLE_MAPS_API_KEY` that Places API/Place Details already use successfully. Enable **Geocoding API** for that project in the Google Cloud Console (APIs & Services → Library) to make the fallback actually take effect — no code change needed once it's on. Until then, pincode autofill only works for results specific enough to carry their own `postal_code` component.

### Styling

Bootstrap 5 is imported globally in `app/layout.tsx` and used for base primitives (navbar, modals, form controls — see `Header.tsx`, `EditProfileModal.tsx`). Beyond that, styling is one large `app/globals.css` plus a dedicated hand-written `.css` file per feature page, using a consistent dark/gold "glass card" visual language (gradient headings, translucent bordered cards, pill buttons/badges). The Wallet / Add Money pages instead use a "dark outer card + white inner cards" look (`.card-bg-primary` wrapper with white `.wallet-balance`/`.transaction-card`/`.price-card` elements); the astrologer dashboard family uses a third convention, a persistent left sidebar with `.astrologer-panel` cards (see Astrologer App above) — match whichever convention the page family already uses. Tailwind is installed and configured in `postcss.config.mjs` but is not actually used anywhere in the app — don't introduce Tailwind utility classes; follow the existing custom-CSS-class convention instead.

Header nav dropdowns (`.horoscope-dropdown` in `globals.css`, used for Consultation/Horoscope/Free Service/Panchang/Tarot) are React-state-driven, not Bootstrap-JS-driven, and have an explicit mobile media query (`max-width: 991.98px`) forcing `position: static !important` so they render inline instead of absolutely-positioned inside the collapsing mobile nav. Any new dropdown-style menu inside the mobile nav needs this same static-position override (see `.profile-dropdown-menu` for the pattern) — Bootstrap's own built-in mobile fix only targets `.navbar-nav .dropdown-menu`, so anything outside the main `<ul className="navbar-nav">` (like the profile avatar menu) needs it added explicitly. Bootstrap's default `.form-control`/`.input-group-text` render with a light background — any plain (non-`.form-control-lg`) input inside an `.input-group` needs the dark-theme override added explicitly (see the `Experience`/`Daily hours` unit-suffix inputs on the astrologer Basic Details page) rather than assuming `.form-control-lg`'s dark styling cascades down.

### Other notes

- `app/lib/db.ts` sets up a `mysql2` connection pool from `DB_*` env vars, but nothing in the app currently imports it — direct DB access is unused; all data goes through the Go API proxies.
- There's a duplicated static-pages structure: both `app/(website)/{aboutus,privacy,terms,blogs,numerology}` and `app/app/{aboutus,privacy,terms}` exist. Check which is actually linked before editing either.
