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

View File

@@ -1,7 +1,11 @@
<!-- BEGIN:nextjs-agent-rules --> <!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know # 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.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
<!-- ASTRYX:START --> <!-- ASTRYX:START -->

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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -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<Forced>('');
// 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 = <T,>(e: {path: string; params: Record<string, string>}) =>
({...e, params: {...e.params, ...extra}}) as never as T;
const series = useResource<TimePoint[]>(
withExtra(dashboardRepository.timeseries(scope)),
);
const kpis = useResource<Kpi[]>(withExtra(dashboardRepository.kpis(scope)));
const peak = useResource<HourCell[]>(
withExtra(dashboardRepository.peakHours(scope)),
);
return (
<VStack gap={6} padding={6}>
<VStack gap={2}>
<Heading level={1}>Chart primitives</Heading>
<Text color="secondary">
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.
</Text>
<HStack gap={2} wrap="wrap">
{(
[
['', 'Live data'],
['delay', 'Loading (4s)'],
['empty', 'Empty'],
['error', 'Error'],
] as [Forced, string][]
).map(([v, label]) => (
<Button
key={label}
size="sm"
variant={forced === v ? 'primary' : 'secondary'}
label={label}
onClick={() => setForced(v)}
/>
))}
</HStack>
</VStack>
{/* KPI sparklines — never animated, semantic tone on the delta only. */}
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={4}>
{kpis.status === 'success'
? kpis.data.map((k) => (
<Card key={k.id}>
<VStack gap={2}>
<Text size="sm" color="secondary">
{k.label}
</Text>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>
{k.unit === 'inr'
? formatInrCompact(k.value)
: formatCompact(k.value)}
</Heading>
<Badge
variant={
k.deltaPct === 0
? 'neutral'
: k.deltaPct > 0 === k.isRiseGood
? 'success'
: 'error'
}
label={formatDelta(k.deltaPct)}
/>
</HStack>
<Sparkline
data={k.trend}
dataKey="v"
tone={
k.deltaPct === 0
? 'neutral'
: k.deltaPct > 0 === k.isRiseGood
? 'positive'
: 'negative'
}
/>
</VStack>
</Card>
))
: null}
</Grid>
<Grid columns={{minWidth: 420, repeat: 'fit'}} gap={4}>
<ChartCard
title="Footfall"
subtitle="Visitors per day"
resource={series}
>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
/>
)}
</ChartCard>
<ChartCard
title="Visitors vs purchases"
subtitle="Two series — solid and dashed, same ramp"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Visitors'},
{key: 'purchases', label: 'Purchases'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Revenue"
subtitle="Bars, with a target reference line"
resource={series}
>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
reference={{y: 90000, label: 'Daily target', tone: 'positive'}}
/>
)}
</ChartCard>
<ChartCard
title="Three series"
subtitle="The encoding test: luminance, then hatch — never hue"
resource={series}
>
{(d) => (
<BarChartView
// Three series only tells you anything if they share a scale, so
// this splits visitors into who bought and who didn't rather
// than mixing a percentage onto a counts axis.
data={d.slice(-10).map((p) => ({
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'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Dotted third encoding at line scale"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
reference={{y: 22, label: 'Benchmark'}}
/>
)}
</ChartCard>
<ChartCard
title="Peak hours"
subtitle="CSS grid, not recharts — monochrome by construction"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
</Grid>
</VStack>
);
}

View File

@@ -3,44 +3,34 @@
import {VStack} from '@astryxdesign/core/Layout'; import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls'; import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline'; import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard'; import {useRecentVisits} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
/** /**
* The complete EVENT history — where "View all" on the dashboard's recent * Every arrival, where the dashboard's "View all" leads.
* activity feed leads.
* *
* Deliberately narrow. The grouped activity ecosystem — all ten activities, * Same component and same resource as the dashboard panel, at a larger page
* their status, their LYT cost and their impact chains — lives on /lyts, * size — one feed implementation, two budgets. The cursor the platform returns
* because that is where a merchant manages the programme. It briefly lived * is the supported way to page further; the dashboard never needs it, so it is
* here too; two homes for one dataset is exactly the duplication that lets * wired here first when infinite scroll lands.
* 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.
*/ */
export default function ActivityPage() { export default function ActivityPage() {
const activity = useDashboardActivity(); const visits = useRecentVisits(50);
const scopeLabel = useScopeLabel(); const scopeLabel = useScopeLabel();
return ( return (
<VStack gap={5}> <VStack gap={5}>
<PageHeader <PageHeader
title="Activity" title="Activity"
description={`Every recorded event across ${scopeLabel}, newest first.`} description={`Every recognised arrival across ${scopeLabel}, newest first.`}
controls={<ScopeControls />} controls={<ScopeControls />}
/> />
<ActivityTimeline <ArrivalsFeed
resource={activity} resource={visits}
title="All activity" title="All arrivals"
subtitle="Redemptions, purchases, attendance, store events and expiries" subtitle="Customers recognised at the door"
/> />
</VStack> </VStack>
); );

View File

@@ -1,10 +1,70 @@
import {CommerceWorkspace} from '@/features/commerce/components/CommerceWorkspace'; 'use client';
export const metadata = { import {VStack} from '@astryxdesign/core/Layout';
title: 'Commerce | Loyaly Merchant OS', import {PageHeader} from '@/shared/components/primitives/PageHeader';
description: 'AI-first commerce workspace for sales, orders, revenue and business performance telemetry.', 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() { export default function CommercePage() {
return <CommerceWorkspace />; const conversion = useConversionReport({bucket: 'day'});
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
<PageHeader
eyebrow="Sales & revenue"
title="Sales"
description={`Revenue and conversion across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
<ChartCard
title="Revenue"
subtitle="Daily takings, from the conversion report"
resource={conversion}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
yFormat={formatInrCompact}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
<FeatureUnavailable
title="Orders, products and payments"
description="The Loyaly platform records a purchase against a visit and reports revenue in aggregate, but does not yet expose an orders list, a product catalogue, stock levels, payment-method splits or refunds. Those panels are held back rather than filled with sample data."
endpoints={[
'GET /api/purchases — orders list (POST exists; there is no read)',
'GET /api/products — catalogue, categories, stock',
'GET /api/reports/payments — method split',
'GET /api/reports/refunds',
]}
/>
</VStack>
);
} }

View File

@@ -1,119 +1,56 @@
'use client'; 'use client';
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout'; import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid'; 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 {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls'; import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard'; import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView'; import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView'; import {BarChartView} from '@/shared/components/charts/BarChartView';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/components/KpiRow'; import {KpiRow} from '@/features/dashboard/components/KpiRow';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline'; import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart'; import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
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 {CHART} from '@/shared/components/charts/palette'; import {CHART} from '@/shared/components/charts/palette';
import { import {
useActivityMetrics, useConversionReport,
useCampaigns,
useCustomerJourney,
useDashboardActivity,
useDashboardKpis, useDashboardKpis,
useDashboardPeakHours, useFootfallReport,
useDashboardRewardUsage, useRecentVisits,
useDashboardStoreComparison,
useDashboardTimeseries,
useStoreInsights,
} from '@/features/dashboard/hooks/useDashboard'; } 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 {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {greetingFor} from '@/features/dashboard/services/dashboardService'; import {greetingFor} from '@/features/dashboard/services/dashboardService';
import type {Granularity} from '@/features/dashboard/types/dashboard'; import {formatCompact, formatInrCompact} from '@/shared/utils/format';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/shared/utils/format';
/** /**
* Store intelligence, in the order a merchant actually asks the questions: * The dashboard, as a READ MODEL over the platform.
* what happened → why is it happening → what should I do next.
* *
* what happened the four headline metrics, then the two trends behind them * Every number on this page now traces to a resource the Loyaly platform
* why what customers DID (activity), where they stopped * actually serves — footfall, conversion, arrivals — and the same resources
* (journey), which campaigns moved them (campaigns), and the * answer the mobile app. There is no dashboard database, no dashboard-shaped
* supporting analytics for all three * endpoint, and no fixture: if the platform returns nothing, this page shows
* what next findings with one action each, then the raw event feed * nothing rather than something plausible.
* *
* The activity band is the change that makes this a Merchant OS rather than an * ── What was removed and why ─────────────────────────────────────────────
* analytics page. Purchases are not the only thing that creates customer * The engagement layer that used to sit here — ten activity types, impact
* value: a spin, a challenge, a referral or an event all produce engagement, * chains, campaign funnels, a customer journey, generated insights — was built
* and every one of them is rendered here WITH its downstream chain — customers, * against a loyalty domain the platform does not expose. It rendered numbers
* return visits, purchases — so no activity can be read as a vanity count. * 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 * Bucket labels from the reports are rendered as STRINGS. They are local wall
* controls, the KPI row, the Footfall and Revenue charts, the conversion pair, * time with no offset; parsing one into a Date re-interprets it in the
* peak hours, reward usage, the period rollup and store comparison. This is a * viewer's zone and shifts every label on the axis.
* 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.
*/ */
export default function DashboardPage() { export default function DashboardPage() {
const {storeId} = useWorkspace();
const [granularity, setGranularity] = useState<Granularity>('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 kpis = useDashboardKpis();
const series = useDashboardTimeseries(); const footfall = useFootfallReport({bucket: 'day'});
const peak = useDashboardPeakHours(); const conversion = useConversionReport({bucket: 'day'});
const activity = useDashboardActivity(); const visits = useRecentVisits(6);
const rewards = useDashboardRewardUsage();
const comparison = useDashboardStoreComparison();
const activityMetrics = useActivityMetrics();
const journey = useCustomerJourney();
const campaigns = useCampaigns();
const insights = useStoreInsights();
const isAllStores = storeId === 'all';
const scopeLabel = useScopeLabel(); const scopeLabel = useScopeLabel();
return ( return (
<VStack gap={5}> <VStack gap={5}>
{/* 1 + 2 — Sticky header: greeting, title, description, and filter controls. */}
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm"> <div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
<PageHeader <PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)} eyebrow={greetingFor(kpis.meta?.generatedAt)}
@@ -123,26 +60,18 @@ export default function DashboardPage() {
/> />
</div> </div>
{/* 3 — headline numbers. */}
<KpiRow resource={kpis} /> <KpiRow resource={kpis} />
{/*
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.
*/}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}> <Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}> <ChartCard
{(d) => ( title="Footfall"
subtitle="Visitors per day"
resource={footfall}
>
{(report) => (
<AreaChartView <AreaChartView
data={d} data={report.buckets}
xKey="t" xKey="label"
xFormat={formatDayLabel}
yFormat={formatCompact} yFormat={formatCompact}
series={[ series={[
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool}, {key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
@@ -151,12 +80,15 @@ export default function DashboardPage() {
)} )}
</ChartCard> </ChartCard>
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}> <ChartCard
{(d) => ( title="Revenue"
subtitle="Daily takings"
resource={conversion}
>
{(report) => (
<BarChartView <BarChartView
data={d} data={report.buckets}
xKey="t" xKey="label"
xFormat={formatDayLabel}
yFormat={formatInrCompact} yFormat={formatInrCompact}
series={[ series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar}, {key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
@@ -166,128 +98,36 @@ export default function DashboardPage() {
</ChartCard> </ChartCard>
</Grid> </Grid>
{/* 5 — what customers did, and what each activity was worth. Six of ten; <ChartCard
the full ecosystem is on /activity. */} title="New vs returning visitors"
<CustomerActivity resource={activityMetrics} /> subtitle="Who walked in, split by whether the platform had seen them before"
resource={footfall}
{/* 6 — where that engagement progresses, and where it stops. */}
<CustomerJourney resource={journey} />
{/* 7 — which deliberate campaigns produced the movement above. */}
<CampaignPerformance resource={campaigns} />
{/*
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.
*/}
<Collapsible
isOpen={isSupportingOpen}
onOpenChange={setSupportingOpen}
trigger={
<VStack gap={0.5} hAlign="start">
{/* Text, not Heading: Collapsible renders the trigger inside a
<button>, and a heading in button content is invalid markup.
The panels within carry their own h2s when expanded. */}
<Text size="base" weight="semibold">
Supporting analytics
</Text>
<Text size="sm" color="secondary">
Conversion, peak hours, reward usage, the period rollup and store
comparison
</Text>
</VStack>
}
> >
<VStack gap={5} className="pt-4"> {(report) => (
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}> <BarChartView
<ChartCard data={report.buckets}
title="Visitors vs purchases" xKey="label"
subtitle="The gap is the conversion opportunity" isStacked
resource={series} yFormat={formatCompact}
> series={[
{(d) => ( {key: 'newVisitors', label: 'New', color: CHART.brand.cool},
<LineChartView {key: 'returningVisitors', label: 'Returning', color: CHART.brand.warmBar},
data={d} ]}
xKey="t" />
xFormat={formatDayLabel} )}
yFormat={formatCompact} </ChartCard>
series={[
{key: 'visitors', label: 'Visitors'},
{key: 'purchases', label: 'Purchases'},
]}
/>
)}
</ChartCard>
<ChartCard <ArrivalsFeed resource={visits} viewAllHref="/activity" />
title="Conversion"
subtitle="Share of visitors who bought"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => 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'}}
/>
)}
</ChartCard>
</Grid>
{/* 9 — operational analytics: when traffic lands, what it redeems. */} <FeatureUnavailable
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}> title="Customer activity and engagement"
<ChartCard description="Selfies, spins, scratch cards, challenges, referrals, events and their impact on repeat visits and revenue are not part of the Loyaly platform contract yet. These panels previously showed generated figures; they are held back until the resources exist so no number on this page is invented."
title="Peak hours" endpoints={[
subtitle="Where the week's footfall actually lands" 'GET /api/activities — the activity catalogue and participation',
resource={peak} 'GET /api/activities/impact — customers, repeat visits, attributed purchases',
height={200} 'GET /api/campaigns — campaign funnels',
> 'GET /api/reports/journey — visit → engage → purchase → return → refer',
{(d) => <HeatmapGrid data={d} />} ]}
</ChartCard>
<RewardUsageChart resource={rewards} />
</Grid>
{/* 10 — period rollup. */}
<PerformancePanel
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* 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 ? (
<StoreComparisonPanel resource={comparison} />
) : null}
</VStack>
</Collapsible>
{/* 12 — what to do next. Last because it is the conclusion: every claim
it makes is checkable against a panel above it. */}
<StoreInsights resource={insights} />
{/* 13 — bounded feed: six rows, fixed box, full history on /activity. */}
<ActivityTimeline
resource={activity}
subtitle="Latest events across the selected store and period"
limit={6}
height={285}
viewAllHref="/activity"
/> />
</VStack> </VStack>
); );

View File

@@ -1,230 +1,38 @@
'use client'; 'use client';
import {VStack} from '@astryxdesign/core/Layout'; 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 {PageHeader} from '@/shared/components/primitives/PageHeader';
import {MetricCard} from '@/shared/components/patterns/MetricCard'; import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
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';
/** /**
* 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 * The entire loyalty programme — reward catalogue, claims, redemptions,
* headline numbers, then what customers can DO to earn, then the catalogue of * outstanding LYT liability, expiry windows, accrual rate — has no counterpart
* what they spend on, then the analysis. A merchant opening this page is * in the Loyaly platform contract. Every figure this page used to show was
* usually here because something needs extending or pausing. * 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 * The page is kept, and states what it needs. Nothing is simulated.
* 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.
*/ */
export default function LytsPage() { 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 ( return (
<VStack gap={5}> <VStack gap={5}>
<PageHeader <PageHeader
title="Lyts" title="Lyts"
description="Customer activities and rewards — what earns LYTs, what spends them, and what is still outstanding. 1 LYT = ₹1." description="Customer activities and rewards. 1 LYT = ₹1."
controls={<ScopeControls />}
/> />
<ExpiryAlerts resource={rewards} nowMs={nowMs} /> <FeatureUnavailable
title="The LYT programme"
<AsyncBoundary description="Rewards, claims, redemptions and outstanding LYT balance are not part of the platform contract. This page previously showed generated figures — including an outstanding liability in rupees — which a merchant could not tell from real ones."
resource={rewards} endpoints={[
loading={<SkeletonMetricGrid columns={columns} />} 'GET /api/rewards — catalogue, cost in LYTs, status, expiry',
> 'GET /api/rewards/redemptions — claimed vs redeemed over time',
{(rows) => { 'GET /api/lyts/ledger — issued, redeemed, outstanding balance',
const active = rows.filter( 'GET /api/activities — what earns LYTs, and participation',
(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 (
<Grid columns={columns} gap={4}>
<MetricCard
label="Active rewards"
value={active.length}
format={(v) => String(Math.round(v))}
icon={ICONS.activeRewards}
footer={
<Caption text={`${rows.length} in catalogue`} />
}
/>
<MetricCard
label="Redemption rate"
value={usageRate(claimed, used)}
format={(v) => formatPct(v, 0)}
icon={ICONS.lyts}
deltaPct={usageRate(claimed, used) - 50}
footer={
<Caption
text={`${formatCompact(used)} of ${formatCompact(claimed)} claims`}
/>
}
/>
<MetricCard
label="Most claimed"
value={mostClaimed.claimed}
format={formatCompact}
icon={ICONS.up}
footer={<Caption text={mostClaimed.name} />}
/>
<MetricCard
label="Outstanding liability"
value={outstanding}
format={formatLyt}
icon={ICONS.alert}
footer={
<Caption
text={`Least used: ${leastUsed.name} (${formatPct(usageRate(leastUsed.claimed, leastUsed.used), 0)})`}
/>
}
/>
</Grid>
);
}}
</AsyncBoundary>
{/*
The earning side, before the spending side. All ten activities, their
status and their LYT cost — the same model the dashboard summarises.
*/}
<ActivityProgramme />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Redemption"
subtitle="LYTs redeemed per day"
resource={redemptions}
>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'purchases', label: 'Redeemed'}]}
/>
)}
</ChartCard>
<ChartCard
title="Issued vs redeemed"
subtitle="The gap is LYTs still in customers' hands"
resource={redemptions}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Issued'},
{key: 'purchases', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
</Grid>
<ChartCard
title="Top rewards"
subtitle="Claimed vs redeemed, by reward"
resource={rewards}
>
{(rows) => (
<BarChartView
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'claimed', label: 'Claimed'},
{key: 'used', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
<RewardGrid resource={rewards} nowMs={nowMs} />
<RewardPerformanceTable resource={rewards} />
<ActivityTimeline resource={activity} limit={6} height={285} />
</VStack> </VStack>
); );
} }
/**
* 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 size="xsm" color="secondary">
{text}
</Text>
);
}

View File

@@ -1,5 +1,6 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage'; import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm'; import {ProfileForm} from '@/features/settings/components/ProfileForm';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository'; import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
/** /**
@@ -19,7 +20,15 @@ export default async function MerchantProfilePage() {
title="Personal Profile" title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference." description="Your user credentials, contact details, account email and timezone preference."
> >
<ProfileForm initialData={profile} /> {profile ? (
<ProfileForm initialData={profile} />
) : (
<FeatureUnavailable
title="Business profile"
description="The platform identifies the signed-in user but does not yet expose the company record this form edits — business name, GSTIN, timezone, currency and LYT accrual. Rather than showing an editable form backed by nothing, it is held until the resource exists."
endpoints={['GET /api/settings/profile', 'PATCH /api/settings/profile']}
/>
)}
</SettingsPage> </SettingsPage>
); );
} }

View File

@@ -1,79 +1,45 @@
'use client'; 'use client';
import {VStack} from '@astryxdesign/core/Layout'; import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ChartCard} from '@/shared/components/charts/ChartCard'; import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {BarChartView} from '@/shared/components/charts/BarChartView'; import {TeamTable} from '@/features/team/components/TeamTable';
import {StaffKpis} from '@/features/staff/components/StaffKpis'; import {useTeam} from '@/features/team/hooks/useTeam';
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';
/** /**
* The team. * Leaderboard.
* *
* Ordered attendance-first: the question that brings a merchant here in the * ── An important distinction ─────────────────────────────────────────────
* morning is "who is in today", and only then "who is performing". * 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() { export default function LeaderboardPage() {
const summary = useStaffSummary(); const team = useTeam();
const staff = useStaffList();
const attendance = useStaffAttendance();
const scopeLabel = useScopeLabel();
return ( return (
<VStack gap={5}> <VStack gap={5}>
<PageHeader <PageHeader
title="Leaderboard" title="Leaderboard"
description={`Attendance, sales contribution and performance across ${scopeLabel}.`} description="People with access to this console."
controls={<ScopeControls />}
/> />
<StaffKpis resource={summary} /> <TeamTable resource={team} />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}> <FeatureUnavailable
<AttendanceChart resource={attendance} /> title="Attendance and performance ranking"
description="Ranking staff needs rostering data — shifts, attendance, sales attributed per person — which the platform does not record. /api/team answers who can sign in, not who was on the floor."
<ChartCard endpoints={[
title="Sales by team member" 'GET /api/staff — shop-floor roster, distinct from console accounts',
subtitle="Top 8 by transactions in the selected period" 'GET /api/staff/attendance — present, late, absent, on leave',
resource={staff} 'GET /api/reports/staff-sales — sales attributed per person',
> ]}
{(rows) => ( />
<BarChartView
data={[...rows]
.sort((a, b) => 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'},
]}
/>
)}
</ChartCard>
</Grid>
<Leaderboard resource={staff} />
<StaffGrid resource={staff} />
</VStack> </VStack>
); );
} }

View File

@@ -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 (
<VStack gap={5}>
<Breadcrumbs variant="supporting">
<BreadcrumbItem href="/stores">Store</BreadcrumbItem>
<BreadcrumbItem isCurrent>{name}</BreadcrumbItem>
</Breadcrumbs>
<PageHeader
title={name}
description={
store.data
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
: 'Loading store…'
}
// Store scope is the route here, so only the period is selectable.
controls={<ScopeControls hasStore={false} />}
actions={
<HStack gap={2} vAlign="center">
{store.data ? (
<Badge
variant={STATUS[store.data.status].variant}
label={STATUS[store.data.status].label}
/>
) : null}
<Button
variant="secondary"
size="sm"
label="All stores"
href="/stores"
/>
</HStack>
}
/>
<AsyncBoundary
resource={kpis}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(rows) => (
<Grid columns={columns} gap={4}>
{rows.map((k) => (
<MetricCard
key={k.id}
label={k.label}
value={k.value}
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
icon={
k.id === 'visitors'
? ICONS.visitors
: k.id === 'purchases'
? ICONS.purchases
: k.id === 'revenue'
? ICONS.revenue
: ICONS.activeRewards
}
deltaPct={k.deltaPct}
isRiseGood={k.isRiseGood}
trend={k.trend}
/>
))}
</Grid>
)}
</AsyncBoundary>
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
/>
)}
</ChartCard>
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Share of visitors who bought"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
/>
)}
</ChartCard>
<ChartCard
title="Peak hours"
subtitle="Where this store's week actually lands"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
</Grid>
<ActivityTimeline resource={activity} limit={6} height={285} />
</VStack>
);
}

View File

@@ -1,35 +1,96 @@
'use client'; '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 {PageHeader} from '@/shared/components/primitives/PageHeader';
import {StoreGrid} from '@/features/stores/components/StoreGrid'; import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {useStoreList} from '@/features/stores/hooks/useStores'; import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import { import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
RANGE_LABELS, import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
useWorkspace, import {useSites} from '@/features/stores/hooks/useSites';
} from '@/shared/providers/WorkspaceProvider'; import {formatPct} from '@/shared/utils/format';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
/** /**
* The store roster. * The estate, from GET /api/sites.
* *
* This page deliberately ignores the shared STORE scope — a list of every * Health fields are nullable and rendered as "—" when the platform does not
* store is the whole point, and narrowing it to the one already selected would * report them. A deployment that sends no camera health is not a deployment
* leave a one-card page. The RANGE scope still applies, because the totals on * with zero cameras up, and printing "0/0" for "not reported" makes a working
* each card have to be measured over some period. * estate look broken.
*/ */
export default function StoresPage() { export default function StoresPage() {
const {range} = useWorkspace(); const sites = useSites();
const stores = useStoreList();
return ( return (
<VStack gap={5}> <VStack gap={5}>
<PageHeader <PageHeader
title="Store" title="Store"
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`} description="Every shop in the network, with its camera health."
controls={<ScopeControls hasStore={false} />}
/> />
<StoreGrid resource={stores} />
<AsyncBoundary
resource={sites}
loading={<SkeletonCardGrid count={4} height={170} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Shops appear here once they are registered on the platform."
/>
}
>
{(rows) => (
<Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
{rows.map((site) => (
<Card key={site.id}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>{site.name}</Heading>
{site.isOnline === null ? null : (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={site.isOnline ? 'success' : 'error'}
label={site.isOnline ? 'Online' : 'Offline'}
/>
<Text size="xsm" color="secondary">
{site.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
)}
</HStack>
<Text size="sm" color="secondary" className="font-mono">
{site.id}
</Text>
<StatRow>
<StatPair
label="Cameras up"
value={
site.camerasUp === null || site.camerasTotal === null
? '—'
: `${site.camerasUp}/${site.camerasTotal}`
}
/>
<StatPair
label="Below gate"
value={
site.fractionBelowGate === null
? '—'
: formatPct(site.fractionBelowGate * 100, 0)
}
align="end"
/>
</StatRow>
</VStack>
</Card>
))}
</Grid>
)}
</AsyncBoundary>
</VStack> </VStack>
); );
} }

View File

@@ -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'}},
);
}
}

View File

@@ -1,6 +1,7 @@
import {NextResponse} from 'next/server'; import {NextResponse} from 'next/server';
import type {NextRequest} 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 { import {
LOGIN_ERROR_PARAM, LOGIN_ERROR_PARAM,
type LoginErrorCode, type LoginErrorCode,
@@ -13,232 +14,145 @@ import {
createSessionToken, createSessionToken,
sessionCookieOptions, sessionCookieOptions,
} from '@/features/auth/services/sessionToken'; } from '@/features/auth/services/sessionToken';
import type {AuthSession, LoginError} from '@/features/auth/types/auth'; import {storeTokens} from '@/features/auth/services/upstreamSession';
import type {ApiFailure, ApiSuccess} from '@/shared/types/api'; 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'; 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 * This route is the BFF's front door. It swaps an email and password for a
* service, the form — speaks in {credentials} → {session | error} and does not * platform token pair, seals that pair into an httpOnly cookie the browser
* care whether the check happened against a fixture or an identity provider. * 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.
* 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
* *
* ── Two content types, one endpoint ────────────────────────────────────── * ── Two content types, one endpoint ──────────────────────────────────────
* It answers both `application/json` (the hydrated form, via authRepository) * It answers both `application/json` (the hydrated form) and
* and `application/x-www-form-urlencoded` (the browser posting the form * `application/x-www-form-urlencoded` (the browser posting natively, before
* natively, before React has hydrated or when its bundle never arrived). * React has hydrated). That second path is not a nicety: the sign-in form has
* * named inputs, and a <form> with no method submits GET to its own URL — which
* That second path is not a nicety, it is the fix for a real leak. The sign-in * put the password in the address bar, in history, and in every access log
* form has named inputs; a <form> with no method and no action submits GET to * between here and the user.
* its own URL, so a submit landing in the pre-hydration window rewrote the
* address bar to `/login?email=…&password=…` — putting the password in the
* URL bar, in browser history, in the referrer and in every access log between
* here and the user. Declaring method="post" action="/api/auth/login" means
* the worst case is now a normal POST with the credentials in the body.
*
* The two paths differ ONLY in how the answer is shaped: JSON gets an
* envelope, a native post gets a 303 redirect, because a browser that just
* submitted a form needs somewhere to land, not a document full of braces.
*/ */
interface LoginRequestBody {
email?: unknown;
password?: unknown;
rememberMe?: unknown;
}
/** What the request asked for, normalised across both content types. */
interface ParsedLogin { interface ParsedLogin {
email: string; email: string;
password: string; password: string;
rememberMe: boolean; rememberMe: boolean;
/** Only meaningful on the native path — where to land after success. */ isForm: boolean;
next: string | null; next: string | null;
/** True when the browser posted the form itself, so answer in redirects. */
isFormPost: boolean;
} }
function asString(value: unknown): string { async function parse(req: NextRequest): Promise<ParsedLogin> {
return typeof value === 'string' ? value : ''; const type = req.headers.get('content-type') ?? '';
}
async function parseRequest(req: NextRequest): Promise<ParsedLogin | null> { if (type.includes('application/json')) {
const contentType = req.headers.get('content-type') ?? ''; const body = (await req.json()) as Record<string, unknown>;
if (
contentType.includes('application/x-www-form-urlencoded') ||
contentType.includes('multipart/form-data')
) {
const form = await req.formData();
return { return {
email: asString(form.get('email')).trim(), email: String(body.email ?? '').trim(),
password: asString(form.get('password')), password: String(body.password ?? ''),
// An unchecked checkbox is absent from the payload entirely; any present rememberMe: body.rememberMe === true,
// value means checked. Never parsed as a boolean-ish string. isForm: false,
rememberMe: form.get('rememberMe') !== null, next: typeof body.next === 'string' ? body.next : null,
next: asString(form.get('next')) || null,
isFormPost: true,
}; };
} }
let body: LoginRequestBody; const form = await req.formData();
try {
body = (await req.json()) as LoginRequestBody;
} catch {
return null;
}
return { return {
email: asString(body.email).trim(), email: String(form.get('email') ?? '').trim(),
password: asString(body.password), password: String(form.get('password') ?? ''),
rememberMe: body.rememberMe === true, rememberMe: form.get('rememberMe') === 'on' || form.get('rememberMe') === 'true',
next: null, isForm: true,
isFormPost: false, next: typeof form.get('next') === 'string' ? String(form.get('next')) : null,
}; };
} }
/** function failJson(code: LoginErrorCode, message: string, status: number) {
* A failure, in whichever dialect the caller speaks. return Response.json(
* {error: {code: status === 429 ? 'bad_request' : 'unauthorized', message}, field: 'form', reason: code},
* The redirect carries a code, never the submitted values — bouncing the email {status, headers: {'cache-control': 'no-store'}},
* back through the URL to repopulate the field would reintroduce exactly the );
* leak this endpoint exists to close.
*/
function failure(
req: NextRequest,
parsed: Pick<ParsedLogin, 'isFormPost' | 'next'> | null,
code: LoginErrorCode,
error: LoginError,
status: number,
): NextResponse {
if (parsed?.isFormPost) {
const target = new URL('/login', req.url);
target.searchParams.set(LOGIN_ERROR_PARAM, code);
// Preserve the deep link so a failed attempt does not cost the user the
// page they were originally trying to reach. Validated, not echoed raw.
const next = resolveRedirectTarget(parsed.next);
if (next !== '/dashboard') target.searchParams.set('next', next);
// 303: the browser must follow with GET, not repeat the POST.
return NextResponse.redirect(target, 303);
}
// Shaped as the app's standard envelope so the client's error path is the
// same one every other endpoint uses; `field` rides alongside for the form.
const body: ApiFailure & {field: LoginError['field']} = {
error: {
code: status === 401 ? 'unauthorized' : 'bad_request',
message: error.message,
},
field: error.field,
};
return NextResponse.json(body, {
status,
headers: {'cache-control': 'no-store'},
});
} }
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest) {
const parsed = await parseRequest(req); const {email, password, rememberMe, isForm, next} = await parse(req);
if (!parsed) { if (!email || !password) {
return failure( const code: LoginErrorCode = !email ? 'email_required' : 'password_required';
req, if (isForm) {
null, return NextResponse.redirect(
'malformed', new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
{field: 'form', message: 'Malformed request body.'}, 303,
400, );
); }
return failJson(code, 'Enter your email address and password.', 400);
} }
// Server-side validation, repeated rather than trusted from the client. The let bundle;
// form validates too, for latency; this validates because the form is not a try {
// security boundary and a POST can arrive without it. bundle = await authApi.login(email, password);
if (!parsed.email) { } catch (err) {
return failure(req, parsed, 'email_required', { const up = err instanceof UpstreamError ? err : null;
field: 'email',
message: 'Enter your email address.', // The platform answers wrong-password and no-such-account identically, on
}, 400); // purpose: telling them apart turns this form into a way to find out who
} // works at a customer. Pass its message through rather than writing our own.
if (!/^\S+@\S+\.\S+$/.test(parsed.email)) { /*
return failure(req, parsed, 'email_invalid', { * A failure to REACH the platform is not a failure to authenticate.
field: 'email', *
message: 'Enter a valid email address.', * `UpstreamError.status === 0` means the request never arrived — offline,
}, 400); * DNS, TLS — and a contract mismatch (502) means it arrived somewhere that
} * is not the Loyaly platform. Reporting either as "invalid email or
if (!parsed.password) { * password" sends somebody to reset a password that was never the problem,
return failure(req, parsed, 'password_required', { * so those keep their own message and their own status.
field: 'password', */
message: 'Enter your password.', const isUnreachable = up ? up.status === 0 || up.status >= 500 : true;
}, 400);
const code: LoginErrorCode = isUnreachable
? 'platform_unreachable'
: up?.code === 'too_many_attempts'
? 'too_many_attempts'
: 'invalid_credentials';
const status = isUnreachable ? 502 : up?.status === 429 ? 429 : 401;
const message = up?.message ?? 'Invalid email or password.';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, message, status);
} }
const check = verifyCredentials(parsed.email, parsed.password); await storeTokens(bundle);
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') { const user = toAuthUser(bundle.user);
return failure(req, parsed, 'invalid_credentials', { const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;
field: 'form', const sessionCookie = createSessionToken(
message: 'Invalid email or password.',
}, 401);
}
const maxAge = parsed.rememberMe
? REMEMBERED_MAX_AGE_SECONDS
: SESSION_MAX_AGE_SECONDS;
const token = createSessionToken(
{ {
sub: check.user.id, sub: user.id,
email: check.user.email, email: user.email,
name: check.user.name, name: user.name,
role: check.user.role, role: user.role,
organisation: check.user.organisation, organisation: user.organisation,
}, },
maxAge, maxAge,
); );
const session: AuthSession = { const session: AuthSession = {user, expiresAt: bundle.expires_at};
user: check.user,
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
};
const response = parsed.isFormPost const res = isForm
? // 303 so the browser re-issues as GET. Without it, a refresh on the ? NextResponse.redirect(new URL(resolveRedirectTarget(next), req.url), 303)
// landing page would re-POST the credentials. : NextResponse.json<ApiSuccess<AuthSession>>(
NextResponse.redirect( {data: session, meta: {generatedAt: new Date().toISOString()}},
new URL(resolveRedirectTarget(parsed.next), req.url),
303,
)
: NextResponse.json(
{
data: session,
meta: {generatedAt: new Date().toISOString()},
} satisfies ApiSuccess<AuthSession>,
{headers: {'cache-control': 'no-store'}}, {headers: {'cache-control': 'no-store'}},
); );
/** res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(maxAge));
* Set on the response rather than through the `cookies()` store, because return res;
* this response may be a redirect: the header has to ride along with the 303
* itself or the browser follows it to the dashboard still signed out, gets
* bounced back to /login by the proxy, and the sign-in appears to have
* silently failed.
*
* A browser-session cookie still needs a server-side expiry, or a tab left
* open for a week would hold a valid token indefinitely — hence the token's
* own `exp` regardless of whether maxAge is sent.
*/
response.cookies.set(
SESSION_COOKIE,
token,
sessionCookieOptions(parsed.rememberMe ? maxAge : undefined),
);
return response;
} }

