update ui and remove mock data
This commit is contained in:
@@ -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
41
docs/API-GAP-REPORT.md
Normal 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
279
docs/API-INVENTORY.md
Normal 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.
|
||||||
|
|
||||||
277
docs/BEHAVISION-GAP-ANALYSIS.md
Normal file
277
docs/BEHAVISION-GAP-ANALYSIS.md
Normal 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
60
docs/PLATFORM-STATUS.md
Normal 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.
|
||||||
BIN
public/brand/login-hero-1.jpeg
Normal file
BIN
public/brand/login-hero-1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
BIN
public/brand/login-hero-2.jpeg
Normal file
BIN
public/brand/login-hero-2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/app/api/assistant/route.ts
Normal file
78
src/app/api/assistant/route.ts
Normal 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'}},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
57
src/app/api/faces/route.ts
Normal file
57
src/app/api/faces/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
35
src/app/api/reports/conversion/route.ts
Normal file
35
src/app/api/reports/conversion/route.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
52
src/app/api/reports/footfall/route.ts
Normal file
52
src/app/api/reports/footfall/route.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
38
src/app/api/sites/route.ts
Normal file
38
src/app/api/sites/route.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
26
src/app/api/team/route.ts
Normal 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
107
src/app/api/visits/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
110
src/features/auth/services/tokenStore.ts
Normal file
110
src/features/auth/services/tokenStore.ts
Normal 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} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
156
src/features/auth/services/upstreamSession.ts
Normal file
156
src/features/auth/services/upstreamSession.ts
Normal 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;
|
||||||
|
}
|
||||||
31
src/features/auth/services/userMapper.ts
Normal file
31
src/features/auth/services/userMapper.ts
Normal 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 === '';
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
122
src/features/dashboard/components/ArrivalsFeed.tsx
Normal file
122
src/features/dashboard/components/ArrivalsFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/features/dashboard/hooks/useReports.ts
Normal file
34
src/features/dashboard/hooks/useReports.ts
Normal 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}));
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
38
src/features/dashboard/repositories/reportRepository.ts
Normal file
38
src/features/dashboard/repositories/reportRepository.ts
Normal 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} : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
83
src/features/dashboard/services/kpiBuilder.ts
Normal file
83
src/features/dashboard/services/kpiBuilder.ts
Normal 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
Reference in New Issue
Block a user