update ui and remove mock data

This commit is contained in:
2026-09-09 19:50:52 +05:30
parent 5ce580dced
commit bfe4d3d2f2
172 changed files with 4280 additions and 8775 deletions

41
docs/API-GAP-REPORT.md Normal file
View File

@@ -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 `<img>` 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 | — |

279
docs/API-INVENTORY.md Normal file
View File

@@ -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": <T>, "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.

View File

@@ -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 `<img>` cannot send `Authorization`.** A BFF can proxy
`/api/faces/…` and the browser just uses a normal `<img src>`. 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=<slug>&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.

60
docs/PLATFORM-STATUS.md Normal file
View File

@@ -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.