View File

@@ -1,29 +1,50 @@
import {cookies} from 'next/headers'; import {NextResponse} from 'next/server';
import { import {authApi} from '@/services/api/authApi';
SESSION_COOKIE, import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
sessionCookieOptions, import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
} from '@/features/auth/services/sessionToken'; import {peekAccessToken} from '@/features/auth/services/upstreamSession';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** /**
* POST /api/auth/logout * POST /api/auth/logout
* *
* Ending a session is a server action, not a client one: only the server can * Revokes the session upstream first, then clears both cookies. The order is
* invalidate an httpOnly cookie. The client clearing its own state would leave * deliberate, and so is the fact that an upstream failure does NOT abort the
* the credential intact and the next request still authenticated. * local clear: if the platform is unreachable, the least bad outcome is that
* this browser is signed out immediately and the server-side session lapses on
* its own expiry. Leaving the user apparently signed in because a revoke call
* failed is the one outcome nobody expects from pressing Sign out.
* *
* Overwritten with an expired value rather than only `.delete()`-ed — some * No refresh attempt: the token is about to be thrown away, so spending a
* proxies drop a bare deletion, and an empty value fails signature * refresh token to revoke it is pure waste.
* verification anyway, so the session is dead by two independent routes.
*/ */
export async function POST(): Promise<Response> { export async function POST() {
const store = await cookies(); const accessToken = await peekAccessToken();
store.set(SESSION_COOKIE, '', sessionCookieOptions(0));
store.delete(SESSION_COOKIE);
return Response.json( if (accessToken) {
try {
await authApi.logout(accessToken);
} catch {
// Already-expired, revoked, or unreachable — all fine. The cookies below
// are what actually ends this browser's session.
}
}
const res = NextResponse.json(
{data: {ok: true}, meta: {generatedAt: new Date().toISOString()}}, {data: {ok: true}, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}}, {headers: {'cache-control': 'no-store'}},
); );
// Overwrite with an expired cookie rather than only deleting: a delete that
// misses on `path` leaves a live session behind.
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
res.cookies.set(TOKEN_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
} }

View File

@@ -1,42 +1,77 @@
import {cookies} from 'next/headers'; import {NextResponse} from 'next/server';
import {findUserById} from '@/features/auth/mock/users.mock'; import {authApi} from '@/services/api/authApi';
import { import {UpstreamError} from '@/services/api/apiClient';
SESSION_COOKIE, import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
verifySessionToken, import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
} from '@/features/auth/services/sessionToken'; import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {AuthSession} from '@/features/auth/types/auth'; import type {AuthSession} from '@/features/auth/types/auth';
import type {ApiSuccess} from '@/shared/types/api';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** /**
* GET /api/auth/session * GET /api/auth/session — is this browser really signed in?
* *
* The client's only source of truth about who it is. It cannot read the * This asks the PLATFORM, every time, rather than trusting the cookie. That is
* httpOnly cookie, so it asks — and the answer comes from verifying a * the whole point: a signed cookie proves only that this server minted it, so
* signature, not from believing something the browser stored. * a user who was deactivated, whose role changed, or whose session was revoked
* from another device would otherwise keep a working console until the cookie
* happened to expire.
* *
* Returns 200 with `data: null` for "no session" rather than 401. A signed-out * The cost is one upstream call per page load, and it buys the property the
* visitor is a normal state for this endpoint, not an error, and modelling it * brief asks for: refreshing the browser preserves a session only when the
* as one means every caller has to special-case a failure that is not one. * auth provider confirms it is valid.
* *
* The user is re-resolved from the directory rather than read straight off the * On a confirmed 401 the cookies are cleared in the response, so the very next
* token: a role change or a deactivation must take effect on the next request, * navigation is redirected to /login by the proxy rather than looping through
* not whenever the cookie happens to expire. * a shell that cannot load data.
*/ */
export async function GET(): Promise<Response> { export async function GET() {
const store = await cookies(); try {
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value); const user = await withUpstream((token) => authApi.me(token));
const user = payload ? findUserById(payload.sub) : null; const session: AuthSession = {
user: toAuthUser(user),
// The cookie's own expiry is the browser-side lifetime; the platform's
// access token expiry is refreshed transparently underneath it.
expiresAt: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(),
};
const body: ApiSuccess<AuthSession | null> = { return NextResponse.json(
data: {data: session, meta: {generatedAt: new Date().toISOString()}},
payload && user {headers: {'cache-control': 'no-store'}},
? {user, expiresAt: new Date(payload.exp * 1000).toISOString()} );
: null, } catch (err) {
meta: {generatedAt: new Date().toISOString()}, const isAnonymous =
}; err instanceof NoSessionError ||
(err instanceof UpstreamError && err.status === 401);
return Response.json(body, {headers: {'cache-control': 'no-store'}}); // A network failure is NOT a sign-out. Returning null here would log
// everyone out the moment the platform blipped; a 503 lets the client keep
// the shell it already has and retry.
if (!isAnonymous) {
const message =
err instanceof UpstreamError
? err.message
: 'Could not reach Loyaly. Retrying shortly.';
return NextResponse.json(
{error: {code: 'internal', message}},
{status: 503, headers: {'cache-control': 'no-store'}},
);
}
const res = NextResponse.json(
{data: null, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
res.cookies.set(TOKEN_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}
} }

View File

@@ -1,17 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildActivityMetrics} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(
wantsEmpty(q) ? [] : buildActivityMetrics(q.range, q.storeId, q.nowMs),
q,
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildActivity} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildActivity(q.storeId, q.nowMs), q);
}

View File

@@ -1,19 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildBriefing} from '@/features/dashboard/mock/briefing.mock';
export const dynamic = 'force-dynamic';
const EMPTY = {summary: '', alerts: [], tasks: []};
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(
wantsEmpty(q) ? EMPTY : buildBriefing(q.storeId, q.range, q.nowMs),
q,
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildCampaigns} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildCampaigns(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,20 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildInsights} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
/**
* Separate from /briefing on purpose. The briefing is Loyaly AI's narrative
* for the panel; these are store-intelligence findings computed from the
* activity layer, and the dashboard must be able to render one without
* pulling in the other.
*/
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildInsights(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildJourney} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildJourney(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildKpis} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildKpis(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildPeakHours} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildPeakHours(q.storeId), q);
}

View File

@@ -1,22 +0,0 @@
import type {NextRequest} from 'next/server';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildPeriodPerformance} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const raw = req.nextUrl.searchParams.get('granularity');
const granularity: Granularity = raw === 'monthly' ? 'monthly' : 'weekly';
return ok(
wantsEmpty(q) ? [] : buildPeriodPerformance(granularity, q.storeId, q.nowMs),
q,
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRewardUsage} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRewardUsage(q.storeId, q.range), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStoreComparison} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStoreComparison(q.range, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildTimeseries(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,57 @@
import type {NextRequest} from 'next/server';
import {upstreamRaw} from '@/services/api/apiClient';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failResponse} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
*
* A browser `<img>` cannot send an Authorization header, and the platform's
* own image URLs require one. The alternatives were fetch + createObjectURL +
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
* photograph on a screen left open all afternoon — or this: one hop through
* the origin that already holds the token.
*
* ── Why `src` is validated rather than trusted ───────────────────────────
* An unchecked pass-through would be an open proxy that attaches the
* merchant's bearer token to any URL an attacker can get into a page. Only
* same-origin platform paths under /api/faces/ are forwarded.
*
* Every hand-out of a photo is written to the platform's audit log, so this
* must be requested once per screen rather than once per component: two
* components asking for the same face puts two rows in "who looked at my
* customers" for one glance at one person.
*/
export async function GET(req: NextRequest) {
const src = new URL(req.url).searchParams.get('src') ?? '';
// Relative, no traversal, and inside the faces namespace. Anything else is
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
// attacker-supplied.
if (!src.startsWith('/api/faces/') || src.includes('..')) {
return Response.json(
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
{status: 400},
);
}
try {
const upstream = await withUpstream((token) =>
upstreamRaw({path: src, accessToken: token}),
);
return new Response(upstream.body, {
status: 200,
headers: {
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
// Private: this is one merchant's customer, and a shared cache holding
// it would serve it across tenants.
'cache-control': 'private, max-age=300',
},
});
} catch (err) {
return failResponse(err);
}
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildLytActivity} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildLytActivity(q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRedemptions} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRedemptions(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRewards(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -0,0 +1,35 @@
import type {NextRequest} from 'next/server';
import {reportsApi} from '@/services/api/reportsApi';
import {toConversion} from '@/services/api/reportMapper';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {previousWindow} from '@/services/api/previousWindow';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiConversionReport, BucketSize} from '@/services/api/types';
export const dynamic = 'force-dynamic';
const BUCKETS: BucketSize[] = ['hour', 'day', 'week', 'month'];
/** GET /api/reports/conversion — purchases, revenue and basket size. */
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const bucketParam = url.searchParams.get('bucket');
const bucket = BUCKETS.includes(bucketParam as BucketSize)
? (bucketParam as BucketSize)
: undefined;
const wantsPrevious = url.searchParams.get('compare') === 'previous';
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
const site = toSiteParam(query.storeId);
const [current, previous] = await Promise.all([
reportsApi.conversion(token, window, site),
wantsPrevious
? reportsApi.conversion(token, previousWindow(window), site)
: Promise.resolve(null as ApiConversionReport | null),
]);
return toConversion(current, previous);
});
}

View File

@@ -0,0 +1,52 @@
import type {NextRequest} from 'next/server';
import {reportsApi} from '@/services/api/reportsApi';
import {toFootfall} from '@/services/api/reportMapper';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {previousWindow} from '@/services/api/previousWindow';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiFootfallReport} from '@/services/api/types';
import type {BucketSize} from '@/services/api/types';
export const dynamic = 'force-dynamic';
const BUCKETS: BucketSize[] = ['hour', 'day', 'week', 'month'];
/**
* GET /api/reports/footfall
*
* Domain-named on purpose: this is the platform's own resource, not a
* dashboard-shaped one. Mobile calls the same report on the same platform, so
* there is exactly one definition of footfall in the product.
*
* `?compare=previous` fetches the preceding window of equal length in the same
* round trip. Without it every KPI card would issue a second request and do
* its own date arithmetic, which is how two panels start disagreeing about
* what "last 30 days" means.
*/
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const bucketParam = url.searchParams.get('bucket');
const bucket = BUCKETS.includes(bucketParam as BucketSize)
? (bucketParam as BucketSize)
: undefined;
const wantsPrevious = url.searchParams.get('compare') === 'previous';
return serveUpstream(
req,
async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
const site = toSiteParam(query.storeId);
// Sequential would double the latency of every dashboard load; both
// windows are independent reads.
const [current, previous] = await Promise.all([
reportsApi.footfall(token, window, site),
wantsPrevious
? reportsApi.footfall(token, previousWindow(window), site)
: Promise.resolve(null as ApiFootfallReport | null),
]);
return toFootfall(current, previous);
},
);
}

View File

@@ -1,32 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {readProfile} from '@/features/settings/mock/settings.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(readProfile(), q);
}
/**
* Accepts a profile patch and echoes the merged result.
*
* There is no persistence layer yet, so this deliberately does NOT pretend to
* save — it validates the shape and returns what the merged record would be,
* which is enough for the form to exercise its success path honestly.
*/
export async function PATCH(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const patch = (await req.json()) as Record<string, unknown>;
return ok({...readProfile(), ...patch}, q);
}

View File

@@ -0,0 +1,38 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiSite} from '@/services/api/types';
import type {Site} from '@/features/stores/types/site';
export const dynamic = 'force-dynamic';
/**
* GET /api/sites — the estate.
*
* This is the most load-bearing read in the console: the site switcher scopes
* every other request in the app, so a hardcoded list here meant every screen
* was filtered by a store that might not exist.
*
* `slug` is carried through as the identifier the UI keys on because it is
* IMMUTABLE upstream and safe to persist in a URL or a saved report, while the
* display name is expected to change.
*/
function toSite(s: ApiSite): Site {
return {
// Slug first: it is immutable and is what every scoped request sends as
// `?site=`. `site_id` is the uuid — the server does not send a bare `id`.
id: s.slug || s.site_id,
uuid: s.site_id,
name: s.name,
isOnline: s.online ?? null,
camerasTotal: s.cameras_total ?? null,
camerasUp: s.cameras_up ?? null,
fractionBelowGate: s.fraction_below_gate ?? null,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => sitesApi.list(token), (sites) =>
sites.map(toSite),
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildAttendance} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildAttendance(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStaff} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStaff(q.storeId, q.range), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(buildStaffSummary(q.storeId, q.range), q);
}

View File

@@ -1,23 +0,0 @@
import type {NextRequest} from 'next/server';
import {fail, ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {buildStore} from '@/features/stores/mock/stores.mock';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{params}: {params: Promise<{storeId: string}>},
) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const {storeId} = await params;
const store = buildStore(storeId, q.range, q.nowMs);
if (!store) {
return fail('not_found', `No store with id "${storeId}"`, 404);
}
return ok(store, q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStores} from '@/features/stores/mock/stores.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStores(q.range, q.nowMs), q);
}

26
src/app/api/team/route.ts Normal file
View File

@@ -0,0 +1,26 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {ApiUser} from '@/services/api/types';
import type {TeamMember} from '@/features/team/types/team';
export const dynamic = 'force-dynamic';
/** GET /api/team — console accounts for this company. */
function toMember(u: ApiUser): TeamMember {
const user = toAuthUser(u);
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
organisation: user.organisation,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.list(token), (users) =>
users.map(toMember),
);
}

107
src/app/api/visits/route.ts Normal file
View File

@@ -0,0 +1,107 @@
import type {NextRequest} from 'next/server';
import {visitsApi} from '@/services/api/visitsApi';
import {purchasesApi} from '@/services/api/purchasesApi';
import {toSiteParam} from '@/services/api/range';
import {failResponse, serveUpstream} from '@/shared/services/bff';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {parseQuery, ok} from '@/shared/services/apiRoute';
import type {ApiArrival, ApiVisitsPage} from '@/services/api/types';
import type {Arrival, VisitsPage} from '@/features/dashboard/types/visits';
export const dynamic = 'force-dynamic';
/**
* GET /api/visits — the arrivals feed.
*
* Cursor in, cursor out, unchanged. It is opaque and version-prefixed, so this
* route must not interpret, shorten or regenerate it: doing so is how a feed
* silently starts skipping rows. An empty poll returns the caller's own cursor
* back, which is what distinguishes "nothing new" from "start over".
*/
function toArrival(a: ApiArrival): Arrival {
return {
visitId: a.visit_id,
occurredAt: a.occurred_at,
siteId: a.site_slug || a.site_id,
siteName: a.site,
cameraId: a.camera_id,
visitorId: a.visitor_id,
visitorRef: a.visitor_ref,
label: a.label,
isNewVisitor: a.is_new_visitor,
similarity: a.similarity,
image: a.image
? {
available: a.image.available,
// Relative URLs are proxied through this app so an <img> works
// without an Authorization header it cannot send.
url: a.image.url
? a.image.url.startsWith('http')
? a.image.url
: `/api/faces?src=${encodeURIComponent(a.image.url)}`
: null,
reason: a.image.reason ?? null,
}
: {available: false, url: null, reason: null},
};
}
function toPage(p: ApiVisitsPage): VisitsPage {
return {
arrivals: (p.arrivals ?? []).map(toArrival),
cursor: p.cursor,
polledAt: p.polled_at,
};
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const cursor = url.searchParams.get('cursor') ?? undefined;
const limitRaw = Number(url.searchParams.get('limit') ?? 50);
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(limitRaw, 1), 200)
: 50;
return serveUpstream(
req,
(token, query) =>
visitsApi.list(token, {
limit,
cursor,
site: toSiteParam(query.storeId),
}),
toPage,
);
}
/**
* POST /api/visits is NOT proxied here.
*
* Arrivals are written by the shop's camera pipeline, not by a console user.
* The mobile app writes PURCHASES against an existing visit — see
* POST /api/purchases — and a console-authored arrival would be a fabricated
* observation in a system whose whole value is that its observations are real.
*/
export async function POST(req: NextRequest) {
const query = parseQuery(req);
try {
const body = (await req.json()) as Record<string, unknown>;
// Marshalled before the first attempt so the retry after a token refresh
// can send it again — a request stream is spent once it has been read.
const created = await withUpstream((token) =>
purchasesApi.create(token, {
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
site: typeof body.site === 'string' ? body.site : undefined,
amount: Number(body.amount),
currency: typeof body.currency === 'string' ? body.currency : 'INR',
items: typeof body.items === 'number' ? body.items : undefined,
occurred_at:
typeof body.occurredAt === 'string' ? body.occurredAt : undefined,
}),
);
return ok(created, query);
} catch (err) {
return failResponse(err);
}
}

View File

