diff --git a/AGENTS.md b/AGENTS.md index cf6a1e4..d916a0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,11 @@ + # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/docs/API-GAP-REPORT.md b/docs/API-GAP-REPORT.md new file mode 100644 index 0000000..678f478 --- /dev/null +++ b/docs/API-GAP-REPORT.md @@ -0,0 +1,41 @@ +# API gap report — after backend integration + +Generated 2026-09-09. Format: `Feature | Existing API | Frontend status | Missing API` + +## Wired to the platform + +| Feature | Existing API | Frontend status | Missing API | +|---|---|---|---| +| Login | `POST /api/auth/login` | Wired | — | +| Session restore | `GET /api/auth/me` | Wired — confirmed on every load | — | +| Token refresh | `POST /api/auth/refresh` | Wired — single-flight, persist-before-use | — | +| Logout | `POST /api/auth/logout` | Wired — revokes upstream, then clears | — | +| Site switcher / Store page | `GET /api/sites` | Wired | — | +| Dashboard Visitors | `GET /api/reports/footfall` | Wired — server `total` | — | +| Dashboard Purchases / Revenue / Conversion | `GET /api/reports/conversion` | Wired | — | +| Footfall chart | `GET /api/reports/footfall?bucket=day` | Wired | — | +| Revenue chart · Sales page | `GET /api/reports/conversion?bucket=day` | Wired | — | +| Recent arrivals · Activity page | `GET /api/visits` | Wired — cursor echoed, deduped on `visit_id` | — | +| Customer photos | `GET /api/faces/…` | Wired — proxied so `` works | — | +| Team | `GET /api/team` | Wired | — | +| Mobile → dashboard purchases | `POST /api/purchases` | Wired via `POST /api/visits` | `GET /api/purchases` | + +## Cannot be wired — backend required + +| Feature | Existing API | Frontend status | Missing API | +|---|---|---|---| +| LYT programme | none | Unavailable panel | `GET /api/rewards`, `/api/rewards/redemptions`, `/api/lyts/ledger` | +| Engagement activities | none | Unavailable panel | `GET /api/activities`, `/api/activities/impact` | +| Campaigns | none | Unavailable panel | `GET /api/campaigns` | +| Customer journey | partial (visit/purchase/return only) | Unavailable panel | `GET /api/reports/journey` | +| Orders / products / stock | none | Unavailable panel | `GET /api/purchases`, `/api/products` | +| Payment split / refunds | none | Unavailable panel | `GET /api/reports/payments`, `/api/reports/refunds` | +| Staff attendance & ranking | `/api/team` is console accounts | Unavailable panel | `GET /api/staff`, `/api/staff/attendance`, `/api/reports/staff-sales` | +| Business profile | none | Unavailable panel | `GET/PATCH /api/settings/profile` | +| Roles matrix · Integrations · API keys · Billing | none | Still local state | `GET /api/settings/{roles,integrations,api-keys,billing}` | +| Security → sessions | `GET/DELETE /api/auth/sessions` | **Not yet wired** — endpoint exists | — | +| Invitations / join flow | `POST /api/team/invitations`, `GET /api/auth/invitation`, `POST /api/auth/register` | **Not yet wired** — endpoints exist, service written | — | +| Loyaly AI chat | none | Still mock replies | `POST /api/ai/chat` | +| Live arrivals stream | `GET /api/visits/stream` | **Not yet wired** — polling only | — | +| Cameras / site health | `GET /api/cameras`, `/api/sites/{id}/check` | **Not yet wired** — service written | — | +| Visitor directory | `GET /api/visitors`, `/history`, `PUT /profile`, `DELETE` | **Not yet wired** — service written | — | diff --git a/docs/API-INVENTORY.md b/docs/API-INVENTORY.md new file mode 100644 index 0000000..f43ff20 --- /dev/null +++ b/docs/API-INVENTORY.md @@ -0,0 +1,279 @@ +# Loyaly Merchant OS — API inventory & backend wiring plan + +Audit date: 2026-09-09 · Branch `main` · Audited against the running app on :3100 + +Purpose: establish exactly what data the frontend consumes today, which of it is +fake, and what a real backend must expose. Compare this against your API spec and +mark each row **match / rename / missing / extra**. + +--- + +## 1. How data flows today + +``` +component → hook (useResource) → repository (owns the URL) → httpClient + │ + NEXT_PUBLIC_API_BASE ────┤ + │ + (unset) → Next route handler → fixture generator + (set) → YOUR BACKEND +``` + +**The seam already exists and works.** `src/shared/services/httpClient.ts` line 22: + +```ts +const BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; +``` + +Set that to your API host and every endpoint in §2 bypasses the local route +handlers entirely. No component changes. This is the intended cutover. + +**Response envelope** — every endpoint must return this shape (`src/shared/types/api.ts`): + +```jsonc +// success +{ "data": , "meta": { "generatedAt": "ISO-8601", "range": "30d", "storeId": "all" } } +// failure (any non-2xx, or 2xx with error present) +{ "error": { "code": "internal|not_found|bad_request|unauthorized", "message": "…" } } +``` + +`meta.generatedAt` is **required**: the UI measures every relative time +("2 hours ago", days-to-expiry, the greeting) against the server clock, never +`Date.now()`. + +**Scope query** — every analytics endpoint is filtered by the same two params: + +| param | values | +|---|---| +| `storeId` | `all` \| a store id | +| `range` | `7d` \| `30d` \| `90d` \| `mtd` \| `ytd` \| `custom` | + +**Auth** — session is an httpOnly cookie. `credentials: 'same-origin'` is sent on +every request. A cross-origin backend needs CORS + `SameSite=None; Secure`, or +keep the Next routes as a thin proxy. + +--- + +## 2. Endpoints that EXIST (24) — all fixture-backed + +Legend: **T** = TypeScript response type, defined in the file noted. + +### Auth — `src/features/auth/types/auth.ts` + +| Method | Path | Body / Query | Returns | +|---|---|---|---| +| POST | `/api/auth/login` | `{email, password, rememberMe}` (JSON **and** form-encoded) | `AuthSession` | +| POST | `/api/auth/logout` | `{}` | `{ok: boolean}` | +| GET | `/api/auth/session` | — | `AuthSession \| null` | + +`AuthSession = {user: {id, email, name, role: 'owner'|'manager'|'analyst', organisation}, expiresAt}` + +### Dashboard — `src/features/dashboard/types/dashboard.ts` + `intelligence.ts` + +| Method | Path | Extra query | Returns | +|---|---|---|---| +| GET | `/api/dashboard/kpis` | scope | `Kpi[]` | +| GET | `/api/dashboard/timeseries` | scope | `TimePoint[]` | +| GET | `/api/dashboard/peak-hours` | scope | `HourCell[]` | +| GET | `/api/dashboard/activity` | scope | `ActivityEvent[]` | +| GET | `/api/dashboard/store-comparison` | scope | `StoreComparison[]` | +| GET | `/api/dashboard/reward-usage` | scope | `RewardUsagePoint[]` | +| GET | `/api/dashboard/performance` | scope + `granularity=weekly\|monthly` | `PeriodPoint[]` | +| GET | `/api/dashboard/briefing` | scope | `DashboardBriefing` | +| GET | `/api/dashboard/activity-metrics` | scope | `ActivityMetric[]` | +| GET | `/api/dashboard/journey` | scope | `JourneyStage[]` | +| GET | `/api/dashboard/campaigns` | scope | `CampaignSummary[]` | +| GET | `/api/dashboard/insights` | scope | `Insight[]` | + +Key shapes: + +```ts +Kpi { id:'visitors'|'purchases'|'revenue'|'activeRewards', label, value, + unit:'count'|'inr'|'lyt'|'pct', deltaPct, isRiseGood, trend:{t,v}[] } +TimePoint { t:'YYYY-MM-DD', visitors, purchases, revenue, conversion } +HourCell { day:0-6 (0=Mon), hour:0-23, value } +ActivityEvent{ id, at:ISO, kind:'reward_redeemed'|'staff_checked_in'|'purchase' + |'reward_expired'|'store_opened', title, detail?, storeId } +PeriodPoint { label:'W32'|'Aug', visitors, purchases, revenue } +Insight { id, severity:'info'|'success'|'warning'|'error', title, body, + action?:{label, href} } +``` + +**Activity intelligence** (`intelligence.ts`) — the model shared by Dashboard and Lyts: + +```ts +ActivityMetric { + id: 'walk'|'visit'|'selfie'|'spin'|'scratch'|'brand'|'challenge'|'friend'|'shop'|'event' + label, description + group: 'engagement'|'growth'|'commerce' + accent: 'warm'|'cool' // fixed per activity, travels on the payload + count, deltaPct + status: 'live'|'paused'|'draft' + lytsIssued // MEASURED (ledger). 1 LYT = ₹1 + isFeatured // the 6 the dashboard summarises + impact: { + customers // MEASURED + rewardClaims? // omitted, never 0, when activity grants none + repeatVisits, purchases // ATTRIBUTED + attributedRevenueInr // ATTRIBUTED — never name this `revenue` + attribution: 'estimated'|'observed' + } +} +JourneyStage { id:'visit'|'engage'|'purchase'|'return'|'refer', label, value, conversionPct? } +CampaignSummary { id, name, activityId, accent, status:'live'|'ended'|'scheduled', + steps:{label,value}[], attributedRevenueInr, attribution } +``` + +> **Contract rule.** `attribution` is not decorative. When it is `'estimated'` the +> UI shows a disclosure ("Attribution is estimated…") beside every attributed +> figure; when your backend can join purchases to activity events, return +> `'observed'` and the disclosure disappears with no frontend change. +> Invariants the UI assumes: `purchases ≤ repeatVisits ≤ customers ≤ count`. + +### Lyts — `src/features/lyts/types/reward.ts` + +| Method | Path | Returns | +|---|---|---| +| GET | `/api/lyts/rewards` | `Reward[]` | +| GET | `/api/lyts/redemptions` | `TimePoint[]` ⚠️ | +| GET | `/api/lyts/activity` | `ActivityEvent[]` | + +`Reward { id, name, costLyt, claimed, used, expiresAt: ISO|null, status: 'active'|'paused'|'expiring'|'expired' }` + +⚠️ **Known contract smell:** redemptions reuses `TimePoint`, and the Lyts charts +read `visitors` as "Issued" and `purchases` as "Redeemed". Your backend should +return a purpose-built shape — `{t, issued, redeemed}` — and I'll update the two +chart call sites. + +### Stores — `src/features/stores/types/store.ts` + +| Method | Path | Returns | +|---|---|---| +| GET | `/api/stores` | `Store[]` | +| GET | `/api/stores/:storeId` | `Store` (404 → `not_found`) | + +`Store { id, name, status:'open'|'closed'|'maintenance', visitors, purchases, revenueInr, conversionPct, staffCount }` + +### Staff — `src/features/staff/types/staff.ts` + +| Method | Path | Returns | +|---|---|---| +| GET | `/api/staff` | `StaffMember[]` | +| GET | `/api/staff/summary` | `StaffSummary` | +| GET | `/api/staff/attendance` | `AttendancePoint[]` | + +### Settings — `src/features/settings/types/settings.ts` + +| Method | Path | Returns | +|---|---|---| +| GET | `/api/settings/profile` | `MerchantProfile` | +| PATCH | `/api/settings/profile` | `MerchantProfile` (merged) — **does not persist today** | + +--- + +## 3. Endpoints that DO NOT EXIST — must be created + +These screens are live in the UI with **no API, no repository, and no persistence**. +Mutations are `useState` only: refresh the page and every change is gone. + +### 3a. Commerce — the worst-wired module + +`/commerce` imports `commerceService.ts` **directly and synchronously** from 10 +component files. No repository, no route handler, no loading or error state. + +Needed: + +| Method | Path | Returns | +|---|---|---| +| GET | `/api/commerce/kpis` | revenue, orders, AOV, net sales, refunds, conversion (value + delta each) | +| GET | `/api/commerce/revenue-trend` | `{t, revenue, target}[]` | +| GET | `/api/commerce/orders-trend` | `{t, orders}[]` | +| GET | `/api/commerce/payment-breakdown` | `{method, amount, sharePct}[]` | +| GET | `/api/commerce/store-performance` | `{storeId, name, revenueInr}[]` | +| GET | `/api/commerce/top-products` | `{id, name, category, unitsSold, revenueInr, growthPct, stockCount, stockStatus}[]` | +| GET | `/api/commerce/alerts` | `{id, severity, title, body}[]` | +| GET | `/api/commerce/orders` | recent orders feed | + +### 3b. Settings — 7 screens, all local-state fakes + +| Screen | Needed endpoints | +|---|---| +| Team & Staff | `GET/POST/PATCH/DELETE /api/settings/team` (+ suspend, password reset, invite) | +| Stores | `GET/POST/PATCH/DELETE /api/settings/stores` | +| Roles & Permissions | `GET/PUT /api/settings/roles` (permission matrix) | +| Integrations | `GET /api/settings/integrations`, `POST/DELETE .../:id/connection` | +| API & Webhooks | `GET/POST/DELETE /api/settings/api-keys`, `GET/POST/DELETE /api/settings/webhooks`, `GET /api/settings/webhooks/logs` | +| Security | `GET /api/settings/sessions`, `DELETE /api/settings/sessions/:id`, `GET /api/settings/audit-log`, `POST /api/auth/password`, `POST/DELETE /api/auth/2fa` | +| Billing | `GET /api/settings/billing`, `GET /api/settings/invoices`, `PATCH /api/settings/payout-account` | + +### 3c. Loyaly AI + +The chat panel streams from `services/ai/mockAi.ts` — a local prompt classifier +picking canned templates. `loyalyAiRepository` is the only repository in the app +that imports a mock directly. + +Needed: `POST /api/ai/chat` (streaming — SSE or chunked), plus +`GET/POST/DELETE /api/ai/conversations` if history is to survive reload. + +### 3d. Store switcher — **critical** + +`STORE_OPTIONS` is a **hardcoded array in `src/shared/providers/WorkspaceProvider.tsx`** +(line 45), duplicated from the fixture roster. This array scopes *every request in +the app*. It must be fed from `GET /api/stores`, or a real merchant sees fixture +store names in the global switcher. + +--- + +## 4. Hardcoded / dummy data inventory + +| Location | What | Removal | +|---|---|---| +| `src/features/*/mock/*.ts` (11 files, ~1,970 lines) | all fixture generators | delete at cutover | +| `src/shared/mock/rng.ts` | seeded RNG | delete at cutover | +| `src/features/commerce/services/commerceService.ts` | KPIs, trends, products, alerts, store multipliers | replace with repository | +| `src/features/settings/components/*.tsx` × 7 | `INITIAL_STAFF`, `INITIAL_STORES`, `INITIAL_MATRIX`, `INITIAL_APPS`, `INITIAL_KEYS`, `INITIAL_WEBHOOKS`, `WEBHOOK_LOGS`, `INITIAL_SESSIONS`, `AUDIT_LOGS`, `INVOICES` | replace with hooks | +| `src/shared/providers/WorkspaceProvider.tsx` | `STORE_OPTIONS` | feed from `/api/stores` | +| `src/features/loyaly-ai/services/ai/**` (~570 lines) | prompt router + reply templates | replace with real AI endpoint | +| `src/features/auth/mock/users.mock.ts` | 5 users, **plaintext passwords** | replace `verifyCredentials()` body | +| `src/app/api/**/route.ts` (24 files) | fixture wiring + `?_state=` simulation | delete or keep as proxy — see §6 | + +**Auth note.** `verifyCredentials()` is already the single credential seam — its +body becomes an HTTP call and nothing else changes. But it currently returns +`unknown_email` vs `wrong_password` separately, which is a **user-enumeration +oracle**. Collapse both to one message at the route when you go live. +`AUTH_SECRET` must be set in production (`sessionToken.ts` throws without it). + +--- + +## 5. What I recommend NOT doing yet + +Deleting the fixtures before the real endpoints exist leaves every screen in an +error state and removes the only way to verify the wiring. The fixtures are also +the **executable spec** — `intelligence.mock.ts` encodes the funnel invariants +your backend has to honour. + +Order that keeps the app working at every step: + +1. **Now (backend-independent):** add the missing seams — commerce repository + + route, settings hooks + routes, `STORE_OPTIONS` from `/api/stores`, AI + repository seam. Fixtures stay behind them. Every module then has one file to + swap. +2. **You send the API spec MD.** I diff it against §2/§3 and report + match / rename / missing / extra per endpoint. +3. **Cutover:** point `NEXT_PUBLIC_API_BASE` at the real host, adapt any shape + mismatches in the repository layer only, delete `mock/` + `src/app/api/**`. +4. **Verify:** typecheck, build, and walk every screen with the network tab. + +--- + +## 6. Decision needed from you + +**Do the Next.js route handlers stay?** + +- **A — Direct:** frontend calls your backend. Delete `src/app/api/**`. Needs CORS + and a cross-origin-safe session cookie. +- **B — Proxy (recommended):** keep the route handlers, replace each fixture call + with a `fetch` to your backend. Session cookie stays first-party, your API host + is never exposed to the browser, and the `?_state=error|empty|loading` dev + harness keeps working. + diff --git a/docs/BEHAVISION-GAP-ANALYSIS.md b/docs/BEHAVISION-GAP-ANALYSIS.md new file mode 100644 index 0000000..1ab74f5 --- /dev/null +++ b/docs/BEHAVISION-GAP-ANALYSIS.md @@ -0,0 +1,277 @@ +# Behavision API ↔ Loyaly Merchant OS — gap analysis + +Audit date: 2026-09-09 · API: `https://platform.loyaly.ai` · Frontend: this repo + +--- + +## Headline finding + +**These are two different products.** + +This frontend was built as a **loyalty & rewards console**: LYT points, reward +catalogues, engagement activities (spin / selfie / scratch / challenge / +referral), campaigns, redemption liability, commerce orders. + +Behavision is a **camera-based footfall & visitor-recognition platform**: sites, +cameras, face templates, arrivals, visitor identity, footfall and conversion +reports. + +They overlap on roughly **one third** of the surface — the part that is genuinely +about *people arriving at a shop and buying something*. The rest of the UI has no +data source in this API and never will until a loyalty backend exists. + +So "remove all hardcodes" has a consequence that needs a decision, not a +guess: **removing the fixtures makes about half the current UI go blank.** §4 +lays out the options. + +The reverse is also true and more interesting: **Behavision exposes a lot of real +product this console does not surface at all** — a visitor directory, live camera +views, site health checks, an invitation/join flow, device management, GDPR +erasure. See §5. + +--- + +## 1. What maps — build these for real + +| Screen / panel | Behavision endpoint | Notes | +|---|---|---| +| Login | `POST /api/auth/login` | different token model — §3 | +| Session restore | `GET /api/auth/me` | | +| Logout | `POST /api/auth/logout` | | +| Dashboard · Visitors KPI | `GET /api/reports/footfall` | use server `total`, never sum buckets | +| Dashboard · Purchases KPI | `GET /api/reports/conversion` | | +| Dashboard · Revenue KPI | `GET /api/reports/conversion` | | +| Dashboard · Footfall chart | `GET /api/reports/footfall?bucket=day` | | +| Dashboard · Revenue chart | `GET /api/reports/conversion?bucket=day` | | +| Dashboard · Visitors vs purchases | both reports | | +| Dashboard · Conversion chart | `GET /api/reports/conversion` | | +| Dashboard · Peak hours heatmap | `GET /api/reports/footfall?bucket=hour` | 7×24 grid from hourly buckets | +| Dashboard · Period rollup | `…?bucket=week\|month` | | +| Dashboard · Store comparison | `GET /api/reports/footfall?site=` per site | | +| Dashboard · Recent activity feed | `GET /api/visits` | **arrivals only** — see below | +| Stores list | `GET /api/sites` | + `fraction_below_gate`, cameras up | +| Store detail | `GET /api/sites/{id}/check` | five-step smoke test | +| Settings · Team | `GET /api/team`, `PATCH /api/team/{id}` | replaces `INITIAL_STAFF` | +| Settings · Security → sessions | `GET/DELETE /api/auth/sessions`, `POST …/revoke-others` | replaces `INITIAL_SESSIONS` | +| Store switcher (`STORE_OPTIONS`) | `GET /api/sites` | **critical — scopes every request** | + +**Customer journey** maps partially and honestly: + +| stage | source | +|---|---| +| Visit | footfall `total` | +| Engage | ✗ no source | +| Purchase | conversion | +| Return | footfall `returning` | +| Refer | ✗ no source | + +**Activity feed caveat.** The current feed renders five event kinds +(`reward_redeemed`, `staff_checked_in`, `purchase`, `reward_expired`, +`store_opened`). `/api/visits` supplies **arrivals only**. Four of the five kinds +have no source. The feed becomes an arrivals feed — which is arguably the better +screen, and is what the spec calls "the screen a mobile app is for". + +--- + +## 2. What does NOT map — no endpoint exists + +| Area | Frontend surface | Status | +|---|---|---| +| **LYT programme** | entire `/lyts` page: rewards, claimed/used, redemption rate, outstanding liability, expiry alerts, reward usage chart, LYTs issued | ✗ no rewards concept in the API | +| **Activity intelligence** | walk / selfie / spin / scratch / brand / challenge / friend / shop / event, impact chains, attribution, activity grouping | ✗ only *visit* has an analogue | +| **Campaign performance** | campaign funnels | ✗ | +| **Store insights** | "What needs your attention", AI briefing | ✗ | +| **Commerce** | orders, products, categories, stock, payment methods, refunds, AOV, alerts | ✗ except revenue/basket via conversion report; `POST /api/purchases` writes but there is no orders read | +| **Staff module** | attendance (present/absent/late/leave), punctuality, sales per head, rewards issued, performance score | ✗ **different concept** — `/api/team` is console *user accounts*, not shop-floor rostering | +| **Settings · Profile** | business name, GSTIN, timezone, currency, LYTs per ₹100 | ✗ no business-profile endpoint | +| **Settings · Roles matrix** | per-permission grid | ✗ role is a single enum via `PATCH /api/team/{id}` | +| **Settings · Integrations** | connected apps | ✗ | +| **Settings · API & Webhooks** | keys, endpoints, delivery logs | ✗ | +| **Settings · Billing** | plan, invoices, payout account | ✗ | +| **Settings · Security → audit log, 2FA** | | ✗ (sessions *do* exist) | +| **Loyaly AI** | chat panel | ✗ | + +--- + +## 3. Architectural changes required + +These are not cosmetic. Each one has a defined failure mode. + +### 3.1 Auth: cookie+HMAC → Bearer JWT with rotation + +Today: `proxy.ts` gates every route on an httpOnly cookie carrying an +HMAC-signed payload minted locally by `sessionToken.ts`. There is no upstream. + +Behavision: `access_token` + `refresh_token`, both rotating. + +**Recommendation — keep the Next routes as a BFF (Backend-For-Frontend).** +Tokens live server-side; the browser keeps only the existing httpOnly cookie. +This is not conservatism, it buys five specific things: + +1. `proxy.ts` and the whole SSR gate keep working unchanged. +2. Tokens never reach JS, so an XSS cannot exfiltrate a refresh token. +3. **Web `` cannot send `Authorization`.** A BFF can proxy + `/api/faces/…` and the browser just uses a normal ``. Otherwise + every avatar needs fetch + `createObjectURL` + revoke-on-unmount. +4. The single-flight refresh lock lives in one server process, not in N tabs. +5. CORS and `SameSite=None` disappear as problems. + +**Three client rules the spec calls out — all must be implemented in the BFF:** + +- `401` + `"error": "token_expired"` → refresh **once**, retry, silently. Any + other 401 is a real sign-out. +- **Serialise refresh behind one lock.** Refresh tokens are single-use; four + concurrent panels would each spend it and three would lose. This dashboard + fires **9 parallel requests on one page load** — it will hit this on the first + expiry, every time, without the lock. +- **Persist rotated tokens before using them.** A crash between refresh and + persist leaves a token the server has already invalidated. + +Also: marshal the request body before the first attempt — a retry re-sends it, +and a stream is spent after the first read. + +### 3.2 Response envelope + +Frontend expects `{data, meta:{generatedAt, range, storeId}}`. Behavision +returns bare objects/arrays. + +**`meta.generatedAt` is load-bearing** — every relative timestamp, the greeting, +and every days-to-expiry countdown measures against the server clock, never +`Date.now()`. The BFF must synthesise it (from `polled_at`, or the response +time) or ~20 `useResource` call sites all need rewriting. + +Wrap in the BFF. Cheapest correct option by a wide margin. + +### 3.3 Scope parameters + +`?storeId=all&range=30d` → `?site=&from=YYYY-MM-DD&to=YYYY-MM-DD&tz=…&bucket=…` + +- `storeId: 'all'` → omit `site` entirely. +- `RangeKey` → concrete `from`/`to`. `to` is **inclusive**. +- Send `tz`; buckets come back as **local wall time with no offset**. +- Use `site` (documented spelling). An unknown query param is *silently ignored*, + so `site_id` in the wrong place returns the whole estate instead of an error. + +### 3.4 Report arithmetic — three traps + +- **Do not sum buckets to get the total.** `total` is unique people over the + window; someone who came Monday and Thursday is 1 person, 2 bucket-visitors. + Show the server's `total`, visits underneath. +- **`new + returning` can be less than `total`** — a site sending counts without + templates records real footfall by an unidentified person. +- **Do not `new Date()` a bucket label.** They are wall-time strings with no + offset; parsing shifts every label into the viewer's zone. + `formatDayLabel()` currently does `new Date(iso)` — it pins `timeZone:'UTC'` + so it survives, but bucket labels should be passed through, not parsed. + +### 3.5 Errors + +Behavision: `{"error": "invalid_code", "message": "…"}` (flat). +Frontend: `{error: {code, message}}` (nested), with codes +`internal|not_found|bad_request|unauthorized`. + +Map in the BFF. And **show the server's `message`** — it is written for humans; +branch on `error`, never on the prose. New codes to handle: `token_expired`, +`too_many_attempts` (429), `last_owner` (409), `invalid_code` (404), +**`501` = feature off for this deployment, not an error**. + +`404` is also what another tenant's data returns — never surface it as "deleted". + +### 3.6 Cursor pagination — new concept + +`GET /api/visits` is cursor-based and lossless; polling by timestamp +**permanently skips rows** when a burst exceeds `limit`. `useResource` has no +cursor concept — the arrivals feed needs a cursor-aware hook. + +- Echo the returned `cursor` on every poll. +- An empty poll returns **your own cursor**, not `""`. +- A cursor that fails to parse → drop it, re-poll without one. +- Delivery is at-least-once → de-duplicate on `visit_id`. +- `GET /api/visits/stream` (SSE) is the low-latency path; needs an HTTP client + that can set `Authorization` (browser `EventSource` cannot). + +### 3.7 Images — new subsystem + +- **Missing photo is data, not an error.** Images are off by default across the + product; `available: false` with a `reason` is the normal case. Render + initials, never an error state. +- `auth: true` → send Bearer. `auth` absent/false → presigned, use directly. + **Do not infer from the URL shape.** Treat any relative URL as needing auth. +- **Every hand-out is written to the audit log.** Fetch once per screen, not + once per component — two components asking for one face puts two rows in + "who looked at my customers" for one glance. +- Web: object URL + **revoke on unmount**, or a screen left open all afternoon + holds hundreds of copies of one photograph. (The BFF proxy in §3.1 avoids + this entirely.) + +### 3.8 Roles + +`UserRole = 'owner' | 'manager' | 'analyst'` → **`'staff' | 'manager' | 'owner'`**. +`analyst` does not exist. Platform admin = `role === 'admin'` **and** empty +`client_id` — the two together, never the role alone. + +### 3.9 References instead of uuids + +`V-42`, `chennai`, `Office1`, `priya@tenext.in` all work in paths and filters, +and are **immutable** — safe to put in a URL or a saved report. Display names are +not. The store switcher should key on `site_slug`, and deep links should use it. + +Ambiguity resolves to nothing, not a guess. Unknown ref: **404 in a path, 400 in +a filter**. + +--- + +## 4. The decision: what happens to the unmapped half + +The API gives a clean idiom for this: **`501` — "the feature is off for this +deployment, not an error."** + +| | option | result | +|---|---|---| +| **A** | Delete the unmapped modules | Smallest, most honest app. Lose `/lyts`, `/commerce`, activity intelligence, campaigns, insights, staff attendance, most of settings. Recoverable from git when a loyalty backend ships. | +| **B** | Keep the UI, render "not available in this deployment" | Nothing hardcoded, nothing invented, screens stay for when the backend arrives. Costs a small unavailable-state component. | +| **C** | Keep fixtures behind an explicit `DEMO_MODE` flag | Sales demos keep working; real deployments show B. Highest complexity. | + +**My recommendation: B**, with A for `/commerce` specifically — commerce is the +one module with no seam at all (10 components import a service synchronously), +so there is nothing to preserve, and conversion-report revenue can move onto the +dashboard where it belongs. + +Either way **no invented number survives**, which is what you asked for. + +--- + +## 5. Real product this console is not exposing + +Worth knowing before we decide what to delete — the API supports screens that do +not exist here yet: + +- **Visitor directory** — `GET /api/visitors?q=` search by name/phone/`V-42`, + `/history`, `PUT /profile` (name, phone, notes), and **`DELETE` erasure** + (destroys face template + photo, keeps visits unlinked, irreversible; a `502` + means *nothing* was deleted and must be reported as failure, never swallowed). +- **Live camera view** — `GET /api/cameras/{id}/live`, SSE relayed from the shop PC. +- **Camera management** — `GET /api/cameras` with latest still, `PATCH /api/cameras/{id}`. +- **Site health** — `GET /api/sites/{id}/check`, five-step smoke test. +- **Invitation / join flow** — mint a code, preview it unauthenticated, redeem it + into a full session. Replaces the fake "Add Staff Member" form entirely. +- **Device management** — the user's own signed-in devices, with `current` marked. +- **Arrivals SSE stream** — the live feed. + +--- + +## 6. Proposed sequence + +1. **BFF + auth** — Behavision login/refresh/logout/me behind the existing + cookie; single-flight refresh; envelope + error adapters; scope→params + mapper. Nothing else can be wired until this exists. +2. **Kill the highest-risk hardcode** — `STORE_OPTIONS` → `GET /api/sites`. +3. **Reports** — dashboard KPIs, footfall, conversion, peak hours, rollup, + comparison. +4. **Arrivals** — cursor-aware feed replacing the mock activity timeline. +5. **Team + Sessions + Invitations** — replaces four fake settings screens with + real ones. +6. **Apply the §4 decision** to everything unmapped; delete `src/features/*/mock/**` + and `src/shared/mock/`. +7. **Verify** — typecheck, build, walk every screen against the live API. + diff --git a/docs/PLATFORM-STATUS.md b/docs/PLATFORM-STATUS.md new file mode 100644 index 0000000..8c17e47 --- /dev/null +++ b/docs/PLATFORM-STATUS.md @@ -0,0 +1,60 @@ +# Live platform status — mcp.loyaly.ai + +Probed 2026-09-09. `LOYALY_API_BASE=https://mcp.loyaly.ai` + +The host is confirmed as the Behavision platform: it answers the documented flat +`{"error": "...", "message": "..."}` contract with the documented codes +(`bad_credentials`, `unauthorized`, `not_found`, `bad_request`). + +**8 of the 16 endpoints the spec documents are not deployed yet.** +A `401 unauthorized` proves the route exists and is gated. A +`404 {"error":"not_found","message":"No such endpoint."}` means it is absent. + +## Live + +| Endpoint | Probe | Console feature | +|---|---|---| +| `POST /api/auth/login` | 401 `bad_credentials` | Sign in — **wired** | +| `POST /api/auth/refresh` | 400 `refresh_token is required` | Token rotation — **wired** | +| `POST /api/auth/logout` | 401 `unauthorized` | Sign out — **wired** | +| `GET /api/auth/me` | 401 `unauthorized` | Session confirmation — **wired** | +| `GET /api/sites` | 401 `unauthorized` | Site switcher + Store page — **wired** | +| `GET /api/visitors` | 401 `unauthorized` | Customer directory — service written, UI not built | +| `GET /api/reports/footfall` | 401 `unauthorized` | Visitors KPI + Footfall chart — **wired** | +| `GET /api/reports/conversion` | 401 `unauthorized` | Purchases/Revenue/Conversion + Sales — **wired** | + +## Not deployed + +| Endpoint | Probe | Blocks | +|---|---|---| +| `GET /api/visits` | 404 | **Recent arrivals feed, Activity page** | +| `POST /api/purchases` | 404 | **Mobile → dashboard purchase flow** | +| `GET /api/team` | 404 | Leaderboard team table | +| `GET /api/team/invitations` | 404 | Invite flow | +| `GET /api/auth/invitation` | 404 | Join preview | +| `POST /api/auth/register` | 404 | Redeeming an invitation | +| `GET /api/auth/sessions` | 404 | Device management | +| `GET /api/cameras` | 404 | Camera list / live view | + +## Consequence for the "most important requirement" + +The brief's §7 flow — + +``` +Mobile → POST /api/visits → DB → Dashboard GET /api/visits → new visit appears +Mobile → POST /api/purchases → DB → reports update +``` + +— cannot run today. **Neither `/api/visits` nor `/api/purchases` is deployed.** +The console side is built and pointed at both; they return 404 until the +platform ships them. + +What *does* work end to end once there is an account: sign in, site switching, +and every KPI and chart on the Dashboard and Sales pages, since those read the +two report endpoints that are live. + +## Still needed + +A valid account on `mcp.loyaly.ai`. There is no open registration by design, +and `POST /api/auth/register` is not deployed either — so an account has to be +created directly on the platform side. diff --git a/public/brand/login-hero-1.jpeg b/public/brand/login-hero-1.jpeg new file mode 100644 index 0000000..b521af4 Binary files /dev/null and b/public/brand/login-hero-1.jpeg differ diff --git a/public/brand/login-hero-2.jpeg b/public/brand/login-hero-2.jpeg new file mode 100644 index 0000000..06ac541 Binary files /dev/null and b/public/brand/login-hero-2.jpeg differ diff --git a/src/app/(dev)/charts/page.tsx b/src/app/(dev)/charts/page.tsx deleted file mode 100644 index 25942d8..0000000 --- a/src/app/(dev)/charts/page.tsx +++ /dev/null @@ -1,243 +0,0 @@ -'use client'; - -/** - * Dev-only chart gallery. - * - * Every primitive is exercised here against the REAL route handlers, not - * inline arrays — so this page simultaneously proves the charts render, the - * data layer round-trips, and the loading/empty/error skins are correct. - * Use the state buttons to force each branch. - */ - -import {useState} from 'react'; -import {VStack, HStack} from '@astryxdesign/core/Layout'; -import {Grid} from '@astryxdesign/core/Grid'; -import {Card} from '@astryxdesign/core/Card'; -import {Heading, Text} from '@astryxdesign/core/Text'; -import {Button} from '@astryxdesign/core/Button'; -import {Badge} from '@astryxdesign/core/Badge'; -import {useResource} from '@/shared/hooks/useResource'; -import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository'; -import {ChartCard} from '@/shared/components/charts/ChartCard'; -import {LineChartView} from '@/shared/components/charts/LineChartView'; -import {AreaChartView} from '@/shared/components/charts/AreaChartView'; -import {BarChartView} from '@/shared/components/charts/BarChartView'; -import {Sparkline} from '@/shared/components/charts/Sparkline'; -import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid'; -import type {HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard'; -import { - formatCompact, - formatDayLabel, - formatDelta, - formatInrCompact, - formatPct, -} from '@/shared/utils/format'; - -type Forced = '' | 'empty' | 'error' | 'delay'; - -export default function ChartsGallery() { - const [forced, setForced] = useState(''); - - // The forced state is folded into the endpoint params, so it becomes part of - // the request identity and useResource refetches when it changes. - const extra = - forced === 'delay' - ? {_delay: '4000'} - : forced - ? {_state: forced} - : {}; - - const scope = {range: '30d' as const, storeId: 'all'}; - const withExtra = (e: {path: string; params: Record}) => - ({...e, params: {...e.params, ...extra}}) as never as T; - - const series = useResource( - withExtra(dashboardRepository.timeseries(scope)), - ); - const kpis = useResource(withExtra(dashboardRepository.kpis(scope))); - const peak = useResource( - withExtra(dashboardRepository.peakHours(scope)), - ); - - return ( - - - Chart primitives - - Monochrome by construction. Series beyond the second are separated by - dash pattern and hatch fill, never hue — semantic colour is reserved - for thresholds and deltas. - - - {( - [ - ['', 'Live data'], - ['delay', 'Loading (4s)'], - ['empty', 'Empty'], - ['error', 'Error'], - ] as [Forced, string][] - ).map(([v, label]) => ( - - -
-
-
-
- - or continue with - -
- - {/* - 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. - */} -
- - -
); } diff --git a/src/features/auth/components/LoginHeroPanel.tsx b/src/features/auth/components/LoginHeroPanel.tsx index 0f23c7a..8513d6d 100644 --- a/src/features/auth/components/LoginHeroPanel.tsx +++ b/src/features/auth/components/LoginHeroPanel.tsx @@ -1,50 +1,152 @@ 'use client'; -import {useState} from 'react'; +import {useEffect, useState} from 'react'; 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 - * the other — this one is entirely decorative and holds the only piece of - * state on the screen that has nothing to do with signing in (the image - * fallback), while the form half holds all the behaviour. + * the other — this one is entirely decorative and holds the only state on the + * screen that has nothing to do with signing in (which slide is showing), + * 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() { - 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 ( -
-
- setHeroSrc(HERO_FALLBACK_URL)} +
+
+ {/* + Both slides are always mounted and stacked; only opacity moves. A + crossfade needs the outgoing frame to still be painted, and swapping + a single would flash the panel background between them. + */} + {SLIDES.map((slide, index) => ( + 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. */} +
-
-
+
+

+ Smarter rewards for stronger brands +

+

+ Empower your customers. Grow your business. All in one place. +

-
- - - Loyaly.ai · Merchant Operating System - + {/* Left-aligned with the type above it, not centred under it. */} +
+ {SLIDES.map((slide, index) => ( +
diff --git a/src/features/auth/components/LoginSplit.tsx b/src/features/auth/components/LoginSplit.tsx index 33b028f..d33ed6b 100644 --- a/src/features/auth/components/LoginSplit.tsx +++ b/src/features/auth/components/LoginSplit.tsx @@ -5,104 +5,105 @@ import {LoginCredentialsForm} from './LoginCredentialsForm'; import {LoginHeroPanel} from './LoginHeroPanel'; /** - * The sign-in screen's frame: ambient background, the split card, and the two - * halves that fill it. + * The sign-in screen's frame: the ivory canvas, the ambient yellow shapes, the + * split card, and the two halves that fill it. * * 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 — * behaviour lives in useLoginForm, the form markup in LoginCredentialsForm, - * the photograph in LoginHeroPanel — which is what makes each of them - * separately readable and separately changeable. + * the carousel in LoginHeroPanel — which is what makes each of them separately + * 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() { return ( -
- {/* 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. + */}
- {/* Soft Vignette Layer focusing vision on the center container */} -
- - {/* Ultra-subtle High-Frequency Fine Grain Noise Overlay (1.8% Opacity) */} -
- -
+
-
-
-
- -
- - Merchant OS - -
- - v2.4 Enterprise +
+
+ +
+ + Merchant OS
-
-
-

- Welcome back -

-

- Access your Merchant Operating System to manage stores, rewards, - staff and AI insights. -

-
- - +
+

+ Welcome back +

+

+ Access your Merchant Operating System to manage stores, rewards, + staff and AI insights. +

-
-
-

- By continuing, you agree to Loyaly's{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . -

-
+ {/* + In normal flow rather than pinned to the viewport bottom: the card grows + with the form's error messages, and an absolutely positioned footer sat + on top of it as soon as it did. + */} +

+ By continuing, you agree to Loyaly's{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

); } diff --git a/src/features/auth/components/ProviderIcons.tsx b/src/features/auth/components/ProviderIcons.tsx deleted file mode 100644 index 1040a26..0000000 --- a/src/features/auth/components/ProviderIcons.tsx +++ /dev/null @@ -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 ( - - ); -} - -export function MicrosoftIcon() { - return ( - - ); -} diff --git a/src/features/auth/hooks/useLoginForm.ts b/src/features/auth/hooks/useLoginForm.ts index 8606dc6..6123c3c 100644 --- a/src/features/auth/hooks/useLoginForm.ts +++ b/src/features/auth/hooks/useLoginForm.ts @@ -87,10 +87,14 @@ export function useLoginForm() { if (!result.ok) { setErrors({[result.error.field]: result.error.message}); - // The toast carries what the inline message cannot: it is announced, - // it survives a field the user has scrolled past, and it distinguishes - // "we rejected your credentials" from "we never reached the server". - toast({type: 'error', body: result.error.message}); + toast({ + type: 'error', + body: result.error.message, + isAutoHide: true, + autoHideDuration: 4000, + uniqueID: 'login-error', + collisionBehavior: 'overwrite', + }); setIsSubmitting(false); return; } diff --git a/src/features/auth/mock/users.mock.ts b/src/features/auth/mock/users.mock.ts deleted file mode 100644 index 3d2eca5..0000000 --- a/src/features/auth/mock/users.mock.ts +++ /dev/null @@ -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; -} diff --git a/src/features/auth/services/loginErrorCodes.ts b/src/features/auth/services/loginErrorCodes.ts index 170d94a..00e52b7 100644 --- a/src/features/auth/services/loginErrorCodes.ts +++ b/src/features/auth/services/loginErrorCodes.ts @@ -26,6 +26,17 @@ const LOGIN_ERRORS: Record = { password_required: {field: 'password', message: 'Invalid email or password.'}, invalid_credentials: {field: 'form', message: 'Invalid email or password.'}, 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; diff --git a/src/features/auth/services/serverSession.ts b/src/features/auth/services/serverSession.ts index b1cfddc..22043b8 100644 --- a/src/features/auth/services/serverSession.ts +++ b/src/features/auth/services/serverSession.ts @@ -1,47 +1,54 @@ import 'server-only'; import {cookies} from 'next/headers'; -import {findUserById} from '@/features/auth/mock/users.mock'; import { SESSION_COOKIE, verifySessionToken, } 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: - * • the root layout, to SEED the client provider — so a full page load - * paints the signed-in shell immediately instead of flashing a spinner - * while the browser asks who it is - * • route handlers, to authorise a request before it touches data + * ── Identity here, AUTHORITY upstream ──────────────────────────────────── + * This resolves identity for RENDERING — seeding the shell so a page load + * paints the signed-in header instead of flashing a spinner, and refusing + * anonymous route handlers early. It is intentionally cheap: verifying an HMAC + * costs microseconds, and this runs on every request. * - * `import 'server-only'` is load-bearing: this module reads cookies and the - * user directory, and importing it from a client component would be a build - * error rather than a silent leak of the fixture into the browser bundle. + * It is NOT the authority on whether the session is still good. That lives on + * the platform, and it answers on two paths that cannot be skipped: + * + * • /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 { const store = await cookies(); const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value); if (!payload) return null; - // Re-resolved from the directory rather than trusted off the token, so a - // role change or a deactivation takes effect on the next request instead of - // whenever the cookie happens to expire. - const user = findUserById(payload.sub); - if (!user) return null; - - return {user, expiresAt: new Date(payload.exp * 1000).toISOString()}; + return { + user: { + id: payload.sub, + email: payload.email, + name: payload.name, + role: payload.role as UserRole, + organisation: payload.organisation, + }, + expiresAt: new Date(payload.exp * 1000).toISOString(), + }; } /** * 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(); * if ('response' in auth) return auth.response; - * // auth.session is now available */ export async function requireSession(): Promise< {session: AuthSession} | {response: Response} diff --git a/src/features/auth/services/tokenStore.ts b/src/features/auth/services/tokenStore.ts new file mode 100644 index 0000000..dec411b --- /dev/null +++ b/src/features/auth/services/tokenStore.ts @@ -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} : {}), + }; +} diff --git a/src/features/auth/services/upstreamSession.ts b/src/features/auth/services/upstreamSession.ts new file mode 100644 index 0000000..d992995 --- /dev/null +++ b/src/features/auth/services/upstreamSession.ts @@ -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>(); + +function lockKey(refreshToken: string): string { + return createHash('sha256').update(refreshToken).digest('base64url'); +} + +async function readTokens(): Promise { + 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 { + 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 { + const k = lockKey(current.refreshToken); + const existing = inFlight.get(k); + if (existing) return existing; + + const run = (async () => { + const res = await upstreamRequest({ + 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( + fn: (accessToken: string) => Promise, +): Promise { + 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 { + const bundle = toBundle(res); + await persistTokens(bundle); + return bundle; +} + +export async function clearTokens(): Promise { + 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 { + const tokens = await readTokens(); + return tokens?.accessToken ?? null; +} diff --git a/src/features/auth/services/userMapper.ts b/src/features/auth/services/userMapper.ts new file mode 100644 index 0000000..cd7ae42 --- /dev/null +++ b/src/features/auth/services/userMapper.ts @@ -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 === ''; +} diff --git a/src/features/auth/types/auth.ts b/src/features/auth/types/auth.ts index fec40a5..6a3b984 100644 --- a/src/features/auth/types/auth.ts +++ b/src/features/auth/types/auth.ts @@ -7,7 +7,14 @@ * 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. */ export interface AuthUser { diff --git a/src/features/commerce/components/CommerceKpiRow.tsx b/src/features/commerce/components/CommerceKpiRow.tsx deleted file mode 100644 index 7fa93b9..0000000 --- a/src/features/commerce/components/CommerceKpiRow.tsx +++ /dev/null @@ -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 ( - - {kpiList.map((kpi) => ( - - ))} - - ); -} diff --git a/src/features/commerce/components/CommerceRecentOrders.tsx b/src/features/commerce/components/CommerceRecentOrders.tsx deleted file mode 100644 index f5db832..0000000 --- a/src/features/commerce/components/CommerceRecentOrders.tsx +++ /dev/null @@ -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 ( -
-
-
- - - Recent Orders - - - Real-time completed orders across your network - -
- -
- -
- {RECENT_ORDERS.map((ord) => ( -
-
-
- {ord.code} - • {ord.customer} -
-
- {ord.store} ({ord.method}) -
-
-
-
{ord.amount}
-
{ord.time}
-
-
- ))} -
-
- ); -} diff --git a/src/features/commerce/components/CommerceRevenueSummary.tsx b/src/features/commerce/components/CommerceRevenueSummary.tsx deleted file mode 100644 index 9033f0a..0000000 --- a/src/features/commerce/components/CommerceRevenueSummary.tsx +++ /dev/null @@ -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 ( -
-
- - Revenue Summary - - - Financial performance rollup for the selected period - -
- -
- - - - -
-
- ); -} diff --git a/src/features/commerce/components/CommerceSalesInsights.tsx b/src/features/commerce/components/CommerceSalesInsights.tsx deleted file mode 100644 index 4446894..0000000 --- a/src/features/commerce/components/CommerceSalesInsights.tsx +++ /dev/null @@ -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 ( -
-
-
- - - Sales Insights - - - Automated intelligence highlights & telemetry warnings - -
-
- -
- {SALES_INSIGHTS.map((item) => ( -
-
- {item.title} - -
- - {item.description} - -
- ))} -
-
- ); -} diff --git a/src/features/commerce/components/CommerceWorkspace.tsx b/src/features/commerce/components/CommerceWorkspace.tsx deleted file mode 100644 index 284f344..0000000 --- a/src/features/commerce/components/CommerceWorkspace.tsx +++ /dev/null @@ -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 ( - - {/* Sticky Header matching Dashboard visual language */} -
- } - /> -
- - {/* 1 — Headline KPI Cards Row */} - - - {/* 2 — Primary Sales Trends (Revenue Trend & Orders Trend) */} - - - - - - {/* 3 — Revenue Breakdown & Store Performance */} - - - - - - {/* 4 — Top Selling Products Leaderboard & Automated Sales Insights */} - - - - - - {/* 5 — Financial Revenue Rollup & Recent Orders Live Feed */} - - - - -
- ); -} diff --git a/src/features/commerce/components/analytics/BusinessAlerts.tsx b/src/features/commerce/components/analytics/BusinessAlerts.tsx deleted file mode 100644 index 5298f13..0000000 --- a/src/features/commerce/components/analytics/BusinessAlerts.tsx +++ /dev/null @@ -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 ( -
- - Real-Time Business & Operational Alerts - - -
- {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 ( -
-
-
- -
- {alert.title} - - {alert.description} - -
-
- -
- -
- -
-
- ); - })} -
-
- ); -} diff --git a/src/features/commerce/components/analytics/OperationalFlowcharts.tsx b/src/features/commerce/components/analytics/OperationalFlowcharts.tsx deleted file mode 100644 index 77c2b6a..0000000 --- a/src/features/commerce/components/analytics/OperationalFlowcharts.tsx +++ /dev/null @@ -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 ( -
-
- - Operational Flow Diagrams - - - Visual operational pipeline lifecycle maps for orders, revenue settlements and inventory movement - -
- - {/* Order Flow Diagram */} -
- - 1. Order Lifecycle Flow - -
-
- {ORDER_FLOW.map((node, i) => ( -
-
-
{node.step}
-
{node.detail}
-
- {i < ORDER_FLOW.length - 1 ? ( - → - ) : null} -
- ))} -
-
-
- - {/* Revenue Flow Diagram */} -
- - 2. Revenue Settlement Flow - -
-
- {REVENUE_FLOW.map((node, i) => ( -
-
-
{node.step}
-
{node.detail}
-
- {i < REVENUE_FLOW.length - 1 ? ( - → - ) : null} -
- ))} -
-
-
- - {/* Inventory Flow Diagram */} -
- - 3. Inventory & Supply Chain Flow - -
-
- {INVENTORY_FLOW.map((node, i) => ( -
-
-
{node.step}
-
{node.detail}
-
- {i < INVENTORY_FLOW.length - 1 ? ( - → - ) : null} -
- ))} -
-
-
-
- ); -} diff --git a/src/features/commerce/components/analytics/OrdersChart.tsx b/src/features/commerce/components/analytics/OrdersChart.tsx deleted file mode 100644 index 95c9605..0000000 --- a/src/features/commerce/components/analytics/OrdersChart.tsx +++ /dev/null @@ -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 ( -
-
-
- - Orders Volume Trend - - - Completed order count grouped by hourly buckets - -
- - {totalOrders.toLocaleString()} Orders - -
- -
- -
-
- ); -} diff --git a/src/features/commerce/components/analytics/PaymentBreakdown.tsx b/src/features/commerce/components/analytics/PaymentBreakdown.tsx deleted file mode 100644 index b16d3bd..0000000 --- a/src/features/commerce/components/analytics/PaymentBreakdown.tsx +++ /dev/null @@ -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(null); - - const activeItem = selectedMethod - ? paymentData.find((p) => p.method === selectedMethod) - : null; - - return ( -
-
-
- - Payment Methods Breakdown - - - Revenue share distribution by payment channel - -
- - {/* Interactive Channel Filter Buttons */} -
- - {paymentData.map((item) => ( - - ))} -
-
- - {/* Visual Ring / Stack Bar */} -
- {paymentData.map((item) => { - const isSelected = selectedMethod === null || selectedMethod === item.method; - return ( -
- 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`} - /> - ); - })} -
- - {/* Interactive Breakdown Cards */} -
- {paymentData.map((item) => { - const isSelected = selectedMethod === item.method; - return ( -
- 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, - }} - > -
- -
- - {item.method} - - - {item.percentage}% share - -
-
- -
-
- {item.amount} -
- {isSelected ? ( - - ) : null} -
-
- ); - })} -
- - {/* Selected Channel Deep-Dive Banner */} - {activeItem ? ( -
-
- - {activeItem.method} Channel Details: - - {activeItem.amount} collected across selected scope terminals. - -
- -
- ) : null} -
- ); -} diff --git a/src/features/commerce/components/analytics/RevenueChart.tsx b/src/features/commerce/components/analytics/RevenueChart.tsx deleted file mode 100644 index 781c165..0000000 --- a/src/features/commerce/components/analytics/RevenueChart.tsx +++ /dev/null @@ -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 ( -
-
-
- - Revenue Trend - - - Hourly revenue performance against targets - -
- - +14.2% Above Target - -
- -
- -
-
- ); -} diff --git a/src/features/commerce/components/analytics/StorePerformanceChart.tsx b/src/features/commerce/components/analytics/StorePerformanceChart.tsx deleted file mode 100644 index 70a737a..0000000 --- a/src/features/commerce/components/analytics/StorePerformanceChart.tsx +++ /dev/null @@ -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 ( -
-
- - Top Performing Outlets - - - Store revenue comparison across your network - -
- -
- {STORE_PERFORMANCE_DATA.map((store) => { - const widthPercent = (store.Revenue / maxRevenue) * 100; - return ( -
-
- {store.store} - - ₹{(store.Revenue / 100000).toFixed(1)}L - -
- {/* 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. */} -
-
-
-
- ); - })} -
-
- ); -} diff --git a/src/features/commerce/components/analytics/TopProductsTable.tsx b/src/features/commerce/components/analytics/TopProductsTable.tsx deleted file mode 100644 index 8e90ced..0000000 --- a/src/features/commerce/components/analytics/TopProductsTable.tsx +++ /dev/null @@ -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 ( -
-
- - Top Selling Products Leaderboard - - - Product sales volume, revenue growth and real-time inventory counts - -
- -
- - - - - - - - - - - - - {products.map((prod) => ( - - - - - - - - - ))} - -
Product NameCategoryUnits SoldTotal RevenueGrowthStock Status
{prod.name}{prod.category}{prod.unitsSold.toLocaleString()}{prod.revenue} - - {prod.growth} - - - -
-
-
- ); -} diff --git a/src/features/commerce/components/filters/CommerceFilterToolbar.tsx b/src/features/commerce/components/filters/CommerceFilterToolbar.tsx deleted file mode 100644 index 394824e..0000000 --- a/src/features/commerce/components/filters/CommerceFilterToolbar.tsx +++ /dev/null @@ -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 ( - - {/* Date Range Selector */} -
- {(['today', '7d', '30d', '90d'] as const).map((r) => ( - - ))} -
- - {/* Store Filter */} - - - {/* Compare Button */} - {onCompare ? ( -