diff --git a/AGENTS.md b/AGENTS.md
index cf6a1e4..d916a0d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,11 @@
+
# This is NOT the Next.js you know
-This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
diff --git a/docs/API-GAP-REPORT.md b/docs/API-GAP-REPORT.md
new file mode 100644
index 0000000..678f478
--- /dev/null
+++ b/docs/API-GAP-REPORT.md
@@ -0,0 +1,41 @@
+# API gap report — after backend integration
+
+Generated 2026-09-09. Format: `Feature | Existing API | Frontend status | Missing API`
+
+## Wired to the platform
+
+| Feature | Existing API | Frontend status | Missing API |
+|---|---|---|---|
+| Login | `POST /api/auth/login` | Wired | — |
+| Session restore | `GET /api/auth/me` | Wired — confirmed on every load | — |
+| Token refresh | `POST /api/auth/refresh` | Wired — single-flight, persist-before-use | — |
+| Logout | `POST /api/auth/logout` | Wired — revokes upstream, then clears | — |
+| Site switcher / Store page | `GET /api/sites` | Wired | — |
+| Dashboard Visitors | `GET /api/reports/footfall` | Wired — server `total` | — |
+| Dashboard Purchases / Revenue / Conversion | `GET /api/reports/conversion` | Wired | — |
+| Footfall chart | `GET /api/reports/footfall?bucket=day` | Wired | — |
+| Revenue chart · Sales page | `GET /api/reports/conversion?bucket=day` | Wired | — |
+| Recent arrivals · Activity page | `GET /api/visits` | Wired — cursor echoed, deduped on `visit_id` | — |
+| Customer photos | `GET /api/faces/…` | Wired — proxied so `` works | — |
+| Team | `GET /api/team` | Wired | — |
+| Mobile → dashboard purchases | `POST /api/purchases` | Wired via `POST /api/visits` | `GET /api/purchases` |
+
+## Cannot be wired — backend required
+
+| Feature | Existing API | Frontend status | Missing API |
+|---|---|---|---|
+| LYT programme | none | Unavailable panel | `GET /api/rewards`, `/api/rewards/redemptions`, `/api/lyts/ledger` |
+| Engagement activities | none | Unavailable panel | `GET /api/activities`, `/api/activities/impact` |
+| Campaigns | none | Unavailable panel | `GET /api/campaigns` |
+| Customer journey | partial (visit/purchase/return only) | Unavailable panel | `GET /api/reports/journey` |
+| Orders / products / stock | none | Unavailable panel | `GET /api/purchases`, `/api/products` |
+| Payment split / refunds | none | Unavailable panel | `GET /api/reports/payments`, `/api/reports/refunds` |
+| Staff attendance & ranking | `/api/team` is console accounts | Unavailable panel | `GET /api/staff`, `/api/staff/attendance`, `/api/reports/staff-sales` |
+| Business profile | none | Unavailable panel | `GET/PATCH /api/settings/profile` |
+| Roles matrix · Integrations · API keys · Billing | none | Still local state | `GET /api/settings/{roles,integrations,api-keys,billing}` |
+| Security → sessions | `GET/DELETE /api/auth/sessions` | **Not yet wired** — endpoint exists | — |
+| Invitations / join flow | `POST /api/team/invitations`, `GET /api/auth/invitation`, `POST /api/auth/register` | **Not yet wired** — endpoints exist, service written | — |
+| Loyaly AI chat | none | Still mock replies | `POST /api/ai/chat` |
+| Live arrivals stream | `GET /api/visits/stream` | **Not yet wired** — polling only | — |
+| Cameras / site health | `GET /api/cameras`, `/api/sites/{id}/check` | **Not yet wired** — service written | — |
+| Visitor directory | `GET /api/visitors`, `/history`, `PUT /profile`, `DELETE` | **Not yet wired** — service written | — |
diff --git a/docs/API-INVENTORY.md b/docs/API-INVENTORY.md
new file mode 100644
index 0000000..f43ff20
--- /dev/null
+++ b/docs/API-INVENTORY.md
@@ -0,0 +1,279 @@
+# Loyaly Merchant OS — API inventory & backend wiring plan
+
+Audit date: 2026-09-09 · Branch `main` · Audited against the running app on :3100
+
+Purpose: establish exactly what data the frontend consumes today, which of it is
+fake, and what a real backend must expose. Compare this against your API spec and
+mark each row **match / rename / missing / extra**.
+
+---
+
+## 1. How data flows today
+
+```
+component → hook (useResource) → repository (owns the URL) → httpClient
+ │
+ NEXT_PUBLIC_API_BASE ────┤
+ │
+ (unset) → Next route handler → fixture generator
+ (set) → YOUR BACKEND
+```
+
+**The seam already exists and works.** `src/shared/services/httpClient.ts` line 22:
+
+```ts
+const BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
+```
+
+Set that to your API host and every endpoint in §2 bypasses the local route
+handlers entirely. No component changes. This is the intended cutover.
+
+**Response envelope** — every endpoint must return this shape (`src/shared/types/api.ts`):
+
+```jsonc
+// success
+{ "data": , "meta": { "generatedAt": "ISO-8601", "range": "30d", "storeId": "all" } }
+// failure (any non-2xx, or 2xx with error present)
+{ "error": { "code": "internal|not_found|bad_request|unauthorized", "message": "…" } }
+```
+
+`meta.generatedAt` is **required**: the UI measures every relative time
+("2 hours ago", days-to-expiry, the greeting) against the server clock, never
+`Date.now()`.
+
+**Scope query** — every analytics endpoint is filtered by the same two params:
+
+| param | values |
+|---|---|
+| `storeId` | `all` \| a store id |
+| `range` | `7d` \| `30d` \| `90d` \| `mtd` \| `ytd` \| `custom` |
+
+**Auth** — session is an httpOnly cookie. `credentials: 'same-origin'` is sent on
+every request. A cross-origin backend needs CORS + `SameSite=None; Secure`, or
+keep the Next routes as a thin proxy.
+
+---
+
+## 2. Endpoints that EXIST (24) — all fixture-backed
+
+Legend: **T** = TypeScript response type, defined in the file noted.
+
+### Auth — `src/features/auth/types/auth.ts`
+
+| Method | Path | Body / Query | Returns |
+|---|---|---|---|
+| POST | `/api/auth/login` | `{email, password, rememberMe}` (JSON **and** form-encoded) | `AuthSession` |
+| POST | `/api/auth/logout` | `{}` | `{ok: boolean}` |
+| GET | `/api/auth/session` | — | `AuthSession \| null` |
+
+`AuthSession = {user: {id, email, name, role: 'owner'|'manager'|'analyst', organisation}, expiresAt}`
+
+### Dashboard — `src/features/dashboard/types/dashboard.ts` + `intelligence.ts`
+
+| Method | Path | Extra query | Returns |
+|---|---|---|---|
+| GET | `/api/dashboard/kpis` | scope | `Kpi[]` |
+| GET | `/api/dashboard/timeseries` | scope | `TimePoint[]` |
+| GET | `/api/dashboard/peak-hours` | scope | `HourCell[]` |
+| GET | `/api/dashboard/activity` | scope | `ActivityEvent[]` |
+| GET | `/api/dashboard/store-comparison` | scope | `StoreComparison[]` |
+| GET | `/api/dashboard/reward-usage` | scope | `RewardUsagePoint[]` |
+| GET | `/api/dashboard/performance` | scope + `granularity=weekly\|monthly` | `PeriodPoint[]` |
+| GET | `/api/dashboard/briefing` | scope | `DashboardBriefing` |
+| GET | `/api/dashboard/activity-metrics` | scope | `ActivityMetric[]` |
+| GET | `/api/dashboard/journey` | scope | `JourneyStage[]` |
+| GET | `/api/dashboard/campaigns` | scope | `CampaignSummary[]` |
+| GET | `/api/dashboard/insights` | scope | `Insight[]` |
+
+Key shapes:
+
+```ts
+Kpi { id:'visitors'|'purchases'|'revenue'|'activeRewards', label, value,
+ unit:'count'|'inr'|'lyt'|'pct', deltaPct, isRiseGood, trend:{t,v}[] }
+TimePoint { t:'YYYY-MM-DD', visitors, purchases, revenue, conversion }
+HourCell { day:0-6 (0=Mon), hour:0-23, value }
+ActivityEvent{ id, at:ISO, kind:'reward_redeemed'|'staff_checked_in'|'purchase'
+ |'reward_expired'|'store_opened', title, detail?, storeId }
+PeriodPoint { label:'W32'|'Aug', visitors, purchases, revenue }
+Insight { id, severity:'info'|'success'|'warning'|'error', title, body,
+ action?:{label, href} }
+```
+
+**Activity intelligence** (`intelligence.ts`) — the model shared by Dashboard and Lyts:
+
+```ts
+ActivityMetric {
+ id: 'walk'|'visit'|'selfie'|'spin'|'scratch'|'brand'|'challenge'|'friend'|'shop'|'event'
+ label, description
+ group: 'engagement'|'growth'|'commerce'
+ accent: 'warm'|'cool' // fixed per activity, travels on the payload
+ count, deltaPct
+ status: 'live'|'paused'|'draft'
+ lytsIssued // MEASURED (ledger). 1 LYT = ₹1
+ isFeatured // the 6 the dashboard summarises
+ impact: {
+ customers // MEASURED
+ rewardClaims? // omitted, never 0, when activity grants none
+ repeatVisits, purchases // ATTRIBUTED
+ attributedRevenueInr // ATTRIBUTED — never name this `revenue`
+ attribution: 'estimated'|'observed'
+ }
+}
+JourneyStage { id:'visit'|'engage'|'purchase'|'return'|'refer', label, value, conversionPct? }
+CampaignSummary { id, name, activityId, accent, status:'live'|'ended'|'scheduled',
+ steps:{label,value}[], attributedRevenueInr, attribution }
+```
+
+> **Contract rule.** `attribution` is not decorative. When it is `'estimated'` the
+> UI shows a disclosure ("Attribution is estimated…") beside every attributed
+> figure; when your backend can join purchases to activity events, return
+> `'observed'` and the disclosure disappears with no frontend change.
+> Invariants the UI assumes: `purchases ≤ repeatVisits ≤ customers ≤ count`.
+
+### Lyts — `src/features/lyts/types/reward.ts`
+
+| Method | Path | Returns |
+|---|---|---|
+| GET | `/api/lyts/rewards` | `Reward[]` |
+| GET | `/api/lyts/redemptions` | `TimePoint[]` ⚠️ |
+| GET | `/api/lyts/activity` | `ActivityEvent[]` |
+
+`Reward { id, name, costLyt, claimed, used, expiresAt: ISO|null, status: 'active'|'paused'|'expiring'|'expired' }`
+
+⚠️ **Known contract smell:** redemptions reuses `TimePoint`, and the Lyts charts
+read `visitors` as "Issued" and `purchases` as "Redeemed". Your backend should
+return a purpose-built shape — `{t, issued, redeemed}` — and I'll update the two
+chart call sites.
+
+### Stores — `src/features/stores/types/store.ts`
+
+| Method | Path | Returns |
+|---|---|---|
+| GET | `/api/stores` | `Store[]` |
+| GET | `/api/stores/:storeId` | `Store` (404 → `not_found`) |
+
+`Store { id, name, status:'open'|'closed'|'maintenance', visitors, purchases, revenueInr, conversionPct, staffCount }`
+
+### Staff — `src/features/staff/types/staff.ts`
+
+| Method | Path | Returns |
+|---|---|---|
+| GET | `/api/staff` | `StaffMember[]` |
+| GET | `/api/staff/summary` | `StaffSummary` |
+| GET | `/api/staff/attendance` | `AttendancePoint[]` |
+
+### Settings — `src/features/settings/types/settings.ts`
+
+| Method | Path | Returns |
+|---|---|---|
+| GET | `/api/settings/profile` | `MerchantProfile` |
+| PATCH | `/api/settings/profile` | `MerchantProfile` (merged) — **does not persist today** |
+
+---
+
+## 3. Endpoints that DO NOT EXIST — must be created
+
+These screens are live in the UI with **no API, no repository, and no persistence**.
+Mutations are `useState` only: refresh the page and every change is gone.
+
+### 3a. Commerce — the worst-wired module
+
+`/commerce` imports `commerceService.ts` **directly and synchronously** from 10
+component files. No repository, no route handler, no loading or error state.
+
+Needed:
+
+| Method | Path | Returns |
+|---|---|---|
+| GET | `/api/commerce/kpis` | revenue, orders, AOV, net sales, refunds, conversion (value + delta each) |
+| GET | `/api/commerce/revenue-trend` | `{t, revenue, target}[]` |
+| GET | `/api/commerce/orders-trend` | `{t, orders}[]` |
+| GET | `/api/commerce/payment-breakdown` | `{method, amount, sharePct}[]` |
+| GET | `/api/commerce/store-performance` | `{storeId, name, revenueInr}[]` |
+| GET | `/api/commerce/top-products` | `{id, name, category, unitsSold, revenueInr, growthPct, stockCount, stockStatus}[]` |
+| GET | `/api/commerce/alerts` | `{id, severity, title, body}[]` |
+| GET | `/api/commerce/orders` | recent orders feed |
+
+### 3b. Settings — 7 screens, all local-state fakes
+
+| Screen | Needed endpoints |
+|---|---|
+| Team & Staff | `GET/POST/PATCH/DELETE /api/settings/team` (+ suspend, password reset, invite) |
+| Stores | `GET/POST/PATCH/DELETE /api/settings/stores` |
+| Roles & Permissions | `GET/PUT /api/settings/roles` (permission matrix) |
+| Integrations | `GET /api/settings/integrations`, `POST/DELETE .../:id/connection` |
+| API & Webhooks | `GET/POST/DELETE /api/settings/api-keys`, `GET/POST/DELETE /api/settings/webhooks`, `GET /api/settings/webhooks/logs` |
+| Security | `GET /api/settings/sessions`, `DELETE /api/settings/sessions/:id`, `GET /api/settings/audit-log`, `POST /api/auth/password`, `POST/DELETE /api/auth/2fa` |
+| Billing | `GET /api/settings/billing`, `GET /api/settings/invoices`, `PATCH /api/settings/payout-account` |
+
+### 3c. Loyaly AI
+
+The chat panel streams from `services/ai/mockAi.ts` — a local prompt classifier
+picking canned templates. `loyalyAiRepository` is the only repository in the app
+that imports a mock directly.
+
+Needed: `POST /api/ai/chat` (streaming — SSE or chunked), plus
+`GET/POST/DELETE /api/ai/conversations` if history is to survive reload.
+
+### 3d. Store switcher — **critical**
+
+`STORE_OPTIONS` is a **hardcoded array in `src/shared/providers/WorkspaceProvider.tsx`**
+(line 45), duplicated from the fixture roster. This array scopes *every request in
+the app*. It must be fed from `GET /api/stores`, or a real merchant sees fixture
+store names in the global switcher.
+
+---
+
+## 4. Hardcoded / dummy data inventory
+
+| Location | What | Removal |
+|---|---|---|
+| `src/features/*/mock/*.ts` (11 files, ~1,970 lines) | all fixture generators | delete at cutover |
+| `src/shared/mock/rng.ts` | seeded RNG | delete at cutover |
+| `src/features/commerce/services/commerceService.ts` | KPIs, trends, products, alerts, store multipliers | replace with repository |
+| `src/features/settings/components/*.tsx` × 7 | `INITIAL_STAFF`, `INITIAL_STORES`, `INITIAL_MATRIX`, `INITIAL_APPS`, `INITIAL_KEYS`, `INITIAL_WEBHOOKS`, `WEBHOOK_LOGS`, `INITIAL_SESSIONS`, `AUDIT_LOGS`, `INVOICES` | replace with hooks |
+| `src/shared/providers/WorkspaceProvider.tsx` | `STORE_OPTIONS` | feed from `/api/stores` |
+| `src/features/loyaly-ai/services/ai/**` (~570 lines) | prompt router + reply templates | replace with real AI endpoint |
+| `src/features/auth/mock/users.mock.ts` | 5 users, **plaintext passwords** | replace `verifyCredentials()` body |
+| `src/app/api/**/route.ts` (24 files) | fixture wiring + `?_state=` simulation | delete or keep as proxy — see §6 |
+
+**Auth note.** `verifyCredentials()` is already the single credential seam — its
+body becomes an HTTP call and nothing else changes. But it currently returns
+`unknown_email` vs `wrong_password` separately, which is a **user-enumeration
+oracle**. Collapse both to one message at the route when you go live.
+`AUTH_SECRET` must be set in production (`sessionToken.ts` throws without it).
+
+---
+
+## 5. What I recommend NOT doing yet
+
+Deleting the fixtures before the real endpoints exist leaves every screen in an
+error state and removes the only way to verify the wiring. The fixtures are also
+the **executable spec** — `intelligence.mock.ts` encodes the funnel invariants
+your backend has to honour.
+
+Order that keeps the app working at every step:
+
+1. **Now (backend-independent):** add the missing seams — commerce repository +
+ route, settings hooks + routes, `STORE_OPTIONS` from `/api/stores`, AI
+ repository seam. Fixtures stay behind them. Every module then has one file to
+ swap.
+2. **You send the API spec MD.** I diff it against §2/§3 and report
+ match / rename / missing / extra per endpoint.
+3. **Cutover:** point `NEXT_PUBLIC_API_BASE` at the real host, adapt any shape
+ mismatches in the repository layer only, delete `mock/` + `src/app/api/**`.
+4. **Verify:** typecheck, build, and walk every screen with the network tab.
+
+---
+
+## 6. Decision needed from you
+
+**Do the Next.js route handlers stay?**
+
+- **A — Direct:** frontend calls your backend. Delete `src/app/api/**`. Needs CORS
+ and a cross-origin-safe session cookie.
+- **B — Proxy (recommended):** keep the route handlers, replace each fixture call
+ with a `fetch` to your backend. Session cookie stays first-party, your API host
+ is never exposed to the browser, and the `?_state=error|empty|loading` dev
+ harness keeps working.
+
diff --git a/docs/BEHAVISION-GAP-ANALYSIS.md b/docs/BEHAVISION-GAP-ANALYSIS.md
new file mode 100644
index 0000000..1ab74f5
--- /dev/null
+++ b/docs/BEHAVISION-GAP-ANALYSIS.md
@@ -0,0 +1,277 @@
+# Behavision API ↔ Loyaly Merchant OS — gap analysis
+
+Audit date: 2026-09-09 · API: `https://platform.loyaly.ai` · Frontend: this repo
+
+---
+
+## Headline finding
+
+**These are two different products.**
+
+This frontend was built as a **loyalty & rewards console**: LYT points, reward
+catalogues, engagement activities (spin / selfie / scratch / challenge /
+referral), campaigns, redemption liability, commerce orders.
+
+Behavision is a **camera-based footfall & visitor-recognition platform**: sites,
+cameras, face templates, arrivals, visitor identity, footfall and conversion
+reports.
+
+They overlap on roughly **one third** of the surface — the part that is genuinely
+about *people arriving at a shop and buying something*. The rest of the UI has no
+data source in this API and never will until a loyalty backend exists.
+
+So "remove all hardcodes" has a consequence that needs a decision, not a
+guess: **removing the fixtures makes about half the current UI go blank.** §4
+lays out the options.
+
+The reverse is also true and more interesting: **Behavision exposes a lot of real
+product this console does not surface at all** — a visitor directory, live camera
+views, site health checks, an invitation/join flow, device management, GDPR
+erasure. See §5.
+
+---
+
+## 1. What maps — build these for real
+
+| Screen / panel | Behavision endpoint | Notes |
+|---|---|---|
+| Login | `POST /api/auth/login` | different token model — §3 |
+| Session restore | `GET /api/auth/me` | |
+| Logout | `POST /api/auth/logout` | |
+| Dashboard · Visitors KPI | `GET /api/reports/footfall` | use server `total`, never sum buckets |
+| Dashboard · Purchases KPI | `GET /api/reports/conversion` | |
+| Dashboard · Revenue KPI | `GET /api/reports/conversion` | |
+| Dashboard · Footfall chart | `GET /api/reports/footfall?bucket=day` | |
+| Dashboard · Revenue chart | `GET /api/reports/conversion?bucket=day` | |
+| Dashboard · Visitors vs purchases | both reports | |
+| Dashboard · Conversion chart | `GET /api/reports/conversion` | |
+| Dashboard · Peak hours heatmap | `GET /api/reports/footfall?bucket=hour` | 7×24 grid from hourly buckets |
+| Dashboard · Period rollup | `…?bucket=week\|month` | |
+| Dashboard · Store comparison | `GET /api/reports/footfall?site=` per site | |
+| Dashboard · Recent activity feed | `GET /api/visits` | **arrivals only** — see below |
+| Stores list | `GET /api/sites` | + `fraction_below_gate`, cameras up |
+| Store detail | `GET /api/sites/{id}/check` | five-step smoke test |
+| Settings · Team | `GET /api/team`, `PATCH /api/team/{id}` | replaces `INITIAL_STAFF` |
+| Settings · Security → sessions | `GET/DELETE /api/auth/sessions`, `POST …/revoke-others` | replaces `INITIAL_SESSIONS` |
+| Store switcher (`STORE_OPTIONS`) | `GET /api/sites` | **critical — scopes every request** |
+
+**Customer journey** maps partially and honestly:
+
+| stage | source |
+|---|---|
+| Visit | footfall `total` |
+| Engage | ✗ no source |
+| Purchase | conversion |
+| Return | footfall `returning` |
+| Refer | ✗ no source |
+
+**Activity feed caveat.** The current feed renders five event kinds
+(`reward_redeemed`, `staff_checked_in`, `purchase`, `reward_expired`,
+`store_opened`). `/api/visits` supplies **arrivals only**. Four of the five kinds
+have no source. The feed becomes an arrivals feed — which is arguably the better
+screen, and is what the spec calls "the screen a mobile app is for".
+
+---
+
+## 2. What does NOT map — no endpoint exists
+
+| Area | Frontend surface | Status |
+|---|---|---|
+| **LYT programme** | entire `/lyts` page: rewards, claimed/used, redemption rate, outstanding liability, expiry alerts, reward usage chart, LYTs issued | ✗ no rewards concept in the API |
+| **Activity intelligence** | walk / selfie / spin / scratch / brand / challenge / friend / shop / event, impact chains, attribution, activity grouping | ✗ only *visit* has an analogue |
+| **Campaign performance** | campaign funnels | ✗ |
+| **Store insights** | "What needs your attention", AI briefing | ✗ |
+| **Commerce** | orders, products, categories, stock, payment methods, refunds, AOV, alerts | ✗ except revenue/basket via conversion report; `POST /api/purchases` writes but there is no orders read |
+| **Staff module** | attendance (present/absent/late/leave), punctuality, sales per head, rewards issued, performance score | ✗ **different concept** — `/api/team` is console *user accounts*, not shop-floor rostering |
+| **Settings · Profile** | business name, GSTIN, timezone, currency, LYTs per ₹100 | ✗ no business-profile endpoint |
+| **Settings · Roles matrix** | per-permission grid | ✗ role is a single enum via `PATCH /api/team/{id}` |
+| **Settings · Integrations** | connected apps | ✗ |
+| **Settings · API & Webhooks** | keys, endpoints, delivery logs | ✗ |
+| **Settings · Billing** | plan, invoices, payout account | ✗ |
+| **Settings · Security → audit log, 2FA** | | ✗ (sessions *do* exist) |
+| **Loyaly AI** | chat panel | ✗ |
+
+---
+
+## 3. Architectural changes required
+
+These are not cosmetic. Each one has a defined failure mode.
+
+### 3.1 Auth: cookie+HMAC → Bearer JWT with rotation
+
+Today: `proxy.ts` gates every route on an httpOnly cookie carrying an
+HMAC-signed payload minted locally by `sessionToken.ts`. There is no upstream.
+
+Behavision: `access_token` + `refresh_token`, both rotating.
+
+**Recommendation — keep the Next routes as a BFF (Backend-For-Frontend).**
+Tokens live server-side; the browser keeps only the existing httpOnly cookie.
+This is not conservatism, it buys five specific things:
+
+1. `proxy.ts` and the whole SSR gate keep working unchanged.
+2. Tokens never reach JS, so an XSS cannot exfiltrate a refresh token.
+3. **Web `` cannot send `Authorization`.** A BFF can proxy
+ `/api/faces/…` and the browser just uses a normal ``. Otherwise
+ every avatar needs fetch + `createObjectURL` + revoke-on-unmount.
+4. The single-flight refresh lock lives in one server process, not in N tabs.
+5. CORS and `SameSite=None` disappear as problems.
+
+**Three client rules the spec calls out — all must be implemented in the BFF:**
+
+- `401` + `"error": "token_expired"` → refresh **once**, retry, silently. Any
+ other 401 is a real sign-out.
+- **Serialise refresh behind one lock.** Refresh tokens are single-use; four
+ concurrent panels would each spend it and three would lose. This dashboard
+ fires **9 parallel requests on one page load** — it will hit this on the first
+ expiry, every time, without the lock.
+- **Persist rotated tokens before using them.** A crash between refresh and
+ persist leaves a token the server has already invalidated.
+
+Also: marshal the request body before the first attempt — a retry re-sends it,
+and a stream is spent after the first read.
+
+### 3.2 Response envelope
+
+Frontend expects `{data, meta:{generatedAt, range, storeId}}`. Behavision
+returns bare objects/arrays.
+
+**`meta.generatedAt` is load-bearing** — every relative timestamp, the greeting,
+and every days-to-expiry countdown measures against the server clock, never
+`Date.now()`. The BFF must synthesise it (from `polled_at`, or the response
+time) or ~20 `useResource` call sites all need rewriting.
+
+Wrap in the BFF. Cheapest correct option by a wide margin.
+
+### 3.3 Scope parameters
+
+`?storeId=all&range=30d` → `?site=&from=YYYY-MM-DD&to=YYYY-MM-DD&tz=…&bucket=…`
+
+- `storeId: 'all'` → omit `site` entirely.
+- `RangeKey` → concrete `from`/`to`. `to` is **inclusive**.
+- Send `tz`; buckets come back as **local wall time with no offset**.
+- Use `site` (documented spelling). An unknown query param is *silently ignored*,
+ so `site_id` in the wrong place returns the whole estate instead of an error.
+
+### 3.4 Report arithmetic — three traps
+
+- **Do not sum buckets to get the total.** `total` is unique people over the
+ window; someone who came Monday and Thursday is 1 person, 2 bucket-visitors.
+ Show the server's `total`, visits underneath.
+- **`new + returning` can be less than `total`** — a site sending counts without
+ templates records real footfall by an unidentified person.
+- **Do not `new Date()` a bucket label.** They are wall-time strings with no
+ offset; parsing shifts every label into the viewer's zone.
+ `formatDayLabel()` currently does `new Date(iso)` — it pins `timeZone:'UTC'`
+ so it survives, but bucket labels should be passed through, not parsed.
+
+### 3.5 Errors
+
+Behavision: `{"error": "invalid_code", "message": "…"}` (flat).
+Frontend: `{error: {code, message}}` (nested), with codes
+`internal|not_found|bad_request|unauthorized`.
+
+Map in the BFF. And **show the server's `message`** — it is written for humans;
+branch on `error`, never on the prose. New codes to handle: `token_expired`,
+`too_many_attempts` (429), `last_owner` (409), `invalid_code` (404),
+**`501` = feature off for this deployment, not an error**.
+
+`404` is also what another tenant's data returns — never surface it as "deleted".
+
+### 3.6 Cursor pagination — new concept
+
+`GET /api/visits` is cursor-based and lossless; polling by timestamp
+**permanently skips rows** when a burst exceeds `limit`. `useResource` has no
+cursor concept — the arrivals feed needs a cursor-aware hook.
+
+- Echo the returned `cursor` on every poll.
+- An empty poll returns **your own cursor**, not `""`.
+- A cursor that fails to parse → drop it, re-poll without one.
+- Delivery is at-least-once → de-duplicate on `visit_id`.
+- `GET /api/visits/stream` (SSE) is the low-latency path; needs an HTTP client
+ that can set `Authorization` (browser `EventSource` cannot).
+
+### 3.7 Images — new subsystem
+
+- **Missing photo is data, not an error.** Images are off by default across the
+ product; `available: false` with a `reason` is the normal case. Render
+ initials, never an error state.
+- `auth: true` → send Bearer. `auth` absent/false → presigned, use directly.
+ **Do not infer from the URL shape.** Treat any relative URL as needing auth.
+- **Every hand-out is written to the audit log.** Fetch once per screen, not
+ once per component — two components asking for one face puts two rows in
+ "who looked at my customers" for one glance.
+- Web: object URL + **revoke on unmount**, or a screen left open all afternoon
+ holds hundreds of copies of one photograph. (The BFF proxy in §3.1 avoids
+ this entirely.)
+
+### 3.8 Roles
+
+`UserRole = 'owner' | 'manager' | 'analyst'` → **`'staff' | 'manager' | 'owner'`**.
+`analyst` does not exist. Platform admin = `role === 'admin'` **and** empty
+`client_id` — the two together, never the role alone.
+
+### 3.9 References instead of uuids
+
+`V-42`, `chennai`, `Office1`, `priya@tenext.in` all work in paths and filters,
+and are **immutable** — safe to put in a URL or a saved report. Display names are
+not. The store switcher should key on `site_slug`, and deep links should use it.
+
+Ambiguity resolves to nothing, not a guess. Unknown ref: **404 in a path, 400 in
+a filter**.
+
+---
+
+## 4. The decision: what happens to the unmapped half
+
+The API gives a clean idiom for this: **`501` — "the feature is off for this
+deployment, not an error."**
+
+| | option | result |
+|---|---|---|
+| **A** | Delete the unmapped modules | Smallest, most honest app. Lose `/lyts`, `/commerce`, activity intelligence, campaigns, insights, staff attendance, most of settings. Recoverable from git when a loyalty backend ships. |
+| **B** | Keep the UI, render "not available in this deployment" | Nothing hardcoded, nothing invented, screens stay for when the backend arrives. Costs a small unavailable-state component. |
+| **C** | Keep fixtures behind an explicit `DEMO_MODE` flag | Sales demos keep working; real deployments show B. Highest complexity. |
+
+**My recommendation: B**, with A for `/commerce` specifically — commerce is the
+one module with no seam at all (10 components import a service synchronously),
+so there is nothing to preserve, and conversion-report revenue can move onto the
+dashboard where it belongs.
+
+Either way **no invented number survives**, which is what you asked for.
+
+---
+
+## 5. Real product this console is not exposing
+
+Worth knowing before we decide what to delete — the API supports screens that do
+not exist here yet:
+
+- **Visitor directory** — `GET /api/visitors?q=` search by name/phone/`V-42`,
+ `/history`, `PUT /profile` (name, phone, notes), and **`DELETE` erasure**
+ (destroys face template + photo, keeps visits unlinked, irreversible; a `502`
+ means *nothing* was deleted and must be reported as failure, never swallowed).
+- **Live camera view** — `GET /api/cameras/{id}/live`, SSE relayed from the shop PC.
+- **Camera management** — `GET /api/cameras` with latest still, `PATCH /api/cameras/{id}`.
+- **Site health** — `GET /api/sites/{id}/check`, five-step smoke test.
+- **Invitation / join flow** — mint a code, preview it unauthenticated, redeem it
+ into a full session. Replaces the fake "Add Staff Member" form entirely.
+- **Device management** — the user's own signed-in devices, with `current` marked.
+- **Arrivals SSE stream** — the live feed.
+
+---
+
+## 6. Proposed sequence
+
+1. **BFF + auth** — Behavision login/refresh/logout/me behind the existing
+ cookie; single-flight refresh; envelope + error adapters; scope→params
+ mapper. Nothing else can be wired until this exists.
+2. **Kill the highest-risk hardcode** — `STORE_OPTIONS` → `GET /api/sites`.
+3. **Reports** — dashboard KPIs, footfall, conversion, peak hours, rollup,
+ comparison.
+4. **Arrivals** — cursor-aware feed replacing the mock activity timeline.
+5. **Team + Sessions + Invitations** — replaces four fake settings screens with
+ real ones.
+6. **Apply the §4 decision** to everything unmapped; delete `src/features/*/mock/**`
+ and `src/shared/mock/`.
+7. **Verify** — typecheck, build, walk every screen against the live API.
+
diff --git a/docs/PLATFORM-STATUS.md b/docs/PLATFORM-STATUS.md
new file mode 100644
index 0000000..8c17e47
--- /dev/null
+++ b/docs/PLATFORM-STATUS.md
@@ -0,0 +1,60 @@
+# Live platform status — mcp.loyaly.ai
+
+Probed 2026-09-09. `LOYALY_API_BASE=https://mcp.loyaly.ai`
+
+The host is confirmed as the Behavision platform: it answers the documented flat
+`{"error": "...", "message": "..."}` contract with the documented codes
+(`bad_credentials`, `unauthorized`, `not_found`, `bad_request`).
+
+**8 of the 16 endpoints the spec documents are not deployed yet.**
+A `401 unauthorized` proves the route exists and is gated. A
+`404 {"error":"not_found","message":"No such endpoint."}` means it is absent.
+
+## Live
+
+| Endpoint | Probe | Console feature |
+|---|---|---|
+| `POST /api/auth/login` | 401 `bad_credentials` | Sign in — **wired** |
+| `POST /api/auth/refresh` | 400 `refresh_token is required` | Token rotation — **wired** |
+| `POST /api/auth/logout` | 401 `unauthorized` | Sign out — **wired** |
+| `GET /api/auth/me` | 401 `unauthorized` | Session confirmation — **wired** |
+| `GET /api/sites` | 401 `unauthorized` | Site switcher + Store page — **wired** |
+| `GET /api/visitors` | 401 `unauthorized` | Customer directory — service written, UI not built |
+| `GET /api/reports/footfall` | 401 `unauthorized` | Visitors KPI + Footfall chart — **wired** |
+| `GET /api/reports/conversion` | 401 `unauthorized` | Purchases/Revenue/Conversion + Sales — **wired** |
+
+## Not deployed
+
+| Endpoint | Probe | Blocks |
+|---|---|---|
+| `GET /api/visits` | 404 | **Recent arrivals feed, Activity page** |
+| `POST /api/purchases` | 404 | **Mobile → dashboard purchase flow** |
+| `GET /api/team` | 404 | Leaderboard team table |
+| `GET /api/team/invitations` | 404 | Invite flow |
+| `GET /api/auth/invitation` | 404 | Join preview |
+| `POST /api/auth/register` | 404 | Redeeming an invitation |
+| `GET /api/auth/sessions` | 404 | Device management |
+| `GET /api/cameras` | 404 | Camera list / live view |
+
+## Consequence for the "most important requirement"
+
+The brief's §7 flow —
+
+```
+Mobile → POST /api/visits → DB → Dashboard GET /api/visits → new visit appears
+Mobile → POST /api/purchases → DB → reports update
+```
+
+— cannot run today. **Neither `/api/visits` nor `/api/purchases` is deployed.**
+The console side is built and pointed at both; they return 404 until the
+platform ships them.
+
+What *does* work end to end once there is an account: sign in, site switching,
+and every KPI and chart on the Dashboard and Sales pages, since those read the
+two report endpoints that are live.
+
+## Still needed
+
+A valid account on `mcp.loyaly.ai`. There is no open registration by design,
+and `POST /api/auth/register` is not deployed either — so an account has to be
+created directly on the platform side.
diff --git a/public/brand/login-hero-1.jpeg b/public/brand/login-hero-1.jpeg
new file mode 100644
index 0000000..b521af4
Binary files /dev/null and b/public/brand/login-hero-1.jpeg differ
diff --git a/public/brand/login-hero-2.jpeg b/public/brand/login-hero-2.jpeg
new file mode 100644
index 0000000..06ac541
Binary files /dev/null and b/public/brand/login-hero-2.jpeg differ
diff --git a/src/app/(dev)/charts/page.tsx b/src/app/(dev)/charts/page.tsx
deleted file mode 100644
index 25942d8..0000000
--- a/src/app/(dev)/charts/page.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-'use client';
-
-/**
- * Dev-only chart gallery.
- *
- * Every primitive is exercised here against the REAL route handlers, not
- * inline arrays — so this page simultaneously proves the charts render, the
- * data layer round-trips, and the loading/empty/error skins are correct.
- * Use the state buttons to force each branch.
- */
-
-import {useState} from 'react';
-import {VStack, HStack} from '@astryxdesign/core/Layout';
-import {Grid} from '@astryxdesign/core/Grid';
-import {Card} from '@astryxdesign/core/Card';
-import {Heading, Text} from '@astryxdesign/core/Text';
-import {Button} from '@astryxdesign/core/Button';
-import {Badge} from '@astryxdesign/core/Badge';
-import {useResource} from '@/shared/hooks/useResource';
-import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
-import {ChartCard} from '@/shared/components/charts/ChartCard';
-import {LineChartView} from '@/shared/components/charts/LineChartView';
-import {AreaChartView} from '@/shared/components/charts/AreaChartView';
-import {BarChartView} from '@/shared/components/charts/BarChartView';
-import {Sparkline} from '@/shared/components/charts/Sparkline';
-import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
-import type {HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
-import {
- formatCompact,
- formatDayLabel,
- formatDelta,
- formatInrCompact,
- formatPct,
-} from '@/shared/utils/format';
-
-type Forced = '' | 'empty' | 'error' | 'delay';
-
-export default function ChartsGallery() {
- const [forced, setForced] = useState('');
-
- // The forced state is folded into the endpoint params, so it becomes part of
- // the request identity and useResource refetches when it changes.
- const extra =
- forced === 'delay'
- ? {_delay: '4000'}
- : forced
- ? {_state: forced}
- : {};
-
- const scope = {range: '30d' as const, storeId: 'all'};
- const withExtra = (e: {path: string; params: Record}) =>
- ({...e, params: {...e.params, ...extra}}) as never as T;
-
- const series = useResource(
- withExtra(dashboardRepository.timeseries(scope)),
- );
- const kpis = useResource(withExtra(dashboardRepository.kpis(scope)));
- const peak = useResource(
- withExtra(dashboardRepository.peakHours(scope)),
- );
-
- return (
-
-
- Chart primitives
-
- Monochrome by construction. Series beyond the second are separated by
- dash pattern and hatch fill, never hue — semantic colour is reserved
- for thresholds and deltas.
-
-
- {(
- [
- ['', 'Live data'],
- ['delay', 'Loading (4s)'],
- ['empty', 'Empty'],
- ['error', 'Error'],
- ] as [Forced, string][]
- ).map(([v, label]) => (
-
-
-
- {/* KPI sparklines — never animated, semantic tone on the delta only. */}
-
- {kpis.status === 'success'
- ? kpis.data.map((k) => (
-
-
-
- {k.label}
-
-
-
- {k.unit === 'inr'
- ? formatInrCompact(k.value)
- : formatCompact(k.value)}
-
- 0 === k.isRiseGood
- ? 'success'
- : 'error'
- }
- label={formatDelta(k.deltaPct)}
- />
-
- 0 === k.isRiseGood
- ? 'positive'
- : 'negative'
- }
- />
-
-
- ))
- : null}
-
-
-
-
- {(d) => (
-
- )}
-
-
-
- {(d) => (
-
- )}
-
-
-
- {(d) => (
-
- )}
-
-
-
- {(d) => (
- ({
- t: p.t,
- purchases: p.purchases,
- browsed: p.visitors - p.purchases,
- repeat: Math.round(p.purchases * 0.42),
- }))}
- xKey="t"
- xFormat={formatDayLabel}
- yFormat={formatCompact}
- series={[
- {key: 'browsed', label: 'Browsed only'},
- {key: 'purchases', label: 'Purchased'},
- {key: 'repeat', label: 'Repeat customers'},
- ]}
- />
- )}
-
-
-
- {(d) => (
- formatPct(v, 0)}
- series={[{key: 'conversion', label: 'Conversion'}]}
- reference={{y: 22, label: 'Benchmark'}}
- />
- )}
-
-
-
- {(d) => }
-
-
-
- );
-}
diff --git a/src/app/(workspace)/activity/page.tsx b/src/app/(workspace)/activity/page.tsx
index 940caa0..27e016c 100644
--- a/src/app/(workspace)/activity/page.tsx
+++ b/src/app/(workspace)/activity/page.tsx
@@ -3,44 +3,34 @@
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
-import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
-import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard';
+import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
+import {useRecentVisits} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
/**
- * The complete EVENT history — where "View all" on the dashboard's recent
- * activity feed leads.
+ * Every arrival, where the dashboard's "View all" leads.
*
- * Deliberately narrow. The grouped activity ecosystem — all ten activities,
- * their status, their LYT cost and their impact chains — lives on /lyts,
- * because that is where a merchant manages the programme. It briefly lived
- * here too; two homes for one dataset is exactly the duplication that lets
- * two screens drift apart, so this page kept the half that is genuinely its
- * own: the raw, chronological log of individual events.
- *
- * Not in the sidebar on purpose. It is a drill-down from one panel, the same
- * relationship /stores/[storeId] has to the store roster, and the nav is the
- * modules the product is organised around.
- *
- * Reads the same resource and the same component the dashboard panel does,
- * uncapped: one feed implementation, two budgets.
+ * Same component and same resource as the dashboard panel, at a larger page
+ * size — one feed implementation, two budgets. The cursor the platform returns
+ * is the supported way to page further; the dashboard never needs it, so it is
+ * wired here first when infinite scroll lands.
*/
export default function ActivityPage() {
- const activity = useDashboardActivity();
+ const visits = useRecentVisits(50);
const scopeLabel = useScopeLabel();
return (
}
/>
-
);
diff --git a/src/app/(workspace)/commerce/page.tsx b/src/app/(workspace)/commerce/page.tsx
index f3eadbd..6a86142 100644
--- a/src/app/(workspace)/commerce/page.tsx
+++ b/src/app/(workspace)/commerce/page.tsx
@@ -1,10 +1,70 @@
-import {CommerceWorkspace} from '@/features/commerce/components/CommerceWorkspace';
+'use client';
-export const metadata = {
- title: 'Commerce | Loyaly Merchant OS',
- description: 'AI-first commerce workspace for sales, orders, revenue and business performance telemetry.',
-};
+import {VStack} from '@astryxdesign/core/Layout';
+import {PageHeader} from '@/shared/components/primitives/PageHeader';
+import {ScopeControls} from '@/shared/components/scope/ScopeControls';
+import {ChartCard} from '@/shared/components/charts/ChartCard';
+import {BarChartView} from '@/shared/components/charts/BarChartView';
+import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
+import {CHART} from '@/shared/components/charts/palette';
+import {useConversionReport} from '@/features/dashboard/hooks/useReports';
+import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
+import {formatInrCompact} from '@/shared/utils/format';
+/**
+ * Sales.
+ *
+ * ── What changed and why ─────────────────────────────────────────────────
+ * This page previously rendered eight panels — product leaderboards, payment
+ * method splits, stock levels, refund rates, hourly targets — every number of
+ * which came from a hardcoded service imported synchronously by ten
+ * components. None of it had an API, a loading state, or a way to become real.
+ *
+ * The platform reports revenue and basket size through the conversion report,
+ * and nothing else on this page. So the page now shows the part that is real
+ * and names the resources the rest is waiting for, rather than presenting
+ * invented inventory as though a merchant could act on it.
+ */
export default function CommercePage() {
- return ;
+ const conversion = useConversionReport({bucket: 'day'});
+ const scopeLabel = useScopeLabel();
+
+ return (
+
+ }
+ />
+
+
+ {(report) => (
+
+ )}
+
+
+
+
+ );
}
diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx
index 5474cac..02ac35d 100644
--- a/src/app/(workspace)/dashboard/page.tsx
+++ b/src/app/(workspace)/dashboard/page.tsx
@@ -1,119 +1,56 @@
'use client';
-import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
-import {Collapsible} from '@astryxdesign/core/Collapsible';
-import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
-import {LineChartView} from '@/shared/components/charts/LineChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
-import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/components/KpiRow';
-import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
-import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
-import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
-import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
-import {CustomerActivity} from '@/features/dashboard/components/CustomerActivity';
-import {CustomerJourney} from '@/features/dashboard/components/CustomerJourney';
-import {CampaignPerformance} from '@/features/dashboard/components/CampaignPerformance';
-import {StoreInsights} from '@/features/dashboard/components/StoreInsights';
+import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
+import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {CHART} from '@/shared/components/charts/palette';
import {
- useActivityMetrics,
- useCampaigns,
- useCustomerJourney,
- useDashboardActivity,
+ useConversionReport,
useDashboardKpis,
- useDashboardPeakHours,
- useDashboardRewardUsage,
- useDashboardStoreComparison,
- useDashboardTimeseries,
- useStoreInsights,
+ useFootfallReport,
+ useRecentVisits,
} from '@/features/dashboard/hooks/useDashboard';
-import {usePersistentFlag} from '@/shared/hooks/usePersistentFlag';
-import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {greetingFor} from '@/features/dashboard/services/dashboardService';
-import type {Granularity} from '@/features/dashboard/types/dashboard';
-import {
- formatCompact,
- formatDayLabel,
- formatInrCompact,
- formatPct,
-} from '@/shared/utils/format';
+import {formatCompact, formatInrCompact} from '@/shared/utils/format';
/**
- * Store intelligence, in the order a merchant actually asks the questions:
- * what happened → why is it happening → what should I do next.
+ * The dashboard, as a READ MODEL over the platform.
*
- * what happened the four headline metrics, then the two trends behind them
- * why what customers DID (activity), where they stopped
- * (journey), which campaigns moved them (campaigns), and the
- * supporting analytics for all three
- * what next findings with one action each, then the raw event feed
+ * Every number on this page now traces to a resource the Loyaly platform
+ * actually serves — footfall, conversion, arrivals — and the same resources
+ * answer the mobile app. There is no dashboard database, no dashboard-shaped
+ * endpoint, and no fixture: if the platform returns nothing, this page shows
+ * nothing rather than something plausible.
*
- * The activity band is the change that makes this a Merchant OS rather than an
- * analytics page. Purchases are not the only thing that creates customer
- * value: a spin, a challenge, a referral or an event all produce engagement,
- * and every one of them is rendered here WITH its downstream chain — customers,
- * return visits, purchases — so no activity can be read as a vanity count.
+ * ── What was removed and why ─────────────────────────────────────────────
+ * The engagement layer that used to sit here — ten activity types, impact
+ * chains, campaign funnels, a customer journey, generated insights — was built
+ * against a loyalty domain the platform does not expose. It rendered numbers
+ * with no source. Rather than keep them behind a demo flag where a merchant
+ * could mistake them for real, the panels are replaced by a statement of what
+ * they need. The layout, spacing and hierarchy are otherwise untouched.
*
- * What did not change, on purpose: the sticky header, the store and range
- * controls, the KPI row, the Footfall and Revenue charts, the conversion pair,
- * peak hours, reward usage, the period rollup and store comparison. This is a
- * restructure of an already-sound page, not a rebuild — the new sections were
- * inserted at the points in the hierarchy where they answer the next question,
- * and the existing analytics moved below them because they are the evidence
- * for the activity story rather than the story itself.
- *
- * Operational widgets — quick actions, tasks, the AI briefing — still do NOT
- * live here. "What needs your attention" is not that: it is four findings
- * computed from this page's own numbers, each with a single action, which is a
- * different thing from a to-do list a merchant has to maintain.
- *
- * Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
- * set from this page's header. `series` is fetched once and shared by the four
- * charts drawn from it, and the activity, journey, campaign and insight
- * fixtures are all derived from that same series server-side, so no two panels
- * on this page can report different numbers for the same fact.
+ * Bucket labels from the reports are rendered as STRINGS. They are local wall
+ * time with no offset; parsing one into a Date re-interprets it in the
+ * viewer's zone and shifts every label on the axis.
*/
-
export default function DashboardPage() {
- const {storeId} = useWorkspace();
- const [granularity, setGranularity] = useState('weekly');
- // On by default: store comparison has always been part of the all-stores
- // dashboard, so Compare starts pressed and switches the panel off, rather
- // than hiding a panel that used to be there until someone finds the button.
- const [isComparing, setIsComparing] = useState(true);
- // Persisted, not component state: a merchant who wants the charts expanded
- // wants them expanded tomorrow too, and re-collapsing them on every visit is
- // the fastest way to make a disclosure control feel like an obstacle.
- const [isSupportingOpen, setSupportingOpen] = usePersistentFlag(
- 'loyaly.dashboard.supporting-analytics',
- false,
- );
-
const kpis = useDashboardKpis();
- const series = useDashboardTimeseries();
- const peak = useDashboardPeakHours();
- const activity = useDashboardActivity();
- const rewards = useDashboardRewardUsage();
- const comparison = useDashboardStoreComparison();
- const activityMetrics = useActivityMetrics();
- const journey = useCustomerJourney();
- const campaigns = useCampaigns();
- const insights = useStoreInsights();
-
- const isAllStores = storeId === 'all';
+ const footfall = useFootfallReport({bucket: 'day'});
+ const conversion = useConversionReport({bucket: 'day'});
+ const visits = useRecentVisits(6);
const scopeLabel = useScopeLabel();
return (
- {/* 1 + 2 — Sticky header: greeting, title, description, and filter controls. */}
- {/* 3 — headline numbers. */}
- {/*
- 4 — primary analytics: the two series everything else explains.
-
- These two carry the brand hues and nothing else on the page does at
- chart scale. Footfall is cool because it is the system measuring the
- store; revenue is warm because it is the outcome the brand is for. One
- of each, on the two charts that matter most — that is the whole colour
- budget for analytics, and it is why the four charts further down stay
- monochrome.
- */}
-
- {(d) => (
+
+ {(report) => (
-
- {(d) => (
+
+ {(report) => (
- {/* 5 — what customers did, and what each activity was worth. Six of ten;
- the full ecosystem is on /activity. */}
-
-
- {/* 6 — where that engagement progresses, and where it stops. */}
-
-
- {/* 7 — which deliberate campaigns produced the movement above. */}
-
-
- {/*
- 8 — supporting analytics, COLLAPSED BY DEFAULT.
-
- These six panels are preserved exactly as they were; what changed is
- their rank. Left expanded they run 1,724px on desktop and roughly
- 3,000px on a phone, which put "What needs your attention" — the only
- section on the page a merchant can act on — at 81% of the scroll,
- behind four charts that explain rather than prompt. Evidence should be
- available on demand, not stand between the finding and the action.
-
- Collapsed, not deleted, and the choice is remembered: a merchant who
- opens it once gets it open every visit thereafter. Nothing is
- unreachable and nothing was redesigned.
- */}
-
- {/* Text, not Heading: Collapsible renders the trigger inside a
-
- }
+
-
-
-
- {(d) => (
-
- )}
-
+ {(report) => (
+
+ )}
+
-
- {(d) => (
- formatPct(v, 0)}
- series={[{key: 'conversion', label: 'Conversion'}]}
- // The only semantic colour on this chart: above the benchmark is
- // good, below is not, and that is what the tint communicates.
- reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
- />
- )}
-
-
+
- {/* 9 — operational analytics: when traffic lands, what it redeems. */}
-
-
- {(d) => }
-
-
-
-
-
- {/* 10 — period rollup. */}
-
-
- {/* 11 — comparing stores is meaningless when scoped to one of them, which
- is why Compare is disabled rather than merely off in that case. */}
- {isAllStores && isComparing ? (
-
- ) : null}
-
-
-
- {/* 12 — what to do next. Last because it is the conclusion: every claim
- it makes is checkable against a panel above it. */}
-
-
- {/* 13 — bounded feed: six rows, fixed box, full history on /activity. */}
-
);
diff --git a/src/app/(workspace)/lyts/page.tsx b/src/app/(workspace)/lyts/page.tsx
index 5b27c3e..6437dd7 100644
--- a/src/app/(workspace)/lyts/page.tsx
+++ b/src/app/(workspace)/lyts/page.tsx
@@ -1,230 +1,38 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
-import {Grid} from '@astryxdesign/core/Grid';
-import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
-import {MetricCard} from '@/shared/components/patterns/MetricCard';
-import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
-import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
-import {ChartCard} from '@/shared/components/charts/ChartCard';
-import {AreaChartView} from '@/shared/components/charts/AreaChartView';
-import {LineChartView} from '@/shared/components/charts/LineChartView';
-import {BarChartView} from '@/shared/components/charts/BarChartView';
-import {RewardGrid} from '@/features/lyts/components/RewardGrid';
-import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
-import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
-import {ActivityProgramme} from '@/features/lyts/components/ActivityProgramme';
-import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
-import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
-import {
- useLytsActivity,
- useRedemptions,
- useRewards,
-} from '@/features/lyts/hooks/useLyts';
-import {ScopeControls} from '@/shared/components/scope/ScopeControls';
-import {ICONS} from '@/shared/utils/icons';
-import {usageRate} from '@/features/lyts/services/lytsService';
-import {
- formatCompact,
- formatDayLabel,
- formatLyt,
- formatPct,
-} from '@/shared/utils/format';
+import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
/**
- * The LYT programme — activities and the rewards they pay out in.
+ * LYTs.
*
- * Ordered by urgency rather than by data type: what's expiring, then the
- * headline numbers, then what customers can DO to earn, then the catalogue of
- * what they spend on, then the analysis. A merchant opening this page is
- * usually here because something needs extending or pausing.
+ * The entire loyalty programme — reward catalogue, claims, redemptions,
+ * outstanding LYT liability, expiry windows, accrual rate — has no counterpart
+ * in the Loyaly platform contract. Every figure this page used to show was
+ * generated locally, including the "outstanding liability" a merchant would
+ * reasonably read as money they owe.
*
- * 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
- * rupee liability — which is why they lead.
- *
- * ── The activity half ─────────────────────────────────────────────────────
- * Activities sit here, above rewards, because they are the earning side of
- * the same ledger: an activity issues LYTs, a reward spends them, and a
- * merchant tuning the programme is trading one against the other. The
- * dashboard shows six activities as a summary of engagement; this page is
- * where all ten are managed, and it reads the SAME activity model — see
- * ActivityProgramme.
+ * The page is kept, and states what it needs. Nothing is simulated.
*/
export default function LytsPage() {
- const rewards = useRewards();
- const redemptions = useRedemptions();
- const activity = useLytsActivity();
-
- const columns = useMetricColumns();
-
- // "Now" comes from the SERVER's clock, carried on every response.
- //
- // Date.now() here would be impure during render, would disagree between the
- // SSR and hydration passes, and would report the wrong countdown to anyone
- // whose device clock is off — on a panel whose entire job is telling a
- // merchant an offer expires in two days. Falls back to 0 only before the
- // first response lands, when no expiry panel is rendered anyway.
- const nowMs = rewards.meta
- ? new Date(rewards.meta.generatedAt).getTime()
- : 0;
-
return (
}
+ description="Customer activities and rewards. 1 LYT = ₹1."
/>
-
-
- }
- >
- {(rows) => {
- const active = rows.filter(
- (r) => r.status === 'active' || r.status === 'expiring',
- );
- const claimed = rows.reduce((a, r) => a + r.claimed, 0);
- const used = rows.reduce((a, r) => a + r.used, 0);
- const outstanding = rows.reduce(
- (a, r) => a + (r.claimed - r.used) * r.costLyt,
- 0,
- );
- const mostClaimed = rows.reduce((a, b) =>
- b.claimed > a.claimed ? b : a,
- );
- const leastUsed = rows.reduce((a, b) =>
- usageRate(b.claimed, b.used) < usageRate(a.claimed, a.used) ? b : a,
- );
-
- return (
-
- String(Math.round(v))}
- icon={ICONS.activeRewards}
- footer={
-
- }
- />
- }
- />
-
- }
- />
-
- );
- }}
-
-
- {/*
- The earning side, before the spending side. All ten activities, their
- status and their LYT cost — the same model the dashboard summarises.
- */}
-
-
-
-
- {(d) => (
-
- )}
-
-
-
- {(d) => (
-
- )}
-
-
-
-
- {(rows) => (
- b.claimed - a.claimed).slice(0, 6)}
- xKey="name"
- yFormat={formatCompact}
- series={[
- {key: 'claimed', label: 'Claimed'},
- {key: 'used', label: 'Redeemed'},
- ]}
- />
- )}
-
-
-
-
-
-
-
+
);
}
-
-/**
- * Stands in for the sparkline on metrics with no meaningful time series —
- * "most claimed" is a name, not a trend. MetricCard's `footer` slot exists
- * for exactly this so the card keeps its height and the row stays aligned.
- */
-function Caption({text}: {text: string}) {
- return (
-
- {text}
-
- );
-}
diff --git a/src/app/(workspace)/settings/profile/page.tsx b/src/app/(workspace)/settings/profile/page.tsx
index 9ea1c22..69df6fc 100644
--- a/src/app/(workspace)/settings/profile/page.tsx
+++ b/src/app/(workspace)/settings/profile/page.tsx
@@ -1,5 +1,6 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm';
+import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
/**
@@ -19,7 +20,15 @@ export default async function MerchantProfilePage() {
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
-
+ {profile ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/src/app/(workspace)/staff/page.tsx b/src/app/(workspace)/staff/page.tsx
index cd6025a..b00f7e1 100644
--- a/src/app/(workspace)/staff/page.tsx
+++ b/src/app/(workspace)/staff/page.tsx
@@ -1,79 +1,45 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
-import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
-import {ChartCard} from '@/shared/components/charts/ChartCard';
-import {BarChartView} from '@/shared/components/charts/BarChartView';
-import {StaffKpis} from '@/features/staff/components/StaffKpis';
-import {StaffGrid} from '@/features/staff/components/StaffGrid';
-import {AttendanceChart} from '@/features/staff/components/AttendanceChart';
-import {Leaderboard} from '@/features/staff/components/Leaderboard';
-import {
- useStaffAttendance,
- useStaffList,
- useStaffSummary,
-} from '@/features/staff/hooks/useStaff';
-import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
-import {ScopeControls} from '@/shared/components/scope/ScopeControls';
-import {formatCompact} from '@/shared/utils/format';
+import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
+import {TeamTable} from '@/features/team/components/TeamTable';
+import {useTeam} from '@/features/team/hooks/useTeam';
/**
- * The team.
+ * Leaderboard.
*
- * Ordered attendance-first: the question that brings a merchant here in the
- * morning is "who is in today", and only then "who is performing".
+ * ── An important distinction ─────────────────────────────────────────────
+ * The platform's `/api/team` is who can SIGN IN to the console, at what
+ * privilege. It is not shop-floor rostering: there is no attendance, no shift,
+ * no sales-per-head and no performance score anywhere in the contract.
+ *
+ * The page used to show all of those from a fixture. The real team list is
+ * shown instead, and the ranking metrics are named as the gap they are —
+ * because a leaderboard built from invented performance scores is the single
+ * most damaging fake number in this product.
*/
-export default function StaffPage() {
- const summary = useStaffSummary();
- const staff = useStaffList();
- const attendance = useStaffAttendance();
- const scopeLabel = useScopeLabel();
+export default function LeaderboardPage() {
+ const team = useTeam();
return (
}
+ description="People with access to this console."
/>
-
+
-
-
-
-
- {(rows) => (
- b.salesCount - a.salesCount)
- .slice(0, 8)
- .map((m) => ({
- // First name only — full names overflow the axis at this
- // width and the leaderboard below carries the detail.
- name: m.name.split(' ')[0],
- sales: m.salesCount,
- rewards: m.rewardsIssued,
- }))}
- xKey="name"
- yFormat={formatCompact}
- series={[
- {key: 'sales', label: 'Sales'},
- {key: 'rewards', label: 'Rewards issued'},
- ]}
- />
- )}
-
-
-
-
-
-
+
);
}
diff --git a/src/app/(workspace)/stores/[storeId]/page.tsx b/src/app/(workspace)/stores/[storeId]/page.tsx
deleted file mode 100644
index 924e168..0000000
--- a/src/app/(workspace)/stores/[storeId]/page.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-'use client';
-
-import {use} from 'react';
-import {VStack, HStack} from '@astryxdesign/core/Layout';
-import {Grid} from '@astryxdesign/core/Grid';
-import {Badge} from '@astryxdesign/core/Badge';
-import {Button} from '@astryxdesign/core/Button';
-import {
- BreadcrumbItem,
- Breadcrumbs,
-} from '@astryxdesign/core/Breadcrumbs';
-import {PageHeader} from '@/shared/components/primitives/PageHeader';
-import {MetricCard} from '@/shared/components/patterns/MetricCard';
-import {ChartCard} from '@/shared/components/charts/ChartCard';
-import {AreaChartView} from '@/shared/components/charts/AreaChartView';
-import {BarChartView} from '@/shared/components/charts/BarChartView';
-import {LineChartView} from '@/shared/components/charts/LineChartView';
-import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
-import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
-import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
-import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
-import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
-import {useResource} from '@/shared/hooks/useResource';
-import {storeRepository} from '@/features/stores/repositories/storeRepository';
-import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
-import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
-import {ScopeControls} from '@/shared/components/scope/ScopeControls';
-import {ICONS} from '@/shared/utils/icons';
-import {
- formatCompact,
- formatDayLabel,
- formatInrCompact,
- formatPct,
-} from '@/shared/utils/format';
-import type {StoreStatus} from '@/features/stores/types/store';
-
-const STATUS: Record<
- StoreStatus,
- {label: string; variant: 'success' | 'warning' | 'error'}
-> = {
- open: {label: 'Open', variant: 'success'},
- maintenance: {label: 'Maintenance', variant: 'warning'},
- closed: {label: 'Closed', variant: 'error'},
-};
-
-/**
- * One store's dashboard.
- *
- * Deliberately the SAME chart components as the main dashboard, scoped by the
- * route's storeId rather than the shared scope's. Building store-specific chart
- * variants would double the surface area and guarantee the two drift apart —
- * the only thing that differs here is which store the query asks about.
- */
-export default function StoreDetailPage({
- params,
-}: {
- params: Promise<{storeId: string}>;
-}) {
- const {storeId} = use(params);
- const {range} = useWorkspace();
- const scope = {range, storeId};
-
- const store = useResource(storeRepository.byId(scope, storeId), {
- isEmpty: () => false,
- });
- const kpis = useResource(dashboardRepository.kpis(scope));
- const series = useResource(dashboardRepository.timeseries(scope));
- const peak = useResource(dashboardRepository.peakHours(scope));
- const activity = useResource(dashboardRepository.activity(scope));
-
- const columns = useMetricColumns();
- const name = store.data?.name ?? 'Store';
-
- return (
-
-
- Store
- {name}
-
-
- }
- actions={
-
- {store.data ? (
-
- ) : null}
-
-
- }
- />
-
- }
- >
- {(rows) => (
-
- {rows.map((k) => (
-
- ))}
-
- )}
-
-
-
-
- {(d) => (
-
- )}
-
-
-
- {(d) => (
-
- )}
-
-
-
- {(d) => (
- formatPct(v, 0)}
- series={[{key: 'conversion', label: 'Conversion'}]}
- reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
- />
- )}
-
-
-
- {(d) => }
-
-
-
-
-
- );
-}
diff --git a/src/app/(workspace)/stores/page.tsx b/src/app/(workspace)/stores/page.tsx
index 9543d42..9a99fd2 100644
--- a/src/app/(workspace)/stores/page.tsx
+++ b/src/app/(workspace)/stores/page.tsx
@@ -1,35 +1,96 @@
'use client';
-import {VStack} from '@astryxdesign/core/Layout';
+import {VStack, HStack} from '@astryxdesign/core/Layout';
+import {Grid} from '@astryxdesign/core/Grid';
+import {Card} from '@astryxdesign/core/Card';
+import {Text, Heading} from '@astryxdesign/core/Text';
+import {StatusDot} from '@astryxdesign/core/StatusDot';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
-import {StoreGrid} from '@/features/stores/components/StoreGrid';
-import {useStoreList} from '@/features/stores/hooks/useStores';
-import {
- RANGE_LABELS,
- useWorkspace,
-} from '@/shared/providers/WorkspaceProvider';
-import {ScopeControls} from '@/shared/components/scope/ScopeControls';
+import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
+import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
+import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
+import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
+import {useSites} from '@/features/stores/hooks/useSites';
+import {formatPct} from '@/shared/utils/format';
/**
- * The store roster.
+ * The estate, from GET /api/sites.
*
- * This page deliberately ignores the shared STORE scope — a list of every
- * store is the whole point, and narrowing it to the one already selected would
- * leave a one-card page. The RANGE scope still applies, because the totals on
- * each card have to be measured over some period.
+ * Health fields are nullable and rendered as "—" when the platform does not
+ * report them. A deployment that sends no camera health is not a deployment
+ * with zero cameras up, and printing "0/0" for "not reported" makes a working
+ * estate look broken.
*/
export default function StoresPage() {
- const {range} = useWorkspace();
- const stores = useStoreList();
+ const sites = useSites();
return (
}
+ description="Every shop in the network, with its camera health."
/>
-
+
+ }
+ empty={
+
+ }
+ >
+ {(rows) => (
+
+ {rows.map((site) => (
+
+
+
+ {site.name}
+ {site.isOnline === null ? null : (
+
+
+
+ {site.isOnline ? 'Online' : 'Offline'}
+
+
+ )}
+
+
+
+ {site.id}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
);
}
diff --git a/src/app/api/assistant/route.ts b/src/app/api/assistant/route.ts
new file mode 100644
index 0000000..7252bfb
--- /dev/null
+++ b/src/app/api/assistant/route.ts
@@ -0,0 +1,78 @@
+import type {NextRequest} from 'next/server';
+import {assistantApi} from '@/services/api/assistantApi';
+import {withUpstream} from '@/features/auth/services/upstreamSession';
+import {failureFrom} from '@/shared/services/bff';
+import type {ApiAssistantTurn} from '@/services/api/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/assistant — Loyaly AI, against the platform's own assistant.
+ *
+ * ── What this replaced ───────────────────────────────────────────────────
+ * This route did not exist. The chat panel called
+ * `services/ai/mockAi.ts`, which classified the prompt by keyword and returned
+ * a hardcoded template — "Indiranagar Flagship · 12,400 visitors · ₹9.1L",
+ * "98% confidence", "Conduct staff training on checkout upsell workflows" —
+ * with no network call anywhere in the feature. Four shops that do not exist,
+ * revenue nobody earned, and a confidence score for a number that was a string
+ * literal. A merchant cannot tell that from a real answer, which is the whole
+ * reason it had to go.
+ *
+ * Every figure the assistant now quotes comes back from a platform tool that
+ * computed it, scoped to the signed-in user's own tenant.
+ *
+ * ── Errors are answers here, not failures ────────────────────────────────
+ * `501 assistant_off` means this deployment has no assistant configured. It is
+ * a supported state and the panel says so plainly. The one thing this route
+ * must never do is invent a reply to fill the gap.
+ */
+export async function POST(req: NextRequest) {
+ let history: ApiAssistantTurn[];
+
+ try {
+ const body = (await req.json()) as {history?: unknown};
+ if (!Array.isArray(body.history)) {
+ return Response.json(
+ {error: {code: 'bad_request', message: 'Ask a question.'}},
+ {status: 400, headers: {'cache-control': 'no-store'}},
+ );
+ }
+ // Normalised here rather than trusted: the platform coerces anything that
+ // is not "assistant" to "user" anyway, and sending it a shape it has to
+ // repair is how a client starts depending on that repair.
+ history = body.history
+ .map((t) => t as {role?: unknown; text?: unknown})
+ .filter((t) => typeof t.text === 'string' && t.text.trim() !== '')
+ .map((t) => ({
+ role: t.role === 'assistant' ? ('assistant' as const) : ('user' as const),
+ text: String(t.text),
+ }));
+ } catch {
+ return Response.json(
+ {error: {code: 'bad_request', message: 'Malformed request body.'}},
+ {status: 400, headers: {'cache-control': 'no-store'}},
+ );
+ }
+
+ if (history.length === 0) {
+ return Response.json(
+ {error: {code: 'bad_request', message: 'Ask a question.'}},
+ {status: 400, headers: {'cache-control': 'no-store'}},
+ );
+ }
+
+ try {
+ const answer = await withUpstream((token) => assistantApi.ask(token, history));
+ return Response.json(answer, {headers: {'cache-control': 'no-store'}});
+ } catch (err) {
+ const f = failureFrom(err);
+ // `reason` carries the platform's own code — `assistant_off`,
+ // `assistant_misconfigured` — so the panel can distinguish "switched off
+ // here" from "broken" without matching on prose that is rewritten freely.
+ return Response.json(
+ {error: {code: f.code, message: f.message}, reason: f.reason},
+ {status: f.status, headers: {'cache-control': 'no-store'}},
+ );
+ }
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 262a7a1..cf552bf 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -1,6 +1,7 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
-import {verifyCredentials} from '@/features/auth/mock/users.mock';
+import {authApi} from '@/services/api/authApi';
+import {UpstreamError} from '@/services/api/apiClient';
import {
LOGIN_ERROR_PARAM,
type LoginErrorCode,
@@ -13,232 +14,145 @@ import {
createSessionToken,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
-import type {AuthSession, LoginError} from '@/features/auth/types/auth';
-import type {ApiFailure, ApiSuccess} from '@/shared/types/api';
+import {storeTokens} from '@/features/auth/services/upstreamSession';
+import {toAuthUser} from '@/features/auth/services/userMapper';
+import type {AuthSession} from '@/features/auth/types/auth';
+import type {ApiSuccess} from '@/shared/types/api';
export const dynamic = 'force-dynamic';
/**
- * POST /api/auth/login
+ * POST /api/auth/login — the credential exchange, now against the platform.
*
- * The seam a real backend replaces. Everything above it — the repository, the
- * service, the form — speaks in {credentials} → {session | error} and does not
- * care whether the check happened against a fixture or an identity provider.
- *
- * Responsibilities that deliberately live HERE and not in the client:
- * • deciding whether the credentials are valid
- * • deciding how long the session lasts (rememberMe is a request, not an
- * instruction — the server sets the cookie lifetime)
- * • issuing the httpOnly cookie the client can never read or forge
+ * This route is the BFF's front door. It swaps an email and password for a
+ * platform token pair, seals that pair into an httpOnly cookie the browser
+ * cannot read, and hands back only the user object. The access token never
+ * reaches JavaScript, so an XSS on this origin cannot steal a session.
*
* ── Two content types, one endpoint ──────────────────────────────────────
- * It answers both `application/json` (the hydrated form, via authRepository)
- * and `application/x-www-form-urlencoded` (the browser posting the form
- * natively, before React has hydrated or when its bundle never arrived).
- *
- * That second path is not a nicety, it is the fix for a real leak. The sign-in
- * form has named inputs; a