@@ -143,6 +143,33 @@ html[data-theme='light'] {
display: none; display: none;
} }
/*
* The sign-in screen is a light-only surface.
*
* It renders outside the Astryx shell on an ivory canvas whatever the theme
* cookie says, so `color-scheme` has to be pinned here: inherited from an
* html that resolved to dark, the native checkbox and the autofill highlight
* paint dark controls onto a white card.
*
* The lockup follows for the same reason. BrandLogo renders both wordmarks
* and lets the cascade choose (see above), but "which theme is active" is
* the wrong question on this page — the card is white in every theme, and
* /white-logo.png is invisible on it. These two rules carry a leading `html`
* purely to match the specificity of the html[data-theme='dark'] rules above
* and win on source order.
*/
.login-surface {
color-scheme: light;
}
html .login-surface .brand-lockup-light {
display: block;
}
html .login-surface .brand-lockup-dark {
display: none;
}
/* /*
* Nested card surface — the activity tiles inside a panel. * Nested card surface — the activity tiles inside a panel.
* *
@@ -210,17 +237,61 @@ html[data-theme='light'] {
padding-inline-end: var(--spacing-8) !important; padding-inline-end: var(--spacing-8) !important;
} }
/* Sidebar Navigation typography & bold icon styling */ /* Sidebar Navigation modern SaaS redesign */
.astryx-side-nav {
border-right: 1px solid var(--color-border);
background-color: var(--color-background-body);
}
.astryx-side-nav-item { .astryx-side-nav-item {
font-size: 15px !important; font-size: 14px !important;
font-weight: 600 !important; font-weight: 500 !important;
letter-spacing: -0.01em !important; letter-spacing: -0.01em !important;
height: 40px !important;
min-height: 40px !important;
padding-inline: 12px !important;
border-radius: 8px !important;
margin-block: 2px !important;
color: var(--color-text-secondary) !important;
transition: all 160ms cubic-bezier(0.4, 0, 0.2, 1) !important;
display: flex !important;
align-items: center !important;
gap: 12px !important;
}
.astryx-side-nav-item:hover {
color: var(--color-text-primary) !important;
background-color: var(--color-overlay-hover) !important;
} }
.astryx-side-nav-item svg { .astryx-side-nav-item svg {
width: 20px !important; width: 18px !important;
height: 20px !important; height: 18px !important;
stroke-width: 2.2px !important; stroke-width: 1.75px !important;
color: var(--color-icon-secondary) !important;
transition: color 160ms ease, stroke-width 160ms ease !important;
flex-shrink: 0 !important;
}
.astryx-side-nav-item:hover svg {
color: var(--color-text-primary) !important;
}
/* Selected / Active navigation item */
.astryx-side-nav-item[aria-current='page'],
.astryx-side-nav-item[aria-selected='true'],
.astryx-side-nav-item.is-selected {
color: var(--color-text-primary) !important;
font-weight: 600 !important;
background-color: var(--color-neutral) !important;
box-shadow: inset 0 0 0 1px var(--color-border-emphasized) !important;
}
.astryx-side-nav-item[aria-current='page'] svg,
.astryx-side-nav-item[aria-selected='true'] svg,
.astryx-side-nav-item.is-selected svg {
color: var(--color-text-primary) !important;
stroke-width: 2px !important;
} }
/* Selected item contrast fix: ensure text & icons inside selected/active items remain high-contrast and legible */ /* Selected item contrast fix: ensure text & icons inside selected/active items remain high-contrast and legible */

View File

@@ -2,11 +2,10 @@
import {useState} from 'react'; import {useState} from 'react';
import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm'; import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm';
import {GoogleIcon, MicrosoftIcon} from './ProviderIcons';
/** /**
* The sign-in form itself: two fields, the remember-me control, the submit * The sign-in form itself: two fields, the remember-me control and the submit
* button and the federated options. * button.
* *
* All state and every rule come from useLoginForm — this file decides only how * All state and every rule come from useLoginForm — this file decides only how
* things look. That separation is what lets the error copy, the redirect * things look. That separation is what lets the error copy, the redirect
@@ -15,10 +14,15 @@ import {GoogleIcon, MicrosoftIcon} from './ProviderIcons';
* ── On the hand-rolled Tailwind here ───────────────────────────────────── * ── On the hand-rolled Tailwind here ─────────────────────────────────────
* The rest of the app is built from Astryx components, and this screen * The rest of the app is built from Astryx components, and this screen
* deliberately is not: it predates the workspace, it is the only page a * deliberately is not: it predates the workspace, it is the only page a
* merchant sees before the product, and it was signed off looking like this. * merchant sees before the product, and it renders outside the shell on a
* The brief says preserve the existing UI, so the markup is unchanged — * surface pinned to the light palette. See the note in LoginSplit for why the
* what changed is that the button beneath it now talks to a real endpoint. * colour values are literal.
*/ */
/** One string, both inputs — the two fields must not drift apart. */
const FIELD_BASE =
'w-full h-11 rounded-xl bg-white border text-[15px] text-[#14161F] placeholder:text-[#AEA99B] transition-all outline-none focus:border-[#F4C430] focus:ring-4 focus:ring-[#F4C430]/20 disabled:opacity-60 disabled:cursor-not-allowed';
export function LoginCredentialsForm() { export function LoginCredentialsForm() {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { const {
@@ -55,7 +59,7 @@ export function LoginCredentialsForm() {
method="post" method="post"
action={LOGIN_ENDPOINT} action={LOGIN_ENDPOINT}
noValidate noValidate
className="space-y-4" className="space-y-[18px]"
> >
{/* {/*
The deep link the proxy preserved, carried through the native path — The deep link the proxy preserved, carried through the native path —
@@ -67,7 +71,7 @@ export function LoginCredentialsForm() {
<div> <div>
<label <label
htmlFor="login-email" htmlFor="login-email"
className="block text-xs font-medium text-neutral-300 mb-1.5" className="block text-[13px] font-medium text-[#3F3D36] mb-2"
> >
Email address Email address
</label> </label>
@@ -84,12 +88,12 @@ export function LoginCredentialsForm() {
aria-invalid={!!errors.email || !!errors.form} aria-invalid={!!errors.email || !!errors.form}
aria-describedby={errors.email ? 'login-email-error' : undefined} aria-describedby={errors.email ? 'login-email-error' : undefined}
disabled={isSubmitting} disabled={isSubmitting}
className={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${ className={`${FIELD_BASE} px-4 ${
errors.email || errors.form ? 'border-red-500/80' : 'border-white/10' errors.email || errors.form ? 'border-[#DC2626]' : 'border-[#E4E1D6]'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all disabled:opacity-60`} }`}
/> />
{errors.email && ( {errors.email && (
<p id="login-email-error" className="text-xs text-red-400 mt-1"> <p id="login-email-error" className="text-xs text-[#DC2626] mt-1.5">
{errors.email} {errors.email}
</p> </p>
)} )}
@@ -98,7 +102,7 @@ export function LoginCredentialsForm() {
<div> <div>
<label <label
htmlFor="login-password" htmlFor="login-password"
className="block text-xs font-medium text-neutral-300 mb-1.5" className="block text-[13px] font-medium text-[#3F3D36] mb-2"
> >
Password Password
</label> </label>
@@ -116,21 +120,23 @@ export function LoginCredentialsForm() {
errors.password ? 'login-password-error' : undefined errors.password ? 'login-password-error' : undefined
} }
disabled={isSubmitting} disabled={isSubmitting}
className={`w-full h-11 pl-3.5 pr-10 rounded-xl bg-white/[0.04] border ${ className={`${FIELD_BASE} pl-4 pr-12 ${
errors.password || errors.form ? 'border-red-500/80' : 'border-white/10' errors.password || errors.form
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all disabled:opacity-60`} ? 'border-[#DC2626]'
: 'border-[#E4E1D6]'
}`}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
disabled={isSubmitting} disabled={isSubmitting}
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? 'Hide password' : 'Show password'}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-neutral-400 hover:text-white transition-colors focus:outline-none focus:text-white disabled:opacity-50" className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-md text-[#8A8577] hover:text-[#14161F] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#F4C430]/40 disabled:opacity-50"
> >
{showPassword ? ( {showPassword ? (
<svg <svg
width="16" width="18"
height="16" height="18"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -146,8 +152,8 @@ export function LoginCredentialsForm() {
</svg> </svg>
) : ( ) : (
<svg <svg
width="16" width="18"
height="16" height="18"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -163,7 +169,7 @@ export function LoginCredentialsForm() {
</button> </button>
</div> </div>
{errors.password && ( {errors.password && (
<p id="login-password-error" className="text-xs text-red-400 mt-1"> <p id="login-password-error" className="text-xs text-[#DC2626] mt-1.5">
{errors.password} {errors.password}
</p> </p>
)} )}
@@ -171,28 +177,26 @@ export function LoginCredentialsForm() {
{/* Anything the server could not attribute to a field — network, 5xx. */} {/* Anything the server could not attribute to a field — network, 5xx. */}
{errors.form && ( {errors.form && (
<p role="alert" className="text-xs text-red-400"> <p role="alert" className="text-xs text-[#DC2626]">
{errors.form} {errors.form}
</p> </p>
)} )}
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer group"> <label className="flex items-center gap-2.5 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
name="rememberMe" name="rememberMe"
checked={rememberMe} checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)} onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 cursor-pointer accent-white" className="w-4 h-4 rounded border-[#D6D2C4] cursor-pointer accent-[#F4C430]"
/> />
<span className="text-xs text-neutral-300 group-hover:text-white transition-colors"> <span className="text-[13px] text-[#3F3D36]">Remember me</span>
Remember me
</span>
</label> </label>
<a <a
href="#" href="#"
className="text-xs text-neutral-400 hover:text-white transition-colors font-medium" className="text-[13px] font-medium text-[#6B6960] hover:text-[#8A6300] transition-colors"
> >
Forgot password? Forgot password?
</a> </a>
@@ -201,23 +205,25 @@ export function LoginCredentialsForm() {
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full h-11 mt-2 rounded-xl bg-white text-black font-semibold text-sm hover:bg-neutral-200 active:scale-[0.99] transition-all shadow-md shadow-white/5 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed" className="w-full h-11 rounded-xl bg-[#F4C430] text-[#14161F] font-semibold text-[15px] hover:bg-[#E8B81C] active:scale-[0.995] transition-all shadow-[0_8px_20px_-8px_rgba(244,196,48,0.85)] flex items-center justify-center gap-2 outline-none focus-visible:ring-4 focus-visible:ring-[#F4C430]/35 disabled:opacity-70 disabled:cursor-not-allowed disabled:shadow-none"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<span className="inline-block w-4 h-4 border-2 border-black/30 border-t-black rounded-full animate-spin" /> <span className="inline-block w-4 h-4 border-2 border-[#14161F]/25 border-t-[#14161F] rounded-full animate-spin" />
<span className="sr-only">Signing in</span> <span className="sr-only">Signing in</span>
</> </>
) : ( ) : (
<> <>
Continue Continue
<svg <svg
width="14" width="16"
height="14" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true" aria-hidden="true"
> >
<path d="M5 12h14M12 5l7 7-7 7" /> <path d="M5 12h14M12 5l7 7-7 7" />
@@ -225,41 +231,6 @@ export function LoginCredentialsForm() {
</> </>
)} )}
</button> </button>
<div className="relative my-5 text-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.08]" />
</div>
<span className="relative px-3 text-xs text-neutral-500 bg-[#14151B]">
or continue with
</span>
</div>
{/*
Federated sign-in is not wired up — there is no OAuth client yet, and a
button that silently does nothing is worse than one that says so. They
stay disabled and labelled until /api/auth/oauth/:provider exists.
*/}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
disabled
title="Google sign-in is not configured yet"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5 opacity-50 cursor-not-allowed"
>
<GoogleIcon />
Google
</button>
<button
type="button"
disabled
title="Microsoft sign-in is not configured yet"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5 opacity-50 cursor-not-allowed"
>
<MicrosoftIcon />
Microsoft
</button>
</div>
</form> </form>
); );
} }

View File

@@ -1,50 +1,152 @@
'use client'; 'use client';
import {useState} from 'react'; import {useEffect, useState} from 'react';
import Image from 'next/image'; import Image from 'next/image';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
const HERO_IMAGE_URL =
'https://images.unsplash.com/photo-1556742049-0a670fc8078a?q=80&w=1400&auto=format&fit=crop';
const HERO_FALLBACK_URL =
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?q=80&w=1400&auto=format&fit=crop';
/** /**
* The left half of the sign-in screen: photography, scrim, brand watermark. * The left half of the sign-in screen: a two-slide auto-advancing carousel,
* the marketing line and the pagination dots.
* *
* Split from LoginSplit purely so that neither file has to be read to change * Split from LoginSplit purely so that neither file has to be read to change
* the other — this one is entirely decorative and holds the only piece of * the other — this one is entirely decorative and holds the only state on the
* state on the screen that has nothing to do with signing in (the image * screen that has nothing to do with signing in (which slide is showing),
* fallback), while the form half holds all the behaviour. * while the form half holds all the behaviour.
* *
* Hidden below `md`, where the photograph would push the form off the fold. * Hidden below `md`, where the panel would push the form off the fold.
*
* ── Full-bleed, and why that is safe here ────────────────────────────────
* The artwork covers the whole panel, edge to edge inside the rounded corner.
*
* That used to cost a lot of picture: against a short, wide panel the 1122×1402
* posters lost roughly a quarter of their height, and a different quarter as
* the card grew or shrank. It no longer does. The panel is now ~530×650, a
* 0.82 ratio against the posters' 0.80, so `cover` scales to width and trims
* only a few percent off the top and bottom — the mascot, the logo and the
* plinth all survive. If this panel is ever made materially wider or shorter
* than the artwork, that stops being true and the crop comes back.
*
* The crop is anchored to the BOTTOM, not centred. Both posters carry brand
* lettering (the bag's wordmark, the booth's plinth) just above their middle,
* and centring drops that straight into the headline band — white type fighting
* a competing wordmark. Anchoring low keeps the clean sweep of floor behind the
* caption and lifts the lettering clear of it, and the few percent that comes
* off is sky at the top of the frame.
*
* The caption sits over the lower band on an explicit-stop scrim rather than
* Tailwind's from/via/to, which puts `via` at the midpoint and left the
* headline in the thin end of the fade — white type over the near-white
* shopping bag, around 1.5:1. Headline, supporting line and dots all share the
* left padding edge: a centred dot row under left-aligned type was one of the
* things that made the panel read as unresolved.
*/ */
/**
* Shipped brand artwork rather than stock photography: these are the only two
* images on the screen and they are the mascot lockups the brand already owns,
* so the panel cannot break when a third-party image host does.
*/
const SLIDES = [
{
src: '/brand/login-hero-1.jpeg',
alt: 'The Loyaly.ai mascot holding a Loyaly.ai shopping bag',
},
{
src: '/brand/login-hero-2.jpeg',
alt: 'The Loyaly.ai selfie booth activation, with SNAP, SMILE and SHARE rewards',
},
] as const;
/** Long enough to read the caption under the slide, short enough to notice. */
const SLIDE_MS = 5000;
export function LoginHeroPanel() { export function LoginHeroPanel() {
const [heroSrc, setHeroSrc] = useState(HERO_IMAGE_URL); const [active, setActive] = useState(0);
useEffect(() => {
// An auto-rotating carousel is exactly the motion the reduced-motion
// preference is about, so honour it by holding on the first slide rather
// than cross-fading forever behind someone's back.
const reduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) return;
const timer = window.setInterval(
() => setActive((prev) => (prev + 1) % SLIDES.length),
SLIDE_MS,
);
return () => window.clearInterval(timer);
}, []);
return ( return (
<div className="hidden md:flex md:w-[40%] lg:w-[55%] h-full p-3 relative"> <div className="hidden md:block md:w-1/2 p-3">
<div className="w-full h-full rounded-[20px] overflow-hidden relative border border-white/10 bg-[#090A0E]"> <div className="relative h-full min-h-[600px] overflow-hidden rounded-[22px] bg-[#F7F3E4]">
<Image {/*
src={heroSrc} Both slides are always mounted and stacked; only opacity moves. A
alt="" crossfade needs the outgoing frame to still be painted, and swapping
fill a single <Image src> would flash the panel background between them.
priority */}
sizes="(max-width: 768px) 0vw, (max-width: 1024px) 40vw, 55vw" {SLIDES.map((slide, index) => (
className="object-cover object-center" <Image
// Unsplash is a third party; a dead URL must degrade to the other key={slide.src}
// photograph rather than to an empty black panel. src={slide.src}
onError={() => setHeroSrc(HERO_FALLBACK_URL)} alt={index === active ? slide.alt : ''}
fill
// Only the first slide is above the fold on arrival; the second
// still preloads eagerly so the first transition never pops.
priority={index === 0}
// The panel is display:none below md, but a hidden <img> is still
// fetched, so this only ever describes the desktop box: half the
// viewport, which at 2x picks the full 1122px source.
sizes="50vw"
className={`object-cover object-bottom transition-opacity duration-1000 ease-in-out ${
index === active ? 'opacity-100' : 'opacity-0'
}`}
aria-hidden={index !== active}
/>
))}
{/* Readability scrim — see the note above on the explicit stops. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-3/5"
style={{
background:
'linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.74) 30%,' +
'rgba(0,0,0,0.5) 60%, rgba(0,0,0,0.2) 83%, transparent 100%)',
}}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none" /> <div className="absolute inset-x-0 bottom-0 px-8 pb-8 lg:px-10 lg:pb-9">
<div className="absolute inset-0 bg-gradient-to-l from-[#0E0E10]/70 via-transparent to-transparent pointer-events-none" /> <h2 className="max-w-[15ch] text-[26px] lg:text-[30px] font-bold leading-[1.2] tracking-[-0.015em] text-white">
Smarter rewards for stronger brands
</h2>
<p className="mt-3 max-w-[40ch] text-sm lg:text-[15px] leading-relaxed text-white/85">
Empower your customers. Grow your business. All in one place.
</p>
<div className="absolute bottom-6 left-6 flex items-center gap-2.5 px-3.5 py-2 rounded-xl bg-black/50 backdrop-blur-md border border-white/10 text-white/80"> {/* Left-aligned with the type above it, not centred under it. */}
<BrandMark size={16} priority /> <div
<span className="text-[11px] font-medium tracking-wide text-neutral-300"> className="mt-7 flex items-center gap-2"
Loyaly.ai · Merchant Operating System role="tablist"
</span> aria-label="Highlights"
>
{SLIDES.map((slide, index) => (
<button
key={slide.src}
type="button"
role="tab"
aria-selected={index === active}
aria-label={`Show highlight ${index + 1} of ${SLIDES.length}`}
onClick={() => setActive(index)}
className={`h-1.5 rounded-full transition-all duration-500 ${
index === active
? 'w-7 bg-[#F4C430]'
: 'w-1.5 bg-white/45 hover:bg-white/75'
}`}
/>
))}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,104 +5,105 @@ import {LoginCredentialsForm} from './LoginCredentialsForm';
import {LoginHeroPanel} from './LoginHeroPanel'; import {LoginHeroPanel} from './LoginHeroPanel';
/** /**
* The sign-in screen's frame: ambient background, the split card, and the two * The sign-in screen's frame: the ivory canvas, the ambient yellow shapes, the
* halves that fill it. * split card, and the two halves that fill it.
* *
* This file used to be 314 lines holding the photograph, the form, the fake * This file used to be 314 lines holding the photograph, the form, the fake
* submit and the page chrome at once. It is now the composition only — * submit and the page chrome at once. It is now the composition only —
* behaviour lives in useLoginForm, the form markup in LoginCredentialsForm, * behaviour lives in useLoginForm, the form markup in LoginCredentialsForm,
* the photograph in LoginHeroPanel — which is what makes each of them * the carousel in LoginHeroPanel — which is what makes each of them separately
* separately readable and separately changeable. * readable and separately changeable.
*
* ── On the literal colour values here ────────────────────────────────────
* The workspace is monochrome and token-driven; this screen deliberately is
* not. It predates the workspace, it is the only page a merchant sees before
* the product, it renders outside the Astryx shell, and it is pinned to the
* light palette whatever the theme cookie says (see `.login-surface` in
* globals.css). The one hue used is #F4C430 — the same Loyaly yellow as
* `--color-brand-warm`, kept literal because no Astryx token is in scope here.
*/ */
export function LoginSplit() { export function LoginSplit() {
return ( return (
<div className="min-h-screen w-full relative flex items-center justify-center p-4 sm:p-6 lg:p-10 bg-[#050506] overflow-hidden selection:bg-white selection:text-black"> <div className="login-surface min-h-screen w-full relative flex flex-col items-center justify-center gap-4 p-4 sm:p-6 lg:px-10 lg:py-8 bg-[#FAF9F4] overflow-hidden selection:bg-[#F4C430] selection:text-[#14161F]">
{/* Premium Charcoal Radial Gradient (Center #17171C -> Mid #101015 -> Edges #050506) */} {/*
The ambient layer: a warm wash at the outer edges, then three large
curved shapes cropped by the viewport so only an arc of each is ever
visible. The brief is "felt, not seen" — nothing here should read as a
distinct object behind the card.
Painted with radial gradients rather than blurred elements, and that is
a performance fix rather than a style choice. Three `blur-[110px]`
divs are three compositing layers the size of the viewport, and the
carousel crossfading on top of them invalidated all three every frame
for a full second every five seconds — enough to lock the renderer.
A gradient is soft for free: no filter, no layer, no repaint.
*/}
<div <div
aria-hidden
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ style={{
background: 'radial-gradient(ellipse 110% 110% at 50% 50%, #17171C 0%, #101015 55%, #050506 100%)', background:
// Top-right arc.
'radial-gradient(60rem 46rem at 104% -14%, rgba(244,196,48,0.30), rgba(244,196,48,0.10) 46%, transparent 72%),' +
// Bottom-left arc.
'radial-gradient(58rem 44rem at -8% 112%, rgba(244,196,48,0.26), rgba(244,196,48,0.09) 46%, transparent 72%),' +
// Left-edge sliver — the tallest and faintest of the three.
'radial-gradient(30rem 40rem at -12% 42%, rgba(244,196,48,0.20), transparent 68%),' +
// The wash that keeps the four corners off flat white.
'radial-gradient(ellipse 80% 70% at 50% 50%, transparent 45%, rgba(244,196,48,0.05) 100%)',
}} }}
/> />
{/* Soft Vignette Layer focusing vision on the center container */} <div className="relative z-10 w-full max-w-[1120px] rounded-[28px] bg-white border border-[#EDEAE0] shadow-[0_28px_70px_-30px_rgba(31,29,20,0.22),0_2px_8px_-2px_rgba(31,29,20,0.06)] flex flex-col md:flex-row overflow-hidden">
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse 80% 80% at 50% 50%, transparent 35%, rgba(5, 5, 6, 0.75) 100%)',
}}
/>
{/* Ultra-subtle High-Frequency Fine Grain Noise Overlay (1.8% Opacity) */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.018] mix-blend-overlay"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 250 250' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
backgroundRepeat: 'repeat',
}}
/>
<div className="w-full max-w-[1360px] min-h-[760px] lg:h-[82vh] lg:max-h-[840px] rounded-[24px] bg-[#14151B]/95 border border-white/[0.1] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.85)] backdrop-blur-2xl flex flex-col md:flex-row overflow-hidden relative z-10">
<LoginHeroPanel /> <LoginHeroPanel />
<div className="w-full md:w-[60%] lg:w-[45%] h-full p-8 sm:p-10 lg:p-12 flex flex-col justify-between overflow-y-auto"> <div className="w-full md:w-1/2 p-8 sm:p-10 lg:px-12 lg:py-10 flex flex-col justify-center">
<div className="flex items-center justify-between gap-4 mb-8"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <BrandLogo height={34} priority />
<BrandLogo height={42} priority /> <div className="h-4 w-px bg-[#E2DFD4]" />
<div className="h-4 w-px bg-white/20" /> <span className="text-[11px] font-semibold tracking-[0.14em] text-[#8A8577] uppercase">
<span className="text-xs font-semibold tracking-wide text-neutral-400 uppercase"> Merchant OS
Merchant OS
</span>
</div>
<span className="px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase rounded-full bg-white/[0.06] border border-white/10 text-neutral-300">
v2.4 Enterprise
</span> </span>
</div> </div>
<div className="my-auto py-4"> <div className="mt-9 lg:mt-10">
<div className="mb-8"> <h1 className="text-[30px] sm:text-[36px] font-bold tracking-[-0.02em] leading-[1.1] text-[#14161F]">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white mb-2.5"> Welcome back
Welcome back </h1>
</h1> <p className="mt-3 text-[15px] leading-relaxed text-[#6B6960] max-w-[46ch]">
<p className="text-sm text-neutral-400 leading-relaxed max-w-md"> Access your Merchant Operating System to manage stores, rewards,
Access your Merchant Operating System to manage stores, rewards, staff and AI insights.
staff and AI insights. </p>
</p>
</div>
<LoginCredentialsForm />
</div> </div>
<div className="pt-6 border-t border-white/[0.06] mt-4"> <div className="mt-7">
<p className="text-xs text-neutral-400"> <LoginCredentialsForm />
Need access?{' '}
<a href="#" className="text-white hover:underline font-medium">
Contact your organization administrator.
</a>
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="absolute bottom-3 text-center w-full pointer-events-auto"> {/*
<p className="text-[11px] text-neutral-500"> In normal flow rather than pinned to the viewport bottom: the card grows
By continuing, you agree to Loyaly&apos;s{' '} with the form's error messages, and an absolutely positioned footer sat
<a on top of it as soon as it did.
href="#" */}
className="text-neutral-400 hover:text-white transition-colors underline" <p className="relative z-10 text-[11px] text-[#9C978A] text-center">
> By continuing, you agree to Loyaly&apos;s{' '}
Terms of Service <a
</a>{' '} href="#"
and{' '} className="underline underline-offset-2 hover:text-[#6B6960] transition-colors"
<a >
href="#" Terms of Service
className="text-neutral-400 hover:text-white transition-colors underline" </a>{' '}
> and{' '}
Privacy Policy <a
</a> href="#"
. className="underline underline-offset-2 hover:text-[#6B6960] transition-colors"
</p> >
</div> Privacy Policy
</a>
.
</p>
</div> </div>
); );
} }

View File

@@ -1,43 +0,0 @@
/**
* Brand marks for the federated sign-in buttons.
*
* Inline SVG rather than the icon registry, and the ONLY place in the app
* exempt from the monochrome rule: Google's and Microsoft's marks are
* trademarked artwork whose colours are part of the identity. Recolouring them
* to grey would be both wrong and, in Google's case, a brand-guideline
* violation.
*/
export function GoogleIcon() {
return (
<svg width={18} height={18} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
);
}
export function MicrosoftIcon() {
return (
<svg width={18} height={18} viewBox="0 0 23 23" aria-hidden="true">
<path fill="#f35325" d="M1 1h10v10H1z" />
<path fill="#81bc06" d="M12 1h10v10H12z" />
<path fill="#05a6f0" d="M1 12h10v10H1z" />
<path fill="#ffba08" d="M12 12h10v10H12z" />
</svg>
);
}

View File

@@ -87,10 +87,14 @@ export function useLoginForm() {
if (!result.ok) { if (!result.ok) {
setErrors({[result.error.field]: result.error.message}); setErrors({[result.error.field]: result.error.message});
// The toast carries what the inline message cannot: it is announced, toast({
// it survives a field the user has scrolled past, and it distinguishes type: 'error',
// "we rejected your credentials" from "we never reached the server". body: result.error.message,
toast({type: 'error', body: result.error.message}); isAutoHide: true,
autoHideDuration: 4000,
uniqueID: 'login-error',
collisionBehavior: 'overwrite',
});
setIsSubmitting(false); setIsSubmitting(false);
return; return;
} }

View File

@@ -1,113 +0,0 @@
import type {AuthUser} from '@/features/auth/types/auth';
/**
* The mock user directory. Server-only — nothing here is ever bundled to the
* client, because the only importers are route handlers.
*
* ── On the plaintext passwords ────────────────────────────────────────────
* They are plaintext BECAUSE this is a fixture, and the fixture is the one
* place where that is safe: it never leaves the server, and there is no real
* credential in it. What matters architecturally is that `verifyCredentials`
* below is the ONLY function that ever sees a password. When a real backend
* arrives, that function's body becomes an HTTP call (or an argon2/bcrypt
* comparison against a user table) and every other file in the app is
* untouched — including the login form, which already only knows how to ask.
*
* Do not add a "demo" user that accepts any password. The whole point of this
* layer is that wrong credentials fail.
*/
interface MockUserRecord extends AuthUser {
password: string;
}
const USERS: MockUserRecord[] = [
{
id: 'usr_owner_01',
email: 'aravind@loyaly.in',
name: 'Aravind',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_owner_02',
email: 'aravind@nearle.in',
name: 'Aravind',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_owner_03',
email: 'fazul@loyaly.in',
name: 'Fazul Ilahi',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_mgr_01',
email: 'suriya@loyaly.in',
name: 'Suriya',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_analyst_01',
email: 'analyst@loyaly.ai',
name: 'Rahul Menon',
role: 'analyst',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'reports@2026',
},
];
/** Public projection — the record minus the credential. */
function toAuthUser(record: MockUserRecord): AuthUser {
// Field-by-field rather than a rest-spread that drops `password`: a spread
// silently carries anything added to the record later, and this projection
// is the boundary that keeps credentials out of every response.
return {
id: record.id,
email: record.email,
name: record.name,
role: record.role,
organisation: record.organisation,
};
}
export type CredentialCheck =
| {outcome: 'ok'; user: AuthUser}
| {outcome: 'unknown_email'}
| {outcome: 'wrong_password'};
/**
* The single credential-checking seam.
*
* Note the two distinct failure outcomes. That is a deliberate product choice
* — the brief asks for "wrong email" and "wrong password" to read differently
* — and it is worth knowing that it also makes the login form a user
* enumeration oracle: an attacker can discover which addresses have accounts.
* If that trade stops being acceptable, collapse both branches at the ROUTE
* (map them to one 'invalid_credentials' message) rather than here, so the
* distinction stays available to audit logging.
*/
export function verifyCredentials(
email: string,
password: string,
): CredentialCheck {
const record = USERS.find(
(u) => u.email.toLowerCase() === email.trim().toLowerCase(),
);
if (!record) return {outcome: 'unknown_email'};
if (record.password !== password) return {outcome: 'wrong_password'};
return {outcome: 'ok', user: toAuthUser(record)};
}
/** Used by the session endpoint to re-resolve a cookie subject to a user. */
export function findUserById(id: string): AuthUser | null {
const record = USERS.find((u) => u.id === id);
return record ? toAuthUser(record) : null;
}

View File

@@ -26,6 +26,17 @@ const LOGIN_ERRORS: Record<string, LoginError> = {
password_required: {field: 'password', message: 'Invalid email or password.'}, password_required: {field: 'password', message: 'Invalid email or password.'},
invalid_credentials: {field: 'form', message: 'Invalid email or password.'}, invalid_credentials: {field: 'form', message: 'Invalid email or password.'},
malformed: {field: 'form', message: 'Sign-in failed. Please try again.'}, malformed: {field: 'form', message: 'Sign-in failed. Please try again.'},
// The platform throttles at 10 failures per account and 60 per IP in 15
// minutes, cleared by a success. Distinct from bad credentials because the
// remedy is different: waiting, not retyping.
too_many_attempts: {
field: 'form',
message: 'Too many sign-in attempts. Wait a few minutes and try again.',
},
platform_unreachable: {
field: 'form',
message: 'Could not reach Loyaly. Check your connection and try again.',
},
}; };
export type LoginErrorCode = keyof typeof LOGIN_ERRORS; export type LoginErrorCode = keyof typeof LOGIN_ERRORS;

View File

@@ -1,47 +1,54 @@
import 'server-only'; import 'server-only';
import {cookies} from 'next/headers'; import {cookies} from 'next/headers';
import {findUserById} from '@/features/auth/mock/users.mock';
import { import {
SESSION_COOKIE, SESSION_COOKIE,
verifySessionToken, verifySessionToken,
} from '@/features/auth/services/sessionToken'; } from '@/features/auth/services/sessionToken';
import type {AuthSession} from '@/features/auth/types/auth'; import type {AuthSession, UserRole} from '@/features/auth/types/auth';
/** /**
* Resolve the session on the server, from the signed cookie. * Who the request claims to be, from the signed cookie.
* *
* Used in two places: * ── Identity here, AUTHORITY upstream ────────────────────────────────────
* • the root layout, to SEED the client provider — so a full page load * This resolves identity for RENDERING — seeding the shell so a page load
* paints the signed-in shell immediately instead of flashing a spinner * paints the signed-in header instead of flashing a spinner, and refusing
* while the browser asks who it is * anonymous route handlers early. It is intentionally cheap: verifying an HMAC
* • route handlers, to authorise a request before it touches data * costs microseconds, and this runs on every request.
* *
* `import 'server-only'` is load-bearing: this module reads cookies and the * It is NOT the authority on whether the session is still good. That lives on
* user directory, and importing it from a client component would be a build * the platform, and it answers on two paths that cannot be skipped:
* error rather than a silent leak of the fixture into the browser bundle. *
* • /api/auth/session calls GET /api/auth/me on every page load, so a
* deactivated user loses the shell on their next navigation
* • every data route carries a platform token, so a revoked session gets a
* 401 from the platform itself the moment it asks for anything real
*
* The previous version re-read a fixture directory here to catch role changes.
* There is no local directory any more — the platform is the directory — so
* the check moved to where the platform actually answers.
*/ */
export async function getServerSession(): Promise<AuthSession | null> { export async function getServerSession(): Promise<AuthSession | null> {
const store = await cookies(); const store = await cookies();
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value); const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value);
if (!payload) return null; if (!payload) return null;
// Re-resolved from the directory rather than trusted off the token, so a return {
// role change or a deactivation takes effect on the next request instead of user: {
// whenever the cookie happens to expire. id: payload.sub,
const user = findUserById(payload.sub); email: payload.email,
if (!user) return null; name: payload.name,
role: payload.role as UserRole,
return {user, expiresAt: new Date(payload.exp * 1000).toISOString()}; organisation: payload.organisation,
},
expiresAt: new Date(payload.exp * 1000).toISOString(),
};
} }
/** /**
* Guard for route handlers that must not answer an anonymous request. * Guard for route handlers that must not answer an anonymous request.
* *
* Returns the session, or a ready-made 401 to return early with:
*
* const auth = await requireSession(); * const auth = await requireSession();
* if ('response' in auth) return auth.response; * if ('response' in auth) return auth.response;
* // auth.session is now available
*/ */
export async function requireSession(): Promise< export async function requireSession(): Promise<
{session: AuthSession} | {response: Response} {session: AuthSession} | {response: Response}

View File

@@ -0,0 +1,110 @@
import 'server-only';
import {createCipheriv, createDecipheriv, createHash, randomBytes} from 'node:crypto';
/**
* Where the platform's access and refresh tokens live.
*
* A SECOND cookie, separate from `loyaly_session`, and the split is deliberate:
*
* loyaly_session HMAC-signed identity. `proxy.ts` reads it on every request
* to gate routing, so it must stay cheap to verify.
* loyaly_tokens AES-256-GCM ENCRYPTED token bundle. Only the code that
* actually calls the platform ever opens it.
*
* Signing is not enough here. A signed cookie is tamper-evident but plainly
* readable, and a refresh token is a credential — anyone who obtains the cookie
* value obtains the ability to mint sessions until it rotates. Encrypting means
* the bundle is worthless without AUTH_SECRET, which never leaves the server.
*
* Both cookies are httpOnly, so neither is reachable from JavaScript at all.
*/
const DEV_SECRET = 'loyaly-dev-secret-not-for-production';
function key(): Buffer {
const fromEnv = process.env.AUTH_SECRET;
if (!fromEnv && process.env.NODE_ENV === 'production') {
throw new Error(
'AUTH_SECRET is required in production — refusing to encrypt platform tokens with the development key.',
);
}
// scrypt would be better against an offline attack on the secret itself, but
// this key is derived per process from a value that is already high-entropy
// and never transmitted; sha256 keeps cookie reads off the event loop.
return createHash('sha256').update(fromEnv ?? DEV_SECRET).digest();
}
export const TOKEN_COOKIE = 'loyaly_tokens';
export interface TokenBundle {
accessToken: string;
refreshToken: string;
/** The ACCESS token's expiry, as reported by the platform. */
expiresAt: string;
}
export function sealTokens(bundle: TokenBundle): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key(), iv);
const body = Buffer.concat([
cipher.update(JSON.stringify(bundle), 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64url')}.${Buffer.concat([body, tag]).toString('base64url')}`;
}
/**
* Every failure mode — missing, malformed, tampered, wrong key — returns null,
* because callers must treat all of them identically: no usable tokens, start
* again at the login form.
*/
export function openTokens(sealed: string | undefined): TokenBundle | null {
if (!sealed) return null;
const dot = sealed.indexOf('.');
if (dot <= 0) return null;
try {
const iv = Buffer.from(sealed.slice(0, dot), 'base64url');
const payload = Buffer.from(sealed.slice(dot + 1), 'base64url');
if (payload.length <= 16) return null;
const tag = payload.subarray(payload.length - 16);
const body = payload.subarray(0, payload.length - 16);
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
decipher.setAuthTag(tag);
const json = Buffer.concat([
decipher.update(body),
decipher.final(),
]).toString('utf8');
const parsed = JSON.parse(json) as TokenBundle;
if (!parsed.accessToken || !parsed.refreshToken) return null;
return parsed;
} catch {
// GCM authentication failure lands here — that is the tamper case, and it
// is indistinguishable from a key rotation, which is the correct outcome.
return null;
}
}
/**
* Cookie attributes, in one place so login and logout cannot disagree about
* scope — a delete that misses on `path` leaves a live credential behind.
*
* The token cookie deliberately carries no Max-Age: it is a session cookie
* tied to the browser process, while `loyaly_session` carries the real
* lifetime. If the two ever disagree, the missing tokens simply force a
* re-login rather than leaving a half-authenticated state.
*/
export function tokenCookieOptions(maxAgeSeconds?: number) {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
...(maxAgeSeconds ? {maxAge: maxAgeSeconds} : {}),
};
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import {cookies} from 'next/headers';
import {createHash} from 'node:crypto';
import {UpstreamError, upstreamRequest} from '@/services/api/apiClient';
import type {ApiTokenBundle} from '@/services/api/types';
import {
TOKEN_COOKIE,
openTokens,
sealTokens,
tokenCookieOptions,
type TokenBundle,
} from './tokenStore';
/**
* Authenticated access to the platform, with the three refresh rules the
* platform documents — each of which has already caused a bug somewhere.
*
* 1. A 401 carrying `token_expired` is NOT a sign-out. Refresh once and retry
* silently, or staff are thrown back to a login form twice a day.
* 2. Serialise refresh behind ONE lock. Refresh tokens are single-use, so
* concurrent callers would each spend it and all but one would lose. This
* dashboard fires nine parallel requests on a single page load, so this is
* not a rare race — it is the first thing that happens after an expiry.
* 3. Persist the rotated pair BEFORE using it. A process killed between
* refresh and persist comes back holding a token the platform has already
* invalidated, which is indistinguishable from a normal expiry at the
* worst possible moment.
*/
/**
* In-flight refreshes, keyed by a digest of the refresh token being spent.
*
* Keyed rather than global so two different users refreshing at the same
* instant do not wait on each other. Module scope means one lock per server
* process — correct for a single instance, and best-effort across a horizontal
* fleet, where two instances can still collide. That residual race is why rule
* 3 exists: the loser gets a 401 and re-authenticates rather than corrupting
* anything.
*/
const inFlight = new Map<string, Promise<TokenBundle>>();
function lockKey(refreshToken: string): string {
return createHash('sha256').update(refreshToken).digest('base64url');
}
async function readTokens(): Promise<TokenBundle | null> {
const store = await cookies();
return openTokens(store.get(TOKEN_COOKIE)?.value);
}
/**
* Write the bundle to the cookie. Only possible inside a route handler —
* a Server Component's cookie store is read-only, which is exactly why every
* platform call goes through a route rather than being made during render.
*/
async function persistTokens(bundle: TokenBundle): Promise<void> {
const store = await cookies();
store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions());
}
export function toBundle(res: ApiTokenBundle): TokenBundle {
return {
accessToken: res.access_token,
refreshToken: res.refresh_token,
expiresAt: res.expires_at,
};
}
/**
* Spend the refresh token for a new pair, at most once per token.
*
* The persist happens INSIDE the locked section, before the promise resolves,
* so every waiter observes a bundle that is already durable.
*/
async function refresh(current: TokenBundle): Promise<TokenBundle> {
const k = lockKey(current.refreshToken);
const existing = inFlight.get(k);
if (existing) return existing;
const run = (async () => {
const res = await upstreamRequest<ApiTokenBundle>({
path: '/api/auth/refresh',
method: 'POST',
body: {refresh_token: current.refreshToken, device: 'Loyaly Web Console'},
});
const next = toBundle(res);
await persistTokens(next);
return next;
})();
inFlight.set(k, run);
try {
return await run;
} finally {
inFlight.delete(k);
}
}
/** Thrown when there is no usable session at all — the caller must 401. */
export class NoSessionError extends Error {
constructor() {
super('Sign in to continue.');
this.name = 'NoSessionError';
}
}
/**
* Run an authenticated platform call, refreshing once if the access token has
* expired.
*
* `fn` receives the token rather than the request being described declaratively
* so that domain services stay plain functions: `sitesApi.list(token)` is
* callable from a test with any token, and this wrapper is the only thing that
* knows about cookies.
*
* `fn` MUST be safe to run twice. Every domain service in src/services/api
* marshals its body from a plain object, so a retry re-sends it correctly; a
* caller that streams a body would break rule 3's retry and must not use this.
*/
export async function withUpstream<T>(
fn: (accessToken: string) => Promise<T>,
): Promise<T> {
const tokens = await readTokens();
if (!tokens) throw new NoSessionError();
try {
return await fn(tokens.accessToken);
} catch (err) {
if (!(err instanceof UpstreamError) || !err.isTokenExpired) throw err;
// One retry, never a loop: if the freshly minted token is also rejected
// the problem is not expiry, and retrying would spend refresh tokens in a
// circle while the user waits.
const next = await refresh(tokens);
return fn(next.accessToken);
}
}
/** Persist a bundle at sign-in / registration. Route handlers only. */
export async function storeTokens(res: ApiTokenBundle): Promise<TokenBundle> {
const bundle = toBundle(res);
await persistTokens(bundle);
return bundle;
}
export async function clearTokens(): Promise<void> {
const store = await cookies();
store.delete(TOKEN_COOKIE);
}
/** The access token without any refresh attempt — for fire-and-forget calls
* such as logout, where a refresh would be pointless work. */
export async function peekAccessToken(): Promise<string | null> {
const tokens = await readTokens();
return tokens?.accessToken ?? null;
}

View File

@@ -0,0 +1,31 @@
import type {ApiUser} from '@/services/api/types';
import type {AuthUser, UserRole} from '@/features/auth/types/auth';
/**
* Platform user → the shape the console's components already consume.
*
* One translation, in one place. Renaming `full_name` at each call site is how
* two screens end up disagreeing about what to show when it is empty.
*/
export function toAuthUser(u: ApiUser): AuthUser {
return {
id: u.id,
email: u.email,
// Falls back to the address rather than rendering an empty header: a user
// invited but not yet named still has to be identifiable.
name: u.full_name || u.email,
role: u.role as UserRole,
organisation: u.client_name,
};
}
/**
* A platform operator, not a merchant.
*
* Both conditions, never the role alone — that pairing is what the platform
* documents, and checking only the role would let a tenant-scoped account with
* an admin-shaped role read as a platform operator.
*/
export function isPlatformAdmin(u: ApiUser): boolean {
return u.role === 'admin' && u.client_id === '';
}

View File

@@ -7,7 +7,14 @@
* UI below it does not move. * UI below it does not move.
*/ */
export type UserRole = 'owner' | 'manager' | 'analyst'; /**
* The platform's roles, in increasing order of privilege.
*
* `analyst` used to be here and does not exist upstream — it was invented by
* the fixture directory. `admin` is a PLATFORM operator, identified by the
* role AND an empty organisation together, never by the role alone.
*/
export type UserRole = 'staff' | 'manager' | 'owner' | 'admin';
/** The authenticated principal. Never contains credentials. */ /** The authenticated principal. Never contains credentials. */
export interface AuthUser { export interface AuthUser {

View File

@@ -1,75 +0,0 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {ICONS} from '@/shared/utils/icons';
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {getCommerceKpis} from '../services/commerceService';
export function CommerceKpiRow() {
const {storeId, range} = useWorkspace();
const kpis = getCommerceKpis(storeId, range);
const kpiList = [
{
label: 'Total Revenue',
value: kpis.revenue.rawValue,
format: formatInrCompact,
icon: ICONS.revenue,
deltaPct: 14.2,
},
{
label: 'Total Orders',
value: kpis.orders.rawValue,
format: formatCount,
icon: ICONS.purchases,
deltaPct: 8.5,
},
{
label: 'Avg Order Value',
value: kpis.aov.rawValue,
format: formatInrCompact,
icon: ICONS.purchases,
deltaPct: 5.1,
},
{
label: 'Net Sales',
value: kpis.netSales.rawValue,
format: formatInrCompact,
icon: ICONS.revenue,
deltaPct: 13.9,
},
{
label: 'Refunds',
value: kpis.refunds.rawValue,
format: formatInrCompact,
icon: ICONS.alert,
deltaPct: -2.4,
isRiseGood: false,
},
{
label: 'Conversion',
value: kpis.conversion.rawValue,
format: (v: number) => formatPct(v, 1),
icon: ICONS.conversion,
deltaPct: 1.4,
},
];
return (
<Grid columns={{minWidth: 220, max: 6, repeat: 'fit'}} gap={4}>
{kpiList.map((kpi) => (
<MetricCard
key={kpi.label}
label={kpi.label}
value={kpi.value}
format={kpi.format}
icon={kpi.icon}
deltaPct={kpi.deltaPct}
isRiseGood={kpi.isRiseGood}
/>
))}
</Grid>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
const RECENT_ORDERS = [
{id: 'o1', code: '#LYT-9842', customer: 'Aarav Sharma', store: 'Indiranagar Flagship', amount: '₹2,450', method: 'UPI', status: 'completed', time: '2m ago'},
{id: 'o2', code: '#LYT-9841', customer: 'Priya Patel', store: 'Koramangala 80ft', amount: '₹1,890', method: 'Credit Card', status: 'completed', time: '5m ago'},
{id: 'o3', code: '#LYT-9840', customer: 'Rohan Mehta', store: 'Jayanagar 4th Block', amount: '₹3,120', method: 'Shopify', status: 'completed', time: '12m ago'},
{id: 'o4', code: '#LYT-9839', customer: 'Ananya Roy', store: 'Whitefield Main', amount: '₹950', method: 'UPI', status: 'completed', time: '18m ago'},
{id: 'o5', code: '#LYT-9838', customer: 'Kabir Verma', store: 'Indiranagar Flagship', amount: '₹4,200', method: 'Card', status: 'completed', time: '24m ago'},
];
export function CommerceRecentOrders() {
return (
<div className="rounded-xl border border-border bg-card p-4 space-y-3 shadow-sm">
<div className="flex items-center justify-between border-b border-border pb-3">
<div>
<Heading level={4} className="text-base font-bold text-primary flex items-center gap-2">
<Icon icon={ICONS.purchases} size="sm" className="text-secondary" />
Recent Orders
</Heading>
<Text size="sm" color="secondary">
Real-time completed orders across your network
</Text>
</div>
<Badge variant="success" label="Live Feed" />
</div>
<div className="divide-y divide-border">
{RECENT_ORDERS.map((ord) => (
<div key={ord.id} className="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between text-sm">
<div>
<div className="font-bold text-primary flex items-center gap-2">
{ord.code}
<span className="text-xs text-secondary font-normal">• {ord.customer}</span>
</div>
<div className="text-xs text-secondary font-medium">
{ord.store} ({ord.method})
</div>
</div>
<div className="text-right">
<div className="font-bold text-primary tabular-nums">{ord.amount}</div>
<div className="text-xs text-secondary">{ord.time}</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {formatInrCompact} from '@/shared/utils/format';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {getCommerceKpis} from '../services/commerceService';
export function CommerceRevenueSummary() {
const {storeId, range} = useWorkspace();
const kpis = getCommerceKpis(storeId, range);
return (
<div className="rounded-xl border border-border bg-card p-4 space-y-4 shadow-sm">
<div className="border-b border-border pb-3">
<Heading level={4} className="text-base font-bold text-primary">
Revenue Summary
</Heading>
<Text size="sm" color="secondary">
Financial performance rollup for the selected period
</Text>
</div>
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="Gross Sales"
value={kpis.grossSales.rawValue}
format={formatInrCompact}
deltaPct={12.8}
/>
<MetricCard
label="Discounts & Promos"
value={kpis.discounts.rawValue}
format={formatInrCompact}
deltaPct={-4.1}
/>
<MetricCard
label="Refunds Processed"
value={kpis.refunds.rawValue}
format={formatInrCompact}
deltaPct={-2.4}
isRiseGood={false}
/>
<MetricCard
label="Net Realized Revenue"
value={kpis.netSales.rawValue}
format={formatInrCompact}
deltaPct={13.9}
/>
</div>
</div>
);
}

View File

@@ -1,72 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
const SALES_INSIGHTS = [
{
id: 'si1',
title: 'Evening Peak Surge',
description: 'Evening hours (17:30 - 19:30) generated 38% of total daily revenue.',
intent: 'positive',
badge: 'Growth Opportunity',
},
{
id: 'si2',
title: 'Stock Reorder Warning',
description: 'Artisanal Cold Brew Pack (18 left) is approaching stockout threshold.',
intent: 'warning',
badge: 'Action Needed',
},
{
id: 'si3',
title: 'Indiranagar Conversion Leader',
description: 'Indiranagar delivered +18.4% MoM conversion growth after digital pick-up lanes.',
intent: 'positive',
badge: 'Best Performer',
},
];
export function CommerceSalesInsights() {
return (
<div className="rounded-xl border border-border bg-card p-4 space-y-3 shadow-sm">
<div className="flex items-center justify-between border-b border-border pb-3">
<div>
<Heading level={4} className="text-base font-bold text-primary flex items-center gap-2">
<Icon icon={ICONS.ai} size="sm" className="text-amber-400" />
Sales Insights
</Heading>
<Text size="sm" color="secondary">
Automated intelligence highlights & telemetry warnings
</Text>
</div>
</div>
<div className="space-y-3">
{SALES_INSIGHTS.map((item) => (
<div
key={item.id}
className={`p-3 rounded-lg border flex flex-col justify-between gap-1.5 ${
item.intent === 'warning'
? 'border-amber-500/30 bg-amber-500/[0.03]'
: 'border-emerald-500/30 bg-emerald-500/[0.03]'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-primary">{item.title}</span>
<Badge
variant={item.intent === 'warning' ? 'warning' : 'success'}
label={item.badge}
/>
</div>
<Text size="sm" color="secondary">
{item.description}
</Text>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {CommerceKpiRow} from './CommerceKpiRow';
import {RevenueChart} from './analytics/RevenueChart';
import {OrdersChart} from './analytics/OrdersChart';
import {PaymentBreakdown} from './analytics/PaymentBreakdown';
import {StorePerformanceChart} from './analytics/StorePerformanceChart';
import {TopProductsTable} from './analytics/TopProductsTable';
import {CommerceSalesInsights} from './CommerceSalesInsights';
import {CommerceRevenueSummary} from './CommerceRevenueSummary';
import {CommerceRecentOrders} from './CommerceRecentOrders';
export function CommerceWorkspace() {
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
{/* Sticky Header matching Dashboard visual language */}
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
<PageHeader
eyebrow="Sales & Revenue Analytics"
title="Commerce"
description={`Sales, orders, revenue and store performance across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
</div>
{/* 1 — Headline KPI Cards Row */}
<CommerceKpiRow />
{/* 2 — Primary Sales Trends (Revenue Trend & Orders Trend) */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<RevenueChart />
<OrdersChart />
</Grid>
{/* 3 — Revenue Breakdown & Store Performance */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<PaymentBreakdown />
<StorePerformanceChart />
</Grid>
{/* 4 — Top Selling Products Leaderboard & Automated Sales Insights */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<TopProductsTable />
<CommerceSalesInsights />
</Grid>
{/* 5 — Financial Revenue Rollup & Recent Orders Live Feed */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<CommerceRevenueSummary />
<CommerceRecentOrders />
</Grid>
</VStack>
);
}

View File

@@ -1,77 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
import {BUSINESS_ALERTS} from '../../services/commerceService';
export function BusinessAlerts() {
return (
<div className="space-y-3">
<Heading level={4} className="text-base font-bold text-primary">
Real-Time Business & Operational Alerts
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{BUSINESS_ALERTS.map((alert) => {
const isCritical = alert.severity === 'critical';
const isWarning = alert.severity === 'warning';
const isSuccess = alert.severity === 'success';
const borderClass = isCritical
? 'border-rose-500/30 bg-rose-500/[0.03]'
: isWarning
? 'border-amber-500/30 bg-amber-500/[0.03]'
: isSuccess
? 'border-emerald-500/30 bg-emerald-500/[0.03]'
: 'border-blue-500/30 bg-blue-500/[0.03]';
return (
<div
key={alert.id}
className={`rounded-xl border p-4 shadow-sm flex flex-col justify-between gap-3 ${borderClass}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2.5">
<Icon
icon={isCritical || isWarning ? ICONS.alert : isSuccess ? ICONS.up : ICONS.info}
size="sm"
className={
isCritical
? 'text-rose-400'
: isWarning
? 'text-amber-400'
: isSuccess
? 'text-emerald-400'
: 'text-blue-400'
}
/>
<div>
<span className="text-sm font-bold text-primary block">{alert.title}</span>
<Text size="sm" color="secondary" className="mt-0.5">
{alert.description}
</Text>
</div>
</div>
<Badge
variant={isCritical ? 'error' : isWarning ? 'warning' : isSuccess ? 'success' : 'info'}
label={alert.type.replace('_', ' ').toUpperCase()}
/>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="button"
className="text-xs font-bold text-primary hover:text-amber-300 transition-colors flex items-center gap-1"
>
{alert.actionText} →
</button>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,109 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
const ORDER_FLOW = [
{step: 'Customer', detail: 'Identified / Guest'},
{step: 'Purchase', detail: 'Cart Checkout'},
{step: 'Payment', detail: 'UPI / Card Auth'},
{step: 'Order', detail: 'KDS & POS Created'},
{step: 'Packing', detail: 'Fulfillment Prep'},
{step: 'Delivery', detail: 'Dispatch / Pickup'},
{step: 'Completed', detail: 'Ledger Settled'},
];
const REVENUE_FLOW = [
{step: 'Visitor', detail: 'Store / Web Traffic'},
{step: 'Conversion', detail: 'Checkout Rate'},
{step: 'Order', detail: 'Gross Volume'},
{step: 'Revenue', detail: 'Net Payment'},
{step: 'Profit', detail: 'Margin Settled'},
];
const INVENTORY_FLOW = [
{step: 'Supplier', detail: 'PO Placed'},
{step: 'Warehouse', detail: 'Stock Received'},
{step: 'Store', detail: 'Shelf Allocation'},
{step: 'Customer', detail: 'Item Fulfilled'},
];
export function OperationalFlowcharts() {
return (
<div className="rounded-xl border border-border bg-card p-5 space-y-6 shadow-sm overflow-hidden">
<div>
<Heading level={4} className="text-base font-bold text-primary">
Operational Flow Diagrams
</Heading>
<Text size="sm" color="secondary">
Visual operational pipeline lifecycle maps for orders, revenue settlements and inventory movement
</Text>
</div>
{/* Order Flow Diagram */}
<div className="space-y-2">
<span className="text-xs font-bold text-amber-400 uppercase tracking-wider">
1. Order Lifecycle Flow
</span>
<div className="overflow-x-auto pb-2">
<div className="flex items-center gap-2 min-w-[640px]">
{ORDER_FLOW.map((node, i) => (
<div key={node.step} className="flex items-center gap-2 flex-1">
<div className="flex-1 rounded-lg border border-border-strong bg-card p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-primary">{node.step}</div>
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
</div>
{i < ORDER_FLOW.length - 1 ? (
<span className="text-disabled font-bold text-sm shrink-0">→</span>
) : null}
</div>
))}
</div>
</div>
</div>
{/* Revenue Flow Diagram */}
<div className="space-y-2">
<span className="text-xs font-bold text-emerald-400 uppercase tracking-wider">
2. Revenue Settlement Flow
</span>
<div className="overflow-x-auto pb-2">
<div className="flex items-center gap-2 min-w-[500px]">
{REVENUE_FLOW.map((node, i) => (
<div key={node.step} className="flex items-center gap-2 flex-1">
<div className="flex-1 rounded-lg border border-emerald-950/60 bg-emerald-950/20 p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-emerald-300">{node.step}</div>
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
</div>
{i < REVENUE_FLOW.length - 1 ? (
<span className="text-emerald-600 font-bold text-sm shrink-0">→</span>
) : null}
</div>
))}
</div>
</div>
</div>
{/* Inventory Flow Diagram */}
<div className="space-y-2">
<span className="text-xs font-bold text-indigo-400 uppercase tracking-wider">
3. Inventory & Supply Chain Flow
</span>
<div className="overflow-x-auto pb-2">
<div className="flex items-center gap-2 min-w-[450px]">
{INVENTORY_FLOW.map((node, i) => (
<div key={node.step} className="flex items-center gap-2 flex-1">
<div className="flex-1 rounded-lg border border-indigo-950/60 bg-indigo-950/20 p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-indigo-300">{node.step}</div>
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
</div>
{i < INVENTORY_FLOW.length - 1 ? (
<span className="text-indigo-600 font-bold text-sm shrink-0">→</span>
) : null}
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {getOrdersTrendData} from '../../services/commerceService';
export function OrdersChart() {
const {storeId, range} = useWorkspace();
const data = getOrdersTrendData(storeId, range);
const totalOrders = data.reduce((acc, curr) => acc + curr.Orders, 0);
return (
<div className="rounded-xl border border-border bg-card p-4 space-y-3 overflow-hidden shadow-sm">
<div className="flex items-center justify-between">
<div>
<Heading level={4} className="text-base font-bold text-primary">
Orders Volume Trend
</Heading>
<Text size="sm" color="secondary">
Completed order count grouped by hourly buckets
</Text>
</div>
<span className="text-xs font-bold text-primary bg-muted px-2.5 py-1 rounded-full border border-border-strong">
{totalOrders.toLocaleString()} Orders
</span>
</div>
<div className="h-64 w-full pt-2">
<BarChartView
data={data}
xKey="time"
series={[{key: 'Orders', label: 'Completed Orders'}]}
height="100%"
/>
</div>
</div>
);
}

View File

@@ -1,165 +0,0 @@
'use client';
import {useState} from 'react';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {getPaymentBreakdownData} from '../../services/commerceService';
export function PaymentBreakdown() {
const {storeId, range} = useWorkspace();
const paymentData = getPaymentBreakdownData(storeId, range);
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const activeItem = selectedMethod
? paymentData.find((p) => p.method === selectedMethod)
: null;
return (
<div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<Heading level={4} className="text-base font-bold text-primary">
Payment Methods Breakdown
</Heading>
<Text size="sm" color="secondary">
Revenue share distribution by payment channel
</Text>
</div>
{/* Interactive Channel Filter Buttons */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setSelectedMethod(null)}
className={`px-2.5 py-1 rounded-md text-xs font-bold transition-all ${
selectedMethod === null
? 'bg-muted text-primary shadow-sm'
: 'bg-card text-secondary hover:text-primary border border-border'
}`}
>
All
</button>
{paymentData.map((item) => (
<button
key={item.method}
type="button"
onClick={() =>
setSelectedMethod(selectedMethod === item.method ? null : item.method)
}
className={`px-2.5 py-1 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${
selectedMethod === item.method
? 'text-primary border shadow-sm'
: 'bg-card text-secondary hover:text-primary border border-border'
}`}
style={{
borderColor: selectedMethod === item.method ? item.color : undefined,
backgroundColor: selectedMethod === item.method ? `${item.color}22` : undefined,
}}
>
<span
className="size-2 rounded-full shrink-0"
style={{backgroundColor: item.color}}
/>
{item.method.split(' ')[0]}
</button>
))}
</div>
</div>
{/* Visual Ring / Stack Bar */}
<div className="h-4 w-full rounded-full bg-muted overflow-hidden flex shadow-inner p-0.5">
{paymentData.map((item) => {
const isSelected = selectedMethod === null || selectedMethod === item.method;
return (
<div
key={item.method}
onClick={() =>
setSelectedMethod(selectedMethod === item.method ? null : item.method)
}
style={{
width: `${item.percentage}%`,
backgroundColor: item.color,
opacity: isSelected ? 1 : 0.25,
}}
className="h-full cursor-pointer transition-all duration-300 first:rounded-l-full last:rounded-r-full hover:brightness-125"
title={`${item.method}: ${item.percentage}% (${item.amount}) — Click to filter`}
/>
);
})}
</div>
{/* Interactive Breakdown Cards */}
<div className="grid grid-cols-2 gap-3 pt-1">
{paymentData.map((item) => {
const isSelected = selectedMethod === item.method;
return (
<div
key={item.method}
onClick={() =>
setSelectedMethod(isSelected ? null : item.method)
}
className={`flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all border ${
isSelected
? 'bg-muted shadow-md ring-1'
: selectedMethod !== null
? 'bg-card border-border opacity-60 hover:opacity-100'
: 'bg-card border-border hover:border-border-strong'
}`}
style={{
borderColor: isSelected ? item.color : undefined,
}}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className="size-3.5 rounded-full shrink-0 shadow-sm"
style={{backgroundColor: item.color}}
/>
<div>
<span className="text-xs font-bold text-primary block truncate">
{item.method}
</span>
<span className="text-[10px] font-semibold text-secondary">
{item.percentage}% share
</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-bold text-primary tabular-nums">
{item.amount}
</div>
{isSelected ? (
<Badge variant="success" label="Selected" />
) : null}
</div>
</div>
);
})}
</div>
{/* Selected Channel Deep-Dive Banner */}
{activeItem ? (
<div className="p-3 rounded-lg bg-card border border-border-strong flex items-center justify-between text-xs animate-fadeIn">
<div className="flex items-center gap-2">
<span
className="size-2.5 rounded-full"
style={{backgroundColor: activeItem.color}}
/>
<span className="font-bold text-primary">{activeItem.method} Channel Details:</span>
<span className="text-secondary">
{activeItem.amount} collected across selected scope terminals.
</span>
</div>
<button
type="button"
onClick={() => setSelectedMethod(null)}
className="text-secondary hover:text-primary font-bold text-xs"
>
Clear Filter ✕
</button>
</div>
) : null}
</div>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {getRevenueTrendData} from '../../services/commerceService';
export function RevenueChart() {
const {storeId, range} = useWorkspace();
const data = getRevenueTrendData(storeId, range);
return (
<div className="rounded-xl border border-border bg-card p-4 space-y-3 overflow-hidden shadow-sm">
<div className="flex items-center justify-between">
<div>
<Heading level={4} className="text-base font-bold text-primary">
Revenue Trend
</Heading>
<Text size="sm" color="secondary">
Hourly revenue performance against targets
</Text>
</div>
<span className="text-xs font-bold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-full border border-emerald-500/20">
+14.2% Above Target
</span>
</div>
<div className="h-64 w-full pt-2">
<AreaChartView
data={data}
xKey="time"
series={[
{key: 'Revenue', label: 'Actual Revenue (₹)'},
{key: 'Target', label: 'Baseline Target (₹)'},
]}
height="100%"
/>
</div>
</div>
);
}

View File

@@ -1,45 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {STORE_PERFORMANCE_DATA} from '../../services/commerceService';
export function StorePerformanceChart() {
const maxRevenue = Math.max(...STORE_PERFORMANCE_DATA.map((s) => s.Revenue)) || 1;
return (
<div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm overflow-hidden">
<div>
<Heading level={4} className="text-base font-bold text-primary">
Top Performing Outlets
</Heading>
<Text size="sm" color="secondary">
Store revenue comparison across your network
</Text>
</div>
<div className="space-y-3 pt-1">
{STORE_PERFORMANCE_DATA.map((store) => {
const widthPercent = (store.Revenue / maxRevenue) * 100;
return (
<div key={store.store} className="space-y-1">
<div className="flex justify-between items-center text-xs font-semibold">
<span className="text-primary font-bold">{store.store}</span>
<span className="text-emerald-400 font-bold tabular-nums">
₹{(store.Revenue / 100000).toFixed(1)}L
</span>
</div>
{/* bg-muted, not bg-card: the track sits INSIDE a card, so it
needs the next surface step up to read as a track at all. */}
<div className="h-6 w-full rounded-md bg-muted p-0.5 border border-border">
<div
className="h-full rounded bg-gradient-to-r from-emerald-600 to-emerald-400 transition-all duration-500"
style={{width: `${widthPercent}%`}}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
'use client';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {getTopProductsData} from '../../services/commerceService';
export function TopProductsTable() {
const {storeId, range} = useWorkspace();
const products = getTopProductsData(storeId, range);
return (
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm space-y-3">
<div className="p-4 border-b border-border bg-card">
<Heading level={4} className="text-base font-bold text-primary">
Top Selling Products Leaderboard
</Heading>
<Text size="sm" color="secondary">
Product sales volume, revenue growth and real-time inventory counts
</Text>
</div>
<div className="overflow-x-auto w-full">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="border-b border-border bg-card text-primary font-bold">
<th className="px-4 py-3 text-left">Product Name</th>
<th className="px-4 py-3 text-left">Category</th>
<th className="px-4 py-3 text-right">Units Sold</th>
<th className="px-4 py-3 text-right">Total Revenue</th>
<th className="px-4 py-3 text-right">Growth</th>
<th className="px-4 py-3 text-center">Stock Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{products.map((prod) => (
<tr key={prod.id} className="hover:bg-overlay-hover transition-colors">
<td className="px-4 py-3.5 text-primary font-semibold">{prod.name}</td>
<td className="px-4 py-3.5 text-secondary font-medium">{prod.category}</td>
<td className="px-4 py-3.5 text-right text-primary font-bold tabular-nums">{prod.unitsSold.toLocaleString()}</td>
<td className="px-4 py-3.5 text-right text-primary font-bold tabular-nums">{prod.revenue}</td>
<td className="px-4 py-3.5 text-right font-bold tabular-nums">
<span className={prod.growthDirection === 'up' ? 'text-emerald-400' : 'text-rose-400'}>
{prod.growth}
</span>
</td>
<td className="px-4 py-3.5 text-center">
<Badge
variant={
prod.stockStatus === 'in_stock'
? 'success'
: prod.stockStatus === 'low_stock'
? 'warning'
: 'error'
}
label={`${prod.stockCount} left`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {DownloadDropdown} from '@/shared/components/patterns/DownloadDropdown';
import {ICONS} from '@/shared/utils/icons';
import type {CommerceFilterState} from '../../types/commerce';
export function CommerceFilterToolbar({
filters,
onFilterChange,
onExport,
onCompare,
}: {
filters: CommerceFilterState;
onFilterChange: (f: CommerceFilterState) => void;
onExport: (format: 'pdf' | 'excel' | 'csv') => void;
onCompare?: () => void;
}) {
return (
<HStack gap={2} vAlign="center" className="flex-wrap">
{/* Date Range Selector */}
<div className="flex items-center rounded-lg border border-border bg-card p-0.5 text-xs font-semibold">
{(['today', '7d', '30d', '90d'] as const).map((r) => (
<button
key={r}
type="button"
onClick={() => onFilterChange({...filters, dateRange: r})}
className={`px-2.5 py-1 rounded-md transition-colors capitalize ${
filters.dateRange === r
? 'bg-muted text-primary font-bold'
: 'text-secondary hover:text-primary'
}`}
>
{r === 'today' ? 'Today' : r === '7d' ? '7 Days' : r === '30d' ? '30 Days' : '90 Days'}
</button>
))}
</div>
{/* Store Filter */}
<select
value={filters.storeId}
onChange={(e) => onFilterChange({...filters, storeId: e.target.value})}
className="h-8 rounded-lg border border-border bg-card px-2.5 text-xs font-semibold text-primary outline-none focus:border-border-strong"
>
<option value="all">All Active Stores</option>
<option value="indiranagar">Indiranagar Flagship</option>
<option value="koramangala">Koramangala 80ft</option>
<option value="jayanagar">Jayanagar 4th Block</option>
<option value="whitefield">Whitefield Main</option>
</select>
{/* Compare Button */}
{onCompare ? (
<Button
size="sm"
variant="secondary"
label="Compare"
icon={<Icon icon={ICONS.compare} size="sm" />}
onClick={onCompare}
/>
) : null}
{/* Export Dropdown */}
<DownloadDropdown
filename={`Commerce_Report_${new Date().toISOString().slice(0, 10)}`}
title="Loyaly Commerce Performance Report"
subtitle="Sales, revenue and order analytics"
columns={[
{key: 'metric', header: 'Metric Name'},
{key: 'value', header: 'Value'},
]}
data={[
{metric: "Today's Revenue", value: '₹3,40,000'},
{metric: 'Total Orders', value: '1,284'},
{metric: 'Average Order Value', value: '₹1,420'},
]}
/>
</HStack>
);
}

View File

@@ -1,91 +0,0 @@
'use client';
import {useCallback, useEffect, useState} from 'react';
import type {CommerceFilterState} from '../types/commerce';
import {
BUSINESS_ALERTS,
getCommerceKpis,
getPaymentBreakdownData,
getTopProductsData,
} from '../services/commerceService';
import {exportData} from '@/shared/utils/export/exportManager';
const STORAGE_WIDTH_KEY = 'loyaly.commerce.pulsePanelWidth';
const DEFAULT_PANEL_WIDTH = 340;
export function useCommerce() {
const [filters, setFilters] = useState<CommerceFilterState>({
dateRange: 'today',
storeId: 'all',
category: 'all',
paymentMethod: 'all',
salesChannel: 'all',
});
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_WIDTH_KEY);
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 280 && parsed <= 500) {
setPanelWidth(parsed);
}
}
} catch {
/* ignore */
}
}, []);
const updatePanelWidth = useCallback((width: number) => {
const clamped = Math.max(280, Math.min(width, 500));
setPanelWidth(clamped);
try {
localStorage.setItem(STORAGE_WIDTH_KEY, clamped.toString());
} catch {
/* ignore */
}
}, []);
const kpis = getCommerceKpis(filters.storeId, filters.dateRange);
const handleExport = useCallback(async (format: 'pdf' | 'excel' | 'csv') => {
const dataRows = [
{metric: 'Total Revenue', value: kpis.revenue.title, change: kpis.revenue.change},
{metric: 'Total Orders', value: kpis.orders.title, change: kpis.orders.change},
{metric: 'Average Order Value', value: kpis.aov.title, change: kpis.aov.change},
{metric: 'Net Sales', value: kpis.netSales.title, change: kpis.netSales.change},
{metric: 'Refunds', value: kpis.refunds.title, change: kpis.refunds.change},
{metric: 'Conversion Rate', value: kpis.conversion.title, change: kpis.conversion.change},
];
await exportData({
filename: `Commerce_Analytics_${new Date().toISOString().slice(0, 10)}`,
title: 'Loyaly Commerce Business Intelligence Report',
subtitle: `Generated on ${new Date().toLocaleDateString()} • Date Range: ${filters.dateRange.toUpperCase()}`,
columns: [
{key: 'metric', header: 'Metric Name'},
{key: 'value', header: 'Current Value'},
{key: 'change', header: 'Trend / Delta'},
],
data: dataRows,
format,
});
}, [filters, kpis]);
return {
filters,
setFilters,
isPanelOpen,
setIsPanelOpen,
panelWidth,
updatePanelWidth,
handleExport,
kpis,
alerts: BUSINESS_ALERTS,
topProducts: getTopProductsData(filters.storeId, filters.dateRange),
paymentSplits: getPaymentBreakdownData(filters.storeId, filters.dateRange),
};
}

View File

@@ -1,27 +0,0 @@
import {
STORE_PERFORMANCE_DATA,
getCommerceKpis,
getOrdersTrendData,
getPaymentBreakdownData,
getRevenueTrendData,
getTopProductsData,
} from '../services/commerceService';
import type {CommerceFilterState} from '../types/commerce';
export class CommerceRepository {
async getAnalytics(filters: CommerceFilterState) {
const storeId = filters.storeId || 'all';
const range = filters.dateRange || '7d';
return {
kpis: getCommerceKpis(storeId, range),
revenueTrend: getRevenueTrendData(storeId, range),
ordersTrend: getOrdersTrendData(storeId, range),
paymentBreakdown: getPaymentBreakdownData(storeId, range),
storePerformance: STORE_PERFORMANCE_DATA,
topProducts: getTopProductsData(storeId, range),
};
}
}
export const commerceRepository = new CommerceRepository();

View File

@@ -1,189 +0,0 @@
import type {
BusinessAlert,
MetricCardData,
PaymentBreakdownItem,
TopProductRow,
} from '../types/commerce';
const STORE_MULTIPLIERS: Record<string, number> = {
all: 1.0,
'blr-indiranagar': 0.42,
'blr-koramangala': 0.28,
'blr-whitefield': 0.18,
'che-anna-nagar': 0.12,
'hyd-jubilee': 0.1,
};
const RANGE_MULTIPLIERS: Record<string, number> = {
'7d': 1.0,
'30d': 3.8,
'90d': 11.2,
mtd: 2.1,
ytd: 24.5,
custom: 1.5,
};
function getMult(storeId: string, range: string): {storeMult: number; rangeMult: number; totalMult: number} {
const storeMult = STORE_MULTIPLIERS[storeId] ?? 1.0;
const rangeMult = RANGE_MULTIPLIERS[range] ?? 1.0;
return {storeMult, rangeMult, totalMult: storeMult * rangeMult};
}
export function getCommerceKpis(storeId = 'all', range = '7d') {
const {totalMult, storeMult} = getMult(storeId, range);
const baseRevenue = 340000 * totalMult;
const baseOrders = Math.round(1284 * totalMult);
const baseAov = Math.round(1420 * (0.9 + storeMult * 0.25));
const baseNetSales = 318000 * totalMult;
const baseRefunds = 22000 * totalMult;
const baseConversion = Number((23.8 * (0.85 + storeMult * 0.3)).toFixed(1));
return {
revenue: {
title: 'Total Revenue',
rawValue: baseRevenue,
change: '+14.2% vs prev',
trendDirection: 'up' as const,
subtitle: 'Net revenue across selected store',
},
orders: {
title: 'Total Orders',
rawValue: baseOrders,
change: '+8.5%',
trendDirection: 'up' as const,
subtitle: 'Completed order volume',
},
aov: {
title: 'Avg Order Value',
rawValue: baseAov,
change: '+5.1%',
trendDirection: 'up' as const,
subtitle: 'Average spend per cart',
},
netSales: {
title: 'Net Sales',
rawValue: baseNetSales,
change: '+13.9%',
trendDirection: 'up' as const,
subtitle: 'Revenue after discounts',
},
refunds: {
title: 'Refunds',
rawValue: baseRefunds,
change: '-2.4%',
trendDirection: 'down' as const,
subtitle: 'Total returns rate',
},
conversion: {
title: 'Conversion',
rawValue: baseConversion,
change: '+1.4%',
trendDirection: 'up' as const,
subtitle: 'Visits to purchase ratio',
},
grossSales: {
title: 'Gross Sales',
rawValue: baseRevenue * 1.065,
change: '+12.8%',
trendDirection: 'up' as const,
},
discounts: {
title: 'Discounts & Promos',
rawValue: baseRefunds,
change: '-4.1%',
trendDirection: 'down' as const,
},
};
}
export function getRevenueTrendData(storeId = 'all', range = '7d') {
const {totalMult} = getMult(storeId, range);
return [
{time: '08:00', Revenue: Math.round(18000 * totalMult), Target: Math.round(15000 * totalMult)},
{time: '10:00', Revenue: Math.round(35000 * totalMult), Target: Math.round(30000 * totalMult)},
{time: '12:00', Revenue: Math.round(72000 * totalMult), Target: Math.round(60000 * totalMult)},
{time: '14:00', Revenue: Math.round(95000 * totalMult), Target: Math.round(80000 * totalMult)},
{time: '16:00', Revenue: Math.round(82000 * totalMult), Target: Math.round(75000 * totalMult)},
{time: '18:00', Revenue: Math.round(110000 * totalMult), Target: Math.round(90000 * totalMult)},
{time: '20:00', Revenue: Math.round(68000 * totalMult), Target: Math.round(60000 * totalMult)},
];
}
export function getOrdersTrendData(storeId = 'all', range = '7d') {
const {totalMult} = getMult(storeId, range);
return [
{time: '08:00', Orders: Math.max(1, Math.round(42 * totalMult))},
{time: '10:00', Orders: Math.max(1, Math.round(88 * totalMult))},
{time: '12:00', Orders: Math.max(1, Math.round(165 * totalMult))},
{time: '14:00', Orders: Math.max(1, Math.round(210 * totalMult))},
{time: '16:00', Orders: Math.max(1, Math.round(184 * totalMult))},
{time: '18:00', Orders: Math.max(1, Math.round(245 * totalMult))},
{time: '20:00', Orders: Math.max(1, Math.round(135 * totalMult))},
];
}
export function getPaymentBreakdownData(storeId = 'all', range = '7d'): PaymentBreakdownItem[] {
const {totalMult} = getMult(storeId, range);
const totalInr = 340000 * totalMult;
return [
{method: 'UPI', amount: `₹${(totalInr * 0.58).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, percentage: 58, color: '#10b981'},
{method: 'Credit & Debit Cards', amount: `₹${(totalInr * 0.28).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, percentage: 28, color: '#3b82f6'},
{method: 'Store Credit / LYT', amount: `₹${(totalInr * 0.10).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, percentage: 10, color: '#f59e0b'},
{method: 'Cash & POS', amount: `₹${(totalInr * 0.04).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, percentage: 4, color: '#8b5cf6'},
];
}
export const STORE_PERFORMANCE_DATA = [
{store: 'Indiranagar Flagship', Revenue: 910000},
{store: 'Koramangala 80ft', Revenue: 520000},
{store: 'Jayanagar 4th Block', Revenue: 400000},
{store: 'Whitefield Main', Revenue: 330000},
];
export function getTopProductsData(storeId = 'all', range = '7d'): TopProductRow[] {
const {totalMult} = getMult(storeId, range);
return [
{id: 'tp1', name: 'Signature Espresso Blend (1kg)', category: 'Beverage & Beans', unitsSold: Math.round(482 * totalMult), revenue: `₹${(144600 * totalMult).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, growth: '+18.4%', growthDirection: 'up', stockCount: 142, stockStatus: 'in_stock'},
{id: 'tp2', name: 'Artisanal Cold Brew Pack (6x)', category: 'Ready to Drink', unitsSold: Math.round(310 * totalMult), revenue: `₹${(93000 * totalMult).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, growth: '+12.1%', growthDirection: 'up', stockCount: 18, stockStatus: 'low_stock'},
{id: 'tp3', name: 'Ceramic Barista Mug (Matte Black)', category: 'Merchandise', unitsSold: Math.round(215 * totalMult), revenue: `₹${(75250 * totalMult).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, growth: '+8.9%', growthDirection: 'up', stockCount: 8, stockStatus: 'low_stock'},
{id: 'tp4', name: 'Vanilla Bean Syrup (750ml)', category: 'Syrups', unitsSold: Math.round(184 * totalMult), revenue: `₹${(46000 * totalMult).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, growth: '+5.4%', growthDirection: 'up', stockCount: 85, stockStatus: 'in_stock'},
{id: 'tp5', name: 'French Press Brewer (8-Cup)', category: 'Equipment', unitsSold: Math.round(94 * totalMult), revenue: `₹${(37600 * totalMult).toLocaleString('en-IN', {maximumFractionDigits: 0})}`, growth: '-2.1%', growthDirection: 'down', stockCount: 0, stockStatus: 'out_of_stock'},
];
}
export const BUSINESS_ALERTS: BusinessAlert[] = [
{
id: 'ba1',
type: 'low_stock',
title: 'Low Stock Reorder Alert',
description: 'Ceramic Barista Mug has only 8 units left at Koramangala 80ft.',
actionText: 'Reorder Stock',
severity: 'critical',
},
{
id: 'ba2',
type: 'refund_spike',
title: 'Refund Spike Detected',
description: 'Whitefield store processed 4 return requests in the last 2 hours.',
actionText: 'Review Return Audit',
severity: 'warning',
},
{
id: 'ba3',
type: 'best_seller',
title: 'Best Seller Momentum',
description: 'Signature Espresso Blend sales spiked +18.4% above weekly baseline.',
actionText: 'Promote Banner',
severity: 'success',
},
{
id: 'ba4',
type: 'promotion_opportunity',
title: 'Weekend Upsell Opportunity',
description: 'Combine Cold Brew Pack with Syrups for a +₹250 AOV expansion.',
actionText: 'Launch Campaign',
severity: 'info',
},
];

View File

@@ -1,146 +0,0 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
import {ACCENT} from '@/shared/utils/accent';
import {ICONS} from '@/shared/utils/icons';
import {formatCompact, formatCount, formatLyt} from '@/shared/utils/format';
import {ACTIVITY_ICON, impactChain} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
/**
* One activity, at a weight the dashboard can afford.
*
* DELIBERATELY QUIETER THAN A KPI CARD. It is `variant="muted"` inside a panel
* rather than a white card on the page, the number is `display-3` against the
* KPI row's own display-3 but in a card a third the height, and there is no
* sparkline. Six activities rendered at KPI weight would out-shout the four
* metrics that actually describe the business — the same reasoning that made
* store comparison small multiples instead of five full charts.
*
* THE CHAIN IS NOT OPTIONAL. Every card ends with at least
* "N customers → N purchases", because an activity count on its own is a
* vanity metric: 724 spins is not a fact a merchant can do anything with until
* they know what it was worth. `detail` opens the chain up to its full length
* for the analytics page, where there is room for the middle of the story.
*/
/**
* Status is a DOT, not a badge.
*
* Ten filled "Live" pills down a catalogue is a wall of green — the loudest
* thing on a page whose subject is the numbers beside them, and a third hue
* competing with the two the brand actually owns. An 8px dot carries the same
* three states at a fraction of the ink, which is the project's own rule:
* status → StatusDot, badge → counts and enumerated states.
*/
const STATUS: Record<
ActivityMetric['status'],
{label: string; variant: 'success' | 'warning' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
paused: {label: 'Paused', variant: 'warning'},
draft: {label: 'Draft', variant: 'neutral'},
};
export function ActivityCard({
metric,
variant = 'summary',
}: {
metric: ActivityMetric;
/**
* `summary` — the dashboard's six. Count, delta, and the two ends of the
* impact chain. No status: a summary of six live activities does not need
* six "Live" badges competing with the numbers.
*
* `catalogue` — the Lyts ecosystem view. Adds what a merchant MANAGES
* rather than reads: whether it is running, what it costs in LYTs, and the
* full chain including reward claims and repeat visits.
*/
variant?: 'summary' | 'catalogue';
}) {
const accent = ACCENT[metric.accent];
const isCatalogue = variant === 'catalogue';
const chain = impactChain(metric, {compact: !isCatalogue});
const status = STATUS[metric.status];
// `card-nested` keeps the dark theme's raised fill and gives the light theme
// a white surface with a hairline edge instead — see globals.css.
return (
<Card variant="muted" className="card-nested">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<HStack gap={2} vAlign="center" className="min-w-0">
{/*
The only place the brand hue appears on this card. A tinted chip
at 16px is enough to identify the activity at a glance; tinting
the card itself is how a grid of six turns into a paint chart.
*/}
<HStack
className={`${accent.soft} rounded-md p-1.5 shrink-0`}
vAlign="center"
>
<Icon
icon={ACTIVITY_ICON[metric.id]}
size="sm"
className={accent.ink}
/>
</HStack>
<Text size="sm" weight="medium" className="truncate">
{metric.label}
</Text>
</HStack>
{isCatalogue ? (
<HStack gap={1.5} vAlign="center" className="shrink-0">
<StatusDot variant={status.variant} label={status.label} />
<Text size="xsm" color="secondary">
{status.label}
</Text>
</HStack>
) : (
<MetricDelta value={metric.deltaPct} size="xsm" />
)}
</HStack>
{/* Data, not a section title — Text rather than Heading, so a grid of
activity counts stays out of the document outline. */}
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
<Text type="display-3">{formatCompact(metric.count)}</Text>
{/* The catalogue traded its delta for the status badge above, so the
trend comes back down here — a merchant deciding whether to keep
an activity running needs both. */}
{isCatalogue ? (
<MetricDelta value={metric.deltaPct} size="xsm" />
) : null}
</HStack>
{isCatalogue ? (
<Text size="xsm" color="secondary">
{metric.description}
</Text>
) : null}
<Text size="xsm" color="secondary">
{chain
.map((s) => `${formatCount(s.value)} ${s.label}`)
.join(' → ')}
</Text>
{/* The LYT link, and the one number on this card that is MEASURED
rather than attributed — the ledger knows what it issued. Omitted
where the activity grants nothing, rather than shown as zero. */}
{isCatalogue && metric.lytsIssued > 0 ? (
<HStack gap={1} vAlign="center">
<Icon icon={ICONS.lyt} size="xsm" className={accent.ink} />
<Text size="xsm" color="secondary">
{formatLyt(metric.lytsIssued)} issued
</Text>
</HStack>
) : null}
</VStack>
</Card>
);
}

View File

@@ -1,68 +0,0 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import {ACTIVITY_GROUPS} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The complete ecosystem, grouped by the question each group answers.
*
* Ten activities laid out as one flat grid is a list of features. Split three
* ways it becomes a diagnosis: engagement says what customers are doing,
* growth says what is bringing people in, commerce says whether either turned
* into business. A merchant with a flat conversion number and a rising
* engagement number knows which of the three to look at.
*
* One resource, three panels. The panels share a single fetch, so the groups
* can never disagree about a total, and a group with nothing in it renders its
* own empty state rather than leaving a headed card with a void under it.
*/
export function ActivityGroups({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<VStack gap={5}>
{ACTIVITY_GROUPS.map((group) => (
<PanelCard
key={group.id}
title={group.label}
subtitle={group.question}
actions={<AttributionNote basis={basis} />}
resource={resource}
loading={<SkeletonCardGrid count={4} height={128} minWidth={220} />}
empty={
<EmptyPanel
icon="activityVisit"
title={`No ${group.label.toLowerCase()} activity`}
description="Activities appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.group === group.id)
.map((m) => (
// `catalogue` — this is the management view, so each card
// adds what the dashboard summary has no room for: status,
// LYTs issued, and the full impact chain.
<ActivityCard key={m.id} metric={m} variant="catalogue" />
))}
</Grid>
)}
</PanelCard>
))}
</VStack>
);
}

View File

@@ -1,192 +0,0 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
import {
ACTIVITY_ICON,
conversionPct,
} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Every activity's chain, side by side.
*
* The cards above answer "how is Spin doing?". This answers the question they
* cannot: "which activity is worth running?" — and that is a comparison across
* rows, which is a table. Ten small charts would say the same thing in ten
* times the space and still not let anyone rank the last column.
*
* Rows are ordered by the END of the chain, not the start. Sorting by
* interactions puts Walk on top, which is the activity that converts worst;
* sorting by attributed revenue puts the table in the order a merchant would
* spend their next hour in.
*/
/** A flattened row. The table generic needs an index signature; the domain
* type deliberately does not have one. */
interface ImpactRow extends Record<string, unknown> {
id: string;
label: string;
accent: ActivityMetric['accent'];
activityId: ActivityMetric['id'];
count: number;
purchases: number;
conversion: number;
revenueInr: number;
}
function toRows(metrics: ActivityMetric[]): ImpactRow[] {
return metrics
.map((m) => ({
id: m.id,
label: m.label,
accent: m.accent,
activityId: m.id,
count: m.count,
purchases: m.impact.purchases,
conversion: conversionPct(m),
revenueInr: m.impact.attributedRevenueInr,
}))
.sort((a, b) => b.revenueInr - a.revenueInr);
}
const num = (v: number) => (
<Text size="sm" className="tabular-nums">
{formatCount(v)}
</Text>
);
const COLUMNS: TableColumn<ImpactRow>[] = [
{
key: 'label',
header: 'Activity',
width: proportional(1),
// Label only. The one-line description belongs on the cards above, where
// there is width for it; in a cell 120px wide it wrapped to four lines and
// tripled the height of every row in a table whose whole job is to be
// scanned down a column.
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Icon
icon={ACTIVITY_ICON[row.activityId]}
size="sm"
className={ACCENT[row.accent].ink}
/>
<Text size="sm" weight="medium">
{row.label}
</Text>
</HStack>
),
},
{
key: 'count',
// "Count", not "Interactions". Astryx table headers are nowrap + ellipsis,
// so a header longer than its column is silently truncated to "Interacti…"
// at EVERY width, because these columns are fixed px. The subtitle already
// says what is being counted; the header only has to say which column it
// is, and a short one lets the whole set fit the 525px the panel gives it
// with Loyaly AI open.
header: 'Count',
align: 'end',
// No delta chip. It cost ~55px, and with Loyaly AI open the content column
// is ~625px — enough width that the LAST column, the attributed revenue
// this table is sorted by, fell off the edge behind an internal scrollbar.
// The trend is already on every card above; the ranking is only here.
width: pixel(80),
renderCell: (row) => num(row.count),
},
/*
* FIVE columns, and the number is measured rather than chosen. Inside a
* panel card with Loyaly AI open, the table gets 525px: 24px card padding
* each side, plus the 24/32px cell inset contract from globals.css. Six
* columns needed 580 and pushed `Attributed` — the column the table is
* SORTED by — behind an internal scrollbar, which makes the ranking
* invisible at exactly the width most merchants use.
*
* So the two intermediate chain steps are dropped here: customers and
* repeat visits are on every card above and in the phone fallback. What
* survives is what ranking needs — how much happened, what it produced,
* how efficiently, and what it was worth.
*/
{
key: 'purchases',
header: 'Purchases',
align: 'end',
width: pixel(105),
renderCell: (row) => num(row.purchases),
},
{
key: 'conversion',
header: 'Converted',
align: 'end',
width: pixel(100),
renderCell: (row) => (
<Text size="sm" weight="medium" className="tabular-nums">
{formatPct(row.conversion, 0)}
</Text>
),
},
{
key: 'revenueInr',
// "Revenue" would be a claim this data cannot support. The column ranks
// the table, so it is the one header that most needs to be honest.
header: 'Attributed',
align: 'end',
width: pixel(112),
renderCell: (row) => (
<Text size="sm" weight="semibold" className="tabular-nums">
{formatInrCompact(row.revenueInr)}
</Text>
),
},
];
export function ActivityImpactTable({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Activity impact"
subtitle="Interactions through to estimated attributed revenue, highest first"
resource={resource}
loading={<SkeletonRows count={6} height={44} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="analytics"
title="Nothing to compare yet"
description="Once two or more activities have run, their conversion can be ranked here."
/>
}
>
{(metrics) => (
<ResponsiveTable
data={toRows(metrics)}
columns={COLUMNS}
idKey="id"
primaryKey="label"
density="balanced"
// Conversion earns its place on the phone card: it is the one field
// that ranks activities against each other, which is the whole
// reason this table exists. Repeat visits is the one dropped.
summaryKeys={['count', 'purchases', 'conversion', 'revenueInr']}
/>
)}
</PanelCard>
);
}

View File

@@ -1,109 +0,0 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ActivityFeed} from '@/shared/components/patterns/ActivityItem';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import type {ActivityEntry, ActivityTone} from '@/shared/components/patterns/ActivityItem';
import type {ActivityEvent, ActivityKind} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Each event kind gets its own glyph, so the feed can be skimmed by category
* without reading a word of it.
*
* Only one kind carries semantic colour. An expiry is the single event here a
* merchant may need to act on; redemptions, purchases, check-ins and openings
* are the shop working normally, and colouring them would make the one thing
* that matters harder to find.
*/
const KIND: Record<
ActivityKind,
{icon: IconKey; tone: ActivityTone; toneLabel: string}
> = {
purchase: {icon: 'purchases', tone: 'neutral', toneLabel: 'Purchase'},
reward_redeemed: {icon: 'lyts', tone: 'neutral', toneLabel: 'Reward'},
staff_checked_in: {icon: 'staff', tone: 'neutral', toneLabel: 'Staff'},
store_opened: {icon: 'stores', tone: 'neutral', toneLabel: 'Store'},
reward_expired: {icon: 'alert', tone: 'warning', toneLabel: 'Alert'},
};
function toEntries(events: ActivityEvent[]): ActivityEntry[] {
return events.map((e) => ({
id: e.id,
title: e.title,
detail: e.detail,
at: e.at,
...KIND[e.kind],
}));
}
/**
* The feed panel, in two sizes.
*
* Bounded by default: `limit` caps the rows and `height` caps the box, because
* an uncapped feed is the one panel on a dashboard that grows without limit as
* the business gets busier. It reached 784px — taller than any chart on the
* page — which is how an operational log ends up outweighing the insights it
* was meant to support.
*
* Pass `viewAllHref` and the header offers the full history instead. Pass
* neither and it renders everything, which is what the Activity page wants.
*/
export function ActivityTimeline({
resource,
title = 'Recent activity',
subtitle = 'Live feed across the selected store and period',
limit,
height,
viewAllHref,
}: {
resource: Resource<ActivityEvent[]>;
title?: string;
subtitle?: string;
/** Rows to show. Omit for the complete history. */
limit?: number;
/** Fixed content height in px. The list scrolls inside it rather than pushing
* the card taller, so the panel's footprint is the same on every load. */
height?: number;
viewAllHref?: string;
}) {
return (
<PanelCard
title={title}
subtitle={subtitle}
resource={resource}
loading={<SkeletonRows count={limit ?? 6} />}
actions={
viewAllHref ? (
<Button
variant="ghost"
size="sm"
label="View all"
href={viewAllHref}
/>
) : undefined
}
empty={
<EmptyPanel
icon="alert"
title="No activity yet"
description="Events appear here as customers redeem rewards and staff check in."
/>
}
>
{(events) =>
height ? (
<VStack height={height} isScrollable>
<ActivityFeed entries={toEntries(events)} limit={limit} />
</VStack>
) : (
<ActivityFeed entries={toEntries(events)} limit={limit} />
)
}
</PanelCard>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Badge} from '@astryxdesign/core/Badge';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import type {Arrival, VisitsPage} from '@/features/dashboard/types/visits';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Who just walked in.
*
* This replaces the generated event timeline. The platform reports ARRIVALS —
* a camera recognising someone at a door — and nothing else, so the five event
* kinds the old feed showed (redemptions, staff check-ins, store openings,
* reward expiries) are gone rather than simulated.
*
* ── Photos ───────────────────────────────────────────────────────────────
* A missing photo is DATA, not an error: images are off by default across the
* product, so on most deployments every row legitimately has none. Initials
* are the normal case, and a screen of broken-image icons for a system working
* as configured is a screen whose real errors stop being read.
*
* When a photo IS available it is served through this origin's /api/faces
* proxy, because an <img> cannot send the Authorization header the platform
* requires — and because every hand-out is written to the platform's audit
* log, so it must be fetched once per row rather than once per component.
*/
function ArrivalRow({arrival}: {arrival: Arrival}) {
return (
<HStack
gap={3}
vAlign="center"
paddingBlock={1.5}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<Avatar
name={arrival.label}
src={arrival.image.url ?? undefined}
size="sm"
tooltip={false}
/>
<VStack gap={0.5} width="100%">
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{arrival.label}
</Text>
{/* The one enumerated state worth a badge: a face the platform has
not seen before is the thing a merchant reacts to. */}
{arrival.isNewVisitor ? (
<Badge variant="success" label="New" />
) : null}
</HStack>
<Timestamp value={arrival.occurredAt} format="relative" isLive />
</HStack>
<Text size="sm" color="secondary">
{arrival.visitorRef} · {arrival.siteName}
</Text>
</VStack>
</HStack>
);
}
export function ArrivalsFeed({
resource,
title = 'Recent arrivals',
subtitle = 'Customers recognised at the door, newest first',
viewAllHref,
}: {
resource: Resource<VisitsPage>;
title?: string;
subtitle?: string;
viewAllHref?: string;
}) {
return (
<PanelCard
title={title}
subtitle={subtitle}
resource={resource}
loading={<SkeletonRows count={6} />}
actions={
viewAllHref ? (
<Button variant="ghost" size="sm" label="View all" href={viewAllHref} />
) : undefined
}
empty={
<EmptyPanel
icon="activityVisit"
title="No arrivals yet"
description="Customers appear here as the shop's cameras recognise them."
/>
}
>
{(page) =>
page.arrivals.length === 0 ? (
<EmptyPanel
icon="activityVisit"
title="No arrivals in this period"
description="Try a wider date range, or another store."
/>
) : (
<VStack gap={0} height={285} isScrollable>
{/* Keyed on visit_id: delivery is at-least-once, so the same visit
can legitimately arrive twice and must not render twice. */}
{page.arrivals.map((a) => (
<ArrivalRow key={a.visitId} arrival={a} />
))}
</VStack>
)
}
</PanelCard>
);
}

View File

@@ -1,44 +0,0 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Icon} from '@astryxdesign/core/Icon';
import {Tooltip} from '@astryxdesign/core/Tooltip';
import {ATTRIBUTION_NOTE} from '@/features/dashboard/types/intelligence';
import type {AttributionBasis} from '@/features/dashboard/types/intelligence';
/**
* The disclosure that has to sit beside any attributed figure.
*
* Every downstream number in the activity chain — repeat visits, purchases,
* revenue — is MODELLED from observed footfall and purchase patterns. None of
* it joins a till receipt to a specific spin or selfie. Rendering ₹6.5L next
* to "Selfie" without saying so invites a merchant to read it as "selfies
* earned me ₹6.5L", make a spend decision on it, and lose trust in the whole
* product when the till disagrees.
*
* So the panels that show attributed figures carry this mark, and the copy
* around them says "attributed", never "generated" or "earned".
*
* It renders NOTHING when the basis is `'observed'`. That is the point of the
* flag: when a backend arrives that can join purchases to activity events, the
* disclosure retires itself with no copy edit and no component removal.
*
* A quiet secondary glyph, not a warning — this is a footnote about method,
* and an amber icon would rank it above the data it annotates.
*/
export function AttributionNote({basis}: {basis: AttributionBasis}) {
if (basis === 'observed') return null;
return (
<Tooltip content={ATTRIBUTION_NOTE}>
{/*
Focusable, so the note is reachable by keyboard and not only by hover —
it is the only place the estimation is explained, which makes it
content rather than decoration.
*/}
<HStack tabIndex={0} vAlign="center" className="rounded-sm">
<Icon icon="info" size="sm" color="secondary" label="How this is calculated" />
</HStack>
</Tooltip>
);
}

View File

@@ -1,129 +0,0 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Badge} from '@astryxdesign/core/Badge';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact} from '@/shared/utils/format';
import {ACTIVITY_ICON} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {
CampaignStatus,
CampaignSummary,
} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* A campaign is a funnel with a name, so each one is a ROW, not a chart.
*
* The temptation here is a second analytics module — participation over time,
* a conversion gauge per campaign, a comparison bar. That is four more charts
* for a question with one shape: how many took part, how many came back, how
* many bought, what it earned. Four numbers fit on a row, and four rows fit in
* the space one chart would have taken.
*/
/**
* `live` is the only status a merchant can still act on, so it is the only one
* that gets a filled badge. Ended and scheduled are stated, not highlighted.
*/
const STATUS: Record<
CampaignStatus,
{label: string; variant: 'success' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
ended: {label: 'Ended', variant: 'neutral'},
scheduled: {label: 'Scheduled', variant: 'neutral'},
};
function CampaignRow({campaign}: {campaign: CampaignSummary}) {
const accent = ACCENT[campaign.accent];
const status = STATUS[campaign.status];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack className={`${accent.soft} rounded-md p-1.5 shrink-0`} vAlign="center">
<Icon
icon={ACTIVITY_ICON[campaign.activityId]}
size="sm"
className={accent.ink}
/>
</HStack>
<VStack gap={1} width="100%">
<HStack gap={3} hAlign="between" vAlign="center" wrap="wrap">
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{campaign.name}
</Text>
<Badge variant={status.variant} label={status.label} />
</HStack>
{/*
The row's outcome, at the end where the eye lands last — the same
place a receipt puts it. And that is exactly why it cannot be a
bare rupee figure: "Weekend Challenge … ₹5L" reads as money the
campaign earned. It is money attributed to it, so the word rides
with the number rather than living only in a tooltip.
*/}
<HStack gap={1} vAlign="center">
<Text size="sm" weight="semibold">
{formatInrCompact(campaign.attributedRevenueInr)}
</Text>
<Text size="xsm" color="secondary">
{campaign.attribution === 'estimated' ? 'attributed (est.)' : 'attributed'}
</Text>
</HStack>
</HStack>
<Text size="sm" color="secondary">
{campaign.steps
.map((s) => `${formatCount(s.value)} ${s.label.toLowerCase()}`)
.join(' → ')}
</Text>
</VStack>
</HStack>
);
}
export function CampaignPerformance({
resource,
}: {
resource: Resource<CampaignSummary[]>;
}) {
const basis = resource.data?.[0]?.attribution ?? 'estimated';
return (
<PanelCard
title="Campaign performance"
subtitle="Participants through to attributed revenue, per campaign"
resource={resource}
loading={<SkeletonRows count={4} height={52} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="campaign"
title="No campaigns running"
description="Challenges, referral drives and events show their funnel here once they go live."
/>
}
>
{(campaigns) => (
<VStack gap={0}>
{campaigns.map((c) => (
<CampaignRow key={c.id} campaign={c} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -1,80 +0,0 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The dashboard's activity summary — six of the ten, and no more.
*
* The full ecosystem is ten activities. Rendering all ten here, at equal
* weight, is how a dashboard becomes a legend: the merchant is asked to hold
* ten categories in their head before they have learned what any single one is
* worth. So the payload carries all ten, this panel filters to the featured
* six, and "View all activity" leads to the page that has room for the rest.
*
* One panel, not six cards on the page. The section reads as a single band of
* secondary information sitting under the primary metrics and the two headline
* charts, which is exactly its rank in the hierarchy.
*/
export function CustomerActivity({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
// Read off the payload rather than hardcoded, so the disclosure retires
// itself the day a backend returns 'observed'.
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Customer activity"
subtitle="What customers did, and what it is estimated to have been worth"
resource={resource}
loading={<SkeletonCardGrid count={6} height={116} minWidth={200} />}
actions={
<>
{/* Every figure after "customers" on these cards is attributed, not
measured. The note says so once for the whole panel. */}
<AttributionNote basis={basis} />
{/*
/lyts, not /activity. The full ecosystem is the Lyts page's job —
all ten activities with their status and LYT cost — and /activity
is the raw event log, which is a different question. Sending "view
all" to the log would answer "what happened at 14:32" when the
merchant asked "what else can customers do".
*/}
<Button
variant="ghost"
size="sm"
label="View all activity"
href="/lyts"
/>
</>
}
empty={
<EmptyPanel
icon="activityVisit"
title="No activity recorded"
description="Visits, spins, challenges and referrals appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.isFeatured)
.map((m) => (
<ActivityCard key={m.id} metric={m} />
))}
</Grid>
)}
</PanelCard>
);
}

View File

@@ -1,114 +0,0 @@
'use client';
import {Fragment} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Badge} from '@astryxdesign/core/Badge';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {JourneyStage} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Visit → Engage → Purchase → Return → Refer, as five numbers and four arrows.
*
* NOT a Sankey, and not a node graph. A funnel of five stages carries exactly
* five numbers and four ratios; a flow diagram spends 400px of vertical space
* and a chart library rendering the same nine facts, and the merchant still
* has to read the labels to know which band is which. The horizontal strip
* says it in one line.
*
* The panel's actual job is the WEAKEST link, so it is called out rather than
* left to be spotted: the stage with the lowest conversion from its
* predecessor gets the badge. Everything else on this strip is context for
* that one finding.
*/
export function CustomerJourney({
resource,
}: {
resource: Resource<JourneyStage[]>;
}) {
return (
<PanelCard
title="Customer journey"
subtitle="Where customers progress, and where they stop"
resource={resource}
loading={<SkeletonRows count={2} height={56} />}
empty={
<EmptyPanel
icon="journey"
title="No journey to plot"
description="Once visits and purchases are recorded the progression appears here."
/>
}
>
{(stages) => {
// The weakest step, measured against the stage before it. The first
// stage has nothing to convert from and can never be the answer.
const weakest = stages.reduce<JourneyStage | undefined>(
(worst, s) =>
s.conversionPct === undefined
? worst
: worst?.conversionPct === undefined ||
s.conversionPct < worst.conversionPct
? s
: worst,
undefined,
);
return (
<HStack gap={4} vAlign="start" wrap="wrap">
{stages.map((stage, i) => (
<Fragment key={stage.id}>
{i === 0 ? null : (
// Decorative: the reading order already carries the
// progression, so the arrow is not announced.
//
// Hidden below `sm`, where the strip wraps to two per row and
// a connector ends up pointing at the start of a line or at
// the stage above it — an arrow that lies about the order is
// worse than no arrow, and stacked cards already read as a
// sequence.
<HStack className="hidden pt-6 sm:flex" vAlign="center">
<Icon
icon={ICONS.arrowRight}
size="sm"
className={ACCENT.cool.ink}
/>
</HStack>
)}
<VStack gap={1}>
<Text size="sm" color="secondary">
{stage.label}
</Text>
<Text type="display-3">{formatCompact(stage.value)}</Text>
{stage.conversionPct === undefined ? (
<Text size="xsm" color="secondary">
Everyone starts here
</Text>
) : stage.id === weakest?.id ? (
<Badge
variant="warning"
label={`${formatPct(stage.conversionPct, 0)} · biggest drop-off`}
/>
) : (
<Text size="xsm" color="secondary">
{formatPct(stage.conversionPct, 0)} of previous
</Text>
)}
</VStack>
</Fragment>
))}
</HStack>
);
}}
</PanelCard>
);
}

View File

@@ -16,7 +16,7 @@ const KPI_ICON: Record<KpiId, IconType> = {
visitors: ICONS.visitors, visitors: ICONS.visitors,
purchases: ICONS.purchases, purchases: ICONS.purchases,
revenue: ICONS.revenue, revenue: ICONS.revenue,
activeRewards: ICONS.activeRewards, conversion: ICONS.conversion,
}; };
/** /**

View File

@@ -1,64 +0,0 @@
'use client';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {useDashboardPerformance} from '@/features/dashboard/hooks/useDashboard';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {formatInrCompact} from '@/shared/utils/format';
/**
* Weekly / monthly rollup.
*
* The granularity is part of the request rather than a client-side regroup:
* the buckets have to come from the same generator the daily series does, or
* the weekly totals quietly stop matching the daily chart above them.
*/
export function PerformancePanel({
granularity,
onGranularityChange,
}: {
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
}) {
// Scope comes from the workspace via the hook rather than from a prop: the
// panel is always showing the same store and period as the page around it,
// and threading that through as an argument only created a way for them to
// disagree.
const resource = useDashboardPerformance(granularity);
return (
<ChartCard
title="Performance"
subtitle={
granularity === 'weekly'
? 'Revenue by ISO week'
: 'Revenue by calendar month'
}
resource={resource}
actions={
<SegmentedControl
value={granularity}
onChange={(v) => onGranularityChange(v as Granularity)}
label="Performance granularity"
size="sm"
>
<SegmentedControlItem value="weekly" label="Weekly" />
<SegmentedControlItem value="monthly" label="Monthly" />
</SegmentedControl>
}
>
{(rows) => (
<BarChartView
data={rows}
xKey="label"
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</ChartCard>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {formatCompact} from '@/shared/utils/format';
import type {RewardUsagePoint} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Claimed vs used, per reward.
*
* The gap between the two bars is the whole point: a reward claimed 700 times
* and redeemed 130 is an outstanding liability and a broken offer, not a
* success. Plotting the usage *rate* alone would hide the volume; plotting
* volume alone would hide the failure. So both bars, side by side.
*/
export function RewardUsageChart({
resource,
}: {
resource: Resource<RewardUsagePoint[]>;
}) {
return (
<ChartCard
title="Reward usage"
subtitle="Claimed vs actually redeemed — the gap is unspent liability"
resource={resource}
>
{(rows) => (
<BarChartView
data={rows}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'claimed', label: 'Claimed'},
{key: 'used', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
);
}

View File

@@ -1,78 +0,0 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Sparkline} from '@/shared/components/charts/Sparkline';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
import type {StoreComparison as StoreComparisonRow} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Small multiples, not a five-series chart.
*
* This is the palette constraint doing its job: five stores on one axis would
* need five colours, and the monochrome ramp only separates about three. One
* card per store is also simply better for the question being asked — "which
* store is the outlier?" is a comparison of shapes, not of overlapping lines.
*/
export function StoreComparisonPanel({
resource,
}: {
resource: Resource<StoreComparisonRow[]>;
}) {
return (
<ChartCard
title="Store comparison"
subtitle="One card per store — five series on one axis would need five colours"
resource={resource}
height={220}
>
{(rows) => {
const best = rows.reduce((a, b) => (b.visitors > a.visitors ? b : a));
const worst = rows.reduce((a, b) => (b.visitors < a.visitors ? b : a));
return (
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
{rows.map((r) => (
<Card key={r.storeId} variant="muted">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<Text size="sm" weight="medium">
{r.name}
</Text>
{r.storeId === best.storeId ? (
<Badge variant="success" label="Top" />
) : r.storeId === worst.storeId ? (
<Badge variant="warning" label="Lowest" />
) : null}
</HStack>
{/* A visitor count is data, not a section title. */}
<Text type="display-3">{formatCompact(r.visitors)}</Text>
<Sparkline data={r.trend} dataKey="v" height={34} />
<StatRow>
<StatPair
label="Revenue"
value={formatInrCompact(r.revenueInr)}
/>
<StatPair
label="Conversion"
value={formatPct(r.conversionPct)}
align="end"
/>
</StatRow>
</VStack>
</Card>
))}
</Grid>
);
}}
</ChartCard>
);
}

View File

@@ -1,108 +0,0 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import type {Insight, InsightSeverity} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {IconColor} from '@astryxdesign/core/Icon';
import type {Resource} from '@/shared/hooks/useResource';
/**
* What needs your attention — findings, not a chat box.
*
* The thing that makes an AI panel get ignored is a paragraph of narration
* with nothing at the end of it. So the contract here is strict: every row
* states a number the merchant can check against the panels above, says what
* it means, and ends in ONE action. An insight with no action does not belong
* on this panel; it belongs in whichever chart already shows it.
*
* Severity earns the only colour on the row. A warning is amber because a
* warning is one of the three semantic states; everything else is a cool-tinted
* bulb, which is this product's mark for "the system worked this out" and is
* the same hue the analytics carry.
*/
const SEVERITY: Record<
InsightSeverity,
{icon: IconKey; color?: IconColor; className?: string}
> = {
error: {icon: 'alert', color: 'error'},
warning: {icon: 'alert', color: 'warning'},
success: {icon: 'leaderboard', color: 'success'},
info: {icon: 'insight', className: ACCENT.cool.ink},
};
function InsightRow({insight}: {insight: Insight}) {
const tone = SEVERITY[insight.severity];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack paddingBlock={0.5}>
<Icon
icon={ICONS[tone.icon]}
size="sm"
color={tone.color}
className={tone.className}
label={insight.severity}
/>
</HStack>
<VStack gap={1} width="100%">
<Text size="sm" weight="medium">
{insight.title}
</Text>
<Text size="sm" color="secondary">
{insight.body}
</Text>
{insight.action ? (
<HStack>
<Button
variant="secondary"
size="sm"
label={insight.action.label}
href={insight.action.href}
/>
</HStack>
) : null}
</VStack>
</HStack>
);
}
export function StoreInsights({resource}: {resource: Resource<Insight[]>}) {
return (
<PanelCard
title="What needs your attention"
subtitle="Read from this period's activity, most urgent first"
resource={resource}
loading={<SkeletonRows count={3} height={72} />}
empty={
<EmptyPanel
icon="insight"
title="Nothing needs attention"
description="Findings appear here when an activity's performance moves enough to act on."
/>
}
>
{(insights) => (
<VStack gap={0}>
{insights.map((i) => (
<InsightRow key={i.id} insight={i} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -1,89 +1,73 @@
'use client'; 'use client';
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository'; import {useMemo} from 'react';
import type {Granularity} from '@/features/dashboard/types/dashboard'; import {buildKpis} from '@/features/dashboard/services/kpiBuilder';
import {useResource} from '@/shared/hooks/useResource'; import {
import {useScope} from '@/shared/hooks/useScope'; useConversionReport,
import type {Scope} from '@/shared/services/httpClient'; useFootfallReport,
useRecentVisits,
} from './useReports';
import type {Kpi} from '@/features/dashboard/types/dashboard';
import type {Resource} from '@/shared/hooks/useResource';
export {useConversionReport, useFootfallReport, useRecentVisits};
/** /**
* The dashboard's data access, one hook per panel. * The KPI row, composed from the two reports that back it.
* *
* Components call these and get {status, data, error, refetch}; they never see * Two resources, one card row. The composition happens here rather than in the
* a URL, a fetch or a scope argument. That is the whole point of the layering * component so that KpiRow keeps taking a plain `Resource<Kpi[]>` and does not
* — swapping the repository for a GraphQL client, or useResource for TanStack * learn which platform endpoints exist.
* Query, changes these files and nothing that renders.
* *
* Each panel gets its own hook rather than one `useDashboardData()` returning * The combined status is deliberately pessimistic: loading while EITHER is in
* everything: the store-detail page reuses four of them without pulling in the * flight, failed if EITHER failed. A row that renders three real cards and one
* two it has no room for, and a panel that fails does not blank its neighbours. * stale one is worse than a row that waits — the merchant cannot tell which is
* which.
*/ */
export function useDashboardKpis(): Resource<Kpi[]> {
const footfall = useFootfallReport({bucket: 'day', compare: true});
const conversion = useConversionReport({bucket: 'day', compare: true});
export function useDashboardKpis(scope?: Scope) { return useMemo<Resource<Kpi[]>>(() => {
const active = useScope(); const refetch = () => {
return useResource(dashboardRepository.kpis(scope ?? active)); footfall.refetch();
} conversion.refetch();
};
export function useDashboardTimeseries(scope?: Scope) { const isRefreshing = footfall.isRefreshing || conversion.isRefreshing;
const active = useScope(); // The server's clock, from whichever response landed — every relative time
return useResource(dashboardRepository.timeseries(scope ?? active)); // on this page measures against it rather than the device.
} const meta = footfall.meta ?? conversion.meta;
export function useDashboardPeakHours(scope?: Scope) { if (footfall.status === 'error' || conversion.status === 'error') {
const active = useScope(); return {
return useResource(dashboardRepository.peakHours(scope ?? active)); status: 'error',
} data: undefined,
error: footfall.error ?? conversion.error!,
export function useDashboardActivity(scope?: Scope) { refetch,
const active = useScope(); isRefreshing,
return useResource(dashboardRepository.activity(scope ?? active)); meta,
} };
}
export function useDashboardRewardUsage(scope?: Scope) {
const active = useScope(); if (footfall.status === 'loading' || conversion.status === 'loading') {
return useResource(dashboardRepository.rewardUsage(scope ?? active)); return {
} status: 'loading',
data: undefined,
export function useDashboardStoreComparison(scope?: Scope) { error: undefined,
const active = useScope(); refetch,
return useResource(dashboardRepository.storeComparison(scope ?? active)); isRefreshing,
} meta,
};
export function useDashboardPerformance(granularity: Granularity) { }
return useResource(dashboardRepository.performance(useScope(), granularity));
} const kpis = buildKpis(footfall.data, conversion.data);
return {
export function useDashboardBriefing() { status: kpis.length === 0 ? 'empty' : 'success',
return useResource(dashboardRepository.briefing(useScope())); data: kpis,
} error: undefined,
refetch,
// --------------------------------------------------------------------------- isRefreshing,
// Store intelligence — the activity layer meta,
// --------------------------------------------------------------------------- } as Resource<Kpi[]>;
}, [footfall, conversion]);
/**
* All ten activities, always. The dashboard filters to `isFeatured` in the
* component rather than asking the server for a subset: the same request then
* serves the summary and the full Activity Analytics page, so the two cannot
* report different counts for Spin, and moving an activity into or out of the
* summary is a fixture change rather than an API change.
*/
export function useActivityMetrics(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.activityMetrics(scope ?? active));
}
export function useCustomerJourney(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.journey(scope ?? active));
}
export function useCampaigns(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.campaigns(scope ?? active));
}
export function useStoreInsights(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.insights(scope ?? active));
} }

View File

@@ -0,0 +1,34 @@
'use client';
import {reportRepository} from '@/features/dashboard/repositories/reportRepository';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
import type {Resource} from '@/shared/hooks/useResource';
import type {
ConversionReport,
FootfallReport,
} from '@/features/dashboard/types/reports';
import type {VisitsPage} from '@/features/dashboard/types/visits';
/**
* One hook per resource, scoped by the workspace's {site, range}.
*
* `compare` asks the BFF for the preceding window in the same round trip, so a
* KPI card gets its delta without a second request and without doing its own
* date arithmetic.
*/
export function useFootfallReport(
opts: {bucket?: string; compare?: boolean} = {},
): Resource<FootfallReport> {
return useResource(reportRepository.footfall(useScope(), opts));
}
export function useConversionReport(
opts: {bucket?: string; compare?: boolean} = {},
): Resource<ConversionReport> {
return useResource(reportRepository.conversion(useScope(), opts));
}
export function useRecentVisits(limit = 6): Resource<VisitsPage> {
return useResource(reportRepository.visits(useScope(), {limit}));
}

View File

@@ -1,130 +0,0 @@
import type {Granularity, PeriodPoint, RewardUsagePoint, StoreComparison} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {buildTimeseries} from './dashboard.mock';
import {STORE_SEED} from '@/features/stores/mock/stores.mock';
/**
* Per-store totals for the comparison small-multiples.
*
* Built from the SAME buildTimeseries() the main charts use, so a store's
* comparison bar and its own dashboard cannot disagree — the alternative
* (independent generators) produces fixtures that quietly contradict each
* other and make real bugs impossible to spot.
*/
export function buildStoreComparison(
range: RangeKey,
endMs: number,
): StoreComparison[] {
return STORE_SEED.map((s) => {
const points = buildTimeseries(range, s.id, endMs);
const visitors = points.reduce((a, p) => a + p.visitors, 0);
const purchases = points.reduce((a, p) => a + p.purchases, 0);
return {
storeId: s.id,
name: s.name,
visitors,
purchases,
revenueInr: points.reduce((a, p) => a + p.revenue, 0),
conversionPct: visitors
? Number(((purchases / visitors) * 100).toFixed(1))
: 0,
trend: points.slice(-14).map((p) => ({t: p.t, v: p.visitors})),
};
});
}
const REWARDS = [
{id: 'r-coffee', name: 'Free Coffee'},
{id: 'r-combo20', name: 'Combo 20% off'},
{id: 'r-weekend', name: 'Weekend Bonus'},
{id: 'r-birthday', name: 'Birthday Treat'},
{id: 'r-bogo', name: 'Buy 1 Get 1'},
{id: 'r-dessert', name: 'Dessert on us'},
];
/**
* Claim-vs-use. The gap between the two is the actual story on this chart:
* a reward claimed 800 times and used 90 is a liability, not a success, and
* the fixtures are shaped so that story is visible.
*/
export function buildRewardUsage(
storeId: string,
range: RangeKey,
): RewardUsagePoint[] {
return REWARDS.map((r, i) => {
const rng = createRng('reward-usage', r.id, storeId, range);
const claimed = Math.round(rng.int(120, 900) * (storeId === 'all' ? 1 : 0.3));
// Redemption rates vary widely by reward type — deliberately spread so
// "most claimed" and "least used" are different rewards.
const rate = [0.72, 0.34, 0.81, 0.55, 0.19, 0.44][i];
const used = Math.round(claimed * rate * rng.float(0.9, 1.1));
return {
rewardId: r.id,
name: r.name,
claimed,
used,
usageRatePct: claimed ? Number(((used / claimed) * 100).toFixed(1)) : 0,
};
}).sort((a, b) => b.claimed - a.claimed);
}
const MONTHS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
/**
* Weekly / monthly rollup behind the SegmentedControl.
*
* Buckets are derived from the daily series rather than generated fresh, so
* "last 30 days" and "the weeks inside it" always sum to the same totals.
*/
export function buildPeriodPerformance(
granularity: Granularity,
storeId: string,
endMs: number,
): PeriodPoint[] {
// Always pull a long window; the granularity decides how it is grouped.
const range: RangeKey = granularity === 'weekly' ? '90d' : 'ytd';
const points = buildTimeseries(range, storeId, endMs);
const buckets = new Map<string, PeriodPoint>();
for (const p of points) {
const d = new Date(p.t);
const label =
granularity === 'weekly'
? `W${isoWeek(d)}`
: MONTHS[d.getUTCMonth()];
const existing = buckets.get(label) ?? {
label,
visitors: 0,
purchases: 0,
revenue: 0,
};
existing.visitors += p.visitors;
existing.purchases += p.purchases;
existing.revenue += p.revenue;
buckets.set(label, existing);
}
const out = [...buckets.values()];
// Drop the leading bucket: a partial first week or month renders as a
// collapsed bar and reads as a crash rather than as missing days.
return out.length > 2 ? out.slice(1) : out;
}
function isoWeek(d: Date): number {
const target = new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
const dayNum = (target.getUTCDay() + 6) % 7;
target.setUTCDate(target.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
return (
1 + Math.round((target.getTime() - firstThursday.getTime()) / 604800000)
);
}

View File

@@ -1,245 +0,0 @@
import type {DashboardBriefing, DashboardTask, Insight} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {buildTimeseries} from './dashboard.mock';
import {buildStoreComparison, buildRewardUsage} from './analytics.mock';
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
import {storeName} from '@/features/stores/mock/stores.mock';
/**
* The narrative half of the dashboard.
*
* Every sentence here is DERIVED from the same generators the charts read —
* buildTimeseries, buildStoreComparison, buildRewardUsage, buildStaffSummary,
* buildRewards. Nothing is invented. That is the whole point: a summary that
* says footfall rose while the footfall chart above it falls is worse than no
* summary at all, and independent fixtures are how that happens. When these
* become a real model call, the model gets fed the same aggregates.
*
* It also means the copy moves when the scope does. Switch to one store and
* the summary talks about that store; switch the range and the percentages
* follow, because they are recomputed from the series rather than templated.
*/
const RUPEES = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
notation: 'compact',
});
const COUNT = new Intl.NumberFormat('en-IN', {
notation: 'compact',
maximumFractionDigits: 1,
});
const RANGE_WORD: Record<RangeKey, string> = {
'7d': 'the last 7 days',
'30d': 'the last 30 days',
'90d': 'the last 90 days',
mtd: 'the month so far',
ytd: 'the year so far',
// The generators receive a RangeKey, not the custom window's endpoints, so
// the narrative cannot name the dates yet. Reads correctly either way; give
// buildBriefing the start/end once the query carries them.
custom: 'the selected period',
};
/** Totals for a window, plus how it moved against the window before it. */
function windowTotals(range: RangeKey, storeId: string, endMs: number) {
const points = buildTimeseries(range, storeId, endMs);
const half = Math.floor(points.length / 2);
const sum = (xs: typeof points, k: 'visitors' | 'purchases' | 'revenue') =>
xs.reduce((a, p) => a + p[k], 0);
const recent = points.slice(half);
const prior = points.slice(0, half);
const visitors = sum(points, 'visitors');
const purchases = sum(points, 'purchases');
const revenue = sum(points, 'revenue');
// Compared half-to-half rather than against a separately generated previous
// period: the two halves come from one series, so the direction stated in
// prose is guaranteed to be the direction drawn on the chart.
const priorRevenue = sum(prior, 'revenue');
const revenueDeltaPct = priorRevenue
? ((sum(recent, 'revenue') - priorRevenue) / priorRevenue) * 100
: 0;
return {
visitors,
purchases,
revenue,
conversionPct: visitors ? (purchases / visitors) * 100 : 0,
revenueDeltaPct,
};
}
function buildAlerts(
storeId: string,
range: RangeKey,
endMs: number,
): Insight[] {
const alerts: Insight[] = [];
// 1. Rewards about to expire — the only thing on this dashboard with a
// hard deadline, so it outranks everything else.
const rewards = buildRewards(storeId, range, endMs);
const expiring = rewards.filter((r) => {
if (!r.expiresAt) return false;
const days = (Date.parse(r.expiresAt) - endMs) / 86400000;
return days > 0 && days <= 3;
});
if (expiring.length > 0) {
alerts.push({
id: 'alert-expiring',
severity: 'error',
title: `${expiring.length} reward${expiring.length > 1 ? 's' : ''} expiring within 72 hours`,
body: expiring.map((r) => r.name).join(', ') + '.',
action: {label: 'Review rewards', href: '/lyts'},
});
}
// 2. Staff absence, measured against the roster rather than a fixed count —
// two absent out of four is a different day from two out of twenty.
const staff = buildStaffSummary(storeId, range);
const missing = staff.absent + staff.onLeave;
if (staff.total > 0 && missing / staff.total >= 0.2) {
alerts.push({
id: 'alert-staffing',
severity: 'warning',
title: `${missing} of ${staff.total} staff are not on the floor`,
body: `${staff.absent} absent, ${staff.onLeave} on leave, ${staff.late} late. Cover may be short at peak.`,
action: {label: 'Open staff', href: '/staff'},
});
}
// 3. The weakest store, but only when there is a spread worth acting on.
if (storeId === 'all') {
const stores = buildStoreComparison(range, endMs);
if (stores.length > 1) {
const sorted = [...stores].sort((a, b) => a.revenueInr - b.revenueInr);
const worst = sorted[0];
const best = sorted[sorted.length - 1];
const gapPct = best.revenueInr
? ((best.revenueInr - worst.revenueInr) / best.revenueInr) * 100
: 0;
if (gapPct >= 25) {
alerts.push({
id: 'alert-store-gap',
severity: 'warning',
title: `${worst.name} is ${Math.round(gapPct)}% behind ${best.name}`,
body: `${RUPEES.format(worst.revenueInr)} against ${RUPEES.format(best.revenueInr)} over ${RANGE_WORD[range]}.`,
action: {label: 'Compare stores', href: '/stores'},
});
}
}
}
// 4. A reward people claim and never redeem is unspent liability sitting on
// the books — worth surfacing even though nothing is technically broken.
const usage = buildRewardUsage(storeId, range);
const dead = usage
.filter((r) => r.claimed >= 100 && r.usageRatePct < 30)
.sort((a, b) => a.usageRatePct - b.usageRatePct)[0];
if (dead) {
alerts.push({
id: 'alert-dead-reward',
severity: 'info',
title: `${dead.name} is claimed but not redeemed`,
body: `${COUNT.format(dead.claimed)} claims, ${Math.round(dead.usageRatePct)}% redeemed. The rest is outstanding liability.`,
action: {label: 'Open LYTs', href: '/lyts'},
});
}
return alerts;
}
function buildTasks(
storeId: string,
range: RangeKey,
endMs: number,
): DashboardTask[] {
const staff = buildStaffSummary(storeId, range);
const rewards = buildRewards(storeId, range, endMs);
const expiring = rewards.filter((r) => {
if (!r.expiresAt) return false;
const days = (Date.parse(r.expiresAt) - endMs) / 86400000;
return days > 0 && days <= 3;
});
const tasks: DashboardTask[] = [
{
id: 'task-roster',
label: 'Confirm the evening roster',
detail:
staff.late > 0
? `${staff.late} arrived late today`
: 'Peak footfall starts at 18:30',
due: 'before 17:00',
isDone: false,
action: {label: 'Staff', href: '/staff'},
},
{
id: 'task-float',
label: 'Reconcile the till float',
due: 'end of day',
isDone: false,
},
];
if (expiring.length > 0) {
tasks.unshift({
id: 'task-expiring',
label: `Decide on ${expiring.length} expiring reward${expiring.length > 1 ? 's' : ''}`,
detail: 'Extend, or let them lapse and clear the liability',
due: 'today',
isDone: false,
action: {label: 'Review', href: '/lyts'},
});
}
// Deterministic and already ticked, so the list reads as a real day in
// progress rather than an empty form.
tasks.push({
id: 'task-open',
label: 'Open-of-day checks',
detail: storeId === 'all' ? 'All stores reported' : storeName(storeId),
due: 'morning',
isDone: true,
});
return tasks;
}
export function buildBriefing(
storeId: string,
range: RangeKey,
endMs: number,
): DashboardBriefing {
const t = windowTotals(range, storeId, endMs);
const scope = storeId === 'all' ? 'across all stores' : `at ${storeName(storeId)}`;
const dir = t.revenueDeltaPct >= 0 ? 'up' : 'down';
const magnitude = Math.abs(Math.round(t.revenueDeltaPct));
const stores = storeId === 'all' ? buildStoreComparison(range, endMs) : [];
const leader = stores.length
? [...stores].sort((a, b) => b.revenueInr - a.revenueInr)[0]
: null;
const sentences = [
`Over ${RANGE_WORD[range]} ${scope}, ${COUNT.format(t.visitors)} visits turned into ${COUNT.format(t.purchases)} purchases — a ${t.conversionPct.toFixed(1)}% conversion rate — and ${RUPEES.format(t.revenue)} in revenue.`,
`Revenue in the back half of the period is ${dir} ${magnitude}% against the front half.`,
];
if (leader) {
sentences.push(
`${leader.name} is carrying the network at ${RUPEES.format(leader.revenueInr)}.`,
);
}
return {
summary: sentences.join(' '),
alerts: buildAlerts(storeId, range, endMs),
tasks: buildTasks(storeId, range, endMs),
};
}

View File

@@ -1,212 +0,0 @@
import type {ActivityEvent, HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {storeScale} from '@/features/stores/mock/stores.mock';
export const RANGE_DAYS: Record<RangeKey, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
mtd: 21,
ytd: 120,
// Placeholder. Query only carries the RangeKey, so a custom window's real
// span is not visible to the generators — swap for (end - start) in days
// once parseQuery threads the dates through.
custom: 30,
};
/**
* Fixtures are shaped, not uniform. Retail footfall has a weekly rhythm —
* weekends run hot, Tuesdays run cold — and a dashboard rendered against flat
* noise looks fine while hiding the fact that nothing reads as a trend. These
* curves make the charts honest to look at.
*/
function dayFactor(dayOfWeek: number): number {
// 0 = Mon … 6 = Sun
return [0.92, 0.86, 0.95, 1.05, 1.25, 1.4, 1.18][dayOfWeek];
}
export function buildTimeseries(
range: RangeKey,
storeId: string,
endMs: number,
): TimePoint[] {
const days = RANGE_DAYS[range];
// The window's end day is part of the seed. Without it, the current and
// previous periods draw the identical random sequence, so every KPI delta
// collapses into an artifact of weekday alignment — and all four move the
// same direction at once, which is exactly what a real dashboard never does.
// Bucketing to the day keeps it deterministic within a day.
const endDay = Math.floor(endMs / 86400000);
const rng = createRng('timeseries', storeId, range, endDay);
// Scale comes from the store roster, not a flat constant. With a flat base
// every store produced near-identical numbers and the comparison view's
// "Top"/"Lowest" badges were effectively random.
const base = 180 * storeScale(storeId);
return Array.from({length: days}, (_, i) => {
const date = new Date(endMs - (days - 1 - i) * 86400000);
const dow = (date.getUTCDay() + 6) % 7;
// Mild upward drift so a 90-day view has something to say.
const drift = 1 + (i / days) * 0.18;
const visitors = Math.round(
base * dayFactor(dow) * drift * rng.float(0.92, 1.08),
);
const convRate = rng.float(0.17, 0.27);
const purchases = Math.round(visitors * convRate);
const avgBasket = rng.float(280, 520);
return {
t: date.toISOString().slice(0, 10),
visitors,
purchases,
revenue: Math.round(purchases * avgBasket),
conversion: Number((convRate * 100).toFixed(1)),
};
});
}
function sum(points: TimePoint[], key: keyof TimePoint): number {
return points.reduce((a, p) => a + (p[key] as number), 0);
}
export function buildKpis(
range: RangeKey,
storeId: string,
endMs: number,
): Kpi[] {
const days = RANGE_DAYS[range];
const current = buildTimeseries(range, storeId, endMs);
// The comparison window is the immediately preceding period of equal length,
// which is what "vs previous" has to mean for the delta to be meaningful.
const previous = buildTimeseries(range, storeId, endMs - days * 86400000);
const delta = (a: number, b: number) =>
b === 0 ? 0 : Number((((a - b) / b) * 100).toFixed(1));
const trend = (key: keyof TimePoint) =>
current.slice(-14).map((p) => ({t: p.t, v: p[key] as number}));
const rng = createRng('rewards', storeId, range);
const activeRewards = rng.int(6, 14);
return [
{
id: 'visitors',
label: 'Visitors',
value: sum(current, 'visitors'),
unit: 'count',
deltaPct: delta(sum(current, 'visitors'), sum(previous, 'visitors')),
isRiseGood: true,
trend: trend('visitors'),
},
{
id: 'purchases',
label: 'Purchases',
value: sum(current, 'purchases'),
unit: 'count',
deltaPct: delta(sum(current, 'purchases'), sum(previous, 'purchases')),
isRiseGood: true,
trend: trend('purchases'),
},
{
id: 'revenue',
label: 'Revenue',
value: sum(current, 'revenue'),
unit: 'inr',
deltaPct: delta(sum(current, 'revenue'), sum(previous, 'revenue')),
isRiseGood: true,
trend: trend('revenue'),
},
{
id: 'activeRewards',
label: 'Active rewards',
value: activeRewards,
unit: 'count',
deltaPct: Number(rng.float(-12, 18).toFixed(1)),
isRiseGood: true,
trend: trend('conversion'),
},
];
}
export function buildPeakHours(storeId: string): HourCell[] {
const rng = createRng('peak', storeId);
const cells: HourCell[] = [];
for (let day = 0; day < 7; day++) {
for (let hour = 0; hour < 24; hour++) {
// Shops are shut overnight — a heatmap that glows at 04:00 is fiction.
if (hour < 8 || hour > 22) {
cells.push({day, hour, value: 0});
continue;
}
// Two humps: lunch and evening, with the evening peak dominant.
const lunch = Math.exp(-((hour - 13) ** 2) / 4);
const evening = Math.exp(-((hour - 19) ** 2) / 6) * 1.6;
const value = Math.round(
(lunch + evening) * dayFactor(day) * 40 * rng.float(0.85, 1.15),
);
cells.push({day, hour, value});
}
}
return cells;
}
const ACTIVITY: {kind: ActivityEvent['kind']; title: string; detail: string}[] =
[
{
kind: 'reward_redeemed',
title: 'Free Coffee redeemed',
detail: '120 LYTs · Indiranagar Flagship',
},
{
kind: 'purchase',
title: 'Purchase completed',
detail: '₹840 · 2 items · Koramangala',
},
{
kind: 'staff_checked_in',
title: 'Anita R checked in',
detail: 'Morning shift · Whitefield',
},
{
kind: 'reward_expired',
title: 'Combo 20% expired',
detail: 'Claimed 214 times · used 61',
},
{
kind: 'store_opened',
title: 'Jubilee Hills opened',
detail: '09:00 · 3 staff on shift',
},
];
/**
* 48 events, not 12.
*
* The dashboard shows six of these and the Activity page shows the rest, so
* the fixture has to be long enough for "View all" to lead somewhere. Callers
* cap what they render — the endpoint returns the history.
*
* The gap is ACCUMULATED rather than drawn per index. `endMs - i * rng.int(…)`
* redraws the spacing on every row, so row 3 could land closer to now than
* row 2 and the feed arrived out of order — invisible at 12 rows on a panel
* with no ordering claim, obvious on a page headed "newest first".
*/
export function buildActivity(storeId: string, endMs: number): ActivityEvent[] {
const rng = createRng('activity', storeId);
let minutesAgo = 0;
return Array.from({length: 48}, (_, i) => {
const tpl = ACTIVITY[i % ACTIVITY.length];
if (i > 0) minutesAgo += rng.int(6, 40);
return {
id: `a${i}`,
at: new Date(endMs - minutesAgo * 60000).toISOString(),
kind: tpl.kind,
title: tpl.title,
detail: tpl.detail,
storeId,
};
});
}

View File

@@ -1,491 +0,0 @@
import type {Insight} from '@/features/dashboard/types/dashboard';
import type {
ActivityId,
ActivityMetric,
CampaignSummary,
JourneyStage,
} from '@/features/dashboard/types/intelligence';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {buildTimeseries} from './dashboard.mock';
/**
* Activity fixtures, DERIVED rather than invented.
*
* Every number here is anchored to buildTimeseries() — the same generator the
* Footfall and Revenue charts read. That is not tidiness: an activity layer
* generated independently would let the dashboard claim 1,248 visitors in one
* card and 3,400 visits in the card directly below it, and no merchant would
* trust either number again. Activity counts are a share of real visitors,
* journey stages are the real funnel, and campaign rows are slices of the
* activity they run on.
*
* The chain is also enforced by construction: customers ≤ interactions,
* repeat visits ≤ customers, purchases ≤ repeat visits. A fixture that can
* produce more purchases than participants would make the funnel UI render
* a widening cone, and the bug would look like a design problem.
*/
interface ActivitySeed {
id: ActivityId;
label: string;
description: string;
group: ActivityMetric['group'];
accent: ActivityMetric['accent'];
isFeatured: boolean;
/** Interactions per visitor. Above 1 means a visitor does it more than once. */
perVisitor: number;
/** Interactions per distinct customer — the repeat rate within the activity. */
intensity: number;
/** Fraction of participants who come back afterwards. */
returnRate: number;
/** Fraction of returning participants who then buy. */
buyRate: number;
/** Whether taking part can grant a reward. */
hasRewards: boolean;
/** LYTs granted per participating customer, on average. 0 where none. */
lytsPerCustomer: number;
status: ActivityMetric['status'];
}
/**
* The ecosystem, in reading order.
*
* Accents alternate deliberately and are fixed per activity: warm is what the
* brand gives the customer, cool is what the system measures back. Two hues
* across ten rows is what keeps this from becoming a colour-coded legend
* nobody can hold in their head.
*
* `isFeatured` is what the dashboard shows. Six is the ceiling — the summary
* exists to be glanced at, and ten small cards is a second dashboard.
*/
const CATALOG: ActivitySeed[] = [
{
id: 'walk',
label: 'Walk',
description: 'Passers-by detected near the store',
group: 'engagement',
accent: 'warm',
isFeatured: false,
perVisitor: 1.9,
intensity: 1.4,
returnRate: 0.11,
buyRate: 0.34,
hasRewards: false,
lytsPerCustomer: 0,
status: 'live',
},
{
id: 'visit',
label: 'Visit',
description: 'Customers who checked in at the store',
group: 'engagement',
accent: 'warm',
isFeatured: true,
perVisitor: 1.0,
intensity: 1.7,
returnRate: 0.31,
buyRate: 0.52,
hasRewards: false,
lytsPerCustomer: 12,
status: 'live',
},
{
id: 'selfie',
label: 'Selfie',
description: 'In-store photos shared to the feed',
group: 'engagement',
accent: 'cool',
isFeatured: true,
perVisitor: 0.3,
intensity: 1.8,
returnRate: 0.32,
buyRate: 0.47,
hasRewards: true,
lytsPerCustomer: 25,
status: 'live',
},
{
id: 'spin',
label: 'Spin',
description: 'Reward wheel plays',
group: 'engagement',
accent: 'warm',
isFeatured: true,
perVisitor: 0.58,
intensity: 1.76,
returnRate: 0.23,
buyRate: 0.43,
hasRewards: true,
lytsPerCustomer: 40,
status: 'live',
},
{
id: 'scratch',
label: 'Scratch',
description: 'Scratch cards opened',
group: 'engagement',
accent: 'cool',
isFeatured: false,
perVisitor: 0.36,
intensity: 1.5,
returnRate: 0.19,
buyRate: 0.38,
hasRewards: true,
lytsPerCustomer: 30,
status: 'live',
},
{
id: 'challenge',
label: 'Challenge',
description: 'Multi-step tasks completed',
group: 'engagement',
accent: 'cool',
isFeatured: true,
perVisitor: 0.17,
intensity: 1.2,
returnRate: 0.48,
buyRate: 0.55,
hasRewards: true,
lytsPerCustomer: 75,
status: 'live',
},
{
id: 'friend',
label: 'Referral',
description: 'Friends invited by existing customers',
group: 'growth',
accent: 'warm',
isFeatured: true,
perVisitor: 0.07,
intensity: 1.1,
returnRate: 0.42,
buyRate: 0.58,
hasRewards: true,
lytsPerCustomer: 120,
status: 'live',
},
{
id: 'event',
label: 'Event',
description: 'Attendance at in-store events',
group: 'growth',
accent: 'cool',
isFeatured: true,
perVisitor: 0.11,
intensity: 1.05,
returnRate: 0.37,
buyRate: 0.62,
hasRewards: false,
lytsPerCustomer: 50,
status: 'paused',
},
{
id: 'brand',
label: 'Brand campaign',
description: 'Reach from partner and brand pushes',
group: 'growth',
accent: 'warm',
isFeatured: false,
perVisitor: 0.24,
intensity: 1.3,
returnRate: 0.16,
buyRate: 0.29,
hasRewards: true,
lytsPerCustomer: 20,
status: 'draft',
},
{
id: 'shop',
label: 'Shop',
description: 'Catalogue browsing that ended in a basket',
group: 'commerce',
accent: 'cool',
isFeatured: false,
perVisitor: 0.26,
intensity: 1.15,
returnRate: 0.35,
buyRate: 0.71,
hasRewards: false,
lytsPerCustomer: 0,
status: 'live',
},
];
function totalVisitors(range: RangeKey, storeId: string, endMs: number): number {
return buildTimeseries(range, storeId, endMs).reduce(
(a, p) => a + p.visitors,
0,
);
}
function totalPurchases(range: RangeKey, storeId: string, endMs: number): number {
return buildTimeseries(range, storeId, endMs).reduce(
(a, p) => a + p.purchases,
0,
);
}
/**
* One activity's numbers for a scope.
*
* Every step is clamped to at least 1 below its parent once the parent is
* non-trivial, so the funnel narrows even for a quiet store where rounding
* would otherwise collapse two stages onto the same value and make a 100%
* conversion appear out of nowhere.
*/
function buildMetric(
seed: ActivitySeed,
visitors: number,
range: RangeKey,
storeId: string,
): ActivityMetric {
const rng = createRng('activity-metric', seed.id, storeId, range);
const jitter = rng.float(0.88, 1.12);
const count = Math.max(1, Math.round(visitors * seed.perVisitor * jitter));
const customers = Math.max(1, Math.round(count / seed.intensity));
const repeatVisits = Math.round(customers * seed.returnRate);
const purchases = Math.round(repeatVisits * seed.buyRate);
const attributedRevenueInr = Math.round(purchases * rng.float(310, 540));
return {
id: seed.id,
label: seed.label,
description: seed.description,
group: seed.group,
accent: seed.accent,
count,
status: seed.status,
// Issued per PARTICIPATING CUSTOMER, not per interaction: a customer who
// spins six times is not granted six rewards, and multiplying by `count`
// would inflate the liability by the intensity factor on every activity.
lytsIssued: Math.round(customers * seed.lytsPerCustomer),
// Compared against the previous period of equal length, same convention as
// the KPI deltas. Drawn rather than recomputed: the previous window's
// activity split is not something the timeseries carries.
deltaPct: Number(rng.float(-9, 24).toFixed(1)),
impact: {
customers,
rewardClaims: seed.hasRewards
? Math.round(customers * rng.float(0.36, 0.62))
: undefined,
repeatVisits,
purchases,
attributedRevenueInr,
// Modelled, and says so. The whole chain above is derived from observed
// footfall through fixed rates — no purchase here is joined to a
// specific activity event, and the UI reads this field to disclose that.
attribution: 'estimated',
},
isFeatured: seed.isFeatured,
};
}
export function buildActivityMetrics(
range: RangeKey,
storeId: string,
endMs: number,
): ActivityMetric[] {
const visitors = totalVisitors(range, storeId, endMs);
return CATALOG.map((seed) => buildMetric(seed, visitors, range, storeId));
}
/**
* The five-stage progression, built from the funnel that already exists.
*
* Visit and Purchase are NOT invented — they are the same visitor and purchase
* totals the KPI row shows, so the first and third stage of the journey and
* the first two KPI cards can never disagree. Engage and Return come from the
* activity layer, Refer is the referral activity's own count.
*/
export function buildJourney(
range: RangeKey,
storeId: string,
endMs: number,
): JourneyStage[] {
const visitors = totalVisitors(range, storeId, endMs);
const purchases = totalPurchases(range, storeId, endMs);
const metrics = buildActivityMetrics(range, storeId, endMs);
const engaged = metrics
.filter((m) => m.group === 'engagement' && m.id !== 'walk')
.reduce((a, m) => Math.max(a, m.impact.customers), 0);
const referrals = metrics.find((m) => m.id === 'friend')?.count ?? 0;
const returned = Math.round(purchases * 0.69);
const raw: {id: JourneyStage['id']; label: string; value: number}[] = [
{id: 'visit', label: 'Visit', value: visitors},
// Engagement cannot exceed footfall, and on a quiet store the strongest
// single activity can round above it.
{id: 'engage', label: 'Engage', value: Math.min(engaged, visitors)},
{id: 'purchase', label: 'Purchase', value: purchases},
{id: 'return', label: 'Return', value: returned},
{id: 'refer', label: 'Refer', value: Math.min(referrals, returned)},
];
return raw.map((stage, i) => ({
...stage,
conversionPct:
i === 0 || raw[i - 1].value === 0
? undefined
: Number(((stage.value / raw[i - 1].value) * 100).toFixed(1)),
}));
}
/** Campaign name and framing per activity. The numbers come from the activity. */
const CAMPAIGNS: {
id: string;
name: string;
activityId: ActivityId;
status: CampaignSummary['status'];
/** Labels for the three funnel steps, in order. */
steps: [string, string, string];
}[] = [
{
id: 'c-challenge',
name: 'Weekend Challenge',
activityId: 'challenge',
status: 'live',
steps: ['Participants', 'Repeat visits', 'Purchases'],
},
{
id: 'c-friend',
name: 'Refer a Friend',
activityId: 'friend',
status: 'live',
steps: ['Referrals', 'New customers', 'Purchases'],
},
{
id: 'c-event',
name: 'Saturday Event',
activityId: 'event',
status: 'ended',
steps: ['Attendees', 'Repeat visits', 'Purchases'],
},
{
id: 'c-spin',
name: 'Spin & Win',
activityId: 'spin',
status: 'live',
steps: ['Spins', 'Reward claims', 'Purchases'],
},
];
export function buildCampaigns(
range: RangeKey,
storeId: string,
endMs: number,
): CampaignSummary[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
return CAMPAIGNS.flatMap((c) => {
const m = metrics.find((x) => x.id === c.activityId);
if (!m) return [];
// The middle step differs by campaign shape: a referral drive converts to
// new customers, a spin converts to claimed rewards, everything else to a
// return visit. Reading it off the impact chain rather than drawing a
// fresh number is what keeps a campaign row consistent with the activity
// card above it.
const middle =
c.activityId === 'friend'
? m.impact.customers
: c.activityId === 'spin'
? (m.impact.rewardClaims ?? m.impact.repeatVisits)
: m.impact.repeatVisits;
return [
{
id: c.id,
name: c.name,
activityId: m.id,
accent: m.accent,
status: c.status,
steps: [
{label: c.steps[0], value: m.count},
{label: c.steps[1], value: middle},
{label: c.steps[2], value: m.impact.purchases},
],
attributedRevenueInr: m.impact.attributedRevenueInr,
attribution: m.impact.attribution,
},
];
});
}
const pct = (a: number, b: number) => (b === 0 ? 0 : (a / b) * 100);
/**
* Insights, computed from the numbers actually on screen.
*
* Deliberately not a static list of sentences. Every claim below is derived
* from the same fixtures the panels render, so an insight cannot contradict
* the card next to it — which is the failure mode that makes merchants stop
* reading an AI panel after the second week.
*
* Each one carries an action. An observation with no next step belongs in a
* chart, not in a section called "What needs your attention".
*/
export function buildInsights(
range: RangeKey,
storeId: string,
endMs: number,
): Insight[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
const by = (id: ActivityId) => metrics.find((m) => m.id === id)!;
const spin = by('spin');
const challenge = by('challenge');
const visit = by('visit');
const friend = by('friend');
const spinConversion = pct(spin.impact.purchases, spin.impact.customers);
const challengeReturn = pct(
challenge.impact.repeatVisits,
challenge.impact.customers,
);
const visitReturn = pct(visit.impact.repeatVisits, visit.impact.customers);
const returnMultiple = visitReturn === 0 ? 0 : challengeReturn / visitReturn;
const visitConversion = pct(visit.impact.purchases, visit.impact.customers);
const insights: Insight[] = [
{
id: 'i-spin',
severity: spinConversion < 15 ? 'warning' : 'info',
// The headline states the CONVERSION, not the direction of the count.
// "Spin engagement is up 16%" reads as good news and is the wrong thing
// to lead with — and it is also plainly wrong on a period where plays
// fell, which is how a generated insight loses a merchant's trust.
title: `Only ${spinConversion.toFixed(0)}% of spin players go on to buy`,
body: `${spin.count.toLocaleString('en-IN')} spins reached ${spin.impact.customers.toLocaleString('en-IN')} customers and plays are ${spin.deltaPct >= 0 ? 'up' : 'down'} ${Math.abs(spin.deltaPct).toFixed(0)}%. The wheel is drawing plays without pulling anyone to the counter.`,
action: {label: 'Refresh reward catalogue', href: '/lyts'},
},
{
id: 'i-challenge',
severity: 'success',
title: `Challenge participants return ${returnMultiple.toFixed(1)}× more often`,
body: `${challengeReturn.toFixed(0)}% of challenge participants came back this period against ${visitReturn.toFixed(0)}% of ordinary visitors. It is the strongest retention lever running.`,
action: {label: 'Plan another challenge', href: '/activity'},
},
{
id: 'i-visit',
severity: visitConversion < 20 ? 'warning' : 'info',
title: `Visit-to-purchase conversion sits at ${visitConversion.toFixed(0)}%`,
body: `${visit.impact.customers.toLocaleString('en-IN')} customers checked in and ${visit.impact.purchases.toLocaleString('en-IN')} bought. A visit-triggered offer is the shortest path between the two.`,
action: {label: 'Launch a visit offer', href: '/lyts'},
},
{
id: 'i-friend',
severity: 'info',
title: `Referrals brought ${friend.impact.customers.toLocaleString('en-IN')} new customers`,
body: `${friend.count.toLocaleString('en-IN')} referrals converted at ${pct(friend.impact.purchases, friend.impact.customers).toFixed(0)}% — the highest of any growth activity, on the smallest volume.`,
action: {label: 'Promote the referral reward', href: '/lyts'},
},
];
// Most severe first, so the panel's top row is always the thing that most
// needs attention rather than whichever activity happens to be listed first.
const rank = {error: 0, warning: 1, success: 2, info: 3} as const;
return insights.sort((a, b) => rank[a.severity] - rank[b.severity]);
}

View File

@@ -1,71 +0,0 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {
ActivityEvent,
DashboardBriefing,
Granularity,
HourCell,
Insight,
Kpi,
PeriodPoint,
RewardUsagePoint,
StoreComparison,
TimePoint,
} from '@/features/dashboard/types/dashboard';
import type {
ActivityMetric,
CampaignSummary,
JourneyStage,
} from '@/features/dashboard/types/intelligence';
/**
* Every URL the dashboard knows, and the only place it knows them.
*
* This is the file a backend integration edits. Each method returns a typed
* Endpoint rather than data, so the transport stays declarative: useResource
* keys off the URL, which is what makes a store or range change refetch while
* an unrelated re-render does not.
*
* A repository never interprets. No formatting, no filtering, no defaults —
* those are the service's job, and keeping them out of here is what allows a
* REST backend to be swapped for GraphQL by changing this file alone.
*/
export const dashboardRepository = {
kpis: (scope: Scope): Endpoint<Kpi[]> =>
scopedEndpoint('/api/dashboard/kpis', scope),
briefing: (scope: Scope): Endpoint<DashboardBriefing> =>
scopedEndpoint('/api/dashboard/briefing', scope),
timeseries: (scope: Scope): Endpoint<TimePoint[]> =>
scopedEndpoint('/api/dashboard/timeseries', scope),
peakHours: (scope: Scope): Endpoint<HourCell[]> =>
scopedEndpoint('/api/dashboard/peak-hours', scope),
activity: (scope: Scope): Endpoint<ActivityEvent[]> =>
scopedEndpoint('/api/dashboard/activity', scope),
storeComparison: (scope: Scope): Endpoint<StoreComparison[]> =>
scopedEndpoint('/api/dashboard/store-comparison', scope),
rewardUsage: (scope: Scope): Endpoint<RewardUsagePoint[]> =>
scopedEndpoint('/api/dashboard/reward-usage', scope),
performance: (scope: Scope, granularity: Granularity): Endpoint<PeriodPoint[]> =>
scopedEndpoint('/api/dashboard/performance', scope, {granularity}),
// ---- Store intelligence: the activity layer -----------------------------
activityMetrics: (scope: Scope): Endpoint<ActivityMetric[]> =>
scopedEndpoint('/api/dashboard/activity-metrics', scope),
journey: (scope: Scope): Endpoint<JourneyStage[]> =>
scopedEndpoint('/api/dashboard/journey', scope),
campaigns: (scope: Scope): Endpoint<CampaignSummary[]> =>
scopedEndpoint('/api/dashboard/campaigns', scope),
insights: (scope: Scope): Endpoint<Insight[]> =>
scopedEndpoint('/api/dashboard/insights', scope),
};

View File

@@ -0,0 +1,38 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {
ConversionReport,
FootfallReport,
} from '@/features/dashboard/types/reports';
import type {VisitsPage} from '@/features/dashboard/types/visits';
/**
* The platform's own resources, addressed by their own names.
*
* There is no `/api/dashboard/*` here and there must not be: the dashboard is
* a READ MODEL over footfall, conversion and visits, not a data domain of its
* own. Mobile reads the same three resources from the same platform, so
* neither client can drift into having its own version of a number.
*/
export const reportRepository = {
footfall: (scope: Scope, opts: {bucket?: string; compare?: boolean} = {}):
Endpoint<FootfallReport> =>
scopedEndpoint('/api/reports/footfall', scope, {
...(opts.bucket ? {bucket: opts.bucket} : {}),
...(opts.compare ? {compare: 'previous'} : {}),
}),
conversion: (scope: Scope, opts: {bucket?: string; compare?: boolean} = {}):
Endpoint<ConversionReport> =>
scopedEndpoint('/api/reports/conversion', scope, {
...(opts.bucket ? {bucket: opts.bucket} : {}),
...(opts.compare ? {compare: 'previous'} : {}),
}),
visits: (scope: Scope, opts: {limit?: number; cursor?: string} = {}):
Endpoint<VisitsPage> =>
scopedEndpoint('/api/visits', scope, {
...(opts.limit ? {limit: String(opts.limit)} : {}),
...(opts.cursor ? {cursor: opts.cursor} : {}),
}),
};

View File

@@ -1,100 +0,0 @@
/**
* Domain rules for the activity layer.
*
* Two things live here that would otherwise be duplicated in every panel that
* renders an activity: which glyph an activity gets, and how its impact chain
* is read out. Both are decisions about the DOMAIN, not about a layout — the
* dashboard summary, the analytics page and a campaign row must all describe
* Spin the same way, or the merchant is looking at three products.
*/
import {ICONS} from '@/shared/utils/icons';
import type {IconType} from '@astryxdesign/core/Icon';
import type {
ActivityGroup,
ActivityId,
ActivityMetric,
} from '@/features/dashboard/types/intelligence';
export const ACTIVITY_ICON: Record<ActivityId, IconType> = {
walk: ICONS.activityWalk,
visit: ICONS.activityVisit,
selfie: ICONS.activitySelfie,
spin: ICONS.activitySpin,
scratch: ICONS.activityScratch,
brand: ICONS.activityBrand,
challenge: ICONS.activityChallenge,
friend: ICONS.activityFriend,
shop: ICONS.activityShop,
event: ICONS.activityEvent,
};
/**
* The three questions the grouping answers. The description is the point of
* the group — a heading that only says "Engagement" tells a merchant nothing
* they could not have guessed from the cards under it.
*/
export const ACTIVITY_GROUPS: {
id: ActivityGroup;
label: string;
question: string;
}[] = [
{
id: 'engagement',
label: 'Engagement',
question: 'What are customers doing in and around the store?',
},
{
id: 'growth',
label: 'Growth',
question: 'What is bringing new people in?',
},
{
id: 'commerce',
label: 'Commerce',
question: 'Is any of it converting into business?',
},
];
/** A step in the activity → customer → return → purchase chain. */
export interface ImpactStep {
label: string;
value: number;
}
/**
* The impact chain as an ordered list, ready to render.
*
* `rewardClaims` is dropped rather than zeroed when an activity grants no
* reward: a chain reading "→ 0 reward claims" states a failure where there is
* only an absence, and a merchant reads those very differently.
*
* `compact` drops the middle of the chain for the dashboard's small cards,
* which have room for the two ends of the story and not the whole of it. The
* full chain always survives on /activity — this trims the summary, it never
* decides what the data contains.
*/
export function impactChain(
m: ActivityMetric,
{compact = false}: {compact?: boolean} = {},
): ImpactStep[] {
const steps: ImpactStep[] = [
{label: 'customers', value: m.impact.customers},
];
if (!compact && m.impact.rewardClaims !== undefined) {
steps.push({label: 'reward claims', value: m.impact.rewardClaims});
}
if (!compact) {
steps.push({label: 'repeat visits', value: m.impact.repeatVisits});
}
steps.push({label: 'purchases', value: m.impact.purchases});
return steps;
}
/** Share of participants who ended up buying — the number the chain exists for. */
export function conversionPct(m: ActivityMetric): number {
if (m.impact.customers === 0) return 0;
return (m.impact.purchases / m.impact.customers) * 100;
}

View File

@@ -0,0 +1,83 @@
import type {Kpi} from '@/features/dashboard/types/dashboard';
import type {
ConversionReport,
FootfallReport,
} from '@/features/dashboard/types/reports';
/**
* The KPI row, derived from the two reports that back it.
*
* ── What this is allowed to compute, and what it is not ──────────────────
* It computes PRESENTATION: a percentage change between two totals the
* platform reported, and a sparkline from buckets the platform returned. It
* does not compute business metrics. In particular it never sums buckets to
* produce a total — `total` is unique people over the window, and adding the
* buckets double-counts everybody who came twice.
*
* A metric the platform did not report is OMITTED, not zeroed. A card reading
* "0" is a claim; an absent card is the truth.
*/
function delta(current: number, previous: number | null): number | undefined {
if (previous === null || previous === 0) return undefined;
return Number((((current - previous) / previous) * 100).toFixed(1));
}
export function buildKpis(
footfall: FootfallReport | undefined,
conversion: ConversionReport | undefined,
): Kpi[] {
const kpis: Kpi[] = [];
if (footfall) {
kpis.push({
id: 'visitors',
label: 'Visitors',
// The server's total — unique people — not the sum of the buckets.
value: footfall.total,
unit: 'count',
deltaPct: delta(footfall.total, footfall.previousTotal) ?? 0,
isRiseGood: true,
trend: footfall.buckets.map((b) => ({t: b.label, v: b.visitors})),
});
}
if (conversion) {
kpis.push({
id: 'purchases',
label: 'Purchases',
value: conversion.purchases,
unit: 'count',
deltaPct: delta(conversion.purchases, conversion.previousPurchases) ?? 0,
isRiseGood: true,
trend: conversion.buckets.map((b) => ({t: b.label, v: b.purchases})),
});
kpis.push({
id: 'revenue',
label: 'Revenue',
value: conversion.revenue,
unit: 'inr',
deltaPct: delta(conversion.revenue, conversion.previousRevenue) ?? 0,
isRiseGood: true,
trend: conversion.buckets.map((b) => ({t: b.label, v: b.revenue})),
});
// Only when the platform reports it. Deriving purchases ÷ visitors here
// would be this app inventing a definition of conversion that the reports
// may not share — exactly the drift the single-API rule exists to stop.
if (conversion.conversionPct !== null) {
kpis.push({
id: 'conversion',
label: 'Conversion',
value: conversion.conversionPct,
unit: 'pct',
deltaPct: 0,
isRiseGood: true,
trend: conversion.buckets.map((b) => ({t: b.label, v: b.conversion})),
});
}
}
return kpis;
}

Some files were not shown because too many files have changed in this diff Show More