update the adminpage ui
This commit is contained in:
310
docs/ADMIN-MONITORING-API.md
Normal file
310
docs/ADMIN-MONITORING-API.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Platform admin monitoring: backend API plan
|
||||
|
||||
Status: **proposal for the platform team** · Written 2026-09-24 · Frontend ready on `main`
|
||||
|
||||
The platform console (`/admin`) now has the full drill-down built:
|
||||
|
||||
```
|
||||
Overview → Merchants → Merchant → Shops → Shop → Cameras → Camera → Events / Alerts
|
||||
```
|
||||
|
||||
(Console routes: `/admin/merchants/{clientId}/shops/{siteId}/cameras/{cameraId}`.
|
||||
The platform's own names stay `clients` / `sites`; "merchant" and "shop" are
|
||||
the console's words for them.)
|
||||
|
||||
Only the first rung has data. Everything below it shows **"Backend integration
|
||||
required"** because the platform has no admin endpoint that can answer it. This
|
||||
document lists what the console needs, the rules each endpoint must enforce, and
|
||||
exactly what the frontend changes when each one ships (usually one line).
|
||||
|
||||
---
|
||||
|
||||
## 1. Where things stand
|
||||
|
||||
| Level | Endpoint | Status |
|
||||
|---|---|---|
|
||||
| Companies | `GET /api/admin/clients` | **Live** |
|
||||
| Create / suspend / reinstate / reset owner password / delete | `/api/admin/clients…` | **Live** |
|
||||
| Company detail | `GET /api/admin/clients/{clientId}` | Missing. The console reads the row from the list, which has every field there is today. |
|
||||
| Company → stores | `GET /api/admin/clients/{clientId}/sites` | **Missing** |
|
||||
| Store detail | `GET /api/admin/clients/{clientId}/sites/{siteId}` | **Missing** |
|
||||
| Store → cameras | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras` | **Missing** |
|
||||
| Camera detail | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras/{cameraId}` | **Missing** |
|
||||
| Events | `GET /api/admin/clients/{clientId}/sites/{siteId}/events` | **Missing** |
|
||||
| Alerts | `GET /api/admin/clients/{clientId}/sites/{siteId}/alerts` | **Missing** |
|
||||
| Platform totals | `GET /api/admin/monitoring/summary` | **Missing** |
|
||||
| Platform-scope assistant | `POST /api/admin/assistant` | **Missing** |
|
||||
|
||||
### Why the tenant endpoints cannot be reused
|
||||
|
||||
`/api/sites`, cameras, visits and `/api/assistant` all take the company from the
|
||||
**signed-in account's `client_id`**. A platform admin has `role = "admin"` and an
|
||||
**empty** `client_id`, which is the very thing that makes `adminOnly` pass. So
|
||||
those routes have no company to scope to, and the frontend proxy refuses them for
|
||||
admin sessions (403).
|
||||
|
||||
The console must **not** get around this by:
|
||||
- passing a company id to a tenant route,
|
||||
- listing every site on the platform and filtering in the browser,
|
||||
- signing in as the tenant's owner behind the scenes.
|
||||
|
||||
Each of those moves the tenancy check out of the server that holds the data.
|
||||
The endpoints below keep it there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rules for every endpoint below
|
||||
|
||||
### 2.1 Authorisation
|
||||
- Same gate as today: `adminOnly` (`role === "admin"` AND empty `client_id`).
|
||||
Anyone else gets **404, not 403**, matching `/api/admin/clients`.
|
||||
|
||||
### 2.2 Ownership, checked on every nested request
|
||||
The URL is a claim, not proof. For
|
||||
`/api/admin/clients/A/sites/B/cameras/C` the server must verify **all** of:
|
||||
|
||||
```
|
||||
client A exists
|
||||
site B.client_id == A → otherwise 404
|
||||
camera C.site_id == B → otherwise 404
|
||||
```
|
||||
|
||||
Return **404** when any link fails, never the object found under a different
|
||||
parent. A camera id that is valid, but under another company's store, has to
|
||||
look exactly like one that does not exist.
|
||||
|
||||
Do this in a single query joined up to `client_id`, e.g.
|
||||
`WHERE c.id = $cam AND c.site_id = $site AND s.client_id = $client`,
|
||||
not as three lookups that each trust the previous id.
|
||||
|
||||
### 2.3 Reuse, don't fork
|
||||
The tenant handlers already compute everything the console shows
|
||||
(`SiteHealth`, camera liveness, `last_seen_at`). Take their query functions
|
||||
and call them with an explicit `client_id` argument, rather than writing a
|
||||
second copy for admin. The only difference between the two routes should be
|
||||
where `client_id` comes from: the session for tenants, the path for admins.
|
||||
|
||||
### 2.4 Redaction
|
||||
Admins see **operational** state, not credentials. From camera rows, drop
|
||||
`username`, `host`, `port`, `path` and `has_password`. The console doesn't
|
||||
need them, and a platform admin has no business collecting a merchant's
|
||||
camera credentials in one place.
|
||||
|
||||
### 2.5 Suspended companies
|
||||
Still readable. A suspended company is usually the one somebody is on the phone
|
||||
about, so the admin needs to see it.
|
||||
|
||||
### 2.6 Audit
|
||||
Log every admin read below the company level (`admin_id`, `client_id`, path).
|
||||
Face and visit data is biometric-adjacent, and looking into a tenant's data is
|
||||
a different act from administering the tenant.
|
||||
|
||||
### 2.7 Pagination
|
||||
Lists that can grow take `?limit=` (default 50, max 200) and `?cursor=`, and
|
||||
return `{items: [...], next_cursor: "…"|null}`. The console reads `items` and
|
||||
handles either a bare array or this envelope in its mapper.
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoints
|
||||
|
||||
Shapes deliberately **match the tenant payloads the frontend already
|
||||
understands** (`ApiSite`, `ApiCamera` in `src/services/api/types.ts`), so one
|
||||
mapper serves both consoles. Field names below are the wire names.
|
||||
|
||||
### 3.1 `GET /api/admin/clients/{clientId}`
|
||||
Company detail. Same `ClientRow` as the list. Optionally add `owner_email` and
|
||||
`owner_name`: the console has a slot for "Owner" and leaves it out today
|
||||
because nothing sends it.
|
||||
|
||||
### 3.2 `GET /api/admin/clients/{clientId}/sites`
|
||||
Stores of one company. **Only** that company's sites.
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
"site_id": "uuid",
|
||||
"slug": "…",
|
||||
"name": "…",
|
||||
"timezone": "Asia/Kolkata",
|
||||
"online": true,
|
||||
"cameras_up": 3,
|
||||
"cameras_total": 4,
|
||||
"created_at": "2026-…"
|
||||
}
|
||||
]
|
||||
```
|
||||
`?q=` (name/slug contains) is useful once a tenant has dozens of stores.
|
||||
|
||||
### 3.3 `GET /api/admin/clients/{clientId}/sites/{siteId}`
|
||||
One store, the same row as 3.2. 404 unless `site.client_id = clientId`.
|
||||
|
||||
### 3.4 `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras`
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"camera_id": "entrance-1",
|
||||
"label": "Entrance",
|
||||
"enabled": true,
|
||||
"connected": true, // null = never reported
|
||||
"last_seen_at": "2026-…",
|
||||
"live_available": false // true only when an admin stream route exists (3.8)
|
||||
}
|
||||
]
|
||||
```
|
||||
The console derives status from `connected`: `true` is Online, `false` is
|
||||
Offline, `null` is Unknown. It invents no "maintenance" or "warning" state.
|
||||
If the platform has such a state, add it as a field and the console will show
|
||||
it.
|
||||
|
||||
### 3.5 `GET …/cameras/{cameraId}`
|
||||
One camera, same row. 404 unless the full chain in §2.2 holds.
|
||||
|
||||
### 3.6 `GET …/sites/{siteId}/events?camera=&since=&limit=&cursor=`
|
||||
```jsonc
|
||||
{ "items": [
|
||||
{ "id": "…", "at": "2026-…", "type": "visit|face_match|…",
|
||||
"camera_id": "…", "camera_label": "…", "severity": "info|warning|critical" }
|
||||
], "next_cursor": null }
|
||||
```
|
||||
`camera` narrows to one camera **of this site**. A camera from another site
|
||||
returns an empty list, never that camera's events.
|
||||
|
||||
### 3.7 `GET …/sites/{siteId}/alerts?camera=&status=open`
|
||||
```jsonc
|
||||
[ { "id": "…", "at": "…", "title": "Camera offline",
|
||||
"severity": "critical|warning|info",
|
||||
"status": "open|acknowledged|resolved",
|
||||
"camera_id": "…", "camera_label": "…" } ]
|
||||
```
|
||||
If the platform has no alert model yet, this is the one endpoint that needs a
|
||||
design decision first, not just plumbing. "Camera offline for more than N
|
||||
minutes" is the obvious first alert, and it can be derived from data already
|
||||
stored.
|
||||
|
||||
### 3.8 Live stream (later)
|
||||
`GET …/cameras/{cameraId}/live`, which reuses the tenant live route with the
|
||||
ownership chain from §2.2 and an audit entry. Until it exists the console shows
|
||||
"Live feed unavailable" and never a placeholder that looks live.
|
||||
|
||||
### 3.9 `GET /api/admin/monitoring/summary`
|
||||
Platform-wide totals for the Overview page. Today it shows cameras online,
|
||||
open alerts and events today as "—".
|
||||
|
||||
```jsonc
|
||||
{ "cameras_total": 0, "cameras_online": 0,
|
||||
"alerts_open": 0, "events_today": 0,
|
||||
"as_of": "2026-…" }
|
||||
```
|
||||
Aggregate counts only, with no per-tenant rows.
|
||||
|
||||
### 3.10 `POST /api/admin/assistant`
|
||||
Platform-scope Loyaly AI. The body is the same as `/api/assistant` plus
|
||||
`context: {level, company_id?, site_id?, camera_id?}`, which the console
|
||||
already tracks per page. Its tools must go through 3.1–3.9, so the assistant
|
||||
inherits the same ownership checks rather than having its own.
|
||||
|
||||
---
|
||||
|
||||
### 3.11 Merchant-level areas (added 2026-09-25)
|
||||
|
||||
The console now lists these on every merchant page with their state, and has
|
||||
top-level **Footfall** and **Commerce** pages that pick Merchant → Shop before
|
||||
asking for anything. Each is a flag in `features/admin/config/capabilities.ts`.
|
||||
Same rules as §2: admin-only (404 otherwise), ownership checked on every nested
|
||||
id, never answered from a tenant route.
|
||||
|
||||
| Area | Endpoint needed | Flag |
|
||||
|---|---|---|
|
||||
| Edit merchant | `PATCH /api/admin/clients/{clientId}` accepting `name` (today it takes `active` only) | `merchantEdit` |
|
||||
| Sales persons | `GET/POST /api/admin/clients/{clientId}/salespersons`, `PATCH/DELETE …/salespersons/{id}` — they sign in on the mobile app | `salesPersons` |
|
||||
| Customers | `GET /api/admin/clients/{clientId}/customers` | `customers` |
|
||||
| Sales | `GET …/sites/{siteId}/sales` | `sales` |
|
||||
| Analytics | `GET …/sites/{siteId}/analytics` | `analytics` |
|
||||
| Footfall | `GET …/sites/{siteId}/visits?since=&until=` | `footfall` |
|
||||
| Commerce | `GET …/sites/{siteId}/commerce` (or reuse `sales`) | `commerce` |
|
||||
| Camera CRUD | `POST …/sites/{siteId}/cameras`, `PATCH/DELETE …/cameras/{cameraId}` (read is §3.4) | `storeCameras` |
|
||||
| Camera heartbeat | `GET …/sites/{siteId}/cameras/heartbeat` — `last_seen_at` per camera | `cameraHeartbeat` |
|
||||
| Device logs | `GET …/sites/{siteId}/device-logs?cursor=` | `deviceLogs` |
|
||||
| Testing software | `GET …/sites/{siteId}/testing` — shop-PC test runs | `testingSoftware` |
|
||||
| Create shop / access code at onboarding | `POST /api/admin/clients/{clientId}/sites`, plus whatever issues the shop-PC access code. Today `POST /api/admin/clients` creates the merchant + owner login only. | — |
|
||||
|
||||
#### Footfall and Commerce — response fields the console needs
|
||||
|
||||
**Why the frontend cannot do this itself:** the only admin data is the merchant
|
||||
list. Tenant routes (`/api/visits`, `/api/purchases`, `/api/sites`) scope by the
|
||||
signed-in account's `client_id`, which a platform admin does not have, and the
|
||||
BFF refuses them for admin sessions. Calling them with a swapped id, or summing
|
||||
every merchant in the browser, would move the tenancy check out of the server.
|
||||
|
||||
| Required backend endpoint | Required response fields |
|
||||
|---|---|
|
||||
| `GET /api/admin/clients/{clientId}/sites` | `site_id, slug, name, area` (or `location`), `online, cameras_total, cameras_up, created_at` — **`area` does not exist on any site today** and is needed for the Area filter and area comparison |
|
||||
| `GET /api/admin/clients/{clientId}/footfall?from=&to=&area=&site=` | `[{date, site_id, area, visits}]` — one row per site per day, so area × date and day-by-day are derived without guessing |
|
||||
| `GET /api/admin/footfall/summary?from=&to=` (optional, platform scope) | `[{client_id, visits}]` — server-side sum, so the browser never fetches every tenant's rows |
|
||||
| `GET /api/admin/clients/{clientId}/sales?from=&to=&site=` | `[{date, site_id, sales_inr, transactions}]` |
|
||||
| `GET /api/admin/sales/summary?from=&to=` | `[{client_id, sales_inr, transactions}]` for the all-merchants comparison |
|
||||
| `GET /api/admin/clients/{clientId}/sites/{siteId}/transactions?from=&to=&cursor=` | `[{id, at, amount_inr, payment_method?, category?}]` — only what the platform records |
|
||||
|
||||
#### Footfall and Commerce — the exact contract the console calls (added 2026-09-25)
|
||||
|
||||
The Footfall and Commerce pages are fully built against these paths
|
||||
(`features/admin/repositories/analyticsRepository.ts`, shapes in
|
||||
`features/admin/types/analytics.ts`). Each panel renders "Backend integration
|
||||
required" until the flag is on; turning it on is the only frontend change.
|
||||
|
||||
Common query parameters on every call: `from`, `to` (ISO `YYYY-MM-DD`,
|
||||
inclusive), and optionally `merchant` (client id), `area`, `shop` (site id).
|
||||
`area`/`shop` are only ever sent together with `merchant`; the server must
|
||||
refuse a shop that is not that merchant's (§2.2) and answer 404 to non-admins.
|
||||
|
||||
| Endpoint | Returns | Flag |
|
||||
|---|---|---|
|
||||
| `GET /api/admin/footfall/overview` | `{totalFootfall, averageDaily, peakDay?: {date, footfall}, activeLocations, reportingShops}` | `footfall` |
|
||||
| `GET /api/admin/footfall/by-area` | `[{area, footfall}]` | `footfall` |
|
||||
| `GET /api/admin/footfall/daily` | `[{date, footfall}]` | `footfall` |
|
||||
| `GET /api/admin/footfall/area-date` | `[{date, area, footfall}]` (long form; the console pivots) | `footfall` |
|
||||
| `GET /api/admin/footfall/details` | `[{date, merchantId, merchantName, area?, shopId, shopName, footfall, entries?, exits?}]` | `footfall` |
|
||||
| `GET /api/admin/commerce/overview` | `{salesInr, transactions, averageTransactionInr, activeMerchants, activeShops}` | `commerce` |
|
||||
| `GET /api/admin/commerce/by-merchant` | `[{merchantId, merchantName, salesInr, transactions}]` | `commerce` |
|
||||
| `GET /api/admin/commerce/daily` | `[{date, salesInr, transactions}]` | `commerce` |
|
||||
| `GET /api/admin/commerce/details` | `[{date, merchantId, merchantName, shopId, shopName, salesInr, transactions, paymentMethod?, category?}]` | `commerce` |
|
||||
|
||||
The BFF routes (`src/app/api/admin/footfall/*`, `src/app/api/admin/commerce/*`)
|
||||
must be added alongside, enveloping the response like `/api/admin/clients`.
|
||||
Area also needs a real field on sites (`area` or `location`) — it exists
|
||||
nowhere today, so the Area filter stays disabled until it does.
|
||||
|
||||
## 4. Frontend wiring per endpoint
|
||||
|
||||
Everything else is already built: the pages, breadcrumbs, empty, loading and
|
||||
error states, and the tables that render the rows.
|
||||
|
||||
For each endpoint:
|
||||
|
||||
1. **Upstream call.** Add a method to `src/services/api/adminApi.ts`.
|
||||
2. **BFF route.** Add `src/app/api/admin/clients/[id]/sites/…/route.ts`, the
|
||||
same pattern as `clients/[id]/route.ts` (`serveUpstream` + a mapper).
|
||||
3. **Mapper.** Wire → `AdminStore` / `AdminCamera` / `AdminEvent` / `AdminAlert`
|
||||
(`src/features/admin/types/monitoring.ts`). `cameras_total` maps to
|
||||
`cameras`, `cameras_up` to `camerasOnline`, `connected` to `status`.
|
||||
4. **Flag.** Set the level to `true` in `src/features/admin/config/capabilities.ts`.
|
||||
|
||||
With the flag on, the repository (`features/admin/repositories/monitoringRepository.ts`)
|
||||
returns its endpoint instead of `null`. The section then fetches and renders
|
||||
the table in place of the integration-required state, and the Overview's
|
||||
"Monitoring coverage" panel switches that row to **Live**. No component
|
||||
changes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance checks for the platform team
|
||||
|
||||
- [ ] A tenant token on any `/api/admin/*` route gets 404.
|
||||
- [ ] `GET /clients/A/sites` never returns a site whose `client_id ≠ A`.
|
||||
- [ ] `GET /clients/A/sites/B` where B belongs to company C gets 404.
|
||||
- [ ] `GET /clients/A/sites/B/cameras/X` where X belongs to another site gets 404.
|
||||
- [ ] `?camera=X` on events/alerts, with X from another site, returns an empty list.
|
||||
- [ ] Camera rows contain no host, port, path, username or password flag.
|
||||
- [ ] Every admin read below the company level writes an audit entry.
|
||||
@@ -10,6 +10,27 @@ const nextConfig: NextConfig = {
|
||||
// local `npm run build` keeps its warm cache.
|
||||
turbopackFileSystemCacheForBuild: process.env.CI_BUILD !== "1",
|
||||
},
|
||||
// The platform console renamed Companies → Merchants and Stores → Shops.
|
||||
// Temporary (307) so bookmarks keep working without browsers caching the
|
||||
// move forever while the admin IA is still settling.
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/admin/companies/:id/stores/:shopId/:rest*",
|
||||
destination: "/admin/merchants/:id/shops/:shopId/:rest*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/admin/companies/:rest*",
|
||||
destination: "/admin/merchants/:rest*",
|
||||
permanent: false,
|
||||
},
|
||||
{source: "/admin/stores", destination: "/admin/merchants", permanent: false},
|
||||
// The overview IS /admin; these are the names people guess for it.
|
||||
{source: "/admin/overview", destination: "/admin", permanent: false},
|
||||
{source: "/admin/dashboard", destination: "/admin", permanent: false},
|
||||
];
|
||||
},
|
||||
allowedDevOrigins: ["192.168.0.117", "192.168.0.*", "192.168.1.*", "localhost", "127.0.0.1"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"bundle": "bash scripts/bundle.sh",
|
||||
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev:preview": "next dev"
|
||||
"dev:preview": "next dev",
|
||||
"test:access": "node --test scripts/staff-access.test.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astryxdesign/core": "^0.2.0",
|
||||
|
||||
95
scripts/staff-access.test.mts
Normal file
95
scripts/staff-access.test.mts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The staff access policy the proxy enforces (src/features/auth/services/
|
||||
* staffAccess.ts). Run with `npm run test:access` — Node's built-in runner, no
|
||||
* dependencies. Covers the role matrix only; tenant isolation is the
|
||||
* platform's (the company comes from the upstream token, never the request).
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STAFF_HOME,
|
||||
isStaffPage,
|
||||
isStaffRole,
|
||||
staffApiDecision,
|
||||
} from '../src/features/auth/services/staffAccess.ts';
|
||||
|
||||
const q = (o: Record<string, string> = {}) => new URLSearchParams(o);
|
||||
const one = q({storeId: 'chennai', range: '30d'});
|
||||
|
||||
test('only role=staff is gated', () => {
|
||||
assert.equal(isStaffRole('staff'), true);
|
||||
for (const r of ['owner', 'manager', 'admin', undefined, '', 'STAFF']) {
|
||||
assert.equal(isStaffRole(r), false, String(r));
|
||||
}
|
||||
});
|
||||
|
||||
test('staff home is a staff page', () => {
|
||||
assert.equal(isStaffPage(STAFF_HOME), true);
|
||||
});
|
||||
|
||||
test('staff pages: floor work allowed, merchant pages refused', () => {
|
||||
for (const p of ['/floor', '/customers', '/customers/abc', '/activity', '/settings/profile', '/settings/security']) {
|
||||
assert.equal(isStaffPage(p), true, p);
|
||||
}
|
||||
for (const p of ['/dashboard', '/commerce', '/stores', '/lyts', '/staff', '/settings', '/settings/team', '/settings/billing', '/settings/roles', '/settings/stores', '/admin', '/floorplan', '/customersX']) {
|
||||
assert.equal(isStaffPage(p), false, p);
|
||||
}
|
||||
});
|
||||
|
||||
test('staff APIs: floor work allowed with one store', () => {
|
||||
const allow: [string, string, URLSearchParams][] = [
|
||||
['GET', '/api/sites', q()],
|
||||
['GET', '/api/floor/visits', one],
|
||||
['POST', '/api/visits/v1/attend', q()],
|
||||
['POST', '/api/visits/v1/release', q()],
|
||||
['POST', '/api/visits/v1/complete', q()],
|
||||
['GET', '/api/visits', one],
|
||||
['GET', '/api/visits/stream', q({storeId: 'chennai'})],
|
||||
['POST', '/api/customers', q()],
|
||||
['GET', '/api/visitors', q()],
|
||||
['GET', '/api/visitors/x/history', q()],
|
||||
['GET', '/api/visitors/x/image', q()],
|
||||
['PUT', '/api/visitors/x/profile', q()],
|
||||
['GET', '/api/faces', q({src: '/api/faces/a'})],
|
||||
['POST', '/api/sales', q()],
|
||||
['POST', '/api/purchases', q()],
|
||||
['GET', '/api/health', q()],
|
||||
];
|
||||
for (const [m, p, s] of allow) assert.equal(staffApiDecision(m, p, s), 'allow', `${m} ${p}`);
|
||||
});
|
||||
|
||||
test('staff APIs: "All stores" or no store is refused on scoped reads', () => {
|
||||
for (const p of ['/api/floor/visits', '/api/visits', '/api/visits/stream']) {
|
||||
assert.equal(staffApiDecision('GET', p, q({storeId: 'all'})), 'needs_store', p);
|
||||
assert.equal(staffApiDecision('GET', p, q()), 'needs_store', p);
|
||||
}
|
||||
});
|
||||
|
||||
test('staff APIs: merchant-only surfaces are forbidden', () => {
|
||||
const deny: [string, string][] = [
|
||||
['GET', '/api/reports/footfall'],
|
||||
['GET', '/api/reports/conversion'],
|
||||
['GET', '/api/reports/journey'],
|
||||
['GET', '/api/dashboard/summary'],
|
||||
['GET', '/api/sales'],
|
||||
['GET', '/api/sales/abc'],
|
||||
['GET', '/api/team'],
|
||||
['GET', '/api/team/invitations'],
|
||||
['GET', '/api/cameras'],
|
||||
['GET', '/api/cameras/c1/live'],
|
||||
['GET', '/api/images'],
|
||||
['GET', '/api/campaigns'],
|
||||
['GET', '/api/activities'],
|
||||
['POST', '/api/assistant'],
|
||||
['POST', '/api/sites'],
|
||||
['PATCH', '/api/sites/chennai'],
|
||||
['DELETE', '/api/sites/chennai'],
|
||||
['DELETE', '/api/visitors/x'],
|
||||
['GET', '/api/admin/clients'],
|
||||
['GET', '/api/unknown-new-route'],
|
||||
// Method matters: an allowed path with the wrong verb is refused.
|
||||
['DELETE', '/api/sales'],
|
||||
['POST', '/api/floor/visits'],
|
||||
];
|
||||
for (const [m, p] of deny) assert.equal(staffApiDecision(m, p, one), 'forbidden', `${m} ${p}`);
|
||||
});
|
||||
10
src/app/(admin)/admin/commerce/page.tsx
Normal file
10
src/app/(admin)/admin/commerce/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {PlatformCommerce} from '@/features/admin/components/PlatformPages';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Commerce',
|
||||
};
|
||||
|
||||
export default function PlatformCommercePage() {
|
||||
return <PlatformCommerce />;
|
||||
}
|
||||
10
src/app/(admin)/admin/footfall/page.tsx
Normal file
10
src/app/(admin)/admin/footfall/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {PlatformFootfall} from '@/features/admin/components/PlatformPages';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Footfall',
|
||||
};
|
||||
|
||||
export default function PlatformFootfallPage() {
|
||||
return <PlatformFootfall />;
|
||||
}
|
||||
15
src/app/(admin)/admin/merchants/[merchantId]/page.tsx
Normal file
15
src/app/(admin)/admin/merchants/[merchantId]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {CompanyDetail} from '@/features/admin/components/CompanyDetail';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Merchant',
|
||||
};
|
||||
|
||||
export default async function MerchantPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{merchantId: string}>;
|
||||
}) {
|
||||
const {merchantId} = await params;
|
||||
return <CompanyDetail companyId={merchantId} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {CameraDetail} from '@/features/admin/components/CameraDetail';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Camera',
|
||||
};
|
||||
|
||||
export default async function CameraPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{merchantId: string; shopId: string; cameraId: string}>;
|
||||
}) {
|
||||
const {merchantId, shopId, cameraId} = await params;
|
||||
return (
|
||||
<CameraDetail companyId={merchantId} storeId={shopId} cameraId={cameraId} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {StoreDetail} from '@/features/admin/components/StoreDetail';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Shop',
|
||||
};
|
||||
|
||||
export default async function ShopPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{merchantId: string; shopId: string}>;
|
||||
}) {
|
||||
const {merchantId, shopId} = await params;
|
||||
return <StoreDetail companyId={merchantId} storeId={shopId} />;
|
||||
}
|
||||
25
src/app/(admin)/admin/merchants/page.tsx
Normal file
25
src/app/(admin)/admin/merchants/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {MerchantsPanel} from '@/features/admin/components/MerchantsPanel';
|
||||
import {AdminPageHeader} from '@/features/admin/components/common/AdminPageHeader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Merchants',
|
||||
};
|
||||
|
||||
/**
|
||||
* Every merchant on the platform as a card: search, filter, sort, create,
|
||||
* suspend, reinstate, reset the owner's password, delete — and the way into
|
||||
* each one.
|
||||
*/
|
||||
export default function MerchantsPage() {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Merchants"
|
||||
subtitle="Create, suspend and remove the merchants on this platform."
|
||||
/>
|
||||
<MerchantsPanel />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,19 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {CompaniesPanel} from '@/features/admin/components/CompaniesPanel';
|
||||
import {PlatformOverview} from '@/features/admin/components/PlatformOverview';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Platform admin · Loyaly',
|
||||
title: 'Overview',
|
||||
};
|
||||
|
||||
/**
|
||||
* The platform console.
|
||||
* The platform console's landing page — where a platform admin arrives after
|
||||
* sign-in (the proxy sends them to /admin).
|
||||
*
|
||||
* Company administration and nothing else — that is the whole of the platform's
|
||||
* admin surface upstream (`/api/admin/*`), and this page deliberately does not
|
||||
* grow past it. There is no endpoint to browse a tenant's visitors, cameras,
|
||||
* reports or shops, and no impersonation: seeing a company's data means signing
|
||||
* in as that company's owner, which is a different decision with a different
|
||||
* audit trail.
|
||||
* Merchant CRUD lives at /admin/merchants; this page is the overview above it.
|
||||
* What the console can and cannot read today is recorded in
|
||||
* features/admin/config/capabilities.ts, and the endpoints still needed in
|
||||
* docs/ADMIN-MONITORING-API.md.
|
||||
*/
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<VStack gap={1}>
|
||||
<Text type="display-3" weight="medium">
|
||||
Platform admin
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Create, suspend and remove the merchant companies on this platform.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<CompaniesPanel />
|
||||
</VStack>
|
||||
);
|
||||
export default function AdminOverviewPage() {
|
||||
return <PlatformOverview />;
|
||||
}
|
||||
|
||||
10
src/app/(admin)/admin/profile/page.tsx
Normal file
10
src/app/(admin)/admin/profile/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {AdminProfile} from '@/features/admin/components/AdminAccount';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Profile',
|
||||
};
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
return <AdminProfile />;
|
||||
}
|
||||
10
src/app/(admin)/admin/settings/page.tsx
Normal file
10
src/app/(admin)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {AdminSettings} from '@/features/admin/components/AdminAccount';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Settings',
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <AdminSettings />;
|
||||
}
|
||||
23
src/app/(public)/join/page.tsx
Normal file
23
src/app/(public)/join/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {LoginSplit} from '@/features/auth/components/LoginSplit';
|
||||
import {JoinForm} from '@/features/auth/components/JoinForm';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join your team',
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an invitation code is redeemed. Public — see PUBLIC_PATHS in proxy.ts —
|
||||
* because the person here has no account yet; that is the point of the page.
|
||||
* Sits in the (public) group with /login and shares its frame.
|
||||
*/
|
||||
export default function JoinPage() {
|
||||
return (
|
||||
<LoginSplit
|
||||
title="Join your team"
|
||||
description="Enter the invitation code your manager gave you, then choose your own password. Nobody else ever sees it."
|
||||
>
|
||||
<JoinForm />
|
||||
</LoginSplit>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
|
||||
import {useRecentVisits} from '@/features/dashboard/hooks/useReports';
|
||||
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
|
||||
/**
|
||||
@@ -14,14 +15,23 @@ import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
* size — one feed implementation, two budgets. The cursor the platform returns
|
||||
* is the supported way to page further; the dashboard never needs it, so it is
|
||||
* wired here first when infinite scroll lands.
|
||||
*
|
||||
* New arrivals are pushed over GET /api/visits/stream and each one re-reads
|
||||
* the feed; "Live" shows only while that stream is actually connected.
|
||||
*/
|
||||
export default function ActivityPage() {
|
||||
const visits = useRecentVisits(50);
|
||||
const {isLive} = useArrivalStream(
|
||||
visits.refetch,
|
||||
// Stale rows from the previous store are not a position to resume from.
|
||||
visits.isRefreshing ? undefined : visits.data?.cursor,
|
||||
);
|
||||
const scopeLabel = useScopeLabel();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
eyebrow={isLive ? 'Live' : undefined}
|
||||
title="Activity"
|
||||
description={`Every recognised arrival across ${scopeLabel}, newest first.`}
|
||||
controls={<ScopeControls />}
|
||||
|
||||
24
src/app/(workspace)/customers/page.tsx
Normal file
24
src/app/(workspace)/customers/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory';
|
||||
|
||||
/**
|
||||
* The customer directory, from GET /api/visitors.
|
||||
*
|
||||
* Company-wide, not scoped by the store switcher: a customer belongs to the
|
||||
* business, and somebody who first walked into one branch is the same person
|
||||
* at another.
|
||||
*/
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Customers"
|
||||
description="Everyone the cameras have recognised — name them, see their visits, record a purchase."
|
||||
/>
|
||||
<CustomerDirectory />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {KpiRow} from '@/features/dashboard/components/KpiRow';
|
||||
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
|
||||
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
|
||||
import {EngagementSection} from '@/features/engagement/components/EngagementSection';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {
|
||||
useConversionReport,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useFootfallReport,
|
||||
useRecentVisits,
|
||||
} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {greetingFor} from '@/features/dashboard/services/dashboardService';
|
||||
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||
@@ -30,23 +31,27 @@ import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||
* endpoint, and no fixture: if the platform returns nothing, this page shows
|
||||
* nothing rather than something plausible.
|
||||
*
|
||||
* ── What was removed and why ─────────────────────────────────────────────
|
||||
* The engagement layer that used to sit here — ten activity types, impact
|
||||
* chains, campaign funnels, a customer journey, generated insights — was built
|
||||
* against a loyalty domain the platform does not expose. It rendered numbers
|
||||
* with no source. Rather than keep them behind a demo flag where a merchant
|
||||
* could mistake them for real, the panels are replaced by a statement of what
|
||||
* they need. The layout, spacing and hierarchy are otherwise untouched.
|
||||
* ── Engagement ───────────────────────────────────────────────────────────
|
||||
* The engagement layer that used to sit here was generated locally and was
|
||||
* removed. It is back, read from the platform's own endpoints — activities and
|
||||
* their impact, campaigns, and the customer journey — so every figure has a
|
||||
* source, and every attributed one says it is estimated.
|
||||
*
|
||||
* Bucket labels from the reports are rendered as STRINGS. They are local wall
|
||||
* time with no offset; parsing one into a Date re-interprets it in the
|
||||
* viewer's zone and shifts every label on the axis.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const kpis = useDashboardKpis();
|
||||
const footfall = useFootfallReport({bucket: 'day'});
|
||||
const conversion = useConversionReport({bucket: 'day'});
|
||||
// One fetch per report, shared by the KPI row and the charts.
|
||||
const footfall = useFootfallReport({bucket: 'day', compare: true});
|
||||
const conversion = useConversionReport({bucket: 'day', compare: true});
|
||||
const kpis = useDashboardKpis(footfall, conversion);
|
||||
const visits = useRecentVisits(6);
|
||||
// Pushes new arrivals into the recent-arrivals panel as they happen.
|
||||
useArrivalStream(
|
||||
visits.refetch,
|
||||
visits.isRefreshing ? undefined : visits.data?.cursor,
|
||||
);
|
||||
const scopeLabel = useScopeLabel();
|
||||
|
||||
return (
|
||||
@@ -119,10 +124,7 @@ export default function DashboardPage() {
|
||||
|
||||
<ArrivalsFeed resource={visits} viewAllHref="/activity" />
|
||||
|
||||
<FeatureUnavailable
|
||||
title="Customer activity and engagement"
|
||||
description="Selfies, spins, scratch cards, challenges, referrals and events are not being recorded by any till or app yet, so there is nothing to measure their effect on repeat visits or revenue. As soon as they are, this section fills in on its own — no number here is estimated."
|
||||
/>
|
||||
<EngagementSection />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,12 @@ import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {NameCustomerDialog} from '@/features/floor/components/NameCustomerDialog';
|
||||
import {SaleEntryDialog} from '@/features/commerce/components/SaleEntryDialog';
|
||||
import {useFloor} from '@/features/floor/hooks/useFloor';
|
||||
import {useFloor, type FloorAction} from '@/features/floor/hooks/useFloor';
|
||||
import {useTodaySummary} from '@/features/floor/hooks/useTodaySummary';
|
||||
import {TodaySummaryCard} from '@/features/floor/components/TodaySummaryCard';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {isStaffRole} from '@/features/auth/services/staffAccess';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
@@ -35,7 +39,13 @@ function whenSeen(iso: string): string {
|
||||
}
|
||||
|
||||
export default function FloorPage() {
|
||||
const {resource, act, pending, conflict} = useFloor();
|
||||
const {resource, act, pending, conflict, failure} = useFloor();
|
||||
// Today's summary carries revenue — merchant-only (staffAccess.ts).
|
||||
const isStaff = isStaffRole(useSession().user?.role);
|
||||
const today = useTodaySummary(!isStaff);
|
||||
// Taking, releasing or completing a customer changes "on the floor now".
|
||||
const run = (visitId: string, action: FloorAction) =>
|
||||
void act(visitId, action).then(today.refetch);
|
||||
const scopeLabel = useScopeLabel();
|
||||
const [naming, setNaming] = useState<FloorVisit | null>(null);
|
||||
const [selling, setSelling] = useState<FloorVisit | null>(null);
|
||||
@@ -49,9 +59,12 @@ export default function FloorPage() {
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
|
||||
{isStaff ? null : <TodaySummaryCard resource={today} />}
|
||||
|
||||
{/* The platform's own refusal, shown verbatim — it names who holds the
|
||||
customer, which is the part staff need. */}
|
||||
{conflict ? <Banner status="warning" title={conflict.message} /> : null}
|
||||
{failure ? <Banner status="error" title={failure.message} /> : null}
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
@@ -120,12 +133,12 @@ export default function FloorPage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={pending === v.visitId}
|
||||
onClick={() => void act(v.visitId, 'release')}
|
||||
onClick={() => run(v.visitId, 'release')}
|
||||
label="Release"
|
||||
/>
|
||||
<Button
|
||||
isDisabled={pending === v.visitId}
|
||||
onClick={() => void act(v.visitId, 'complete')}
|
||||
onClick={() => run(v.visitId, 'complete')}
|
||||
label="Complete"
|
||||
/>
|
||||
</>
|
||||
@@ -136,7 +149,7 @@ export default function FloorPage() {
|
||||
// which is more useful than a control that vanishes.
|
||||
variant={heldByOther ? 'secondary' : 'primary'}
|
||||
isDisabled={pending === v.visitId}
|
||||
onClick={() => void act(v.visitId, 'attend')}
|
||||
onClick={() => run(v.visitId, 'attend')}
|
||||
label={heldByOther ? 'Taken' : 'Take'}
|
||||
/>
|
||||
)}
|
||||
@@ -178,6 +191,7 @@ export default function FloorPage() {
|
||||
onSaved={() => {
|
||||
setSelling(null);
|
||||
resource.refetch();
|
||||
today.refetch();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/config/settingsNav';
|
||||
import {useRoleFilter} from '@/shared/layouts/workspace/useRoleNav';
|
||||
|
||||
/**
|
||||
* Settings gets its own sub-navigation.
|
||||
@@ -36,13 +37,14 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const settingsNav = useRoleFilter(SETTINGS_NAV);
|
||||
const bp = useBreakpoint();
|
||||
const isNarrow = bp === 'mobile' || bp === 'tablet';
|
||||
|
||||
if (isNarrow) {
|
||||
const active =
|
||||
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
|
||||
SETTINGS_NAV[0];
|
||||
settingsNav.find((s) => isSettingsActive(pathname, s.href)) ??
|
||||
settingsNav[0];
|
||||
|
||||
// A dropdown, not a TabList.
|
||||
//
|
||||
@@ -64,7 +66,7 @@ export default function SettingsLayout({
|
||||
icon: <Icon icon={active.icon} size="sm" />,
|
||||
}}
|
||||
menuWidth={260}
|
||||
items={SETTINGS_NAV.map((s) => ({
|
||||
items={settingsNav.map((s) => ({
|
||||
label: s.label,
|
||||
icon: s.icon,
|
||||
onClick: () => router.push(s.href),
|
||||
@@ -98,7 +100,7 @@ export default function SettingsLayout({
|
||||
*/}
|
||||
<SideNav className="w-full">
|
||||
<SideNavSection title="Settings">
|
||||
{SETTINGS_NAV.map((s) => (
|
||||
{settingsNav.map((s) => (
|
||||
<SideNavItem
|
||||
key={s.href}
|
||||
label={s.label}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function StoreSettingsPage() {
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Store Locations"
|
||||
description="Branch operations, outlet directory, operating schedules and manager assignments."
|
||||
description="Open, rename and remove the shops in your company."
|
||||
>
|
||||
<StoreManagement />
|
||||
</SettingsPage>
|
||||
|
||||
33
src/app/api/activities/events/route.ts
Normal file
33
src/app/api/activities/events/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {engagementApi} from '@/services/api/engagementApi';
|
||||
import {resolveVisitorId} from '@/services/api/refs';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function text(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/activities/events — record that a customer took part. Staff and above.
|
||||
*
|
||||
* `sourceEventId` is passed through, never minted here: an id generated per
|
||||
* REQUEST would make every retry a new event. The platform answers 200
|
||||
* `{duplicate: true}` for one it already has, which is success — the caller's
|
||||
* intent is satisfied.
|
||||
*
|
||||
* The customer may be given by number ("V-42"); this endpoint upstream takes a
|
||||
* uuid only, so it is resolved first — see refs.ts.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(req, async (token, body) => {
|
||||
const customer = text(body.visitorId);
|
||||
return engagementApi.recordEvent(token, {
|
||||
kind: text(body.kind) ?? '',
|
||||
source_event_id: text(body.sourceEventId) ?? '',
|
||||
site: text(body.site),
|
||||
visitor_id: customer ? await resolveVisitorId(token, customer) : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
18
src/app/api/activities/impact/route.ts
Normal file
18
src/app/api/activities/impact/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {engagementApi} from '@/services/api/engagementApi';
|
||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/activities/impact — the impact chain on its own, in the platform's
|
||||
* shape. The dashboard reads it already joined through GET /api/activities;
|
||||
* this stays for any caller that wants the chain alone.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
|
||||
});
|
||||
}
|
||||
26
src/app/api/activities/route.ts
Normal file
26
src/app/api/activities/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {engagementApi} from '@/services/api/engagementApi';
|
||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toActivityRows} from '@/features/engagement/services/mapEngagement';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/activities — the activity catalogue, each row joined to its impact
|
||||
* chain (GET /api/activities/impact upstream) for the same window and shop.
|
||||
*
|
||||
* Both reads go out together: they are independent, and running them in
|
||||
* sequence would double the latency of the dashboard panel for no reason.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const site = toSiteParam(query.storeId);
|
||||
const [activities, impact] = await Promise.all([
|
||||
engagementApi.activities(token, window, site),
|
||||
engagementApi.impact(token, window, site),
|
||||
]);
|
||||
return toActivityRows(activities, impact);
|
||||
});
|
||||
}
|
||||
42
src/app/api/auth/invitation/route.ts
Normal file
42
src/app/api/auth/invitation/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
import {fail} from '@/shared/services/apiRoute';
|
||||
import type {ApiSuccess} from '@/shared/types/api';
|
||||
import type {InvitationPreview} from '@/features/auth/types/join';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/auth/invitation?code=… — what an invitation is for. NO session.
|
||||
*
|
||||
* Asked before anybody chooses a password, so the join screen can say "Join
|
||||
* TeNext Retail as Priya R" and a mistyped code is caught before it costs a
|
||||
* password. Outside the proxy's session gate by its matcher (`api/auth` is
|
||||
* excluded), which is what lets somebody with no account call it.
|
||||
*
|
||||
* Unknown, expired, spent and withdrawn codes are all one 404 upstream, with
|
||||
* one message, on purpose; it is passed through as it is.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const code = req.nextUrl.searchParams.get('code')?.trim() ?? '';
|
||||
if (!code) {
|
||||
return fail('bad_request', 'Enter the invitation code you were given.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const p = await authApi.invitationPreview(code);
|
||||
const data: InvitationPreview = {
|
||||
companyName: p.client_name,
|
||||
email: p.email,
|
||||
fullName: p.full_name ?? '',
|
||||
role: p.role,
|
||||
};
|
||||
return Response.json(
|
||||
{data, meta: {generatedAt: new Date().toISOString()}} satisfies ApiSuccess<InvitationPreview>,
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
109
src/app/api/auth/register/route.ts
Normal file
109
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {NextResponse} from 'next/server';
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {ConfigError} from '@/shared/errors/configError';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
import {fail} from '@/shared/services/apiRoute';
|
||||
import {
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
createSessionToken,
|
||||
sessionCookieOptions,
|
||||
} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
TAB_POINTER_COOKIE,
|
||||
sessionCookieFor,
|
||||
tabPointerOptions,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {newTabId, resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {storeTokens} from '@/features/auth/services/upstreamSession';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
import type {ApiSuccess} from '@/shared/types/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/auth/register — redeem an invitation and sign straight in. NO session.
|
||||
*
|
||||
* The platform answers with a full session, exactly like login, so this sets
|
||||
* the same two cookies login does and the person lands in the console without
|
||||
* ever seeing a sign-in form.
|
||||
*
|
||||
* Only the code, a name and a password are forwarded. `email` and `role` come
|
||||
* from the INVITATION upstream and a body naming either is refused there —
|
||||
* which is what stops a forwarded code becoming somebody else's account.
|
||||
*
|
||||
* Not "remember me": a first sign-in on a device nobody has vouched for gets
|
||||
* the ordinary browser-session lifetime, and the next sign-in can opt in.
|
||||
*
|
||||
* Errors pass through with the platform's wording: 400 (password under 8
|
||||
* characters), 404 `invalid_code`, 409 `conflict` (that address already has an
|
||||
* account — sign in instead). A rejected attempt does not spend the code.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = await req.json();
|
||||
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
/* an empty body is refused just below with a readable message */
|
||||
}
|
||||
|
||||
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
||||
const fullName = typeof body.fullName === 'string' ? body.fullName.trim() : '';
|
||||
const password = typeof body.password === 'string' ? body.password : '';
|
||||
if (!code || !password) {
|
||||
return fail('bad_request', 'Enter your invitation code and choose a password.', 400);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await authApi.register(code, fullName, password);
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
|
||||
const user = toAuthUser(bundle.user);
|
||||
const tabId = (await resolveTabId()) ?? newTabId();
|
||||
|
||||
let sessionCookie: string;
|
||||
try {
|
||||
await storeTokens(bundle, undefined, tabId);
|
||||
sessionCookie = createSessionToken(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
organisation: user.organisation,
|
||||
isPlatformAdmin: user.isPlatformAdmin,
|
||||
},
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ConfigError)) throw err;
|
||||
console.error('[loyaly] configuration error:', err.message);
|
||||
// The account now exists upstream; only this console's session could not
|
||||
// be written. Release the platform session rather than leave it orphaned,
|
||||
// and tell them to sign in — their password already works.
|
||||
try {
|
||||
await authApi.logout(bundle.access_token);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return fail(
|
||||
'internal',
|
||||
'Your account was created, but signing in failed. Sign in with your email and new password.',
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const session: AuthSession = {user, expiresAt: bundle.expires_at};
|
||||
const res = NextResponse.json<ApiSuccess<AuthSession>>(
|
||||
{data: session, meta: {generatedAt: new Date().toISOString()}},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
res.cookies.set(sessionCookieFor(tabId), sessionCookie, sessionCookieOptions());
|
||||
res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions());
|
||||
return res;
|
||||
}
|
||||
21
src/app/api/cameras/[id]/live/route.ts
Normal file
21
src/app/api/cameras/[id]/live/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {streamUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/cameras/{id}/live — live view, relayed through the shop PC.
|
||||
*
|
||||
* `event: waiting` arrives at once; `event: frame` follows with a base64 JPEG
|
||||
* once the shop PC answers. Nothing is uploaded while nobody is watching, and
|
||||
* the platform caps one view at five minutes. Closing the viewer cancels this
|
||||
* request, which cancels the upstream one — see streamUpstream.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return streamUpstream(req, (token, signal) => sitesApi.live(token, id, signal));
|
||||
}
|
||||
16
src/app/api/campaigns/route.ts
Normal file
16
src/app/api/campaigns/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {engagementApi} from '@/services/api/engagementApi';
|
||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toCampaign} from '@/features/engagement/services/mapEngagement';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/campaigns — each campaign's funnel over the workspace window. */
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
||||
return (list ?? []).map(toCampaign);
|
||||
});
|
||||
}
|
||||
23
src/app/api/dashboard/summary/route.ts
Normal file
23
src/app/api/dashboard/summary/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {reportsApi} from '@/services/api/reportsApi';
|
||||
import {DEFAULT_TZ, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toTodaySummary} from '@/features/floor/services/mapSummary';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/summary — today, for the selected shop.
|
||||
*
|
||||
* Deliberately NOT scoped by the range picker: "today" is the business day in
|
||||
* the shop's zone, which is the whole value of this read. The platform
|
||||
* computes that window itself when none is sent.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) =>
|
||||
reportsApi.todaySummary(token, DEFAULT_TZ, toSiteParam(query.storeId)),
|
||||
toTodaySummary,
|
||||
);
|
||||
}
|
||||
29
src/app/api/purchases/route.ts
Normal file
29
src/app/api/purchases/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {purchasesApi} from '@/services/api/purchasesApi';
|
||||
import {resolveSiteId} from '@/services/api/refs';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toPurchaseInput} from '@/features/customers/services/mapCustomer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/purchases — link a sale to a customer. Staff and above.
|
||||
*
|
||||
* This is what lets the conversion report say WHO bought. It is distinct from
|
||||
* /api/sales, the till's itemised record; a purchase here is the lighter
|
||||
* "this customer spent this much" link. The platform answers 204.
|
||||
*
|
||||
* The shop is sent as a uuid: this write upstream does not resolve a slug and
|
||||
* answers one with a 500 — see refs.ts.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
async (token, body) => {
|
||||
const input = toPurchaseInput(body);
|
||||
if (input.site_id) input.site_id = await resolveSiteId(token, input.site_id);
|
||||
return purchasesApi.create(token, input);
|
||||
},
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
21
src/app/api/reports/journey/route.ts
Normal file
21
src/app/api/reports/journey/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {engagementApi} from '@/services/api/engagementApi';
|
||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toJourney} from '@/features/engagement/services/mapEngagement';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/reports/journey — visit → take part → buy → come back → refer.
|
||||
*
|
||||
* Distinct people per stage. Not a strict funnel, and the panel says so.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
return toJourney(
|
||||
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
||||
);
|
||||
});
|
||||
}
|
||||
35
src/app/api/sites/[site]/check/route.ts
Normal file
35
src/app/api/sites/[site]/check/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import type {ApiSiteCheck} from '@/services/api/types';
|
||||
import type {SiteCheck} from '@/features/stores/types/siteCheck';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function toSiteCheck(c: ApiSiteCheck): SiteCheck {
|
||||
return {
|
||||
siteName: c.site,
|
||||
ok: c.ok,
|
||||
steps: (c.steps ?? []).map((s) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
detail: s.detail,
|
||||
advice: s.advice || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sites/{site}/check — is this shop working, in five ordered steps.
|
||||
*
|
||||
* Answered from what head office already knows, so it works when the shop PC
|
||||
* is off — which is itself one of the answers. Read-only and cheap, so it is a
|
||||
* GET the screen can repeat as often as somebody taps it.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{site: string}>},
|
||||
) {
|
||||
const {site} = await params;
|
||||
return serveUpstream(req, (token) => sitesApi.check(token, site), toSiteCheck);
|
||||
}
|
||||
40
src/app/api/sites/[site]/route.ts
Normal file
40
src/app/api/sites/[site]/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import type {ApiSiteUpdate} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/sites/{site} — rename a shop or change its timezone. Manager or owner.
|
||||
* DELETE /api/sites/{site} — remove a shop opened by mistake. Owner only.
|
||||
*
|
||||
* `{site}` is the slug, which never changes: renaming edits the display name
|
||||
* only, so every saved URL and scheduled report keeps working.
|
||||
*
|
||||
* DELETE succeeds only for an EMPTY shop. One with cameras or visit history
|
||||
* answers 409 `in_use` with the platform's own explanation, which is passed
|
||||
* through verbatim — removing footfall and faces is an erasure decision, not a
|
||||
* tidy-up this console should make easy.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{site: string}>},
|
||||
) {
|
||||
const {site} = await params;
|
||||
return proxyUpstream(req, (token, body) => {
|
||||
// An omitted field is left alone upstream, so only what was sent is sent.
|
||||
const patch: ApiSiteUpdate = {};
|
||||
if (typeof body.name === 'string') patch.name = body.name.trim();
|
||||
if (typeof body.timezone === 'string') patch.timezone = body.timezone.trim();
|
||||
return sitesApi.update(token, site, patch);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{site: string}>},
|
||||
) {
|
||||
const {site} = await params;
|
||||
return proxyUpstream(req, (token) => sitesApi.remove(token, site));
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {proxyUpstream, 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.
|
||||
* GET /api/sites — the estate.
|
||||
* POST /api/sites — open a shop (owner only; the platform enforces it).
|
||||
*
|
||||
* 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
|
||||
@@ -24,6 +25,7 @@ function toSite(s: ApiSite): Site {
|
||||
id: s.slug || s.site_id,
|
||||
uuid: s.site_id,
|
||||
name: s.name,
|
||||
timezone: s.timezone,
|
||||
isOnline: s.online ?? null,
|
||||
camerasTotal: s.cameras_total ?? null,
|
||||
camerasUp: s.cameras_up ?? null,
|
||||
@@ -36,3 +38,25 @@ export async function GET(req: NextRequest) {
|
||||
sites.map(toSite),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The slug is optional and derived from the name upstream. It becomes the shop
|
||||
* PC's identity and can never be changed, so an empty one is sent as absent
|
||||
* rather than as "" — the platform then derives a good one itself.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => {
|
||||
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
|
||||
const timezone =
|
||||
typeof body.timezone === 'string' ? body.timezone.trim() : '';
|
||||
return sitesApi.create(token, {
|
||||
name: typeof body.name === 'string' ? body.name.trim() : '',
|
||||
slug: slug || undefined,
|
||||
timezone: timezone || undefined,
|
||||
});
|
||||
},
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
|
||||
19
src/app/api/visitors/[id]/history/route.ts
Normal file
19
src/app/api/visitors/[id]/history/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitorsApi} from '@/services/api/visitorsApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toCustomerVisit} from '@/features/customers/services/mapCustomer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/visitors/{id}/history — this customer's visits, newest first. */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => visitorsApi.history(token, id, 50),
|
||||
(rows) => (rows ?? []).map(toCustomerVisit),
|
||||
);
|
||||
}
|
||||
37
src/app/api/visitors/[id]/image/route.ts
Normal file
37
src/app/api/visitors/[id]/image/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitorsApi} from '@/services/api/visitorsApi';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
import {ok, parseQuery} from '@/shared/services/apiRoute';
|
||||
import {toCustomerPhoto} from '@/features/customers/services/mapCustomer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/visitors/{id}/image — the customer's latest photo, described.
|
||||
*
|
||||
* The platform answers "no photo" with a 404 carrying one of two codes —
|
||||
* `no_image` (nothing captured) and `images_disabled` (this deployment stores
|
||||
* none). Both are normal states, not faults, so they are answered here as
|
||||
* `available: false` with the platform's own reason, and the screen shows a
|
||||
* placeholder instead of a red error for a system working as configured.
|
||||
*/
|
||||
const ABSENT = new Set(['no_image', 'images_disabled']);
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
const query = parseQuery(req);
|
||||
try {
|
||||
const img = await withUpstream((token) => visitorsApi.image(token, id));
|
||||
return ok(toCustomerPhoto(img), query);
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 404 && ABSENT.has(err.code)) {
|
||||
return ok({available: false, url: null, reason: err.message}, query);
|
||||
}
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
23
src/app/api/visitors/[id]/profile/route.ts
Normal file
23
src/app/api/visitors/[id]/profile/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitorsApi} from '@/services/api/visitorsApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toProfileInput} from '@/features/customers/services/mapCustomer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PUT /api/visitors/{id}/profile — give a customer a name. Staff and above.
|
||||
*
|
||||
* PUT because the platform's save is a whole-object replace; see
|
||||
* toProfileInput for what that means for the fields this console cannot read.
|
||||
* The platform answers 204, so the caller re-reads the list for the new label.
|
||||
*/
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, body) =>
|
||||
visitorsApi.updateProfile(token, id, toProfileInput(body)),
|
||||
);
|
||||
}
|
||||
23
src/app/api/visitors/[id]/route.ts
Normal file
23
src/app/api/visitors/[id]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitorsApi} from '@/services/api/visitorsApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* DELETE /api/visitors/{id} — erasure. Manager and above; the platform
|
||||
* enforces that, and a staff account gets its 403 with a message saying who can.
|
||||
*
|
||||
* Irreversible: the face template and photo are destroyed, the visit rows are
|
||||
* kept unlinked, the consent record is kept revoked. A 502 means the photo
|
||||
* could not be deleted and NOTHING was erased — it is passed through as a
|
||||
* failure, never softened, because the data the merchant believes is gone is
|
||||
* still there.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token) => visitorsApi.erase(token, id));
|
||||
}
|
||||
28
src/app/api/visitors/route.ts
Normal file
28
src/app/api/visitors/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitorsApi} from '@/services/api/visitorsApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toCustomer} from '@/features/customers/services/mapCustomer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/visitors?q=… — find a customer.
|
||||
*
|
||||
* `q` matches name, phone, email or customer number (`42` or `V-42`); without
|
||||
* it the platform returns the most recently seen. Erased customers never
|
||||
* appear. Unscoped by shop: a customer belongs to the company, not to the
|
||||
* branch they happened to walk into first.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = req.nextUrl.searchParams.get('q')?.trim() || undefined;
|
||||
const limitRaw = Number(req.nextUrl.searchParams.get('limit') ?? 50);
|
||||
const limit = Number.isFinite(limitRaw)
|
||||
? Math.min(Math.max(Math.trunc(limitRaw), 1), 500)
|
||||
: 50;
|
||||
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => visitorsApi.search(token, q, limit),
|
||||
(list) => (list ?? []).map(toCustomer),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitsApi} from '@/services/api/visitsApi';
|
||||
import {purchasesApi} from '@/services/api/purchasesApi';
|
||||
import {resolveSiteId} from '@/services/api/refs';
|
||||
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 {toPurchaseInput} from '@/features/customers/services/mapCustomer';
|
||||
import type {ApiArrival, ApiVisitsPage} from '@/services/api/types';
|
||||
import type {Arrival, VisitsPage} from '@/features/dashboard/types/visits';
|
||||
|
||||
@@ -27,7 +29,10 @@ function toArrival(a: ApiArrival): Arrival {
|
||||
cameraId: a.camera_id,
|
||||
visitorId: a.visitor_id,
|
||||
visitorRef: a.visitor_ref,
|
||||
label: a.label,
|
||||
// The typed name wins; `label` ("Visitor 12") is the fallback. The
|
||||
// platform sends both precisely so a client does not show a named regular
|
||||
// as a number.
|
||||
label: a.name || a.label,
|
||||
isNewVisitor: a.is_new_visitor,
|
||||
similarity: a.similarity,
|
||||
image: a.image
|
||||
@@ -88,19 +93,14 @@ export async function POST(req: NextRequest) {
|
||||
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);
|
||||
const input = toPurchaseInput(body);
|
||||
await withUpstream(async (token) => {
|
||||
// Resolved inside the retry, so a refreshed token resolves it too.
|
||||
if (input.site_id) input.site_id = await resolveSiteId(token, input.site_id);
|
||||
return purchasesApi.create(token, input);
|
||||
});
|
||||
// The platform answers 204: recorded, with nothing to echo back.
|
||||
return ok(null, query);
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
|
||||
27
src/app/api/visits/stream/route.ts
Normal file
27
src/app/api/visits/stream/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitsApi} from '@/services/api/visitsApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {streamUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/visits/stream — arrivals, pushed as they happen.
|
||||
*
|
||||
* The same rows as GET /api/visits, delivered as `event: arrivals`. The
|
||||
* console uses each event as a signal to re-read the feed it already renders
|
||||
* rather than parsing rows out of the stream, so there is one mapping of an
|
||||
* arrival in this app, not two — and polling remains the fallback, so a
|
||||
* dropped stream costs latency, never data.
|
||||
*
|
||||
* `storeId` is the workspace scope, as on every other scoped read.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const p = req.nextUrl.searchParams;
|
||||
const cursor = p.get('cursor') ?? undefined;
|
||||
const site = toSiteParam(p.get('storeId') ?? 'all');
|
||||
|
||||
return streamUpstream(req, (token, signal) =>
|
||||
visitsApi.stream(token, {cursor, site}, signal),
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,52 @@
|
||||
* Astryx's <Theme>. It is absent in system mode — that is what leaves
|
||||
* `light dark` in force so the OS preference decides.
|
||||
*/
|
||||
/*
|
||||
* The ambient canvas, taken from krow-demo: cool blue on the left, neutral
|
||||
* through the middle, the faintest warm cream on the right. The stops sit
|
||||
* close in lightness so it reads as "softer", not as a visible band.
|
||||
*
|
||||
* Every dark branch is #000000, the dark body colour, so in dark mode the
|
||||
* gradient flattens to exactly what was there before.
|
||||
*
|
||||
* It is painted with `background-attachment: fixed` on every layer that fills
|
||||
* with the body colour, not once behind a transparent shell. Fixed attachment
|
||||
* sizes the image to the viewport, so stacked layers line up pixel for pixel
|
||||
* and the seams disappear. The shell layers keep their opaque background-color
|
||||
* underneath, and the text inputs and primary button that read
|
||||
* --color-background-body are not touched.
|
||||
*
|
||||
* `.x1eiddq6` is Astryx 0.2.0's atomic class for
|
||||
* `background-color: var(--color-background-body)` (AppShell wash, nav areas,
|
||||
* layout content). It is a build hash: re-check it after any Astryx upgrade
|
||||
* (`grep -o '.x[a-z0-9]*[^{]*{background-color:var(--color-background-body)'
|
||||
* node_modules/@astryxdesign/core/dist/astryx.css`).
|
||||
*/
|
||||
:root {
|
||||
--loyaly-canvas: radial-gradient(
|
||||
ellipse 85% 55% at 20% -10%,
|
||||
light-dark(rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.18)) 0%,
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 75% 50% at 85% 20%,
|
||||
light-dark(rgba(244, 196, 48, 0.05), rgba(244, 196, 48, 0.1)) 0%,
|
||||
transparent 70%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
light-dark(#f8fafc, #0b0f17) 0%,
|
||||
light-dark(#f1f5f9, #080c13) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html,
|
||||
.x1eiddq6,
|
||||
.astryx-side-nav {
|
||||
background-image: var(--loyaly-canvas);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--color-background-body);
|
||||
color-scheme: light dark;
|
||||
@@ -322,3 +368,12 @@ html[data-theme='light'] {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The server rendered this document as another tab's user (see
|
||||
* FOREIGN_SEED_ATTR in features/auth/services/tabSession.ts). Keep it unseen
|
||||
* until SessionProvider has replaced it with this tab's own state.
|
||||
*/
|
||||
html[data-tab-seed-foreign] body {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export default async function RootLayout({
|
||||
being signed in, and nothing in it signs anybody out. See
|
||||
services/tabSession.ts for what it replaced and why.
|
||||
*/}
|
||||
<script dangerouslySetInnerHTML={{__html: tabSessionScript()}} />
|
||||
<script dangerouslySetInnerHTML={{__html: tabSessionScript(tabId)}} />
|
||||
<Providers
|
||||
initialSession={session}
|
||||
initialTabId={tabId}
|
||||
|
||||
139
src/features/admin/components/AdminAccount.tsx
Normal file
139
src/features/admin/components/AdminAccount.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
|
||||
import type {ThemeMode} from '@/shared/theme/themeMode';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {ActiveSessionsPanel} from '@/features/settings/components/ActiveSessionsPanel';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {PendingIntegration} from './common/PendingIntegration';
|
||||
|
||||
/**
|
||||
* The platform admin's own account — Profile and Settings.
|
||||
*
|
||||
* ── Only what the platform can answer for an admin ───────────────────────
|
||||
* The profile is the signed-in session: id, name, email, role. Nothing is
|
||||
* editable, because the only profile write (`PATCH /api/settings/profile`) is
|
||||
* tenant-scoped and an admin has no tenant.
|
||||
*
|
||||
* Settings has two live parts: appearance (a cookie this app owns) and active
|
||||
* sessions (`/api/auth/sessions`, scoped to the person, not a tenant). Password
|
||||
* change and two-factor have no platform endpoint, so they say so rather than
|
||||
* render a form that saves nowhere — the merchant Security page's password
|
||||
* form and 2FA switch are exactly that, and are not reused here.
|
||||
*/
|
||||
|
||||
export function AdminProfile() {
|
||||
const {user} = useSession();
|
||||
if (!user) return null;
|
||||
const name = user.name || user.email;
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Profile"
|
||||
subtitle="Your platform admin account."
|
||||
actions={<Link href="/admin/settings">Settings</Link>}
|
||||
/>
|
||||
|
||||
<Card padding={6}>
|
||||
<HStack gap={4} vAlign="center" className="min-w-0">
|
||||
<Avatar name={name} size="xl" tooltip={false} />
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text size="lg" weight="semibold" className="truncate">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary" className="truncate">
|
||||
{user.email}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Token size="sm" label="Platform admin" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
|
||||
<StaticPanel
|
||||
title="Account details"
|
||||
subtitle="As the platform returned them at sign-in."
|
||||
>
|
||||
<List>
|
||||
<ListItem label="Name" endContent={<Text size="sm">{user.name || '—'}</Text>} />
|
||||
<ListItem label="Email" endContent={<Text size="sm">{user.email}</Text>} />
|
||||
<ListItem label="Role" endContent={<Text size="sm">Platform admin</Text>} />
|
||||
<ListItem
|
||||
label="Scope"
|
||||
description="A platform admin belongs to no merchant and administers all of them."
|
||||
endContent={<Text size="sm">All merchants</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Account ID"
|
||||
endContent={
|
||||
<Text size="sm" color="secondary">
|
||||
{user.id}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel title="Edit profile">
|
||||
<PendingIntegration
|
||||
icon="settings"
|
||||
title="Name and email cannot be changed here yet"
|
||||
description="The platform's only profile update is scoped to a merchant, and a platform admin has none. An admin endpoint for the signed-in account is needed."
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettings() {
|
||||
const {mode, setMode} = useThemeMode();
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Settings"
|
||||
subtitle="Appearance and sign-in security for your platform admin account."
|
||||
actions={<Link href="/admin/profile">Profile</Link>}
|
||||
/>
|
||||
|
||||
<StaticPanel
|
||||
title="Appearance"
|
||||
subtitle="Saved in this browser. Applies to both consoles."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as ThemeMode)}
|
||||
label="Theme"
|
||||
>
|
||||
<SegmentedControlItem value="light" label="Light" />
|
||||
<SegmentedControlItem value="dark" label="Dark" />
|
||||
<SegmentedControlItem value="system" label="System" />
|
||||
</SegmentedControl>
|
||||
</StaticPanel>
|
||||
|
||||
<ActiveSessionsPanel />
|
||||
|
||||
<StaticPanel title="Password and two-factor">
|
||||
<PendingIntegration
|
||||
icon="security"
|
||||
title="Password change and two-factor are not available yet"
|
||||
description="The platform has no endpoint to change your own password or enrol two-factor. Until it does, a platform admin password can only be changed on the platform itself."
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {AdminShell} from './shell/AdminShell';
|
||||
|
||||
/**
|
||||
* The platform console's frame.
|
||||
*
|
||||
* ── Same guard, different shell ──────────────────────────────────────────
|
||||
* `AuthGuard` is the one the workspace uses, reading the session minted by the
|
||||
* same login route. What is deliberately absent is `WorkspaceShell`: no site
|
||||
* switcher, no tenant navigation, no Loyaly AI rail. Every one of those is
|
||||
* scoped to a company, and a platform operator has none — a store picker with
|
||||
* nothing in it is worse than no store picker.
|
||||
*
|
||||
* ── Why it says "Platform" so loudly ─────────────────────────────────────
|
||||
* This console can suspend a company and delete one irreversibly. Somebody who
|
||||
* has both consoles open needs to know which tab they are typing into before
|
||||
* they click, not after. The badge is the cheapest possible version of that.
|
||||
* same login route. The shell is NOT WorkspaceShell: that one carries a store
|
||||
* switcher, tenant navigation and a tenant account menu, every one of which is
|
||||
* scoped to a company a platform operator does not have. AdminShell keeps the
|
||||
* shape — rail, top bar, content, Loyaly AI — and drops the tenancy.
|
||||
*/
|
||||
export function AdminLayout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<VStack gap={0} width="100%" minHeight="100vh">
|
||||
<AdminTopBar />
|
||||
<VStack gap={0} width="100%" paddingInline={8} paddingBlock={8}>
|
||||
{children}
|
||||
</VStack>
|
||||
</VStack>
|
||||
<OperatorsOnly>
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</OperatorsOnly>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTopBar() {
|
||||
const {user, logout} = useSession();
|
||||
/**
|
||||
* What a merchant session sees if it reaches the platform console.
|
||||
*
|
||||
* The proxy normally redirects a merchant away before this renders, but it
|
||||
* resolves the session from the tab POINTER cookie, while this tab's client
|
||||
* session comes from its own tab id (see tabSession.ts). With an operator
|
||||
* signed in in one tab and a merchant in another, the server can render this
|
||||
* shell for the operator's pointer while the tab itself holds the merchant.
|
||||
*
|
||||
* An automatic redirect to /dashboard was tried and does not work: the proxy,
|
||||
* reading the same operator pointer, sends /dashboard straight back to /admin,
|
||||
* and the tab settles on a blank page. So this says what happened and offers
|
||||
* the two ways out, instead of guessing.
|
||||
*
|
||||
* Routing only, like the proxy. Nothing leaks either way: every admin read
|
||||
* carries this tab's id and the platform answers 404 to a merchant.
|
||||
*/
|
||||
function OperatorsOnly({children}: {children: React.ReactNode}) {
|
||||
const {isAuthenticated, user, logout} = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
if (user?.isPlatformAdmin === true) return <>{children}</>;
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
router.replace('/login?next=%2Fadmin');
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={4}
|
||||
width="100%"
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
paddingInline={8}
|
||||
paddingBlock={4}
|
||||
className="border-b border-border bg-surface"
|
||||
>
|
||||
<HStack gap={3} vAlign="center">
|
||||
<BrandMark size={24} />
|
||||
<Text size="sm" weight="medium">
|
||||
Platform
|
||||
</Text>
|
||||
<Badge variant="neutral" label="Admin" />
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
{/* The address, not the display name: on a console that can delete a
|
||||
company, which ACCOUNT is signed in matters more than whose it is. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void signOut()}
|
||||
label="Sign out"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<VStack width="100%" minHeight="100vh" hAlign="center" vAlign="center" padding={6}>
|
||||
<EmptyState
|
||||
title="This is the platform admin console"
|
||||
description={`This tab is signed in as ${user?.email ?? 'a merchant account'}, which is not a platform admin. Sign out and sign in with a platform admin account, or go back to your dashboard.`}
|
||||
actions={
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label="Go to dashboard"
|
||||
href="/dashboard"
|
||||
/>
|
||||
<Button size="sm" label="Sign out" onClick={() => void signOut()} />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
206
src/features/admin/components/CameraDetail.tsx
Normal file
206
src/features/admin/components/CameraDetail.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
useAlerts,
|
||||
useCamera,
|
||||
useCompany,
|
||||
useEvents,
|
||||
useStore,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {AdminSection} from './common/AdminSection';
|
||||
import {PendingIntegration} from './common/PendingIntegration';
|
||||
import {CompanyNotFound} from './CompanyDetail';
|
||||
import {
|
||||
AlertTable,
|
||||
CameraStatus,
|
||||
EventTable,
|
||||
adminHref,
|
||||
} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* One camera, four levels deep: company / store / camera.
|
||||
*
|
||||
* ── No fake "LIVE" ───────────────────────────────────────────────────────
|
||||
* The live feed panel is always the integration-required state today, even
|
||||
* once camera details arrive. The tenant console streams a camera through its
|
||||
* own authenticated route; there is no admin equivalent, and a looping
|
||||
* placeholder with a red LIVE badge would be the single most misleading thing
|
||||
* this console could draw.
|
||||
*/
|
||||
export function CameraDetail({
|
||||
companyId,
|
||||
storeId,
|
||||
cameraId,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
cameraId: string;
|
||||
}) {
|
||||
const {resource, company} = useCompany(companyId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={<CompanyNotFound />}
|
||||
>
|
||||
{() =>
|
||||
company ? (
|
||||
<CameraBody company={company} storeId={storeId} cameraId={cameraId} />
|
||||
) : (
|
||||
<CompanyNotFound />
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraBody({
|
||||
company,
|
||||
storeId,
|
||||
cameraId,
|
||||
}: {
|
||||
company: Company;
|
||||
storeId: string;
|
||||
cameraId: string;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const store = useStore(company.id, storeId);
|
||||
const camera = useCamera(company.id, storeId, cameraId);
|
||||
const events = useEvents(company.id, storeId, cameraId);
|
||||
const alerts = useAlerts(company.id, storeId, cameraId);
|
||||
|
||||
const storeName =
|
||||
store.isAvailable && store.resource.status === 'success'
|
||||
? store.resource.data.name
|
||||
: 'Shop';
|
||||
const cameraName =
|
||||
camera.isAvailable && camera.resource.status === 'success'
|
||||
? camera.resource.data.name
|
||||
: 'Camera';
|
||||
|
||||
useAiContext({
|
||||
level: 'camera',
|
||||
label: `${cameraName} · ${storeName} · ${company.name}`,
|
||||
companyId: company.id,
|
||||
storeId,
|
||||
cameraId,
|
||||
});
|
||||
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title={cameraName}
|
||||
subtitle={`${storeName} · ${company.name}`}
|
||||
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: company.name, href: adminHref.company(company.id)},
|
||||
{label: storeName, href: adminHref.store(company.id, storeId)},
|
||||
{label: cameraName},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={4}>
|
||||
<AdminSection
|
||||
title="Camera"
|
||||
data={camera}
|
||||
pending={{
|
||||
icon: 'camera',
|
||||
title: 'Camera data unavailable',
|
||||
description:
|
||||
'Status, location and last-seen time need the platform admin camera-detail API.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(c) => (
|
||||
<List>
|
||||
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
|
||||
<ListItem
|
||||
label="Location"
|
||||
endContent={<Text size="sm">{c.zone ?? '—'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Last seen"
|
||||
endContent={
|
||||
<Text size="sm">
|
||||
{c.lastSeenAt ? new Date(c.lastSeenAt).toLocaleString() : '—'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader title="Live feed" />
|
||||
<PendingIntegration
|
||||
icon="camera"
|
||||
title="Live feed unavailable"
|
||||
description="There is no platform admin stream endpoint. No feed is shown rather than a placeholder that looks live."
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={4}>
|
||||
<AdminSection
|
||||
title="Alerts"
|
||||
data={alerts}
|
||||
pending={{
|
||||
icon: 'notifications',
|
||||
title: 'No alerts available',
|
||||
description:
|
||||
'Alert monitoring is not currently available for Platform Admin.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="notifications"
|
||||
title="No alerts"
|
||||
description="This camera has raised no alerts."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <AlertTable alerts={rows} />}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="Events and AI detections"
|
||||
data={events}
|
||||
pending={{
|
||||
icon: 'events',
|
||||
title: 'No event data available',
|
||||
description: 'Platform Admin event API integration is required.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="events"
|
||||
title="No events"
|
||||
description="This camera has not reported any events yet."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <EventTable events={rows} />}
|
||||
</AdminSection>
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
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 {
|
||||
useCompanies,
|
||||
useCompanyFilters,
|
||||
} from '@/features/admin/hooks/useCompanies';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
|
||||
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
|
||||
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
|
||||
import type {Company, CompanyFilter} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Every company on the platform, and the four things an operator can do to one.
|
||||
*
|
||||
* ── The columns are exactly what the API returns ─────────────────────────
|
||||
* Name, slug, shops, accounts, status, created. `GET /api/admin/clients`
|
||||
* returns those seven fields and nothing else, so there is no owner column, no
|
||||
* plan, no revenue and no health. A column this list cannot fill would have to
|
||||
* be faked, and a faked column on the console that can delete a company is the
|
||||
* worst place in the product to put one.
|
||||
*
|
||||
* ── Why suspended rows are not hidden ────────────────────────────────────
|
||||
* A suspended company is the one an operator is most likely to be looking for
|
||||
* — it is the one somebody is on the phone about. It stays in the default list
|
||||
* with its status shown, and the filter is there to narrow deliberately.
|
||||
*/
|
||||
|
||||
interface CompanyRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
sites: number;
|
||||
users: number;
|
||||
status: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function toRow(c: Company): CompanyRow {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
sites: c.sites,
|
||||
users: c.users,
|
||||
status: c.isActive ? 'Active' : 'Suspended',
|
||||
// Date only. A platform operator cares which week a tenant was onboarded,
|
||||
// never which minute.
|
||||
created: new Date(c.createdAt).toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<CompanyRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Company',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
{/* The slug is shown on every row because it is what the delete
|
||||
confirmation asks for, and hunting for it afterwards is how
|
||||
somebody ends up copying the wrong one. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.slug}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{key: 'sites', header: 'Shops', width: proportional(1), align: 'end'},
|
||||
{key: 'users', header: 'Accounts', width: proportional(1), align: 'end'},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.status}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'created', header: 'Created', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
type Action =
|
||||
| {kind: 'create'}
|
||||
| {kind: 'suspend'; company: Company}
|
||||
| {kind: 'reset'; company: Company}
|
||||
| {kind: 'delete'; company: Company}
|
||||
| null;
|
||||
|
||||
export function CompaniesPanel() {
|
||||
const companies = useCompanies();
|
||||
const {query, setQuery, filter, setFilter, visible} = useCompanyFilters(
|
||||
companies.data,
|
||||
);
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
|
||||
/**
|
||||
* Nothing optimistic. Every mutation refetches and the table redraws from
|
||||
* what the server says — a row that claimed to be suspended because a PATCH
|
||||
* was sent, while the platform refused it, is exactly the lie this console
|
||||
* cannot afford.
|
||||
*/
|
||||
function refresh() {
|
||||
companies.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<PanelCard
|
||||
title="Companies"
|
||||
subtitle="Every merchant company on this platform."
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={5} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No companies yet"
|
||||
description="Create your first merchant company to get started."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(all) => (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack gap={3} vAlign="center" hAlign="between">
|
||||
<TextInput
|
||||
label="Search companies"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name or slug…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as CompanyFilter)}
|
||||
label="Filter by status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="active" label="Active" />
|
||||
<SegmentedControlItem value="suspended" label="Suspended" />
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
/* A search that matches nothing is NOT the same as a platform
|
||||
with no companies — saying "no companies yet" here would tell
|
||||
an operator their tenants had vanished. */
|
||||
<Text size="sm" color="secondary">
|
||||
No companies match that search. {all.length}{' '}
|
||||
{all.length === 1 ? 'company' : 'companies'} in total.
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveTable
|
||||
data={visible.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['status', 'sites']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
{visible.map((c) => (
|
||||
<HStack
|
||||
key={c.id}
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.name}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'reset', company: c})}
|
||||
label="Reset owner password"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'suspend', company: c})}
|
||||
label={c.isActive ? 'Suspend' : 'Reinstate'}
|
||||
/>
|
||||
{/* Delete is offered only on a suspended company, because
|
||||
the platform refuses it otherwise. The dialog still
|
||||
sends an active one and shows the real 409 if this is
|
||||
ever reached another way — the guard is a courtesy,
|
||||
never the rule. */}
|
||||
{c.isActive ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setAction({kind: 'delete', company: c})}
|
||||
label="Delete"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{action?.kind === 'create' ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setAction(null)}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'suspend' ? (
|
||||
<SuspendCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDone={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'reset' ? (
|
||||
<ResetOwnerPasswordDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'delete' ? (
|
||||
<DeleteCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDeleted={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
148
src/features/admin/components/CompanyActions.tsx
Normal file
148
src/features/admin/components/CompanyActions.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
|
||||
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
|
||||
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
export type CompanyDialog = 'suspend' | 'reset' | 'delete';
|
||||
|
||||
/**
|
||||
* The things an operator can do to an existing merchant, in one menu.
|
||||
*
|
||||
* Shared by the merchant cards and the merchant page so the two can never
|
||||
* offer different actions for the same merchant.
|
||||
*
|
||||
* There is no "Edit": `PATCH /api/admin/clients/{id}` accepts `active` and
|
||||
* nothing else, so a rename would be a form that saves nowhere
|
||||
* (ADMIN_CAPABILITIES.merchantEdit).
|
||||
*
|
||||
* Delete is offered only on a suspended merchant, because the platform refuses
|
||||
* it otherwise. The dialog still sends the request and shows the real 409 if
|
||||
* it is ever reached another way — the guard here is a courtesy, never the
|
||||
* rule.
|
||||
*
|
||||
* The menu and its dialogs are split so a card can hold the menu while the
|
||||
* dialogs render outside it — a dialog nested in a clickable card would route
|
||||
* its clicks through the card.
|
||||
*/
|
||||
export function CompanyActionsMenu({
|
||||
company,
|
||||
onSelect,
|
||||
viewHref,
|
||||
isIconOnly = false,
|
||||
}: {
|
||||
company: Company;
|
||||
onSelect: (dialog: CompanyDialog) => void;
|
||||
/** Adds a "View details" row — for surfaces that are not the detail page. */
|
||||
viewHref?: string;
|
||||
isIconOnly?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={
|
||||
isIconOnly
|
||||
? {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
label: `Actions for ${company.name}`,
|
||||
isIconOnly: true,
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}
|
||||
: {
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: 'Manage',
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}
|
||||
}
|
||||
hasChevron={!isIconOnly}
|
||||
menuWidth={220}
|
||||
items={[
|
||||
...(viewHref
|
||||
? [
|
||||
{label: 'View details', onClick: () => router.push(viewHref)},
|
||||
{type: 'divider' as const},
|
||||
]
|
||||
: []),
|
||||
{label: 'Reset owner password', onClick: () => onSelect('reset')},
|
||||
{
|
||||
label: company.isActive ? 'Deactivate merchant' : 'Reinstate merchant',
|
||||
onClick: () => onSelect('suspend'),
|
||||
},
|
||||
...(company.isActive
|
||||
? []
|
||||
: [
|
||||
{type: 'divider' as const},
|
||||
{label: 'Remove merchant', onClick: () => onSelect('delete')},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyActionDialog({
|
||||
company,
|
||||
dialog,
|
||||
onClose,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
dialog: CompanyDialog;
|
||||
onClose: () => void;
|
||||
/** After a suspend/reinstate — refetch, never patch optimistically. */
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
if (dialog === 'suspend') {
|
||||
return (
|
||||
<SuspendCompanyDialog company={company} onClose={onClose} onDone={onChanged} />
|
||||
);
|
||||
}
|
||||
if (dialog === 'reset') {
|
||||
return <ResetOwnerPasswordDialog company={company} onClose={onClose} />;
|
||||
}
|
||||
return (
|
||||
<DeleteCompanyDialog company={company} onClose={onClose} onDeleted={onDeleted} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Menu and dialogs together, for a surface with no clickable container. */
|
||||
export function CompanyActions({
|
||||
company,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
isIconOnly = false,
|
||||
}: {
|
||||
company: Company;
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
isIconOnly?: boolean;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<CompanyDialog | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompanyActionsMenu
|
||||
company={company}
|
||||
onSelect={setDialog}
|
||||
isIconOnly={isIconOnly}
|
||||
/>
|
||||
{dialog ? (
|
||||
<CompanyActionDialog
|
||||
company={company}
|
||||
dialog={dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
onChanged={onChanged}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
402
src/features/admin/components/CompanyDetail.tsx
Normal file
402
src/features/admin/components/CompanyDetail.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {
|
||||
SkeletonMetricGrid,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
useCompany,
|
||||
useCompanyStores,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import {AdminPageHeader, BackButton} from './common/AdminPageHeader';
|
||||
import {AdminSection} from './common/AdminSection';
|
||||
import {CompanyActions} from './CompanyActions';
|
||||
import {StoreTable, adminHref} from './MonitoringTables';
|
||||
import {
|
||||
ADMIN_CAPABILITIES,
|
||||
MERCHANT_AREAS,
|
||||
} from '@/features/admin/config/capabilities';
|
||||
import type {MerchantArea} from '@/features/admin/config/capabilities';
|
||||
|
||||
/**
|
||||
* One merchant: what the platform knows about it, and the way down to its
|
||||
* shops.
|
||||
*
|
||||
* ── Only the fields the platform returns ─────────────────────────────────
|
||||
* Name, slug, status, shop count, account count, created. There is no owner
|
||||
* name, plan or contact on the admin surface, so there is no owner field
|
||||
* here. The shop and account COUNTS are real — they come on the merchant row
|
||||
* — even though the shops themselves cannot be listed yet, and the Shops tab
|
||||
* says exactly that rather than rendering an empty table that would read as
|
||||
* "this merchant has no shops".
|
||||
*
|
||||
* ── Tabs only for what can be answered ───────────────────────────────────
|
||||
* General and Shops. Sales persons, cameras, customers, sales, analytics,
|
||||
* footfall and device logs are listed on General with their backend state
|
||||
* (MERCHANT_AREAS) instead of as tabs that could only say "unavailable".
|
||||
*/
|
||||
export function CompanyDetail({companyId}: {companyId: string}) {
|
||||
const router = useRouter();
|
||||
const {resource, company} = useCompany(companyId);
|
||||
|
||||
useAiContext(
|
||||
company
|
||||
? {level: 'company', label: company.name, companyId: company.id}
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={
|
||||
<VStack gap={6} width="100%">
|
||||
<SkeletonRows count={2} />
|
||||
<SkeletonMetricGrid columns={4} />
|
||||
</VStack>
|
||||
}
|
||||
empty={<CompanyNotFound />}
|
||||
>
|
||||
{() =>
|
||||
company ? (
|
||||
<CompanyBody
|
||||
company={company}
|
||||
onChanged={resource.refetch}
|
||||
onDeleted={() => router.replace(adminHref.merchants)}
|
||||
/>
|
||||
) : (
|
||||
<CompanyNotFound />
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyNotFound() {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Merchant not found"
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: 'Not found'},
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchant with this id"
|
||||
description="It may have been deleted, or the link is wrong. The merchant list shows every merchant on the platform."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
href={adminHref.merchants}
|
||||
label="Back to merchants"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyBody({
|
||||
company,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<MerchantTab>('general');
|
||||
const status = company.isActive ? 'Active' : 'Suspended';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<HStack>
|
||||
<BackButton label="Merchants" href={adminHref.merchants} />
|
||||
</HStack>
|
||||
|
||||
{/* Identity: who this is, whether they can sign in, and what can be
|
||||
done to them — in one place, above everything that describes them. */}
|
||||
<Card padding={6}>
|
||||
<HStack
|
||||
gap={4}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-4"
|
||||
>
|
||||
<HStack gap={4} vAlign="center" className="min-w-0">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-14 rounded-xl bg-muted shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.companies} size="lg" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text type="display-3" weight="medium" className="break-words">
|
||||
{company.name}
|
||||
</Text>
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={company.isActive ? 'success' : 'error'}
|
||||
label={status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{status}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
@{company.slug}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Created {fmtDate(company.createdAt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<CompanyActions
|
||||
company={company}
|
||||
onChanged={onChanged}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
</HStack>
|
||||
</Card>
|
||||
|
||||
<TabList
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as MerchantTab)}
|
||||
hasDivider
|
||||
>
|
||||
<Tab value="general" label="General" />
|
||||
<Tab
|
||||
value="shops"
|
||||
label="Shops"
|
||||
endContent={<Badge label={company.sites.toLocaleString()} />}
|
||||
/>
|
||||
</TabList>
|
||||
|
||||
{tab === 'general' ? (
|
||||
<MerchantGeneral company={company} />
|
||||
) : (
|
||||
<MerchantShops company={company} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
type MerchantTab = 'general' | 'shops';
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function MerchantGeneral({company}: {company: Company}) {
|
||||
const bp = useBreakpoint();
|
||||
// Read once per mount: rendering must stay pure, and a day count does not
|
||||
// need to tick while the page is open.
|
||||
const [now] = useState(() => Date.now());
|
||||
// Shops has its own tab; everything else is listed with its state.
|
||||
const areas = MERCHANT_AREAS.filter((a) => a.key !== 'shops');
|
||||
const live = areas.filter((a) => ADMIN_CAPABILITIES[a.capability]).length;
|
||||
// Derived from created_at, which the row carries — not an estimate.
|
||||
const days = Math.max(
|
||||
0,
|
||||
Math.floor((now - new Date(company.createdAt).getTime()) / DAY_MS),
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4} gap={4}>
|
||||
<Fact
|
||||
icon="stores"
|
||||
label="Shops"
|
||||
value={company.sites.toLocaleString()}
|
||||
caption="Registered to this merchant"
|
||||
/>
|
||||
<Fact
|
||||
icon="staff"
|
||||
label="Merchant accounts"
|
||||
value={company.users.toLocaleString()}
|
||||
caption="Logins on this merchant"
|
||||
/>
|
||||
<Fact
|
||||
icon="expiry"
|
||||
label="Created"
|
||||
value={fmtDate(company.createdAt)}
|
||||
caption={`${days.toLocaleString()} ${days === 1 ? 'day' : 'days'} on the platform`}
|
||||
/>
|
||||
<Fact
|
||||
icon={company.isActive ? 'present' : 'absent'}
|
||||
label="Status"
|
||||
value={company.isActive ? 'Active' : 'Suspended'}
|
||||
caption={
|
||||
company.isActive ? 'Can sign in' : 'Sign-in blocked for everyone'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Card padding={6}>
|
||||
<VStack gap={5} width="100%">
|
||||
<SectionHeader
|
||||
title="Merchant data"
|
||||
subtitle={`What this console can read for ${company.name}. Every area is scoped to this merchant and its shops only.`}
|
||||
actions={
|
||||
<Token size="sm" label={`${live} of ${areas.length} live`} />
|
||||
}
|
||||
/>
|
||||
<Grid columns={bp === 'desktop' || bp === 'ultrawide' ? 2 : 1} gap={3}>
|
||||
{areas.map((a) => (
|
||||
<AreaTile
|
||||
key={a.key}
|
||||
area={a}
|
||||
isLive={ADMIN_CAPABILITIES[a.capability]}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One merchant-level area and whether the platform can answer it. A tile, not
|
||||
* a list row with a repeated badge: nine identical "Backend integration
|
||||
* required" tokens read as noise, so the state is a quiet dot + label and the
|
||||
* summary token above carries the count.
|
||||
*/
|
||||
function AreaTile({area, isLive}: {area: MerchantArea; isLive: boolean}) {
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="rounded-lg bg-muted p-4 min-w-0"
|
||||
>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-9 rounded-md bg-surface shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS[area.icon]} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={0} className="min-w-0 flex-1">
|
||||
<Text size="sm" weight="medium">
|
||||
{area.label}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{area.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack gap={1.5} vAlign="center" className="shrink-0">
|
||||
<StatusDot
|
||||
variant={isLive ? 'success' : 'neutral'}
|
||||
label={isLive ? 'Live' : 'Backend integration required'}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{isLive ? 'Live' : 'Needs API'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MerchantShops({company}: {company: Company}) {
|
||||
const stores = useCompanyStores(company.id);
|
||||
const n = company.sites;
|
||||
const shops = `${n} ${n === 1 ? 'shop' : 'shops'}`;
|
||||
|
||||
return (
|
||||
<AdminSection
|
||||
title="Shops"
|
||||
subtitle={`${shops} registered to ${company.name}.`}
|
||||
data={stores}
|
||||
pending={{
|
||||
icon: 'stores',
|
||||
title: 'Shops cannot be listed yet',
|
||||
description:
|
||||
n === 0
|
||||
? `${company.name} has not opened a shop. Once it does, its shops will be listed here when the platform admin shops API is connected.`
|
||||
: `The platform reports ${shops} for ${company.name}, but has no platform admin endpoint to list them. Connect the merchant-shops API to open each shop and its cameras from here.`,
|
||||
}}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No shops"
|
||||
description={`${company.name} has not opened a shop yet.`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <StoreTable companyId={company.id} stores={rows} />}
|
||||
</AdminSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled fact with its icon. Not a KPI card: these do not trend. */
|
||||
function Fact({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={4} className="h-full">
|
||||
<VStack gap={3} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-7 rounded-md bg-muted shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS[icon]} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text size="xl" weight="semibold" className="tracking-tight">
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{caption}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -71,11 +71,11 @@ export function CreateCompanyDialog({
|
||||
// The platform's own wording. It names the rule that was hit — a
|
||||
// duplicate address, a slug already taken — and "Something went wrong"
|
||||
// would throw that away.
|
||||
setError(res.message ?? 'Could not create that company.');
|
||||
setError(res.message ?? 'Could not create that merchant.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreated({email: res.data.ownerEmail, password: res.data.password});
|
||||
setCreated({email: res.data.owner_email, password: res.data.password});
|
||||
// The company exists now, whether or not they close this dialog politely.
|
||||
onCreated();
|
||||
}
|
||||
@@ -99,11 +99,11 @@ export function CreateCompanyDialog({
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Create company"
|
||||
aria-label="Create merchant"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={created ? 'Company created' : 'Create company'}
|
||||
title={created ? 'Merchant created' : 'Create merchant'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
@@ -128,24 +128,24 @@ export function CreateCompanyDialog({
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
label="Merchant name"
|
||||
value={companyName}
|
||||
onChange={setCompanyName}
|
||||
placeholder="TeNext Retail"
|
||||
placeholder="Business name"
|
||||
/>
|
||||
<TextInput
|
||||
label="Slug (optional)"
|
||||
value={slug}
|
||||
onChange={setSlug}
|
||||
placeholder="Derived from the name"
|
||||
description="Permanent once set — it becomes part of the company's broker topic."
|
||||
description="Permanent once set — it becomes part of the merchant's broker topic."
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={ownerEmail}
|
||||
onChange={setOwnerEmail}
|
||||
placeholder="owner@company.com"
|
||||
placeholder="owner@merchant.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner name"
|
||||
@@ -165,7 +165,7 @@ export function CreateCompanyDialog({
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={busy}
|
||||
label="Create company"
|
||||
label="Create merchant"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DeleteCompanyDialog({
|
||||
if (!res.ok) {
|
||||
// `still_active` and `storage_error` both name a condition the operator
|
||||
// can act on — suspend it first, or retry once storage is healthy.
|
||||
setError(res.message ?? 'Could not delete that company.');
|
||||
setError(res.message ?? 'Could not delete that merchant.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@ export function DeleteCompanyDialog({
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Delete company"
|
||||
aria-label="Delete merchant"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Delete company?"
|
||||
title="Delete merchant?"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
@@ -97,12 +97,12 @@ export function DeleteCompanyDialog({
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Still active"
|
||||
description="The platform will refuse this until the company is suspended. Suspend it first, then come back."
|
||||
description="The platform will refuse this until the merchant is suspended. Suspend it first, then come back."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Type the company slug to confirm"
|
||||
label="Type the merchant slug to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={company.slug}
|
||||
@@ -116,7 +116,7 @@ export function DeleteCompanyDialog({
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!matches || busy}
|
||||
isLoading={busy}
|
||||
label="Delete company"
|
||||
label="Delete merchant"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
261
src/features/admin/components/MerchantsPanel.tsx
Normal file
261
src/features/admin/components/MerchantsPanel.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
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 {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import type {Breakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {
|
||||
useCompanies,
|
||||
useCompanyFilters,
|
||||
} from '@/features/admin/hooks/useCompanies';
|
||||
import type {
|
||||
Company,
|
||||
CompanyFilter,
|
||||
CompanySort,
|
||||
} from '@/features/admin/types/company';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {
|
||||
CompanyActionDialog,
|
||||
CompanyActionsMenu,
|
||||
type CompanyDialog,
|
||||
} from './CompanyActions';
|
||||
import {adminHref} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* Every merchant on the platform, one card each.
|
||||
*
|
||||
* ── A card shows exactly what the API returns ────────────────────────────
|
||||
* Name, slug, status, shop count, account count, created — the whole
|
||||
* `GET /api/admin/clients` row. No owner, plan, revenue or health: a field the
|
||||
* row cannot fill would have to be faked.
|
||||
*
|
||||
* ── The card is the way in ───────────────────────────────────────────────
|
||||
* The whole card opens /admin/merchants/{id}. Its menu carries the row
|
||||
* actions; the dialogs those open are rendered at panel level, outside the
|
||||
* card, so a click inside a dialog can never also navigate.
|
||||
*
|
||||
* ── Suspended merchants stay in the default list ─────────────────────────
|
||||
* A suspended merchant is the one somebody is on the phone about. The filter
|
||||
* is there to narrow deliberately.
|
||||
*/
|
||||
|
||||
const SORTS: {value: CompanySort; label: string}[] = [
|
||||
{value: 'newest', label: 'Newest first'},
|
||||
{value: 'oldest', label: 'Oldest first'},
|
||||
{value: 'name', label: 'Name A–Z'},
|
||||
{value: 'shops', label: 'Most shops'},
|
||||
];
|
||||
|
||||
function cardColumns(bp: Breakpoint, isPanelOpen: boolean): number {
|
||||
if (bp === 'mobile') return 1;
|
||||
if (bp === 'tablet') return 2;
|
||||
if (bp === 'laptop') return isPanelOpen ? 2 : 3;
|
||||
if (bp === 'desktop') return 3;
|
||||
return isPanelOpen ? 3 : 4;
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n.toLocaleString()} ${n === 1 ? one : many}`;
|
||||
|
||||
export function MerchantsPanel() {
|
||||
const companies = useCompanies();
|
||||
const {query, setQuery, filter, setFilter, sort, setSort, visible} =
|
||||
useCompanyFilters(companies.data);
|
||||
const [isCreating, setCreating] = useState(false);
|
||||
const [action, setAction] = useState<{
|
||||
company: Company;
|
||||
dialog: CompanyDialog;
|
||||
} | null>(null);
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen} = useLoyalyAi();
|
||||
const columns = cardColumns(bp, isOpen);
|
||||
|
||||
/**
|
||||
* Nothing optimistic. Every mutation refetches and the cards redraw from
|
||||
* what the server says.
|
||||
*/
|
||||
function refresh() {
|
||||
companies.refetch();
|
||||
}
|
||||
|
||||
const create = (
|
||||
<Button size="sm" onClick={() => setCreating(true)} label="Create merchant" />
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-2"
|
||||
>
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-2">
|
||||
<TextInput
|
||||
label="Search merchants"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name or slug…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as CompanyFilter)}
|
||||
label="Filter by status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="active" label="Active" />
|
||||
<SegmentedControlItem value="suspended" label="Suspended" />
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Selector
|
||||
label="Sort merchants"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
options={SORTS}
|
||||
value={sort}
|
||||
onChange={(v) => setSort(v as CompanySort)}
|
||||
/>
|
||||
{create}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonMetricGrid columns={columns} count={columns * 2} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchants yet"
|
||||
description="Create your first merchant to get started."
|
||||
actions={create}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(all) =>
|
||||
visible.length === 0 ? (
|
||||
/* A search that matches nothing is NOT a platform with no
|
||||
merchants — saying "no merchants yet" here would tell an
|
||||
operator their tenants had vanished. */
|
||||
<Text size="sm" color="secondary">
|
||||
No merchants match these filters.{' '}
|
||||
{plural(all.length, 'merchant', 'merchants')} in total.
|
||||
</Text>
|
||||
) : (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{visible.map((c) => (
|
||||
<MerchantCard
|
||||
key={c.id}
|
||||
company={c}
|
||||
onAction={(dialog) => setAction({company: c, dialog})}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
|
||||
{isCreating ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action ? (
|
||||
<CompanyActionDialog
|
||||
company={action.company}
|
||||
dialog={action.dialog}
|
||||
onClose={() => setAction(null)}
|
||||
onChanged={refresh}
|
||||
onDeleted={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MerchantCard({
|
||||
company,
|
||||
onAction,
|
||||
}: {
|
||||
company: Company;
|
||||
onAction: (dialog: CompanyDialog) => void;
|
||||
}) {
|
||||
const href = adminHref.company(company.id);
|
||||
const status = company.isActive ? 'Active' : 'Suspended';
|
||||
|
||||
return (
|
||||
<ClickableCard
|
||||
label={`Open ${company.name}`}
|
||||
href={href}
|
||||
padding={4}
|
||||
className="h-full"
|
||||
>
|
||||
<VStack gap={4} width="100%" height="100%" vAlign="between">
|
||||
<HStack gap={3} vAlign="start" hAlign="between" width="100%">
|
||||
<HStack gap={3} vAlign="center" className="min-w-0">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-9 rounded-md bg-muted shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.companies} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={0} className="min-w-0">
|
||||
<Text weight="medium" className="truncate">
|
||||
{company.name}
|
||||
</Text>
|
||||
{/* The slug is what the remove confirmation asks for. */}
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{company.slug}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<CompanyActionsMenu
|
||||
company={company}
|
||||
onSelect={onAction}
|
||||
viewHref={href}
|
||||
isIconOnly
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={company.isActive ? 'success' : 'error'}
|
||||
label={status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{status}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{plural(company.sites, 'shop', 'shops')} ·{' '}
|
||||
{plural(company.users, 'account', 'accounts')} · created{' '}
|
||||
{new Date(company.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ClickableCard>
|
||||
);
|
||||
}
|
||||
299
src/features/admin/components/MonitoringTables.tsx
Normal file
299
src/features/admin/components/MonitoringTables.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {
|
||||
AdminAlert,
|
||||
AdminCamera,
|
||||
AdminCameraStatus,
|
||||
AdminEvent,
|
||||
AdminSeverity,
|
||||
AdminStore,
|
||||
} from '@/features/admin/types/monitoring';
|
||||
|
||||
/**
|
||||
* The loaded branch of every drill-down section.
|
||||
*
|
||||
* None of these render today — every capability behind them is off, so their
|
||||
* sections show PendingIntegration instead. They exist so that turning a level
|
||||
* on is a flag flip: the rows are already written against the shapes in
|
||||
* types/monitoring.ts, and a missing field prints a dash, never a guess.
|
||||
*/
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
function when(iso?: string): string {
|
||||
return iso ? new Date(iso).toLocaleString() : DASH;
|
||||
}
|
||||
|
||||
export const adminHref = {
|
||||
merchants: '/admin/merchants',
|
||||
company: (companyId: string) =>
|
||||
`/admin/merchants/${encodeURIComponent(companyId)}`,
|
||||
store: (companyId: string, storeId: string) =>
|
||||
`${adminHref.company(companyId)}/shops/${encodeURIComponent(storeId)}`,
|
||||
camera: (companyId: string, storeId: string, cameraId: string) =>
|
||||
`${adminHref.store(companyId, storeId)}/cameras/${encodeURIComponent(cameraId)}`,
|
||||
};
|
||||
|
||||
/* ── Stores ──────────────────────────────────────────────────────────── */
|
||||
|
||||
type StoreRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOnline?: boolean;
|
||||
timezone: string;
|
||||
cameras: string;
|
||||
online: string;
|
||||
};
|
||||
|
||||
export function StoreTable({
|
||||
companyId,
|
||||
stores,
|
||||
}: {
|
||||
companyId: string;
|
||||
stores: AdminStore[];
|
||||
}) {
|
||||
const columns: TableColumn<StoreRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Shop',
|
||||
width: proportional(3),
|
||||
renderCell: (row) => (
|
||||
<Link href={adminHref.store(companyId, row.id)}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isOnline',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) =>
|
||||
row.isOnline === undefined ? (
|
||||
<Text size="sm">{DASH}</Text>
|
||||
) : (
|
||||
<CameraStatus status={row.isOnline ? 'online' : 'offline'} />
|
||||
),
|
||||
},
|
||||
{key: 'timezone', header: 'Timezone', width: proportional(2)},
|
||||
{key: 'cameras', header: 'Cameras', width: proportional(1), align: 'end'},
|
||||
{key: 'online', header: 'Online', width: proportional(1), align: 'end'},
|
||||
];
|
||||
const rows: StoreRow[] = stores.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name || s.slug,
|
||||
slug: s.slug,
|
||||
isOnline: s.isOnline,
|
||||
timezone: s.timezone ?? DASH,
|
||||
cameras: s.cameras === undefined ? DASH : String(s.cameras),
|
||||
online: s.camerasOnline === undefined ? DASH : String(s.camerasOnline),
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
density="balanced"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Cameras ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const CAMERA_STATUS: Record<
|
||||
AdminCameraStatus,
|
||||
{label: string; dot: 'success' | 'error' | 'neutral'}
|
||||
> = {
|
||||
online: {label: 'Online', dot: 'success'},
|
||||
offline: {label: 'Offline', dot: 'error'},
|
||||
unknown: {label: 'Unknown', dot: 'neutral'},
|
||||
};
|
||||
|
||||
export function CameraStatus({status}: {status: AdminCameraStatus}) {
|
||||
const s = CAMERA_STATUS[status];
|
||||
return (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant={s.dot} label={s.label} />
|
||||
<Text size="sm" color="secondary">
|
||||
{s.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
zone: string;
|
||||
status: AdminCameraStatus;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
export function CameraTable({
|
||||
companyId,
|
||||
storeId,
|
||||
cameras,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
cameras: AdminCamera[];
|
||||
}) {
|
||||
const columns: TableColumn<CameraRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Camera',
|
||||
width: proportional(3),
|
||||
renderCell: (row) => (
|
||||
<Link href={adminHref.camera(companyId, storeId, row.id)}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{key: 'zone', header: 'Location', width: proportional(2)},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <CameraStatus status={row.status} />,
|
||||
},
|
||||
{key: 'lastSeen', header: 'Last seen', width: proportional(2), align: 'end'},
|
||||
];
|
||||
const rows: CameraRow[] = cameras.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
zone: c.zone ?? DASH,
|
||||
status: c.status,
|
||||
lastSeen: when(c.lastSeenAt),
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['status', 'lastSeen']}
|
||||
density="balanced"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Events and alerts ───────────────────────────────────────────────── */
|
||||
|
||||
const SEVERITY: Record<
|
||||
AdminSeverity,
|
||||
{label: string; dot: 'error' | 'warning' | 'neutral'}
|
||||
> = {
|
||||
critical: {label: 'Critical', dot: 'error'},
|
||||
warning: {label: 'Warning', dot: 'warning'},
|
||||
info: {label: 'Info', dot: 'neutral'},
|
||||
};
|
||||
|
||||
function Severity({severity}: {severity?: AdminSeverity}) {
|
||||
if (!severity) return <Text size="sm">{DASH}</Text>;
|
||||
const s = SEVERITY[severity];
|
||||
return (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant={s.dot} label={s.label} />
|
||||
<Text size="sm" color="secondary">
|
||||
{s.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
type EventRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
at: string;
|
||||
type: string;
|
||||
camera: string;
|
||||
severity?: AdminSeverity;
|
||||
};
|
||||
|
||||
export function EventTable({events}: {events: AdminEvent[]}) {
|
||||
const columns: TableColumn<EventRow>[] = [
|
||||
{key: 'at', header: 'Time', width: proportional(2)},
|
||||
{key: 'type', header: 'Event', width: proportional(2)},
|
||||
{key: 'camera', header: 'Camera', width: proportional(2)},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Severity severity={row.severity} />,
|
||||
},
|
||||
];
|
||||
const rows: EventRow[] = events.map((e) => ({
|
||||
id: e.id,
|
||||
at: when(e.at),
|
||||
type: e.type,
|
||||
camera: e.cameraName ?? DASH,
|
||||
severity: e.severity,
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="type"
|
||||
density="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
at: string;
|
||||
title: string;
|
||||
camera: string;
|
||||
severity: AdminSeverity;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const ALERT_STATUS: Record<AdminAlert['status'], string> = {
|
||||
open: 'Open',
|
||||
acknowledged: 'Acknowledged',
|
||||
resolved: 'Resolved',
|
||||
};
|
||||
|
||||
export function AlertTable({alerts}: {alerts: AdminAlert[]}) {
|
||||
const columns: TableColumn<AlertRow>[] = [
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Severity severity={row.severity} />,
|
||||
},
|
||||
{key: 'title', header: 'Alert', width: proportional(3)},
|
||||
{key: 'camera', header: 'Camera', width: proportional(2)},
|
||||
{key: 'at', header: 'Time', width: proportional(2)},
|
||||
{key: 'status', header: 'Status', width: proportional(1)},
|
||||
];
|
||||
const rows: AlertRow[] = alerts.map((a) => ({
|
||||
id: a.id,
|
||||
at: when(a.at),
|
||||
title: a.title,
|
||||
camera: a.cameraName ?? DASH,
|
||||
severity: a.severity,
|
||||
status: ALERT_STATUS[a.status],
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="title"
|
||||
density="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
821
src/features/admin/components/PlatformOverview.tsx
Normal file
821
src/features/admin/components/PlatformOverview.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
||||
import {
|
||||
SkeletonMetricGrid,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {summarise} from '@/features/admin/hooks/useMonitoring';
|
||||
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
|
||||
import type {AdminCapability} from '@/features/admin/config/capabilities';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {adminHref} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* The landing page for a platform operator: Admin → Merchant → Shop.
|
||||
*
|
||||
* ── Every number here is a sum over `GET /api/admin/clients` ─────────────
|
||||
* Merchants, active, shops and accounts are the list's own `active`, `sites`
|
||||
* and `users` fields, added up. Nothing is estimated, and no metric the
|
||||
* platform cannot report (cameras, alerts, events) is shown at all.
|
||||
*
|
||||
* The two lists below the KPIs are also derived from the same rows: active
|
||||
* merchants with no shop (onboarded but never set up — the likeliest support
|
||||
* call) and the newest merchants.
|
||||
*/
|
||||
// Rounded: AnimatedNumber springs between values and hands this every
|
||||
// in-between frame — unrounded, 6 → 4 shops paints as "3.108".
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString();
|
||||
|
||||
const RECENT_COUNT = 5;
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlatformOverview() {
|
||||
const companies = useCompanies();
|
||||
const bp = useBreakpoint();
|
||||
const [isCreating, setCreating] = useState(false);
|
||||
|
||||
// Responsive column budget: 1 on mobile, 2 on tablet, 4 on desktop
|
||||
const columns = bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4;
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Platform overview"
|
||||
subtitle="Merchants, their shops and accounts across the Loyaly platform."
|
||||
meta={
|
||||
<HStack gap={2} vAlign="center" className="flex-wrap">
|
||||
<HStack
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
className="px-2.5 py-1 rounded-full bg-muted border border-border"
|
||||
>
|
||||
<StatusDot variant="success" label="Platform operational" />
|
||||
<Text size="xsm" weight="medium">
|
||||
Platform Operational
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
Central Multi-tenant Console
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label="View directory"
|
||||
href={adminHref.merchants}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
label="Create merchant"
|
||||
icon={<Icon icon={ICONS.plus} size="sm" />}
|
||||
onClick={() => setCreating(true)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchants yet"
|
||||
description="Totals appear here once the first merchant is created."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
label="Create merchant"
|
||||
icon={<Icon icon={ICONS.plus} size="sm" />}
|
||||
onClick={() => setCreating(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => {
|
||||
const s = summarise(rows);
|
||||
const activePercent =
|
||||
s.companies > 0 ? Math.round((s.active / s.companies) * 100) : 100;
|
||||
const avgStores = (s.stores / (s.active || 1)).toFixed(1);
|
||||
const avgUsers = (s.accounts / (s.companies || 1)).toFixed(1);
|
||||
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{/* Card 1: Registered Merchants */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Merchants
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.companies} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.companies} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
tenants
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<ProgressBar
|
||||
value={activePercent}
|
||||
label="Active rate"
|
||||
isLabelHidden
|
||||
variant="success"
|
||||
/>
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant="success" label="Active" />
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.active} active
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.suspended} suspended
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
|
||||
{/* Card 2: Active Merchants */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Active merchants
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.present} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.active} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
active
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={
|
||||
s.active === s.companies ? 'success' : 'warning'
|
||||
}
|
||||
label="Health"
|
||||
/>
|
||||
<Text size="xsm" weight="medium">
|
||||
{activePercent}% active rate
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.companies - s.active === 0
|
||||
? 'All nominal'
|
||||
: `${s.companies - s.active} inactive`}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
|
||||
{/* Card 3: Shops */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Shops
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.stores} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.stores} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
locations
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{s.active} active {s.active === 1 ? 'client' : 'clients'}
|
||||
</Text>
|
||||
<Token size="sm" label={`${avgStores} avg / tenant`} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
|
||||
{/* Card 4: Accounts */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Merchant accounts
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.staff} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.accounts} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
credentials
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
Across {s.companies}{' '}
|
||||
{s.companies === 1 ? 'merchant' : 'merchants'}
|
||||
</Text>
|
||||
<Token size="sm" label={`${avgUsers} users / tenant`} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
|
||||
<Grid columns={bp === 'mobile' || bp === 'tablet' ? 1 : 2} gap={4}>
|
||||
{/* Left Panel: Merchant Setup Status */}
|
||||
<Card padding={5} elevation="low">
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack hAlign="between" vAlign="start" width="100%">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="lg" weight="semibold">
|
||||
Merchant setup status
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Active merchants that have not opened a shop yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(rows) => {
|
||||
const s = summarise(rows);
|
||||
const pending = s.withoutStores;
|
||||
|
||||
if (pending.length === 0) {
|
||||
return (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
className="p-4 rounded-xl bg-muted/60 border border-border"
|
||||
>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-10 rounded-full bg-surface text-primary border border-border shrink-0 shadow-sm"
|
||||
>
|
||||
<Icon icon={ICONS.roles} size="md" />
|
||||
</HStack>
|
||||
<VStack gap={0.5} className="min-w-0">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="semibold">
|
||||
All merchants operational
|
||||
</Text>
|
||||
<Token size="sm" label="100% Configured" />
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
Every active merchant has at least one shop
|
||||
configured and provisioned for commerce.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Grid columns={3} gap={2}>
|
||||
<Card padding={3} variant="muted">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Setup complete
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
{s.active} / {s.companies}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
100% active rate
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card padding={3} variant="muted">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Live storefronts
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
{s.stores}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
retail locations
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card padding={3} variant="muted">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Pending setup
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
0
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
no backlog
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<HStack hAlign="between" vAlign="center" className="pt-1">
|
||||
<Text size="xsm" color="secondary">
|
||||
Unconfigured accounts will be highlighted here.
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="+ Add merchant"
|
||||
onClick={() => setCreating(true)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="p-3 rounded-lg bg-muted border border-border"
|
||||
>
|
||||
<StatusDot variant="warning" label="Pending" />
|
||||
<Text size="sm" weight="medium">
|
||||
{pending.length}{' '}
|
||||
{pending.length === 1
|
||||
? 'merchant requires'
|
||||
: 'merchants require'}{' '}
|
||||
shop setup.
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack gap={2} width="100%">
|
||||
{pending.map((c) => (
|
||||
<ClickableCard
|
||||
key={c.id}
|
||||
href={adminHref.company(c.id)}
|
||||
label={`Set up ${c.name}`}
|
||||
padding={3}
|
||||
elevation="none"
|
||||
className="hover:border-primary transition-all"
|
||||
>
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
gap={3}
|
||||
>
|
||||
<HStack gap={3} vAlign="center" className="min-w-0">
|
||||
<Avatar name={c.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0.5} className="min-w-0">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="semibold" className="truncate">
|
||||
{c.name}
|
||||
</Text>
|
||||
<Token size="sm" label={`@${c.slug}`} />
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
0 shops · {c.users}{' '}
|
||||
{c.users === 1 ? 'account' : 'accounts'} ·
|
||||
Added {formatDate(c.createdAt)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Token size="sm" label="Needs shop" />
|
||||
<Icon
|
||||
icon={ICONS.arrowRight}
|
||||
size="sm"
|
||||
color="secondary"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ClickableCard>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Right Panel: Recently Added Merchants */}
|
||||
<Card padding={5} elevation="low">
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack hAlign="between" vAlign="start" width="100%">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="lg" weight="semibold">
|
||||
Recently added merchants
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
The newest merchants on the platform.
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="View all"
|
||||
href={adminHref.merchants}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(rows) => {
|
||||
const recent = summarise(rows).recent.slice(0, RECENT_COUNT);
|
||||
return (
|
||||
<VStack gap={2} width="100%">
|
||||
{recent.map((c) => (
|
||||
<ClickableCard
|
||||
key={c.id}
|
||||
href={adminHref.company(c.id)}
|
||||
label={`View ${c.name}`}
|
||||
padding={3}
|
||||
elevation="none"
|
||||
className="hover:border-primary transition-all"
|
||||
>
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
gap={3}
|
||||
>
|
||||
<HStack gap={3} vAlign="center" className="min-w-0">
|
||||
<Avatar name={c.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0.5} className="min-w-0">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="semibold" className="truncate">
|
||||
{c.name}
|
||||
</Text>
|
||||
<Token size="sm" label={`@${c.slug}`} />
|
||||
</HStack>
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="flex-wrap"
|
||||
>
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Icon
|
||||
icon={ICONS.stores}
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.sites} {c.sites === 1 ? 'shop' : 'shops'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
·
|
||||
</Text>
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Icon
|
||||
icon={ICONS.staff}
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.users}{' '}
|
||||
{c.users === 1 ? 'account' : 'accounts'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
Added {formatDate(c.createdAt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="shrink-0"
|
||||
>
|
||||
<StatusDot
|
||||
variant={c.isActive ? 'success' : 'error'}
|
||||
label={c.isActive ? 'Active' : 'Suspended'}
|
||||
/>
|
||||
<Icon
|
||||
icon={ICONS.arrowRight}
|
||||
size="sm"
|
||||
color="secondary"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ClickableCard>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<MonitoringCoverage />
|
||||
|
||||
{isCreating ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => companies.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rungs of the drill-down the platform can answer today, read straight
|
||||
* from ADMIN_CAPABILITIES — so this panel cannot drift from what the pages
|
||||
* actually do.
|
||||
*/
|
||||
interface CoverageItem {
|
||||
label: string;
|
||||
capability: AdminCapability;
|
||||
description: string;
|
||||
icon: typeof ICONS.companies;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const COVERAGE: CoverageItem[] = [
|
||||
{
|
||||
label: 'Merchants',
|
||||
capability: 'companies',
|
||||
description:
|
||||
'Tenant isolation, company lifecycle, and operator credential provisioning.',
|
||||
icon: ICONS.companies,
|
||||
href: adminHref.merchants,
|
||||
},
|
||||
{
|
||||
label: 'Merchant shops',
|
||||
capability: 'companyStores',
|
||||
description:
|
||||
'Storefront network topology, physical address mapping, and device linkages.',
|
||||
icon: ICONS.stores,
|
||||
},
|
||||
{
|
||||
label: 'Footfall',
|
||||
capability: 'footfall',
|
||||
description:
|
||||
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
|
||||
icon: ICONS.visitors,
|
||||
href: '/admin/footfall',
|
||||
},
|
||||
{
|
||||
label: 'Commerce',
|
||||
capability: 'commerce',
|
||||
description:
|
||||
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
|
||||
icon: ICONS.commerce,
|
||||
href: '/admin/commerce',
|
||||
},
|
||||
{
|
||||
label: 'Platform Loyaly AI',
|
||||
capability: 'platformAssistant',
|
||||
description:
|
||||
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
|
||||
icon: ICONS.ai,
|
||||
},
|
||||
];
|
||||
|
||||
function MonitoringCoverage() {
|
||||
const bp = useBreakpoint();
|
||||
const liveCount = COVERAGE.filter(
|
||||
(c) => ADMIN_CAPABILITIES[c.capability],
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Card padding={5} elevation="low">
|
||||
<VStack gap={5} width="100%">
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-2"
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text size="lg" weight="semibold">
|
||||
Monitoring coverage
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
What this console can read from the platform today.
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
className="px-3 py-1 rounded-full bg-muted border border-border"
|
||||
>
|
||||
<StatusDot variant="success" label="Live systems" />
|
||||
<Text size="xsm" weight="medium">
|
||||
{liveCount} of {COVERAGE.length} Subsystems Live
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3} gap={3}>
|
||||
{COVERAGE.map(({label, capability, description, icon, href}) => {
|
||||
const live = ADMIN_CAPABILITIES[capability];
|
||||
return (
|
||||
<Card
|
||||
key={capability}
|
||||
padding={4}
|
||||
variant="muted"
|
||||
className="h-full border border-border"
|
||||
>
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-surface text-primary border border-border shrink-0 shadow-sm"
|
||||
>
|
||||
<Icon icon={icon} size="sm" />
|
||||
</HStack>
|
||||
{live ? (
|
||||
<HStack
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
className="px-2 py-0.5 rounded-full bg-surface border border-border"
|
||||
>
|
||||
<StatusDot variant="success" label="Live" />
|
||||
<Text size="xsm" weight="medium">
|
||||
Live
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Token
|
||||
size="sm"
|
||||
label="Backend integration required"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<VStack gap={0.5} width="100%">
|
||||
<Text weight="semibold">{label}</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{href && live ? (
|
||||
<HStack
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<Link href={href} className="text-xs">
|
||||
Open {label.toLowerCase()} →
|
||||
</Link>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
336
src/features/admin/components/PlatformPages.tsx
Normal file
336
src/features/admin/components/PlatformPages.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import type {SelectorOptionType} from '@astryxdesign/core/Selector';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {useAdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import {storeRepository} from '@/features/admin/repositories/monitoringRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import type {AnalyticsFilters, DateRange} from '@/features/admin/types/analytics';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {FootfallDashboard} from './analytics/FootfallDashboard';
|
||||
import {CommerceDashboard} from './analytics/CommerceDashboard';
|
||||
|
||||
/**
|
||||
* Footfall and Commerce — platform analytics over per-shop data.
|
||||
*
|
||||
* ── The hierarchy is the filter bar ──────────────────────────────────────
|
||||
* Footfall Merchant → Area → Shop → Date range
|
||||
* Commerce Merchant → Shop → Date range
|
||||
* Each control narrows the next: changing the merchant clears the area and the
|
||||
* shop, and a shop is only ever offered as a shop of the selected merchant.
|
||||
* The filters flow into analyticsRepository as query parameters, so every
|
||||
* chart refetches under the new scope and none can show another merchant's
|
||||
* rows (docs/ADMIN-MONITORING-API.md §2.2 Ownership).
|
||||
*
|
||||
* ── What is real today ───────────────────────────────────────────────────
|
||||
* The merchant list and the scope cards (sums over `GET /api/admin/clients`),
|
||||
* and the date range. Every chart and table is built and wired; each renders
|
||||
* its "backend integration required" frame until its endpoint ships, then
|
||||
* fills in with no component change (config/capabilities.ts).
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
type Kind = 'footfall' | 'commerce';
|
||||
|
||||
const COPY: Record<Kind, {title: string; subtitle: string}> = {
|
||||
footfall: {
|
||||
title: 'Footfall',
|
||||
subtitle: 'Walk-ins by merchant, area and shop, day by day.',
|
||||
},
|
||||
commerce: {
|
||||
title: 'Commerce',
|
||||
subtitle: 'Sales and transactions by merchant and shop, day by day.',
|
||||
},
|
||||
};
|
||||
|
||||
type RangeKey = '7d' | '30d' | '90d';
|
||||
|
||||
const RANGES: {value: RangeKey; label: string; days: number}[] = [
|
||||
{value: '7d', label: 'Last 7 days', days: 7},
|
||||
{value: '30d', label: 'Last 30 days', days: 30},
|
||||
{value: '90d', label: 'Last 90 days', days: 90},
|
||||
];
|
||||
|
||||
const isoDay = (t: number) => new Date(t).toISOString().slice(0, 10);
|
||||
|
||||
function rangeFor(key: RangeKey, now: number): DateRange {
|
||||
const days = RANGES.find((r) => r.value === key)?.days ?? 30;
|
||||
return {from: isoDay(now - (days - 1) * 86_400_000), to: isoDay(now)};
|
||||
}
|
||||
|
||||
export function PlatformFootfall() {
|
||||
useAiContext({level: 'platform', label: 'Footfall'});
|
||||
return <ScopedArea kind="footfall" />;
|
||||
}
|
||||
|
||||
export function PlatformCommerce() {
|
||||
useAiContext({level: 'platform', label: 'Commerce'});
|
||||
return <ScopedArea kind="commerce" />;
|
||||
}
|
||||
|
||||
function ScopedArea({kind}: {kind: Kind}) {
|
||||
const companies = useCompanies();
|
||||
const {title, subtitle} = COPY[kind];
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader title={title} subtitle={subtitle} />
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonMetricGrid columns={3} count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchants yet"
|
||||
description={`${title} belongs to a merchant's shops. Create a merchant first.`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <ScopedBody merchants={rows} kind={kind} />}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopedBody({merchants, kind}: {merchants: Company[]; kind: Kind}) {
|
||||
const [merchantId, setMerchantId] = useState<string>(ALL);
|
||||
const [area, setArea] = useState<string | undefined>();
|
||||
const [shopId, setShopId] = useState<string | undefined>();
|
||||
const [rangeKey, setRangeKey] = useState<RangeKey>('30d');
|
||||
// "Today" is read once per mount — rendering stays pure, and the range does
|
||||
// not drift while the page is open.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
// A merchant that vanished from the list (deleted in another tab) falls
|
||||
// back to the platform scope rather than to a stale name.
|
||||
const merchant = merchants.find((m) => m.id === merchantId) ?? null;
|
||||
|
||||
const filters: AnalyticsFilters = useMemo(
|
||||
() => ({
|
||||
merchantId: merchant?.id,
|
||||
area,
|
||||
shopId,
|
||||
range: rangeFor(rangeKey, now),
|
||||
}),
|
||||
[merchant?.id, area, shopId, rangeKey, now],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<FilterBar
|
||||
kind={kind}
|
||||
merchants={merchants}
|
||||
merchant={merchant}
|
||||
onMerchantChange={(v) => {
|
||||
setMerchantId(v || ALL);
|
||||
// Area and shop belong to the previous merchant — never carried over.
|
||||
setArea(undefined);
|
||||
setShopId(undefined);
|
||||
}}
|
||||
shopId={shopId}
|
||||
onShopChange={setShopId}
|
||||
rangeKey={rangeKey}
|
||||
onRangeChange={setRangeKey}
|
||||
/>
|
||||
|
||||
<ScopeMetrics merchants={merchants} merchant={merchant} />
|
||||
|
||||
{kind === 'footfall' ? (
|
||||
<FootfallDashboard filters={filters} />
|
||||
) : (
|
||||
<CommerceDashboard filters={filters} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected merchant's shops, for the Shop filter — from the real
|
||||
* merchant-shops endpoint when the platform has it, nothing otherwise.
|
||||
*/
|
||||
function useShopOptions(merchantId: string | undefined) {
|
||||
const shops = useAdminData(
|
||||
merchantId ? storeRepository.listByCompany(merchantId) : null,
|
||||
);
|
||||
const options =
|
||||
shops.isAvailable && shops.resource.status === 'success'
|
||||
? shops.resource.data.map((s) => ({value: s.id, label: s.name}))
|
||||
: [];
|
||||
return {isAvailable: shops.isAvailable, options};
|
||||
}
|
||||
|
||||
const NEEDS_MERCHANT = 'Choose a merchant first.';
|
||||
|
||||
function FilterBar({
|
||||
kind,
|
||||
merchants,
|
||||
merchant,
|
||||
onMerchantChange,
|
||||
shopId,
|
||||
onShopChange,
|
||||
rangeKey,
|
||||
onRangeChange,
|
||||
}: {
|
||||
kind: Kind;
|
||||
merchants: Company[];
|
||||
merchant: Company | null;
|
||||
onMerchantChange: (v: string) => void;
|
||||
shopId: string | undefined;
|
||||
onShopChange: (v: string | undefined) => void;
|
||||
rangeKey: RangeKey;
|
||||
onRangeChange: (v: RangeKey) => void;
|
||||
}) {
|
||||
const shops = useShopOptions(merchant?.id);
|
||||
|
||||
const merchantOptions: SelectorOptionType[] = [
|
||||
{value: ALL, label: 'All merchants'},
|
||||
{type: 'divider'},
|
||||
...[...merchants]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.isActive ? m.name : `${m.name} (suspended)`,
|
||||
})),
|
||||
];
|
||||
|
||||
const shopDisabledReason = !merchant
|
||||
? NEEDS_MERCHANT
|
||||
: !shops.isAvailable
|
||||
? 'Backend integration required — the platform has no admin endpoint that lists a merchant’s shops.'
|
||||
: shops.options.length === 0
|
||||
? 'This merchant has no shops.'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<HStack gap={3} vAlign="end" className="flex-wrap gap-y-2">
|
||||
<Selector
|
||||
label="Merchant"
|
||||
options={merchantOptions}
|
||||
value={merchant ? merchant.id : ALL}
|
||||
onChange={onMerchantChange}
|
||||
hasSearch
|
||||
width={240}
|
||||
/>
|
||||
{kind === 'footfall' ? (
|
||||
<Selector
|
||||
label="Area / location"
|
||||
options={[]}
|
||||
placeholder="All areas"
|
||||
isDisabled
|
||||
disabledMessage={
|
||||
merchant
|
||||
? 'Backend integration required — shops carry no area or location field yet.'
|
||||
: NEEDS_MERCHANT
|
||||
}
|
||||
width={200}
|
||||
/>
|
||||
) : null}
|
||||
<Selector
|
||||
label="Shop"
|
||||
options={[{value: ALL, label: 'All shops'}, ...shops.options]}
|
||||
value={shopId ?? ALL}
|
||||
onChange={(v) => onShopChange(v && v !== ALL ? v : undefined)}
|
||||
placeholder="All shops"
|
||||
isDisabled={shopDisabledReason !== undefined}
|
||||
disabledMessage={shopDisabledReason}
|
||||
width={200}
|
||||
/>
|
||||
<Selector
|
||||
label="Date range"
|
||||
options={RANGES.map(({value, label}) => ({value, label}))}
|
||||
value={rangeKey}
|
||||
onChange={(v) => onRangeChange(v as RangeKey)}
|
||||
width={180}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
// Rounded: AnimatedNumber springs between values and hands this every
|
||||
// in-between frame — unrounded, 6 → 4 shops paints as "3.108".
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString();
|
||||
|
||||
/**
|
||||
* What the merchant list can say about the current scope — nothing more.
|
||||
* No footfall, sales or transaction number appears here: none is returned.
|
||||
*/
|
||||
function ScopeMetrics({
|
||||
merchants,
|
||||
merchant,
|
||||
}: {
|
||||
merchants: Company[];
|
||||
merchant: Company | null;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const caption = (t: string) => (
|
||||
<Text size="xsm" color="secondary">
|
||||
{t}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Keyed per scope: a different merchant is a different entity, not a value
|
||||
// that moved, so the cards remount and paint the new numbers directly rather
|
||||
// than springing through counts that belong to neither merchant.
|
||||
if (merchant) {
|
||||
return (
|
||||
<Grid key={merchant.id} columns={bp === 'mobile' ? 1 : 2} gap={4}>
|
||||
<MetricCard
|
||||
label="Shops"
|
||||
value={merchant.sites}
|
||||
format={fmt}
|
||||
icon={ICONS.stores}
|
||||
footer={caption(`Registered to ${merchant.name}`)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Merchant accounts"
|
||||
value={merchant.users}
|
||||
format={fmt}
|
||||
icon={ICONS.staff}
|
||||
footer={caption(merchant.isActive ? 'Active merchant' : 'Suspended merchant')}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const active = merchants.filter((m) => m.isActive);
|
||||
|
||||
return (
|
||||
<Grid key="all" columns={bp === 'mobile' ? 1 : 3} gap={4}>
|
||||
<MetricCard
|
||||
label="Active merchants"
|
||||
value={active.length}
|
||||
format={fmt}
|
||||
icon={ICONS.companies}
|
||||
footer={caption(`${fmt(merchants.length)} on the platform`)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Shops"
|
||||
value={active.reduce((n, m) => n + m.sites, 0)}
|
||||
format={fmt}
|
||||
icon={ICONS.stores}
|
||||
footer={caption('Registered to active merchants')}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Merchants without a shop"
|
||||
value={active.filter((m) => m.sites === 0).length}
|
||||
format={fmt}
|
||||
icon={ICONS.stores}
|
||||
footer={caption('Active, with nothing to report yet')}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +126,8 @@ export function ResetOwnerPasswordDialog({
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="owner@company.com"
|
||||
description="This company has more than one owner — name the one to reset."
|
||||
placeholder="owner@merchant.com"
|
||||
description="This merchant has more than one owner — name the one to reset."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
205
src/features/admin/components/StoreDetail.tsx
Normal file
205
src/features/admin/components/StoreDetail.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
useAlerts,
|
||||
useCompany,
|
||||
useEvents,
|
||||
useStore,
|
||||
useStoreCameras,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {AdminSection} from './common/AdminSection';
|
||||
import {CompanyNotFound} from './CompanyDetail';
|
||||
import {
|
||||
AlertTable,
|
||||
CameraTable,
|
||||
EventTable,
|
||||
adminHref,
|
||||
} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* One store of one company.
|
||||
*
|
||||
* ── The pair, never the store alone ──────────────────────────────────────
|
||||
* The route is /admin/merchants/{merchantId}/shops/{shopId}, and every
|
||||
* request this page makes nests the same way. The company is resolved first
|
||||
* from the real list — a store under a company that does not exist is a
|
||||
* not-found page, not a request. Whether the store really belongs to that
|
||||
* company is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §Ownership);
|
||||
* the URL is a claim, and this page never treats it as proof.
|
||||
*
|
||||
* Today no store-level endpoint exists, so the page is reachable (the route is
|
||||
* real and the breadcrumbs work) and every section says what is missing.
|
||||
*/
|
||||
export function StoreDetail({
|
||||
companyId,
|
||||
storeId,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
}) {
|
||||
const {resource, company} = useCompany(companyId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={<CompanyNotFound />}
|
||||
>
|
||||
{() =>
|
||||
company ? (
|
||||
<StoreBody company={company} storeId={storeId} />
|
||||
) : (
|
||||
<CompanyNotFound />
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreBody({company, storeId}: {company: Company; storeId: string}) {
|
||||
const bp = useBreakpoint();
|
||||
const store = useStore(company.id, storeId);
|
||||
const cameras = useStoreCameras(company.id, storeId);
|
||||
const events = useEvents(company.id, storeId);
|
||||
const alerts = useAlerts(company.id, storeId);
|
||||
|
||||
// The store's NAME only exists once the store endpoint answers. Until then
|
||||
// the page says "Store" rather than printing a raw id as if it were a name.
|
||||
const name =
|
||||
store.isAvailable && store.resource.status === 'success'
|
||||
? store.resource.data.name
|
||||
: 'Shop';
|
||||
|
||||
useAiContext({
|
||||
level: 'store',
|
||||
label: `${name} · ${company.name}`,
|
||||
companyId: company.id,
|
||||
storeId,
|
||||
});
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title={name}
|
||||
subtitle={`A shop of ${company.name}.`}
|
||||
back={{label: company.name, href: adminHref.company(company.id)}}
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: company.name, href: adminHref.company(company.id)},
|
||||
{label: name},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AdminSection
|
||||
title="Shop overview"
|
||||
data={store}
|
||||
pending={{
|
||||
icon: 'stores',
|
||||
title: 'Shop details are not available yet',
|
||||
description:
|
||||
'Status, location and camera counts for this shop need the platform admin shop-detail API.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(s) => (
|
||||
<List>
|
||||
<ListItem label="Timezone" endContent={<Text size="sm">{s.timezone ?? '—'}</Text>} />
|
||||
<ListItem
|
||||
label="Cameras"
|
||||
endContent={<Text size="sm">{s.cameras ?? '—'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Cameras online"
|
||||
endContent={<Text size="sm">{s.camerasOnline ?? '—'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Created"
|
||||
endContent={
|
||||
<Text size="sm">
|
||||
{s.createdAt ? new Date(s.createdAt).toLocaleDateString() : '—'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="Cameras"
|
||||
data={cameras}
|
||||
pending={{
|
||||
icon: 'camera',
|
||||
title: 'Camera data unavailable',
|
||||
description:
|
||||
'Connect the platform admin store-cameras API to list this shop’s cameras with their status and last-seen time.',
|
||||
}}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="camera"
|
||||
title="No cameras"
|
||||
description="No camera is registered to this shop."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<CameraTable companyId={company.id} storeId={storeId} cameras={rows} />
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<Grid columns={bp === 'desktop' || bp === 'ultrawide' ? 2 : 1} gap={4}>
|
||||
<AdminSection
|
||||
title="Active alerts"
|
||||
data={alerts}
|
||||
pending={{
|
||||
icon: 'notifications',
|
||||
title: 'No alerts available',
|
||||
description:
|
||||
'Alert monitoring is not currently available for Platform Admin.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="notifications"
|
||||
title="No open alerts"
|
||||
description="Nothing needs attention at this shop."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <AlertTable alerts={rows} />}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="Recent events"
|
||||
data={events}
|
||||
pending={{
|
||||
icon: 'events',
|
||||
title: 'No event data available',
|
||||
description: 'Platform Admin event API integration is required.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="events"
|
||||
title="No events"
|
||||
description="This shop has not reported any events yet."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <EventTable events={rows} />}
|
||||
</AdminSection>
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -53,8 +53,8 @@ export function SuspendCompanyDialog({
|
||||
setError(
|
||||
res.message ??
|
||||
(suspending
|
||||
? 'Could not suspend that company.'
|
||||
: 'Could not reinstate that company.'),
|
||||
? 'Could not suspend that merchant.'
|
||||
: 'Could not reinstate that merchant.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -69,11 +69,11 @@ export function SuspendCompanyDialog({
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose={suspending ? 'required' : 'info'}
|
||||
width={440}
|
||||
aria-label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
aria-label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={suspending ? 'Suspend company?' : 'Reinstate company?'}
|
||||
title={suspending ? 'Suspend merchant?' : 'Reinstate merchant?'}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function SuspendCompanyDialog({
|
||||
variant={suspending ? 'destructive' : 'primary'}
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
341
src/features/admin/components/analytics/AnalyticsPanel.tsx
Normal file
341
src/features/admin/components/analytics/AnalyticsPanel.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Pagination} from '@astryxdesign/core/Pagination';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {
|
||||
SkeletonChart,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {CHART, dashFor, defaultEncoding} from '@/shared/components/charts/palette';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
|
||||
/**
|
||||
* The analytics layer's frame. Every chart and table on Footfall and Commerce
|
||||
* goes through AnalyticsPanel, which is what keeps the four states distinct:
|
||||
*
|
||||
* capability off → ChartEmpty "pending": the chart's own frame, its icon,
|
||||
* and "Backend integration required". No request is sent,
|
||||
* so this is never shown while something is loading.
|
||||
* loading → a skeleton at the chart's exact height (no layout jump)
|
||||
* request failed → AsyncBoundary's error banner ("Couldn't load this" + the
|
||||
* server's message) — an outage, not a missing feature
|
||||
* loaded, empty → ChartEmpty "empty": "No data for the selected filters"
|
||||
* loaded → children(data)
|
||||
*
|
||||
* Nothing in this file produces a value. A chart with no data draws no bars.
|
||||
*/
|
||||
|
||||
export function AnalyticsPanel<T>({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
data,
|
||||
pendingTitle,
|
||||
height = 260,
|
||||
variant = 'chart',
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: IconKey;
|
||||
data: AdminData<T>;
|
||||
/** e.g. "No area footfall data yet". */
|
||||
pendingTitle: string;
|
||||
height?: number;
|
||||
variant?: 'chart' | 'table';
|
||||
actions?: React.ReactNode;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={5} className="h-full">
|
||||
<VStack gap={4} width="100%">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
data.isAvailable ? actions : (
|
||||
<Token size="sm" label="Backend integration required" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{data.isAvailable ? (
|
||||
<AsyncBoundary
|
||||
resource={data.resource}
|
||||
loading={
|
||||
variant === 'table' ? (
|
||||
<SkeletonRows count={5} />
|
||||
) : (
|
||||
<SkeletonChart height={height} />
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<ChartEmpty
|
||||
icon={icon}
|
||||
height={height}
|
||||
title="No data for the selected filters"
|
||||
description="Try a wider date range, or a different merchant, area or shop."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<ChartEmpty
|
||||
icon={icon}
|
||||
height={height}
|
||||
title={pendingTitle}
|
||||
description="The chart is ready. It fills in as soon as the platform exposes this admin endpoint."
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart's footprint, held open. A dashed frame at the plot's own height
|
||||
* says "a chart lives here" without drawing a single fake mark.
|
||||
*/
|
||||
export function ChartEmpty({
|
||||
icon,
|
||||
height,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
height: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack
|
||||
width="100%"
|
||||
minHeight={height}
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="rounded-lg border border-dashed border-border bg-muted"
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
|
||||
title={title}
|
||||
description={description}
|
||||
isCompact
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Series identity for charts with 2+ lines. Each swatch is the series' actual
|
||||
* stroke — same grey, same dash — so identity is never colour alone.
|
||||
*/
|
||||
export function ChartLegend({labels}: {labels: string[]}) {
|
||||
return (
|
||||
<HStack gap={4} vAlign="center" className="flex-wrap gap-y-1">
|
||||
{labels.map((label, i) => (
|
||||
<HStack key={label} gap={1.5} vAlign="center">
|
||||
<svg width="20" height="8" aria-hidden="true">
|
||||
<line
|
||||
x1="0"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="4"
|
||||
stroke={CHART.seriesAt(i)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={dashFor(defaultEncoding(i))}
|
||||
/>
|
||||
</svg>
|
||||
<Text size="xsm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Overview stat tiles ──────────────────────────────────────────────── */
|
||||
|
||||
export interface Stat {
|
||||
label: string;
|
||||
icon: IconKey;
|
||||
/** `undefined` renders "—" with "Data unavailable" — never a zero. */
|
||||
value?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A KPI row whose values can be absent. When the endpoint is off every tile
|
||||
* shows "—" / "Data unavailable": the row keeps its shape, and a zero never
|
||||
* stands in for "not measured".
|
||||
*/
|
||||
export function StatPanel<T>({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
toStats,
|
||||
placeholders,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
data: AdminData<T>;
|
||||
toStats: (d: T) => Stat[];
|
||||
/** The same tiles with no values — shown while the endpoint is off. */
|
||||
placeholders: Stat[];
|
||||
}) {
|
||||
return (
|
||||
<Card padding={5}>
|
||||
<VStack gap={4} width="100%">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
data.isAvailable ? undefined : (
|
||||
<Token size="sm" label="Backend integration required" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{data.isAvailable ? (
|
||||
<AsyncBoundary
|
||||
resource={data.resource}
|
||||
loading={<StatGrid stats={placeholders} isLoading />}
|
||||
>
|
||||
{(d) => <StatGrid stats={toStats(d)} />}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<StatGrid stats={placeholders} />
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGrid({stats, isLoading}: {stats: Stat[]; isLoading?: boolean}) {
|
||||
const bp = useBreakpoint();
|
||||
const columns =
|
||||
bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : Math.min(stats.length, 5);
|
||||
return (
|
||||
<Grid columns={columns} gap={3}>
|
||||
{stats.map((s) => (
|
||||
<VStack key={s.label} gap={2} className="rounded-lg bg-muted p-4 min-w-0">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="xsm" color="secondary" weight="medium" className="truncate">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Icon icon={ICONS[s.icon]} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<Text size="xl" weight="semibold" className="tracking-tight">
|
||||
{isLoading ? '…' : (s.value ?? '—')}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{isLoading
|
||||
? 'Loading'
|
||||
: s.value === undefined
|
||||
? 'Data unavailable'
|
||||
: (s.caption ?? '')}
|
||||
</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Details table ────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SortOption<R> {
|
||||
value: string;
|
||||
label: string;
|
||||
compare: (a: R, b: R) => number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Sorted and paged on the client, over exactly the rows the API returned.
|
||||
* Columns whose field is absent from every row are dropped by the caller —
|
||||
* a table never shows an "Entries" column the platform does not send.
|
||||
*/
|
||||
export function DetailsTable<R extends Record<string, unknown>>({
|
||||
rows,
|
||||
columns,
|
||||
idKey,
|
||||
primaryKey,
|
||||
sorts,
|
||||
}: {
|
||||
rows: R[];
|
||||
columns: TableColumn<R>[];
|
||||
idKey: keyof R & string;
|
||||
primaryKey: string;
|
||||
sorts: SortOption<R>[];
|
||||
}) {
|
||||
const [sort, setSort] = useState(sorts[0].value);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const cmp = sorts.find((s) => s.value === sort)?.compare;
|
||||
return cmp ? [...rows].sort(cmp) : rows;
|
||||
}, [rows, sorts, sort]);
|
||||
|
||||
const pageRows = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" className="flex-wrap gap-y-2">
|
||||
<Text size="sm" color="secondary">
|
||||
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
|
||||
</Text>
|
||||
<Selector
|
||||
label="Sort rows"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
options={sorts.map(({value, label}) => ({value, label}))}
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
setSort(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<ResponsiveTable
|
||||
data={pageRows}
|
||||
columns={columns}
|
||||
idKey={idKey}
|
||||
primaryKey={primaryKey}
|
||||
density="balanced"
|
||||
/>
|
||||
{rows.length > PAGE_SIZE ? (
|
||||
<HStack hAlign="end">
|
||||
<Pagination
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
totalItems={rows.length}
|
||||
pageSize={PAGE_SIZE}
|
||||
variant="count"
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when at least one row carries the field — the column earns its place. */
|
||||
export function hasField<R>(rows: R[], key: keyof R): boolean {
|
||||
return rows.some((r) => r[key] !== undefined && r[key] !== null);
|
||||
}
|
||||
153
src/features/admin/components/analytics/CommerceDashboard.tsx
Normal file
153
src/features/admin/components/analytics/CommerceDashboard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
formatCount,
|
||||
formatDayLabel,
|
||||
formatInr,
|
||||
formatInrCompact,
|
||||
} from '@/shared/utils/format';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AnalyticsFilters, CommerceDetail} from '@/features/admin/types/analytics';
|
||||
import {useCommerceAnalytics} from '@/features/admin/hooks/useAnalytics';
|
||||
import {
|
||||
AnalyticsPanel,
|
||||
DetailsTable,
|
||||
StatPanel,
|
||||
hasField,
|
||||
type SortOption,
|
||||
} from './AnalyticsPanel';
|
||||
|
||||
/**
|
||||
* Commerce: overview → merchant comparison + sales trend → transactions.
|
||||
*
|
||||
* One measure per chart. Sales (₹) and transactions (a count) never share an
|
||||
* axis: the trend plots sales, and the transaction count lives in the overview
|
||||
* and the table rather than as a second y-scale.
|
||||
*/
|
||||
export function CommerceDashboard({filters}: {filters: AnalyticsFilters}) {
|
||||
const bp = useBreakpoint();
|
||||
const data = useCommerceAnalytics(filters);
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<StatPanel
|
||||
title="Commerce overview"
|
||||
subtitle="Sales for the selected merchant, shop and period."
|
||||
data={data.overview}
|
||||
placeholders={OVERVIEW_TILES}
|
||||
toStats={(o) => [
|
||||
{...OVERVIEW_TILES[0], value: formatInrCompact(o.salesInr), caption: formatInr(o.salesInr)},
|
||||
{...OVERVIEW_TILES[1], value: formatCount(o.transactions), caption: 'Recorded sales'},
|
||||
{...OVERVIEW_TILES[2], value: formatInr(o.averageTransactionInr), caption: 'Per transaction'},
|
||||
{...OVERVIEW_TILES[3], value: formatCount(o.activeMerchants), caption: 'With sales in the period'},
|
||||
{...OVERVIEW_TILES[4], value: formatCount(o.activeShops), caption: 'With sales in the period'},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Sales by merchant"
|
||||
subtitle="Compare merchants for the selected period."
|
||||
icon="analytics"
|
||||
data={data.byMerchant}
|
||||
pendingTitle="No merchant sales data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.salesInr - a.salesInr)}
|
||||
xKey="merchantName"
|
||||
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
|
||||
yFormat={formatInrCompact}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Sales over time"
|
||||
subtitle="Daily sales across the selected range."
|
||||
icon="revenue"
|
||||
data={data.daily}
|
||||
pendingTitle="No daily sales data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<LineChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Transactions"
|
||||
subtitle="Sales by shop and day, with payment details where the platform records them."
|
||||
icon="purchases"
|
||||
data={data.details}
|
||||
pendingTitle="No transaction rows yet"
|
||||
variant="table"
|
||||
height={220}
|
||||
>
|
||||
{(rows) => <CommerceTable rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const OVERVIEW_TILES = [
|
||||
{label: 'Total sales', icon: 'revenue'},
|
||||
{label: 'Transactions', icon: 'purchases'},
|
||||
{label: 'Avg transaction', icon: 'conversion'},
|
||||
{label: 'Active merchants', icon: 'companies'},
|
||||
{label: 'Active shops', icon: 'stores'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
type CommerceRow = CommerceDetail & Record<string, unknown> & {rowId: string};
|
||||
|
||||
const COMMERCE_SORTS: SortOption<CommerceRow>[] = [
|
||||
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
|
||||
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
|
||||
{value: 'sales-desc', label: 'Highest sales', compare: (a, b) => b.salesInr - a.salesInr},
|
||||
{value: 'merchant', label: 'Merchant A–Z', compare: (a, b) => a.merchantName.localeCompare(b.merchantName)},
|
||||
];
|
||||
|
||||
function CommerceTable({rows}: {rows: CommerceDetail[]}) {
|
||||
const data: CommerceRow[] = rows.map((r) => ({
|
||||
...r,
|
||||
rowId: `${r.date}|${r.shopId}|${r.paymentMethod ?? ''}|${r.category ?? ''}`,
|
||||
}));
|
||||
|
||||
const columns: TableColumn<CommerceRow>[] = [
|
||||
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
|
||||
{key: 'merchantName', header: 'Merchant'},
|
||||
{key: 'shopName', header: 'Shop'},
|
||||
{key: 'salesInr', header: 'Sales', align: 'end', renderCell: (r) => <Text size="sm">{formatInr(r.salesInr)}</Text>},
|
||||
{key: 'transactions', header: 'Transactions', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.transactions)}</Text>},
|
||||
...(hasField(rows, 'paymentMethod')
|
||||
? [{key: 'paymentMethod', header: 'Payment'} as TableColumn<CommerceRow>]
|
||||
: []),
|
||||
...(hasField(rows, 'category')
|
||||
? [{key: 'category', header: 'Category'} as TableColumn<CommerceRow>]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<DetailsTable
|
||||
rows={data}
|
||||
columns={columns}
|
||||
idKey="rowId"
|
||||
primaryKey="shopName"
|
||||
sorts={COMMERCE_SORTS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
215
src/features/admin/components/analytics/FootfallDashboard.tsx
Normal file
215
src/features/admin/components/analytics/FootfallDashboard.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {formatCount, formatDayLabel} from '@/shared/utils/format';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AnalyticsFilters, AreaDateFootfall, FootfallDetail} from '@/features/admin/types/analytics';
|
||||
import {useFootfallAnalytics} from '@/features/admin/hooks/useAnalytics';
|
||||
import {
|
||||
AnalyticsPanel,
|
||||
ChartLegend,
|
||||
DetailsTable,
|
||||
StatPanel,
|
||||
hasField,
|
||||
type SortOption,
|
||||
} from './AnalyticsPanel';
|
||||
|
||||
/**
|
||||
* Footfall: overview → area comparison + daily trend → area × date → details.
|
||||
*
|
||||
* Every panel reads its own endpoint through footfallRepository with the same
|
||||
* filters, so a merchant / area / shop / range change refetches all of them
|
||||
* and none can show another scope's rows.
|
||||
*/
|
||||
export function FootfallDashboard({filters}: {filters: AnalyticsFilters}) {
|
||||
const bp = useBreakpoint();
|
||||
const data = useFootfallAnalytics(filters);
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<StatPanel
|
||||
title="Footfall overview"
|
||||
subtitle="Walk-ins for the selected merchant, area, shop and period."
|
||||
data={data.overview}
|
||||
placeholders={OVERVIEW_TILES}
|
||||
toStats={(o) => [
|
||||
{...OVERVIEW_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins in the period'},
|
||||
{...OVERVIEW_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
|
||||
{
|
||||
...OVERVIEW_TILES[2],
|
||||
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
|
||||
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
|
||||
},
|
||||
{...OVERVIEW_TILES[3], value: formatCount(o.activeLocations), caption: 'Areas with walk-ins'},
|
||||
{...OVERVIEW_TILES[4], value: formatCount(o.reportingShops), caption: 'Shops that sent data'},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Footfall by area"
|
||||
subtitle="Compare footfall across locations for the selected period."
|
||||
icon="analytics"
|
||||
data={data.byArea}
|
||||
pendingTitle="No area footfall data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.footfall - a.footfall)}
|
||||
xKey="area"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Daily footfall trend"
|
||||
subtitle="Walk-ins per day across the selected range."
|
||||
icon="visitors"
|
||||
data={data.daily}
|
||||
pendingTitle="No daily footfall data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<AreaChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Area × date"
|
||||
subtitle="Every area across the same dates. The four busiest areas are drawn; the rest fold into Other."
|
||||
icon="compare"
|
||||
data={data.areaDate}
|
||||
pendingTitle="No area trend data yet"
|
||||
height={300}
|
||||
>
|
||||
{(rows) => <AreaDateChart rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Footfall details"
|
||||
subtitle="One row per shop per day, as the platform reports it."
|
||||
icon="events"
|
||||
data={data.details}
|
||||
pendingTitle="No footfall rows yet"
|
||||
variant="table"
|
||||
height={220}
|
||||
>
|
||||
{(rows) => <FootfallTable rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const OVERVIEW_TILES = [
|
||||
{label: 'Total footfall', icon: 'visitors'},
|
||||
{label: 'Average daily', icon: 'analytics'},
|
||||
{label: 'Peak day', icon: 'up'},
|
||||
{label: 'Active locations', icon: 'stores'},
|
||||
{label: 'Reporting shops', icon: 'present'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
/* ── Area × date ──────────────────────────────────────────────────────── */
|
||||
|
||||
const MAX_SERIES = 4;
|
||||
const OTHER = 'Other';
|
||||
|
||||
/**
|
||||
* Pivots the long rows into one object per date with a key per area. Series
|
||||
* follow the AREA, ranked by total — never cycled: past four, the rest sum
|
||||
* into "Other" rather than reusing a grey another area already owns.
|
||||
*/
|
||||
function pivot(rows: AreaDateFootfall[]) {
|
||||
const totals = new Map<string, number>();
|
||||
for (const r of rows) totals.set(r.area, (totals.get(r.area) ?? 0) + r.footfall);
|
||||
const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([a]) => a);
|
||||
const shown = ranked.length > MAX_SERIES + 1 ? ranked.slice(0, MAX_SERIES) : ranked;
|
||||
const folds = ranked.length > shown.length;
|
||||
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const r of rows) {
|
||||
const row = byDate.get(r.date) ?? {date: r.date};
|
||||
const key = shown.includes(r.area) ? r.area : OTHER;
|
||||
row[key] = ((row[key] as number | undefined) ?? 0) + r.footfall;
|
||||
byDate.set(r.date, row);
|
||||
}
|
||||
const data = [...byDate.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date)),
|
||||
);
|
||||
return {data, series: folds ? [...shown, OTHER] : shown};
|
||||
}
|
||||
|
||||
function AreaDateChart({rows}: {rows: AreaDateFootfall[]}) {
|
||||
const {data, series} = useMemo(() => pivot(rows), [rows]);
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<ChartLegend labels={series} />
|
||||
<LineChartView
|
||||
data={data}
|
||||
xKey="date"
|
||||
series={series.map((s) => ({key: s, label: s, format: formatCount}))}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
height={300}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Details table ────────────────────────────────────────────────────── */
|
||||
|
||||
type FootfallRow = FootfallDetail & Record<string, unknown> & {rowId: string};
|
||||
|
||||
const FOOTFALL_SORTS: SortOption<FootfallRow>[] = [
|
||||
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
|
||||
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
|
||||
{value: 'footfall-desc', label: 'Most footfall', compare: (a, b) => b.footfall - a.footfall},
|
||||
{value: 'shop', label: 'Shop A–Z', compare: (a, b) => a.shopName.localeCompare(b.shopName)},
|
||||
];
|
||||
|
||||
function FootfallTable({rows}: {rows: FootfallDetail[]}) {
|
||||
const data: FootfallRow[] = rows.map((r) => ({
|
||||
...r,
|
||||
rowId: `${r.date}|${r.shopId}`,
|
||||
}));
|
||||
|
||||
const columns: TableColumn<FootfallRow>[] = [
|
||||
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
|
||||
{key: 'merchantName', header: 'Merchant'},
|
||||
...(hasField(rows, 'area') ? [{key: 'area', header: 'Area'} as TableColumn<FootfallRow>] : []),
|
||||
{key: 'shopName', header: 'Shop'},
|
||||
{key: 'footfall', header: 'Footfall', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.footfall)}</Text>},
|
||||
...(hasField(rows, 'entries')
|
||||
? [{key: 'entries', header: 'Entries', align: 'end'} as TableColumn<FootfallRow>]
|
||||
: []),
|
||||
...(hasField(rows, 'exits')
|
||||
? [{key: 'exits', header: 'Exits', align: 'end'} as TableColumn<FootfallRow>]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<DetailsTable
|
||||
rows={data}
|
||||
columns={columns}
|
||||
idKey="rowId"
|
||||
primaryKey="shopName"
|
||||
sorts={FOOTFALL_SORTS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
106
src/features/admin/components/common/AdminPageHeader.tsx
Normal file
106
src/features/admin/components/common/AdminPageHeader.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Breadcrumbs, BreadcrumbItem } from "@astryxdesign/core/Breadcrumbs";
|
||||
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||
import { Text } from "@astryxdesign/core/Text";
|
||||
import { Button } from "@astryxdesign/core/Button";
|
||||
import { Icon } from "@astryxdesign/core/Icon";
|
||||
|
||||
export interface Crumb {
|
||||
label: string;
|
||||
/** Omit on the last crumb — it is the current page. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the operator is, what this page is, and what they can do here.
|
||||
*
|
||||
* Breadcrumbs appear only when there is a parent: a top-level page with a
|
||||
* one-item trail is clutter. Four levels deep (company / store / camera) the
|
||||
* trail is the only thing telling an operator which tenant they are looking
|
||||
* at, which on a console that can suspend it is not a nicety.
|
||||
*/
|
||||
export function AdminPageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
back,
|
||||
crumbs,
|
||||
meta,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** One level up. A drill-down page needs a way back that is not the
|
||||
* browser's — the operator may have arrived from a link. */
|
||||
back?: { label: string; href: string };
|
||||
crumbs?: Crumb[];
|
||||
/** A row under the title — status, slug, dates. */
|
||||
meta?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
{back || (crumbs && crumbs.length > 0) ? (
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
|
||||
{back ? <BackButton {...back} /> : null}
|
||||
{crumbs && crumbs.length > 0 ? (
|
||||
<Breadcrumbs variant="supporting">
|
||||
{crumbs.map((c, i) =>
|
||||
i === crumbs.length - 1 ? (
|
||||
<BreadcrumbItem key={i} isCurrent>
|
||||
{c.label}
|
||||
</BreadcrumbItem>
|
||||
) : (
|
||||
<BreadcrumbItem key={i} href={c.href}>
|
||||
{c.label}
|
||||
</BreadcrumbItem>
|
||||
),
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack
|
||||
gap={4}
|
||||
vAlign="start"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-3"
|
||||
>
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text type="display-3" weight="medium" className="break-words">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{meta ? (
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
|
||||
{meta}
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
{actions ? (
|
||||
<HStack gap={2} vAlign="center">
|
||||
{actions}
|
||||
</HStack>
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackButton({ label, href }: { label: string; href: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href={href}
|
||||
label={label}
|
||||
icon={<Icon icon="chevronLeft" size="sm" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
src/features/admin/components/common/AdminSection.tsx
Normal file
59
src/features/admin/components/common/AdminSection.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import {PendingIntegration} from './PendingIntegration';
|
||||
|
||||
/**
|
||||
* A panel over one level of the drill-down, in whichever of its three states
|
||||
* it is in:
|
||||
*
|
||||
* unavailable no admin endpoint → PendingIntegration, no request sent
|
||||
* loading / error / empty → AsyncBoundary, as everywhere else
|
||||
* loaded → `children(data)`
|
||||
*
|
||||
* This is what makes "turn the level on" a flag flip: the section already
|
||||
* renders the loaded branch; only the hook's answer changes.
|
||||
*/
|
||||
export function AdminSection<T>({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
data,
|
||||
pending,
|
||||
loading,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
data: AdminData<T>;
|
||||
pending: {icon: IconKey; title: string; description: string};
|
||||
loading: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={data.isAvailable ? actions : undefined}
|
||||
/>
|
||||
{data.isAvailable ? (
|
||||
<AsyncBoundary resource={data.resource} loading={loading} empty={empty}>
|
||||
{children}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<PendingIntegration {...pending} />
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
41
src/features/admin/components/common/PendingIntegration.tsx
Normal file
41
src/features/admin/components/common/PendingIntegration.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* "The platform cannot answer this yet" — the one treatment for every level
|
||||
* of the drill-down that has no admin endpoint behind it.
|
||||
*
|
||||
* Calm, not alarming: no red, no Banner. Nothing is broken; a capability is
|
||||
* missing, and the token says exactly which kind of missing it is so nobody
|
||||
* mistakes it for "this company has no stores". That distinction is the whole
|
||||
* point — an empty list and an unanswerable question look identical if you
|
||||
* render them the same way, and an operator would act on the wrong one.
|
||||
*
|
||||
* Unlike the merchant console's FeatureUnavailable, this names the gap in
|
||||
* engineering terms. The audience here is the platform team, who are the
|
||||
* people who can close it.
|
||||
*/
|
||||
export function PendingIntegration({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
|
||||
title={title}
|
||||
description={description}
|
||||
actions={<Token label="Backend integration required" size="sm" />}
|
||||
isCompact
|
||||
/>
|
||||
);
|
||||
}
|
||||
274
src/features/admin/components/shell/AdminShell.tsx
Normal file
274
src/features/admin/components/shell/AdminShell.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {usePathname, useRouter} from 'next/navigation';
|
||||
import {AppShell} from '@astryxdesign/core/AppShell';
|
||||
import {
|
||||
Layout,
|
||||
LayoutContent,
|
||||
LayoutPanel,
|
||||
HStack,
|
||||
VStack,
|
||||
} from '@astryxdesign/core/Layout';
|
||||
import {TopNav, TopNavItem} from '@astryxdesign/core/TopNav';
|
||||
import {MobileNav} from '@astryxdesign/core/MobileNav';
|
||||
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {BrandLogo, BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {NavMenuButton} from '@/shared/layouts/workspace/NavMenuButton';
|
||||
import {
|
||||
NAV_DRAWER_ID,
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} from '@/shared/layouts/workspace/SidebarProvider';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {LoyalyAiPanel} from '@/features/loyaly-ai/components/LoyalyAiPanel';
|
||||
import {LoyalyAiSlideOver} from '@/features/loyaly-ai/components/LoyalyAiSlideOver';
|
||||
import {LoyalyAiToggle} from '@/features/loyaly-ai/components/LoyalyAiToggle';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {
|
||||
useBreakpoint,
|
||||
isPanelInline,
|
||||
assistantWidth,
|
||||
contentMaxWidth,
|
||||
} from '@/shared/hooks/useBreakpoint';
|
||||
import {ADMIN_NAV, isAdminNavActive} from './admin-nav';
|
||||
|
||||
/**
|
||||
* The platform console's frame: one top bar, content, Loyaly AI.
|
||||
*
|
||||
* No sidebar. The console has three destinations, which fit a horizontal bar
|
||||
* and leave the full width to the tables — the thing an operator is actually
|
||||
* scanning. Loyaly AI keeps its column on the right, as in the workspace, via
|
||||
* a nested Layout's `end` panel (AppShell has no slot for it).
|
||||
*
|
||||
* Responsive behaviour, by breakpoint:
|
||||
* mobile tabs → drawer behind the menu button (TopNav's mobile-bar mode
|
||||
* drops `startContent`, so the tabs cannot simply shrink),
|
||||
* Loyaly AI → full-height sheet
|
||||
* tablet tabs inline, Loyaly AI → slide-over
|
||||
* laptop+ tabs inline, Loyaly AI inline panel
|
||||
*
|
||||
* SidebarProvider is kept only for the drawer's open state — the same state
|
||||
* NavMenuButton drives — so the mobile menu behaves exactly like the
|
||||
* workspace's.
|
||||
*/
|
||||
export function AdminShell({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<ShellFrame>{children}</ShellFrame>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellFrame({children}: {children: React.ReactNode}) {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen, setIsOpen, panelMode, panelWidth} = useLoyalyAi();
|
||||
const {isDrawerOpen, setDrawerOpen} = useSidebar();
|
||||
|
||||
const inline = isPanelInline(bp);
|
||||
const showInlinePanel = inline && isOpen;
|
||||
const isFullscreen = showInlinePanel && panelMode === 'fullscreen';
|
||||
|
||||
let width: number | string = assistantWidth(bp);
|
||||
if (showInlinePanel) {
|
||||
if (isFullscreen) width = '100%';
|
||||
else if (panelMode === 'expanded') width = Math.max(panelWidth, 800);
|
||||
else width = panelWidth;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
// `wash`, not `elevated`: elevated paints the content column white
|
||||
// (--color-background-surface), which hid the ambient canvas everywhere
|
||||
// except the top bar. Wash gives the bar and content the same ground, so
|
||||
// the gradient in globals.css runs across the full page — as loyaly-staff.
|
||||
variant="wash"
|
||||
height="fill"
|
||||
contentPadding={0}
|
||||
topNav={<AdminTopNav />}
|
||||
mobileNav={{
|
||||
breakpoint: 'sm',
|
||||
isOpen: isDrawerOpen,
|
||||
onOpenChange: setDrawerOpen,
|
||||
hasToggle: false,
|
||||
content: <AdminMobileMenu />,
|
||||
}}
|
||||
>
|
||||
<Layout
|
||||
height="fill"
|
||||
content={
|
||||
!isFullscreen ? (
|
||||
<LayoutContent padding={5}>
|
||||
<VStack
|
||||
width="100%"
|
||||
maxWidth={contentMaxWidth(bp)}
|
||||
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
|
||||
>
|
||||
{children}
|
||||
</VStack>
|
||||
</LayoutContent>
|
||||
) : undefined
|
||||
}
|
||||
end={
|
||||
showInlinePanel ? (
|
||||
<LayoutPanel
|
||||
hasDivider
|
||||
width={width as number}
|
||||
padding={0}
|
||||
role="complementary"
|
||||
label="Loyaly AI"
|
||||
className="transition-[width] duration-300 ease-out"
|
||||
>
|
||||
<LoyalyAiPanel onClose={() => setIsOpen(false)} />
|
||||
</LayoutPanel>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{!inline ? <LoyalyAiSlideOver /> : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected tab reads as an underline, not a filled pill: a 2px bar in the
|
||||
* primary text colour along the bottom edge. Token-backed utilities only —
|
||||
* `border-b-2` is the spacing scale's 2px and `border-primary` the text token,
|
||||
* so the bar stays monochrome in both themes.
|
||||
*/
|
||||
const TAB = 'rounded-none border-b-2 border-transparent';
|
||||
const TAB_SELECTED = 'rounded-none border-b-2 border-primary bg-transparent';
|
||||
|
||||
function AdminTopNav() {
|
||||
const pathname = usePathname();
|
||||
const {isDrawer} = useSidebar();
|
||||
|
||||
return (
|
||||
<TopNav
|
||||
label="Platform admin"
|
||||
heading={
|
||||
<HStack gap={3} vAlign="center">
|
||||
{isDrawer ? <NavMenuButton /> : null}
|
||||
<Link href="/admin">
|
||||
{isDrawer ? <BrandMark size={26} /> : <BrandLogo height={26} />}
|
||||
</Link>
|
||||
{/* Said loudly on purpose: this console can suspend and delete a
|
||||
company, and somebody with both consoles open needs to know
|
||||
which tab they are in before they click. */}
|
||||
<Token label="Admin" size="sm" />
|
||||
</HStack>
|
||||
}
|
||||
startContent={
|
||||
<HStack gap={1} vAlign="center" className="self-stretch">
|
||||
{ADMIN_NAV.map((item) => {
|
||||
const selected = isAdminNavActive(pathname, item.href);
|
||||
return (
|
||||
<TopNavItem
|
||||
key={item.href}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
isSelected={selected}
|
||||
className={selected ? TAB_SELECTED : TAB}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
}
|
||||
endContent={
|
||||
<HStack gap={2} vAlign="center">
|
||||
<LoyalyAiToggle />
|
||||
<AccountMenu />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is signed in, and the way out.
|
||||
*
|
||||
* Not the workspace AccountMenu: every row in that one routes into a tenant's
|
||||
* settings, and this account has no tenant. The EMAIL heads the menu rather
|
||||
* than the display name — on a console that can delete a company, which
|
||||
* account is signed in matters more than whose it is.
|
||||
*/
|
||||
function AccountMenu() {
|
||||
const {user, logout} = useSession();
|
||||
const router = useRouter();
|
||||
const who = user?.email ?? 'Platform admin';
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'ghost',
|
||||
size: 'md',
|
||||
label: `Account: ${who}`,
|
||||
isIconOnly: true,
|
||||
icon: <Avatar name={user?.name || who} size="sm" tooltip={false} />,
|
||||
}}
|
||||
hasChevron={false}
|
||||
menuWidth={260}
|
||||
items={[
|
||||
{
|
||||
type: 'section',
|
||||
title: who,
|
||||
items: [{label: 'Platform admin', isDisabled: true}],
|
||||
},
|
||||
{type: 'divider'},
|
||||
{label: 'Profile', onClick: () => router.push('/admin/profile')},
|
||||
{label: 'Settings', onClick: () => router.push('/admin/settings')},
|
||||
{type: 'divider'},
|
||||
{label: 'Sign out', onClick: () => void signOut()},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tabs again, as a drawer, for the width where TopNav drops them. */
|
||||
const DRAWER = [
|
||||
'[&>div]:w-4/5',
|
||||
'[&>div>*:first-child>button]:size-11',
|
||||
'[&_.astryx-side-nav-item]:h-12',
|
||||
].join(' ');
|
||||
|
||||
function AdminMobileMenu() {
|
||||
const pathname = usePathname();
|
||||
const {isDrawerOpen, setDrawerOpen, closeDrawer} = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
closeDrawer();
|
||||
}, [pathname, closeDrawer]);
|
||||
|
||||
return (
|
||||
<MobileNav
|
||||
id={NAV_DRAWER_ID}
|
||||
label="Navigation"
|
||||
side="start"
|
||||
width={320}
|
||||
isOpen={isDrawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
className={DRAWER}
|
||||
header={<BrandLogo height={26} />}
|
||||
>
|
||||
<SideNavSection title="Platform admin" isHeaderHidden>
|
||||
{ADMIN_NAV.map((item) => (
|
||||
<SideNavItem
|
||||
key={item.href}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
isSelected={isAdminNavActive(pathname, item.href)}
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
</MobileNav>
|
||||
);
|
||||
}
|
||||
36
src/features/admin/components/shell/admin-nav.ts
Normal file
36
src/features/admin/components/shell/admin-nav.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The platform console's top navigation.
|
||||
*
|
||||
* Platform-level destinations only. Everything that belongs to one merchant —
|
||||
* shops, sales persons, cameras, customers, sales, analytics, device logs —
|
||||
* lives inside that merchant (merchants → merchant → shop → camera), never
|
||||
* here, so no global tab can mix two merchants' data.
|
||||
*
|
||||
* Footfall and Commerce are platform views that still resolve Merchant → Shop
|
||||
* before they ask for anything; see PlatformPages.
|
||||
*
|
||||
* Add a tab here, with its route, when the platform can answer it — TopNav
|
||||
* reads best at five or fewer.
|
||||
*/
|
||||
export interface AdminNavEntry {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const ADMIN_NAV: AdminNavEntry[] = [
|
||||
{label: 'Overview', href: '/admin'},
|
||||
{label: 'Merchants', href: '/admin/merchants'},
|
||||
{label: 'Footfall', href: '/admin/footfall'},
|
||||
{label: 'Commerce', href: '/admin/commerce'},
|
||||
];
|
||||
|
||||
/**
|
||||
* `/admin` is the overview and a prefix of every other entry, so it matches
|
||||
* exactly; everything else lights up for its own detail pages too — a shop
|
||||
* page under /admin/merchants/… keeps "Merchants" selected, because that is
|
||||
* the path the operator took to get there.
|
||||
*/
|
||||
export function isAdminNavActive(pathname: string, href: string): boolean {
|
||||
if (href === '/admin') return pathname === '/admin';
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
108
src/features/admin/config/capabilities.ts
Normal file
108
src/features/admin/config/capabilities.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* Which levels of the platform drill-down the backend can answer today.
|
||||
*
|
||||
* The console is built for the whole hierarchy —
|
||||
*
|
||||
* companies → company → stores → store → cameras → camera → events / alerts
|
||||
*
|
||||
* — but the platform's admin surface (`/api/admin/*`) currently stops at the
|
||||
* first rung. Every tenant endpoint below it (`/api/sites`, cameras, visits)
|
||||
* takes its company from the signed-in account, and a platform operator's
|
||||
* account has none. There is no way to ask "Company B's stores" without an
|
||||
* explicit admin endpoint, and this console will not fake one: no reusing a
|
||||
* tenant route with a swapped id, no listing every store under every company,
|
||||
* no placeholder rows.
|
||||
*
|
||||
* So each level is a flag. `false` means the repository hands back no
|
||||
* endpoint, the hook reports `unavailable`, and the section renders the
|
||||
* "backend integration required" state instead of a request.
|
||||
*
|
||||
* ── Turning a level on ───────────────────────────────────────────────────
|
||||
* When the platform ships the endpoint (docs/ADMIN-MONITORING-API.md):
|
||||
* 1. add the upstream call to services/api/adminApi.ts,
|
||||
* 2. add the BFF route under app/api/admin/clients/[id]/…,
|
||||
* 3. flip the flag here.
|
||||
* No component changes — the sections already render real rows when the hook
|
||||
* returns them.
|
||||
*/
|
||||
export const ADMIN_CAPABILITIES = {
|
||||
/** `GET /api/admin/clients` — live. */
|
||||
companies: true,
|
||||
/** `GET /api/admin/clients/{id}` — not upstream yet; the detail page reads
|
||||
* the company out of the list instead, which carries the same row. */
|
||||
companyDetail: false,
|
||||
/** `GET /api/admin/clients/{id}/sites` */
|
||||
companyStores: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}` */
|
||||
storeDetail: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras` */
|
||||
storeCameras: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras/{cameraId}` */
|
||||
cameraDetail: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/events` (optionally ?camera=) */
|
||||
events: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/alerts` (optionally ?camera=) */
|
||||
alerts: false,
|
||||
/** `GET /api/admin/monitoring/summary` — platform-wide camera / alert / event totals. */
|
||||
platformMonitoring: false,
|
||||
/** `PATCH /api/admin/clients/{id}` with name / owner fields. Today the
|
||||
* PATCH accepts `active` only, so a merchant cannot be renamed. */
|
||||
merchantEdit: false,
|
||||
/** `GET /api/admin/clients/{id}/salespersons` (+ POST / PATCH / DELETE). */
|
||||
salesPersons: false,
|
||||
/** `GET /api/admin/clients/{id}/customers` */
|
||||
customers: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/sales` */
|
||||
sales: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/analytics` */
|
||||
analytics: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/visits` — walk-ins. */
|
||||
footfall: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/commerce` */
|
||||
commerce: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras/heartbeat` */
|
||||
cameraHeartbeat: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/device-logs` */
|
||||
deviceLogs: false,
|
||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/testing` — shop-PC test runs. */
|
||||
testingSoftware: false,
|
||||
/** A platform-scope assistant. `/api/assistant` answers from ONE tenant's
|
||||
* data, so it cannot answer about companies it is not signed in as. */
|
||||
platformAssistant: false,
|
||||
} as const;
|
||||
|
||||
export type AdminCapability = keyof typeof ADMIN_CAPABILITIES;
|
||||
|
||||
/**
|
||||
* What an operator can reach INSIDE one merchant, in hierarchy order.
|
||||
*
|
||||
* Admin → Merchant → Shop is the spine; everything else hangs off a merchant
|
||||
* (or one of its shops) and is only ever asked for as that merchant's. The
|
||||
* merchant page lists every area with whether the platform can answer it.
|
||||
* Only Shops has its own tab today — its count is real even before the shops
|
||||
* themselves can be listed. An area earns a tab when its endpoint ships, not
|
||||
* before: a tab that can only ever say "not available" is a wasted click.
|
||||
*/
|
||||
export interface MerchantArea {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: IconKey;
|
||||
capability: AdminCapability;
|
||||
/** One line: what the operator gets once it is live. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const MERCHANT_AREAS: MerchantArea[] = [
|
||||
{key: 'shops', icon: 'stores', label: 'Shops', capability: 'companyStores', description: 'Every shop or business the merchant runs.'},
|
||||
{key: 'sales-persons', icon: 'staff', label: 'Sales persons', capability: 'salesPersons', description: 'Create, edit and remove the staff who sign in on the mobile app.'},
|
||||
{key: 'cameras', icon: 'camera', label: 'Cameras', capability: 'storeCameras', description: 'Add, edit and remove cameras per shop, with online / offline state.'},
|
||||
{key: 'heartbeat', icon: 'events', label: 'Camera heartbeat', capability: 'cameraHeartbeat', description: 'Last-seen time for every camera.'},
|
||||
{key: 'customers', icon: 'profile', label: 'Customers', capability: 'customers', description: 'Customers who belong to this merchant.'},
|
||||
{key: 'sales', icon: 'revenue', label: 'Sales', capability: 'sales', description: 'Sales recorded at the merchant’s shops.'},
|
||||
{key: 'analytics', icon: 'analytics', label: 'Analytics', capability: 'analytics', description: 'Analytics scoped to one merchant and shop.'},
|
||||
{key: 'footfall', icon: 'visitors', label: 'Footfall', capability: 'footfall', description: 'Walk-ins recorded per shop.'},
|
||||
{key: 'device-logs', icon: 'docs', label: 'Device logs', capability: 'deviceLogs', description: 'Logs from the shop PCs and cameras.'},
|
||||
{key: 'testing', icon: 'api', label: 'Testing software', capability: 'testingSoftware', description: 'Test runs of the merchant-side software.'},
|
||||
];
|
||||
28
src/features/admin/hooks/useAiContext.ts
Normal file
28
src/features/admin/hooks/useAiContext.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import type {AiContext} from '@/features/loyaly-ai/types/chat';
|
||||
|
||||
/**
|
||||
* Tell Loyaly AI what this page is showing.
|
||||
*
|
||||
* Set on mount and whenever the identity changes, cleared on unmount, so the
|
||||
* assistant never carries a company's context onto the next page. Keyed on
|
||||
* the primitive fields — callers build the object inline every render.
|
||||
*/
|
||||
export function useAiContext(ctx: AiContext | null): void {
|
||||
const {setContext} = useLoyalyAi();
|
||||
const level = ctx?.level;
|
||||
const label = ctx?.label;
|
||||
const companyId = ctx?.companyId;
|
||||
const storeId = ctx?.storeId;
|
||||
const cameraId = ctx?.cameraId;
|
||||
|
||||
useEffect(() => {
|
||||
setContext(
|
||||
level && label ? {level, label, companyId, storeId, cameraId} : null,
|
||||
);
|
||||
return () => setContext(null);
|
||||
}, [setContext, level, label, companyId, storeId, cameraId]);
|
||||
}
|
||||
63
src/features/admin/hooks/useAnalytics.ts
Normal file
63
src/features/admin/hooks/useAnalytics.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import {useAdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import {
|
||||
commerceRepository,
|
||||
footfallRepository,
|
||||
} from '@/features/admin/repositories/analyticsRepository';
|
||||
import type {
|
||||
AnalyticsFilters,
|
||||
AreaDateFootfall,
|
||||
AreaFootfall,
|
||||
CommerceDetail,
|
||||
CommerceOverview,
|
||||
DailyFootfall,
|
||||
DailySales,
|
||||
FootfallDetail,
|
||||
FootfallOverview,
|
||||
MerchantSales,
|
||||
} from '@/features/admin/types/analytics';
|
||||
|
||||
/**
|
||||
* One resource per chart, not one per page: each panel loads, fails and
|
||||
* empties on its own, so a slow details query never holds the trend chart
|
||||
* hostage and one 500 does not blank the whole dashboard.
|
||||
*
|
||||
* Every hook is called unconditionally; a capability that is off yields
|
||||
* `{isAvailable: false}` with no request sent (see useAdminData).
|
||||
*/
|
||||
|
||||
export interface FootfallData {
|
||||
overview: AdminData<FootfallOverview>;
|
||||
byArea: AdminData<AreaFootfall[]>;
|
||||
daily: AdminData<DailyFootfall[]>;
|
||||
areaDate: AdminData<AreaDateFootfall[]>;
|
||||
details: AdminData<FootfallDetail[]>;
|
||||
}
|
||||
|
||||
export function useFootfallAnalytics(f: AnalyticsFilters): FootfallData {
|
||||
return {
|
||||
overview: useAdminData(footfallRepository.overview(f)),
|
||||
byArea: useAdminData(footfallRepository.byArea(f)),
|
||||
daily: useAdminData(footfallRepository.daily(f)),
|
||||
areaDate: useAdminData(footfallRepository.areaDate(f)),
|
||||
details: useAdminData(footfallRepository.details(f)),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommerceData {
|
||||
overview: AdminData<CommerceOverview>;
|
||||
byMerchant: AdminData<MerchantSales[]>;
|
||||
daily: AdminData<DailySales[]>;
|
||||
details: AdminData<CommerceDetail[]>;
|
||||
}
|
||||
|
||||
export function useCommerceAnalytics(f: AnalyticsFilters): CommerceData {
|
||||
return {
|
||||
overview: useAdminData(commerceRepository.overview(f)),
|
||||
byMerchant: useAdminData(commerceRepository.byMerchant(f)),
|
||||
daily: useAdminData(commerceRepository.daily(f)),
|
||||
details: useAdminData(commerceRepository.details(f)),
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import {useMemo, useState} from 'react';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company, CompanyFilter} from '@/features/admin/types/company';
|
||||
import type {
|
||||
Company,
|
||||
CompanyFilter,
|
||||
CompanySort,
|
||||
} from '@/features/admin/types/company';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
@@ -34,12 +38,13 @@ export function useCompanies(): Resource<Company[]> {
|
||||
export function useCompanyFilters(companies: Company[] | undefined) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<CompanyFilter>('all');
|
||||
const [sort, setSort] = useState<CompanySort>('newest');
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const rows = companies ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
return rows.filter((c) => {
|
||||
const matched = rows.filter((c) => {
|
||||
if (filter === 'active' && !c.isActive) return false;
|
||||
if (filter === 'suspended' && c.isActive) return false;
|
||||
if (!q) return true;
|
||||
@@ -50,7 +55,16 @@ export function useCompanyFilters(companies: Company[] | undefined) {
|
||||
c.name.toLowerCase().includes(q) || c.slug.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [companies, query, filter]);
|
||||
return matched.sort(COMPARE[sort]);
|
||||
}, [companies, query, filter, sort]);
|
||||
|
||||
return {query, setQuery, filter, setFilter, visible};
|
||||
return {query, setQuery, filter, setFilter, sort, setSort, visible};
|
||||
}
|
||||
|
||||
/** Every key orders by a field the list actually returns. */
|
||||
const COMPARE: Record<CompanySort, (a: Company, b: Company) => number> = {
|
||||
newest: (a, b) => b.createdAt.localeCompare(a.createdAt),
|
||||
oldest: (a, b) => a.createdAt.localeCompare(b.createdAt),
|
||||
name: (a, b) => a.name.localeCompare(b.name),
|
||||
shops: (a, b) => b.sites - a.sites || a.name.localeCompare(b.name),
|
||||
};
|
||||
|
||||
135
src/features/admin/hooks/useMonitoring.ts
Normal file
135
src/features/admin/hooks/useMonitoring.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {
|
||||
alertRepository,
|
||||
cameraRepository,
|
||||
eventRepository,
|
||||
storeRepository,
|
||||
} from '@/features/admin/repositories/monitoringRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import type {
|
||||
AdminAlert,
|
||||
AdminCamera,
|
||||
AdminEvent,
|
||||
AdminStore,
|
||||
} from '@/features/admin/types/monitoring';
|
||||
|
||||
/**
|
||||
* A resource that may not exist on this platform yet.
|
||||
*
|
||||
* `unavailable` is a third answer next to "loaded" and "failed", and it is
|
||||
* not an error: it means the platform has no endpoint for this level, so
|
||||
* nothing was asked. Sections branch on it to render the integration-required
|
||||
* state; they never see a fabricated empty array standing in for it.
|
||||
*/
|
||||
export type AdminData<T> =
|
||||
| {isAvailable: false}
|
||||
| {isAvailable: true; resource: Resource<T>};
|
||||
|
||||
export function useAdminData<T>(endpoint: Endpoint<T> | null): AdminData<T> {
|
||||
// Called unconditionally — `null` holds the request, which is what keeps the
|
||||
// hook order stable while a capability is off.
|
||||
const resource = useResource(endpoint);
|
||||
return endpoint ? {isAvailable: true, resource} : {isAvailable: false};
|
||||
}
|
||||
|
||||
export function useCompanyStores(companyId: string): AdminData<AdminStore[]> {
|
||||
return useAdminData(storeRepository.listByCompany(companyId));
|
||||
}
|
||||
|
||||
export function useStore(
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
): AdminData<AdminStore> {
|
||||
return useAdminData(storeRepository.get(companyId, storeId));
|
||||
}
|
||||
|
||||
export function useStoreCameras(
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
): AdminData<AdminCamera[]> {
|
||||
return useAdminData(cameraRepository.listByStore(companyId, storeId));
|
||||
}
|
||||
|
||||
export function useCamera(
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
cameraId: string,
|
||||
): AdminData<AdminCamera> {
|
||||
return useAdminData(cameraRepository.get(companyId, storeId, cameraId));
|
||||
}
|
||||
|
||||
export function useEvents(
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
cameraId?: string,
|
||||
): AdminData<AdminEvent[]> {
|
||||
return useAdminData(eventRepository.list(companyId, storeId, cameraId));
|
||||
}
|
||||
|
||||
export function useAlerts(
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
cameraId?: string,
|
||||
): AdminData<AdminAlert[]> {
|
||||
return useAdminData(alertRepository.list(companyId, storeId, cameraId));
|
||||
}
|
||||
|
||||
/**
|
||||
* One company, read out of the list.
|
||||
*
|
||||
* The platform has no `GET /api/admin/clients/{id}`, but the list row IS the
|
||||
* whole company record — there is nothing more a detail endpoint could add
|
||||
* today. So the detail page reuses the list request (one fetch, and the same
|
||||
* row the table showed) rather than inventing a route.
|
||||
*
|
||||
* `company` is `undefined` until the list lands, and `null` once it has landed
|
||||
* without this id: a deleted company, or a mistyped URL. The page needs to
|
||||
* tell those apart — "loading" and "not found" are different screens.
|
||||
*/
|
||||
export function useCompany(companyId: string): {
|
||||
resource: Resource<Company[]>;
|
||||
company: Company | null | undefined;
|
||||
} {
|
||||
const resource = useCompanies();
|
||||
const company = useMemo(() => {
|
||||
if (resource.status !== 'success' && resource.status !== 'empty') {
|
||||
return undefined;
|
||||
}
|
||||
return resource.data.find((c) => c.id === companyId) ?? null;
|
||||
}, [resource.status, resource.data, companyId]);
|
||||
return {resource, company};
|
||||
}
|
||||
|
||||
/** Platform totals, computed from the company list and nothing else. */
|
||||
export interface PlatformSummary {
|
||||
companies: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
stores: number;
|
||||
accounts: number;
|
||||
/** Companies with no store yet — onboarded but not set up. */
|
||||
withoutStores: Company[];
|
||||
/** Newest first. */
|
||||
recent: Company[];
|
||||
}
|
||||
|
||||
export function summarise(companies: Company[]): PlatformSummary {
|
||||
const active = companies.filter((c) => c.isActive).length;
|
||||
return {
|
||||
companies: companies.length,
|
||||
active,
|
||||
suspended: companies.length - active,
|
||||
stores: companies.reduce((n, c) => n + c.sites, 0),
|
||||
accounts: companies.reduce((n, c) => n + c.users, 0),
|
||||
withoutStores: companies.filter((c) => c.isActive && c.sites === 0),
|
||||
recent: [...companies].sort((a, b) =>
|
||||
b.createdAt.localeCompare(a.createdAt),
|
||||
),
|
||||
};
|
||||
}
|
||||
72
src/features/admin/repositories/analyticsRepository.ts
Normal file
72
src/features/admin/repositories/analyticsRepository.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
|
||||
import type {
|
||||
AnalyticsFilters,
|
||||
AreaDateFootfall,
|
||||
AreaFootfall,
|
||||
CommerceDetail,
|
||||
CommerceOverview,
|
||||
DailyFootfall,
|
||||
DailySales,
|
||||
FootfallDetail,
|
||||
FootfallOverview,
|
||||
MerchantSales,
|
||||
} from '@/features/admin/types/analytics';
|
||||
|
||||
/**
|
||||
* Platform-admin Footfall and Commerce reads — the same posture as
|
||||
* monitoringRepository: each method returns the BFF endpoint when its
|
||||
* capability is on, and `null` while it is off. `null` is what `useResource`
|
||||
* treats as "send nothing", so a report the platform cannot answer never
|
||||
* produces a request, and never falls back to a tenant route (which refuses an
|
||||
* admin session anyway — `/api/reports/footfall` answers 403 "This account has
|
||||
* no company").
|
||||
*
|
||||
* The filters travel as query parameters on every call, so a change of
|
||||
* merchant / area / shop / range is a new URL, which `useResource` keys on —
|
||||
* the previous merchant's rows can never be shown under the next one's name.
|
||||
*/
|
||||
|
||||
function params(f: AnalyticsFilters): Record<string, string> {
|
||||
const p: Record<string, string> = {from: f.range.from, to: f.range.to};
|
||||
if (f.merchantId) p.merchant = f.merchantId;
|
||||
// Area and shop are only meaningful inside a merchant; never sent without one.
|
||||
if (f.merchantId && f.area) p.area = f.area;
|
||||
if (f.merchantId && f.shopId) p.shop = f.shopId;
|
||||
return p;
|
||||
}
|
||||
|
||||
function gated<T>(
|
||||
on: boolean,
|
||||
path: string,
|
||||
f: AnalyticsFilters,
|
||||
): Endpoint<T> | null {
|
||||
return on ? {path, params: params(f)} : null;
|
||||
}
|
||||
|
||||
const FOOTFALL = '/api/admin/footfall';
|
||||
const COMMERCE = '/api/admin/commerce';
|
||||
|
||||
export const footfallRepository = {
|
||||
overview: (f: AnalyticsFilters) =>
|
||||
gated<FootfallOverview>(ADMIN_CAPABILITIES.footfall, `${FOOTFALL}/overview`, f),
|
||||
byArea: (f: AnalyticsFilters) =>
|
||||
gated<AreaFootfall[]>(ADMIN_CAPABILITIES.footfall, `${FOOTFALL}/by-area`, f),
|
||||
daily: (f: AnalyticsFilters) =>
|
||||
gated<DailyFootfall[]>(ADMIN_CAPABILITIES.footfall, `${FOOTFALL}/daily`, f),
|
||||
areaDate: (f: AnalyticsFilters) =>
|
||||
gated<AreaDateFootfall[]>(ADMIN_CAPABILITIES.footfall, `${FOOTFALL}/area-date`, f),
|
||||
details: (f: AnalyticsFilters) =>
|
||||
gated<FootfallDetail[]>(ADMIN_CAPABILITIES.footfall, `${FOOTFALL}/details`, f),
|
||||
};
|
||||
|
||||
export const commerceRepository = {
|
||||
overview: (f: AnalyticsFilters) =>
|
||||
gated<CommerceOverview>(ADMIN_CAPABILITIES.commerce, `${COMMERCE}/overview`, f),
|
||||
byMerchant: (f: AnalyticsFilters) =>
|
||||
gated<MerchantSales[]>(ADMIN_CAPABILITIES.commerce, `${COMMERCE}/by-merchant`, f),
|
||||
daily: (f: AnalyticsFilters) =>
|
||||
gated<DailySales[]>(ADMIN_CAPABILITIES.commerce, `${COMMERCE}/daily`, f),
|
||||
details: (f: AnalyticsFilters) =>
|
||||
gated<CommerceDetail[]>(ADMIN_CAPABILITIES.commerce, `${COMMERCE}/details`, f),
|
||||
};
|
||||
103
src/features/admin/repositories/monitoringRepository.ts
Normal file
103
src/features/admin/repositories/monitoringRepository.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
|
||||
import type {
|
||||
AdminAlert,
|
||||
AdminCamera,
|
||||
AdminEvent,
|
||||
AdminStore,
|
||||
} from '@/features/admin/types/monitoring';
|
||||
|
||||
/**
|
||||
* Everything below the company level, one repository per rung.
|
||||
*
|
||||
* Each method returns the BFF endpoint when its capability is on, and `null`
|
||||
* while it is off. `null` is what `useResource` already treats as "send
|
||||
* nothing", so a level the platform cannot answer never produces a request —
|
||||
* not a 404 in the console, and certainly not a fallback to a tenant route.
|
||||
*
|
||||
* Every path nests under the company. That is the tenancy rule written into
|
||||
* the URL: a store is only ever asked for AS a store of this company, and the
|
||||
* platform is expected to refuse the pair when it does not hold — see
|
||||
* docs/ADMIN-MONITORING-API.md §Ownership. The console never asks for a store
|
||||
* by id alone.
|
||||
*
|
||||
* Paths are BFF routes on this origin, never the platform host — the same
|
||||
* posture as companyRepository.
|
||||
*/
|
||||
|
||||
const seg = encodeURIComponent;
|
||||
|
||||
function clientPath(companyId: string): string {
|
||||
return `/api/admin/clients/${seg(companyId)}`;
|
||||
}
|
||||
|
||||
function sitePath(companyId: string, storeId: string): string {
|
||||
return `${clientPath(companyId)}/sites/${seg(storeId)}`;
|
||||
}
|
||||
|
||||
export const storeRepository = {
|
||||
listByCompany: (companyId: string): Endpoint<AdminStore[]> | null =>
|
||||
ADMIN_CAPABILITIES.companyStores
|
||||
? {path: `${clientPath(companyId)}/sites`, params: {}}
|
||||
: null,
|
||||
|
||||
get: (companyId: string, storeId: string): Endpoint<AdminStore> | null =>
|
||||
ADMIN_CAPABILITIES.storeDetail
|
||||
? {path: sitePath(companyId, storeId), params: {}}
|
||||
: null,
|
||||
};
|
||||
|
||||
export const cameraRepository = {
|
||||
listByStore: (
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
): Endpoint<AdminCamera[]> | null =>
|
||||
ADMIN_CAPABILITIES.storeCameras
|
||||
? {path: `${sitePath(companyId, storeId)}/cameras`, params: {}}
|
||||
: null,
|
||||
|
||||
get: (
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
cameraId: string,
|
||||
): Endpoint<AdminCamera> | null =>
|
||||
ADMIN_CAPABILITIES.cameraDetail
|
||||
? {
|
||||
path: `${sitePath(companyId, storeId)}/cameras/${seg(cameraId)}`,
|
||||
params: {},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
/** Scoped to a store, optionally narrowed to one of its cameras. */
|
||||
function streamParams(cameraId?: string): Record<string, string> {
|
||||
return cameraId ? {camera: cameraId} : {};
|
||||
}
|
||||
|
||||
export const eventRepository = {
|
||||
list: (
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
cameraId?: string,
|
||||
): Endpoint<AdminEvent[]> | null =>
|
||||
ADMIN_CAPABILITIES.events
|
||||
? {
|
||||
path: `${sitePath(companyId, storeId)}/events`,
|
||||
params: streamParams(cameraId),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
export const alertRepository = {
|
||||
list: (
|
||||
companyId: string,
|
||||
storeId: string,
|
||||
cameraId?: string,
|
||||
): Endpoint<AdminAlert[]> | null =>
|
||||
ADMIN_CAPABILITIES.alerts
|
||||
? {
|
||||
path: `${sitePath(companyId, storeId)}/alerts`,
|
||||
params: streamParams(cameraId),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
109
src/features/admin/types/analytics.ts
Normal file
109
src/features/admin/types/analytics.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Platform-admin Footfall and Commerce analytics — the shapes the console is
|
||||
* written against.
|
||||
*
|
||||
* ── These endpoints do not exist yet ─────────────────────────────────────
|
||||
* This is the PROPOSED contract (docs/ADMIN-MONITORING-API.md §3.11). The
|
||||
* console is built against it so that shipping an endpoint is a flag flip in
|
||||
* config/capabilities.ts, not a redesign. Until then the repositories hand
|
||||
* back no endpoint and every chart renders its "backend integration required"
|
||||
* state — nothing here is ever filled with a placeholder value.
|
||||
*
|
||||
* Every optional field is optional because the platform may not send it; the
|
||||
* UI leaves a column out, or prints a dash, rather than guessing.
|
||||
*/
|
||||
|
||||
/** A closed date range, ISO `YYYY-MM-DD`, inclusive. */
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter bar, as the API receives it. `undefined` means "all" — the key is
|
||||
* then omitted from the query rather than sent as a sentinel string.
|
||||
*
|
||||
* Merchant → area → shop is a hierarchy: a shop filter is only ever sent with
|
||||
* the merchant that owns it, and the platform must refuse a pair that does not
|
||||
* hold (§2.2 Ownership). The console never asks for a shop on its own.
|
||||
*/
|
||||
export interface AnalyticsFilters {
|
||||
merchantId?: string;
|
||||
area?: string;
|
||||
shopId?: string;
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
/* ── Footfall ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface FootfallOverview {
|
||||
totalFootfall: number;
|
||||
averageDaily: number;
|
||||
peakDay?: {date: string; footfall: number};
|
||||
activeLocations: number;
|
||||
reportingShops: number;
|
||||
}
|
||||
|
||||
export interface AreaFootfall {
|
||||
area: string;
|
||||
footfall: number;
|
||||
}
|
||||
|
||||
export interface DailyFootfall {
|
||||
date: string;
|
||||
footfall: number;
|
||||
}
|
||||
|
||||
/** Long form — one row per area per day. The chart pivots it into series. */
|
||||
export interface AreaDateFootfall {
|
||||
date: string;
|
||||
area: string;
|
||||
footfall: number;
|
||||
}
|
||||
|
||||
export interface FootfallDetail {
|
||||
date: string;
|
||||
merchantId: string;
|
||||
merchantName: string;
|
||||
area?: string;
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
footfall: number;
|
||||
entries?: number;
|
||||
exits?: number;
|
||||
}
|
||||
|
||||
/* ── Commerce ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CommerceOverview {
|
||||
salesInr: number;
|
||||
transactions: number;
|
||||
averageTransactionInr: number;
|
||||
activeMerchants: number;
|
||||
activeShops: number;
|
||||
}
|
||||
|
||||
export interface MerchantSales {
|
||||
merchantId: string;
|
||||
merchantName: string;
|
||||
salesInr: number;
|
||||
transactions: number;
|
||||
}
|
||||
|
||||
export interface DailySales {
|
||||
date: string;
|
||||
salesInr: number;
|
||||
transactions: number;
|
||||
}
|
||||
|
||||
export interface CommerceDetail {
|
||||
date: string;
|
||||
merchantId: string;
|
||||
merchantName: string;
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
salesInr: number;
|
||||
transactions: number;
|
||||
paymentMethod?: string;
|
||||
category?: string;
|
||||
}
|
||||
@@ -42,10 +42,16 @@ export interface CompanyActiveResult {
|
||||
* The two shapes that carry a password. Both are shown once and never stored:
|
||||
* the platform bcrypt-hashes on the way in and cannot show them again.
|
||||
*/
|
||||
/**
|
||||
* `POST /api/admin/clients` — the platform's `ApiNewClientResult`, VERBATIM.
|
||||
* The BFF route forwards this body without mapping it, so the field names are
|
||||
* the platform's snake_case. This was declared camelCase, and the dialog read
|
||||
* `ownerEmail` off a body that only has `owner_email`: "Password for undefined".
|
||||
*/
|
||||
export interface NewCompany {
|
||||
clientId: string;
|
||||
client_id: string;
|
||||
slug: string;
|
||||
ownerEmail: string;
|
||||
owner_email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -57,3 +63,6 @@ export interface OwnerPassword {
|
||||
/** The status filter above the table. Client-side — the list endpoint takes
|
||||
* no query parameters, so filtering a fetched array is the honest option. */
|
||||
export type CompanyFilter = 'all' | 'active' | 'suspended';
|
||||
|
||||
/** Order of the merchant cards. Client-side, for the same reason. */
|
||||
export type CompanySort = 'newest' | 'oldest' | 'name' | 'shops';
|
||||
|
||||
67
src/features/admin/types/monitoring.ts
Normal file
67
src/features/admin/types/monitoring.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* What the platform console will render below the company level.
|
||||
*
|
||||
* ── None of these endpoints exist yet ────────────────────────────────────
|
||||
* These are the shapes the console is written against, so that wiring a real
|
||||
* endpoint is a mapper and a flag rather than a redesign (see
|
||||
* config/capabilities.ts). They are deliberately the SAME fields the tenant
|
||||
* console already reads for its own stores and cameras (features/stores/types)
|
||||
* — an admin view of a store should not invent columns a merchant's own view
|
||||
* of that store does not have.
|
||||
*
|
||||
* Every optional field is optional because the platform may not send it.
|
||||
* The UI prints a dash for a missing value, never a guess.
|
||||
*/
|
||||
|
||||
export interface AdminStore {
|
||||
id: string;
|
||||
/** Immutable upstream, like a company slug. */
|
||||
slug: string;
|
||||
name: string;
|
||||
timezone?: string;
|
||||
/** The platform's own store liveness (`online` on SiteHealth). */
|
||||
isOnline?: boolean;
|
||||
/** Cameras registered to the store. */
|
||||
cameras?: number;
|
||||
/** Cameras that reported within the platform's liveness window. */
|
||||
camerasOnline?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/** The platform's own camera states. No invented `maintenance`. */
|
||||
export type AdminCameraStatus = 'online' | 'offline' | 'unknown';
|
||||
|
||||
export interface AdminCamera {
|
||||
id: string;
|
||||
name: string;
|
||||
status: AdminCameraStatus;
|
||||
/** ISO-8601, from the server's clock. */
|
||||
lastSeenAt?: string;
|
||||
/** Where in the store it points, if the merchant named it. */
|
||||
zone?: string;
|
||||
/** Whether the platform can serve a live stream for it right now. */
|
||||
hasLiveStream?: boolean;
|
||||
}
|
||||
|
||||
export type AdminSeverity = 'critical' | 'warning' | 'info';
|
||||
|
||||
export interface AdminEvent {
|
||||
id: string;
|
||||
/** ISO-8601. */
|
||||
at: string;
|
||||
type: string;
|
||||
cameraId?: string;
|
||||
cameraName?: string;
|
||||
severity?: AdminSeverity;
|
||||
}
|
||||
|
||||
export interface AdminAlert {
|
||||
id: string;
|
||||
/** ISO-8601. */
|
||||
at: string;
|
||||
title: string;
|
||||
severity: AdminSeverity;
|
||||
status: 'open' | 'acknowledged' | 'resolved';
|
||||
cameraId?: string;
|
||||
cameraName?: string;
|
||||
}
|
||||
175
src/features/auth/components/JoinForm.tsx
Normal file
175
src/features/auth/components/JoinForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {FIELD_BASE} from './LoginCredentialsForm';
|
||||
import {MIN_PASSWORD_LENGTH, useJoinForm} from '@/features/auth/hooks/useJoinForm';
|
||||
|
||||
/**
|
||||
* Redeem an invitation code: check it, then choose a name and a password.
|
||||
*
|
||||
* Styled as the sign-in form is, from the same field string, because the two
|
||||
* sit in the same frame and are the only two ways into the console. The email
|
||||
* and role are SHOWN, never editable: they come from the invitation, and the
|
||||
* platform refuses a registration that names either.
|
||||
*/
|
||||
const LABEL = 'block text-[13px] font-medium text-[#3F3D36] mb-2';
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
staff: 'Staff',
|
||||
manager: 'Manager',
|
||||
owner: 'Owner',
|
||||
};
|
||||
|
||||
export function JoinForm() {
|
||||
const {
|
||||
code,
|
||||
preview,
|
||||
fullName,
|
||||
password,
|
||||
confirm,
|
||||
error,
|
||||
isSubmitting,
|
||||
setCode,
|
||||
setFullName,
|
||||
setPassword,
|
||||
setConfirm,
|
||||
submit,
|
||||
reset,
|
||||
} = useJoinForm();
|
||||
|
||||
const border = error ? 'border-[#DC2626]' : 'border-[#E4E1D6]';
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} noValidate className="space-y-[18px]">
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="rounded-xl border border-[#EDEAE0] bg-[#FAF9F4] px-4 py-3">
|
||||
<p className="text-[15px] font-semibold text-[#14161F]">
|
||||
Join {preview.companyName}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[#6B6960]">
|
||||
{preview.email} · {ROLE_LABEL[preview.role] ?? preview.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="join-name" className={LABEL}>
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
id="join-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="How colleagues will see you"
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 border-[#E4E1D6]`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="join-password" className={LABEL}>
|
||||
Choose a password
|
||||
</label>
|
||||
<input
|
||||
id="join-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
aria-invalid={!!error}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 ${border}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="join-confirm" className={LABEL}>
|
||||
Type it again
|
||||
</label>
|
||||
<input
|
||||
id="join-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 ${border}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor="join-code" className={LABEL}>
|
||||
Invitation code
|
||||
</label>
|
||||
<input
|
||||
id="join-code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="XXXXXX-XXXXXX-XXXXXX-XXXXXX"
|
||||
aria-invalid={!!error}
|
||||
disabled={isSubmitting}
|
||||
className={`${FIELD_BASE} px-4 font-mono tracking-wide ${border}`}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-[#8A8577]">
|
||||
Your manager gave you this code. It works once.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-[#DC2626]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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 ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-[#14161F]/25 border-t-[#14161F] rounded-full animate-spin" />
|
||||
<span className="sr-only">{preview ? 'Creating your account' : 'Checking the code'}</span>
|
||||
</>
|
||||
) : preview ? (
|
||||
'Create account and sign in'
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-[13px] text-[#6B6960]">
|
||||
{preview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={isSubmitting}
|
||||
className="font-medium text-[#14161F] underline underline-offset-2 hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Use a different code
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-[#14161F] underline underline-offset-2 hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm';
|
||||
*/
|
||||
|
||||
/** One string, both inputs — the two fields must not drift apart. */
|
||||
const FIELD_BASE =
|
||||
export 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() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
|
||||
import {LoginCredentialsForm} from './LoginCredentialsForm';
|
||||
import {LoginHeroPanel} from './LoginHeroPanel';
|
||||
@@ -22,7 +23,20 @@ import {LoginHeroPanel} from './LoginHeroPanel';
|
||||
* 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() {
|
||||
/**
|
||||
* `/join` renders in the same frame: redeeming an invitation is the other way
|
||||
* into the console, and a second design for it would be a second thing to
|
||||
* keep in step. Called with no props, this is exactly the sign-in screen.
|
||||
*/
|
||||
export function LoginSplit({
|
||||
title = 'Welcome back',
|
||||
description = 'Access your Merchant Operating System to manage stores, rewards, staff and AI insights.',
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
} = {}) {
|
||||
return (
|
||||
<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]">
|
||||
{/*
|
||||
@@ -68,16 +82,28 @@ export function LoginSplit() {
|
||||
|
||||
<div className="mt-9 lg:mt-10">
|
||||
<h1 className="text-[30px] sm:text-[36px] font-bold tracking-[-0.02em] leading-[1.1] text-[#14161F]">
|
||||
Welcome back
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-[#6B6960] max-w-[46ch]">
|
||||
Access your Merchant Operating System to manage stores, rewards,
|
||||
staff and AI insights.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-7">
|
||||
<LoginCredentialsForm />
|
||||
{children ?? (
|
||||
<>
|
||||
<LoginCredentialsForm />
|
||||
<p className="mt-6 text-[13px] text-[#6B6960]">
|
||||
Got an invitation code?{' '}
|
||||
<Link
|
||||
href="/join"
|
||||
className="font-medium text-[#14161F] underline underline-offset-2 hover:text-[#8A6300] transition-colors"
|
||||
>
|
||||
Join your team
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
123
src/features/auth/hooks/useJoinForm.ts
Normal file
123
src/features/auth/hooks/useJoinForm.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useState} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {authRepository} from '@/features/auth/repositories/authRepository';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
import type {InvitationPreview} from '@/features/auth/types/join';
|
||||
|
||||
/** The platform's own floor, checked here too so the common mistake costs no round trip. */
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Redeeming an invitation, in two steps.
|
||||
*
|
||||
* 1. The code is checked BEFORE anybody chooses a password, so the screen
|
||||
* can say what they are joining and a mistyped code is caught first.
|
||||
* 2. Name and password are sent with the code; the answer is a full session
|
||||
* and the person goes straight into the console — never to a login form.
|
||||
*
|
||||
* A code in the link (`/join?code=…`) is prefilled, so a code sent over
|
||||
* WhatsApp is one tap. Dashes and case in a code do not matter upstream; it is
|
||||
* sent exactly as typed.
|
||||
*/
|
||||
export function useJoinForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const {refresh} = useSession();
|
||||
|
||||
const [code, setCode] = useState(() => searchParams.get('code') ?? '');
|
||||
const [preview, setPreview] = useState<InvitationPreview | null>(null);
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const check = useCallback(
|
||||
async (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter the invitation code you were given.');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const res = await authRepository.invitation(trimmed);
|
||||
setIsSubmitting(false);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'That invitation code is not valid. Ask for a new one.');
|
||||
return;
|
||||
}
|
||||
setPreview(res.data);
|
||||
// Prefilled from the invitation, and still editable: it is how they
|
||||
// appear to colleagues, and whoever invited them may have guessed.
|
||||
setFullName((prev) => prev || res.data!.fullName);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const register = useCallback(async () => {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
setError(`Choose a password of at least ${MIN_PASSWORD_LENGTH} characters.`);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('The two passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const res = await authRepository.register({
|
||||
code: code.trim(),
|
||||
fullName: fullName.trim(),
|
||||
password,
|
||||
});
|
||||
if (!res.ok || !res.data) {
|
||||
setIsSubmitting(false);
|
||||
setError(res.message ?? 'Could not create your account. Please try again.');
|
||||
return;
|
||||
}
|
||||
// The cookies are set; let the provider read them before navigating, so
|
||||
// the workspace does not paint its signed-out state first. isSubmitting
|
||||
// stays true until the navigation commits, as on the sign-in form.
|
||||
await refresh();
|
||||
router.replace(destinationForUser(res.data.user));
|
||||
router.refresh();
|
||||
}, [code, fullName, password, confirm, refresh, router]);
|
||||
|
||||
const submit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
void (preview ? register() : check(code));
|
||||
},
|
||||
[isSubmitting, preview, register, check, code],
|
||||
);
|
||||
|
||||
/** Back to step one, for somebody holding the wrong code. */
|
||||
const reset = useCallback(() => {
|
||||
setPreview(null);
|
||||
setPassword('');
|
||||
setConfirm('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
code,
|
||||
preview,
|
||||
fullName,
|
||||
password,
|
||||
confirm,
|
||||
error,
|
||||
isSubmitting,
|
||||
setCode,
|
||||
setFullName,
|
||||
setPassword,
|
||||
setConfirm,
|
||||
check,
|
||||
submit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {authService} from '@/features/auth/services/authService';
|
||||
import {TAB_ID_STORAGE_KEY} from '@/features/auth/services/tabScope';
|
||||
import {FOREIGN_SEED_ATTR} from '@/features/auth/services/tabSession';
|
||||
import {SIDENAV_COLLAPSED_KEY} from '@/shared/layouts/workspace/sidebarStorage';
|
||||
import type {
|
||||
AuthSession,
|
||||
@@ -80,14 +83,23 @@ export function SessionProvider({
|
||||
initialTabId: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
||||
/**
|
||||
* `seedIsOurs` decides whether the server's answer is about THIS tab.
|
||||
*
|
||||
* Read during the first render rather than in an effect so the first paint is
|
||||
* already right: a tab whose seed belongs to somebody else starts in
|
||||
* 'loading' and shows a guard spinner, instead of painting another person's
|
||||
* name into the shell and swapping it a moment later.
|
||||
* Read through `useSyncExternalStore` with a server snapshot of `true`, and
|
||||
* that split is load-bearing. The server cannot see `sessionStorage`, so it
|
||||
* always renders the seed as ours; hydration has to render the same thing or
|
||||
* React throws a hydration mismatch and regenerates the tree. This used to be
|
||||
* a `useState` initializer that read storage during the hydration render —
|
||||
* which disagreed with the server in every newly opened tab. Now hydration
|
||||
* uses the server's answer and React re-renders with the real one straight
|
||||
* after.
|
||||
*
|
||||
* The server HTML for a foreign seed still contains another person's shell,
|
||||
* so it must not be SEEN: the inline script marks <html> with
|
||||
* FOREIGN_SEED_ATTR before first paint (same comparison as here), CSS hides
|
||||
* the body while it is set, and the layout effect below clears it once this
|
||||
* provider has rendered the client's answer — the guard spinner.
|
||||
*
|
||||
* ── Why a seed can belong to another tab ─────────────────────────────────
|
||||
* A document navigation cannot carry `X-Tab-Id`, so the server resolves the
|
||||
@@ -113,28 +125,49 @@ export function SessionProvider({
|
||||
* with no `sessionStorage` has one shared session, and that is the documented
|
||||
* degraded mode, not a reason to refuse to render.
|
||||
*/
|
||||
const [seedIsOurs] = useState(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
let own: string | null = null;
|
||||
try {
|
||||
own = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return own === null || own === initialTabId;
|
||||
});
|
||||
const seedIsOurs = useSyncExternalStore(
|
||||
subscribeNever,
|
||||
() => readSeedIsOurs(initialTabId),
|
||||
() => true,
|
||||
);
|
||||
|
||||
const [status, setStatus] = useState<SessionStatus>(() => {
|
||||
if (!seedIsOurs) return 'loading';
|
||||
return initialSession ? 'authenticated' : 'unauthenticated';
|
||||
});
|
||||
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
||||
const [status, setStatus] = useState<SessionStatus>(
|
||||
initialSession ? 'authenticated' : 'unauthenticated',
|
||||
);
|
||||
/**
|
||||
* Whether the client has had its OWN answer (a fetch, a login, a logout).
|
||||
* Until it has, a foreign seed reads as 'loading' with no session — derived
|
||||
* at render time rather than written into state, so the correction lands in
|
||||
* the same render that learns the seed is not ours.
|
||||
*/
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const awaitingOwnAnswer = !seedIsOurs && !confirmed;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const next = await authService.currentSession();
|
||||
const settle = useCallback((next: AuthSession | null) => {
|
||||
setSession(next);
|
||||
setStatus(next ? 'authenticated' : 'unauthenticated');
|
||||
setConfirmed(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Un-hide the page once what is rendered is the client's own answer.
|
||||
*
|
||||
* Keyed on agreement, not on `seedIsOurs` alone: on the hydration commit the
|
||||
* rendered value is still the server's `true`, and if the client disagrees a
|
||||
* re-render is already on its way — clearing then would flash the foreign
|
||||
* shell. Whenever the two agree, clear, so a disagreement between the script
|
||||
* and this reader can never leave the page hidden for good.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (seedIsOurs !== readSeedIsOurs(initialTabId)) return;
|
||||
document.documentElement.removeAttribute(FOREIGN_SEED_ATTR);
|
||||
}, [seedIsOurs, initialTabId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
settle(await authService.currentSession());
|
||||
}, [settle]);
|
||||
|
||||
/**
|
||||
* Ask for real when the seed was not ours.
|
||||
*
|
||||
@@ -145,8 +178,8 @@ export function SessionProvider({
|
||||
* here, and AuthGuard sends it to /login — which now always renders, so that
|
||||
* is where it stops.
|
||||
*
|
||||
* `seedIsOurs` is fixed for the life of the mount, so this cannot re-fire —
|
||||
* there is no state it writes that could feed back into its own condition.
|
||||
* `seedIsOurs` only moves once — from the hydration snapshot to the client's
|
||||
* — and nothing this writes feeds back into it, so it cannot re-fire.
|
||||
* Every later re-check is the visibilitychange listener below.
|
||||
*
|
||||
* The request is spelled out rather than delegated to `refresh()` so the
|
||||
@@ -159,14 +192,12 @@ export function SessionProvider({
|
||||
|
||||
let cancelled = false;
|
||||
void authService.currentSession().then((next) => {
|
||||
if (cancelled) return;
|
||||
setSession(next);
|
||||
setStatus(next ? 'authenticated' : 'unauthenticated');
|
||||
if (!cancelled) settle(next);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [seedIsOurs]);
|
||||
}, [seedIsOurs, settle]);
|
||||
|
||||
/**
|
||||
* Re-check on focus.
|
||||
@@ -192,19 +223,15 @@ export function SessionProvider({
|
||||
const login = useCallback(
|
||||
async (credentials: LoginCredentials): Promise<LoginResult> => {
|
||||
const result = await authService.login(credentials);
|
||||
if (result.ok) {
|
||||
setSession(result.session);
|
||||
setStatus('authenticated');
|
||||
}
|
||||
if (result.ok) settle(result.session);
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
[settle],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
setSession(null);
|
||||
setStatus('unauthenticated');
|
||||
settle(null);
|
||||
|
||||
/**
|
||||
* Clear this tab's preferences — and ONLY this tab's.
|
||||
@@ -235,24 +262,48 @@ export function SessionProvider({
|
||||
// Drop the client router cache too, or a Back gesture can repaint the
|
||||
// previous authenticated render from memory before the proxy is consulted.
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
}, [router, settle]);
|
||||
|
||||
const value = useMemo<SessionValue>(
|
||||
() => ({
|
||||
status,
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
isAuthenticated: status === 'authenticated',
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
}),
|
||||
[status, session, login, logout, refresh],
|
||||
() => {
|
||||
const shownStatus = awaitingOwnAnswer ? 'loading' : status;
|
||||
const shownSession = awaitingOwnAnswer ? null : session;
|
||||
return {
|
||||
status: shownStatus,
|
||||
session: shownSession,
|
||||
user: shownSession?.user ?? null,
|
||||
isAuthenticated: shownStatus === 'authenticated',
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
};
|
||||
},
|
||||
[awaitingOwnAnswer, status, session, login, logout, refresh],
|
||||
);
|
||||
|
||||
return <SessionContext value={value}>{children}</SessionContext>;
|
||||
}
|
||||
|
||||
/** This tab's id never changes after the inline script sets it: nothing to subscribe to. */
|
||||
function subscribeNever(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same comparison the inline script makes (see tabSession.ts) — keep the
|
||||
* two in step, or the page is hidden by one and never un-hidden by the other.
|
||||
* Storage that throws reads as ours: the documented fail-open.
|
||||
*/
|
||||
function readSeedIsOurs(seedTabId: string | null): boolean {
|
||||
let own: string | null;
|
||||
try {
|
||||
own = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return own === null || own === seedTabId;
|
||||
}
|
||||
|
||||
export function useSession(): SessionValue {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LoginCredentials,
|
||||
LoginError,
|
||||
} from '@/features/auth/types/auth';
|
||||
import type {InvitationPreview} from '@/features/auth/types/join';
|
||||
|
||||
/**
|
||||
* TRANSPORT ONLY.
|
||||
@@ -35,6 +36,18 @@ export const authRepository = {
|
||||
return postJson<{ok: boolean}>('/api/auth/logout', {});
|
||||
},
|
||||
|
||||
/** What a code is for. No session needed — the holder has no account yet. */
|
||||
invitation(code: string) {
|
||||
return getJson<InvitationPreview>(
|
||||
`/api/auth/invitation?code=${encodeURIComponent(code)}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Redeem a code. Success sets the session cookies, like login does. */
|
||||
register(body: {code: string; fullName: string; password: string}) {
|
||||
return postJson<AuthSession>('/api/auth/register', body);
|
||||
},
|
||||
|
||||
/** Null data means "no session" — a normal answer, not a failure. */
|
||||
currentSession() {
|
||||
return getJson<AuthSession | null>('/api/auth/session');
|
||||
|
||||
15
src/features/auth/services/roles.ts
Normal file
15
src/features/auth/services/roles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Whether a role may be OFFERED an action — never whether it may perform it.
|
||||
*
|
||||
* The platform decides that, on every request, and answers 403 with a message
|
||||
* saying who can. This only keeps buttons off the screen for people who would
|
||||
* be refused, so a staff account is not shown an Erase it cannot use. Roles are
|
||||
* strictly nested: each can do everything the one below can.
|
||||
*/
|
||||
const RANK: Record<UserRole, number> = {staff: 0, manager: 1, owner: 2, admin: 3};
|
||||
|
||||
export function hasRole(role: UserRole | undefined, minimum: UserRole): boolean {
|
||||
return role !== undefined && RANK[role] >= RANK[minimum];
|
||||
}
|
||||
108
src/features/auth/services/staffAccess.ts
Normal file
108
src/features/auth/services/staffAccess.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* What a `staff` session may reach in this console. Enforced by `src/proxy.ts`
|
||||
* on the SERVER for every page and API request; the navigation reads the same
|
||||
* lists only so it never offers a link the server will refuse.
|
||||
*
|
||||
* ── Where the rule comes from ─────────────────────────────────────────────
|
||||
* The platform's only authorisation fact is the user's `role`, carried in the
|
||||
* signed session cookie. It exposes NO staff → store assignment (verified in
|
||||
* production: the session, `/api/team` and `/api/sites` carry no such field),
|
||||
* and no per-user permissions. So this is a ROLE gate:
|
||||
*
|
||||
* owner / manager → the merchant console, company-wide (unchanged)
|
||||
* staff → floor work only: arrivals, serving customers, naming
|
||||
* them, recording a sale. No revenue, reports, team,
|
||||
* cameras, store settings, billing or Loyaly AI.
|
||||
*
|
||||
* ── What it cannot do ─────────────────────────────────────────────────────
|
||||
* It cannot limit staff to "their" store — no such relationship exists. Staff
|
||||
* must pick ONE store (never "All stores") for store-scoped reads, but may pick
|
||||
* any store in their company. Real store-level scoping needs the platform to
|
||||
* expose an assignment; until then this file must not pretend otherwise.
|
||||
*
|
||||
* ── Deny by default ───────────────────────────────────────────────────────
|
||||
* Anything not listed is refused. A new page or API is merchant-only until it
|
||||
* is deliberately added here.
|
||||
*
|
||||
* Edge-safe: imported by the proxy, so no Node or React imports.
|
||||
*/
|
||||
|
||||
export const STAFF_HOME = '/floor';
|
||||
|
||||
/** Page prefixes a staff session may open. `/x` also admits `/x/…`. */
|
||||
export const STAFF_PAGES: readonly string[] = [
|
||||
'/floor',
|
||||
'/customers',
|
||||
'/activity',
|
||||
'/settings/profile',
|
||||
'/settings/preferences',
|
||||
'/settings/security',
|
||||
];
|
||||
|
||||
interface ApiRule {
|
||||
methods: readonly string[];
|
||||
path: RegExp;
|
||||
/**
|
||||
* The request must name ONE store — `storeId` present and not "all". Without
|
||||
* this, an omitted scope is answered company-wide by the platform.
|
||||
*/
|
||||
requiresStore?: boolean;
|
||||
}
|
||||
|
||||
const READ = ['GET', 'HEAD'] as const;
|
||||
|
||||
const STAFF_APIS: readonly ApiRule[] = [
|
||||
// The store picker. Names and camera health only — no analytics.
|
||||
{methods: READ, path: /^\/api\/sites$/},
|
||||
// The floor: who is in the shop, and serving them.
|
||||
{methods: READ, path: /^\/api\/floor\/visits$/, requiresStore: true},
|
||||
{methods: ['POST'], path: /^\/api\/visits\/[^/]+\/(attend|release|complete)$/},
|
||||
// Arrivals feed (/activity) and its live stream.
|
||||
{methods: READ, path: /^\/api\/visits$/, requiresStore: true},
|
||||
{methods: READ, path: /^\/api\/visits\/stream$/, requiresStore: true},
|
||||
// Naming a customer, and the customer record.
|
||||
{methods: ['POST'], path: /^\/api\/customers$/},
|
||||
{methods: READ, path: /^\/api\/visitors$/},
|
||||
{methods: READ, path: /^\/api\/visitors\/[^/]+\/(history|image)$/},
|
||||
{methods: ['PUT'], path: /^\/api\/visitors\/[^/]+\/profile$/},
|
||||
// Customer face thumbnails, proxied (customers and floor cards).
|
||||
{methods: READ, path: /^\/api\/faces$/},
|
||||
// Recording a sale / purchase. Listing and reading sales is commerce
|
||||
// analytics and stays merchant-only.
|
||||
{methods: ['POST'], path: /^\/api\/sales$/},
|
||||
{methods: ['POST'], path: /^\/api\/purchases$/},
|
||||
];
|
||||
|
||||
/** Paths every signed-in role may reach — liveness probes. */
|
||||
const ALWAYS_OPEN = new Set(['/api/health', '/api/ready']);
|
||||
|
||||
export function isStaffRole(role: unknown): boolean {
|
||||
return role === 'staff';
|
||||
}
|
||||
|
||||
function underPrefix(pathname: string, prefix: string): boolean {
|
||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
export function isStaffPage(pathname: string): boolean {
|
||||
return STAFF_PAGES.some((p) => underPrefix(pathname, p));
|
||||
}
|
||||
|
||||
export type StaffApiDecision = 'allow' | 'forbidden' | 'needs_store';
|
||||
|
||||
export function staffApiDecision(
|
||||
method: string,
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams,
|
||||
): StaffApiDecision {
|
||||
if (ALWAYS_OPEN.has(pathname)) return 'allow';
|
||||
const rule = STAFF_APIS.find(
|
||||
(r) => r.path.test(pathname) && r.methods.includes(method.toUpperCase()),
|
||||
);
|
||||
if (!rule) return 'forbidden';
|
||||
if (rule.requiresStore) {
|
||||
const store = searchParams.get('storeId') ?? searchParams.get('site');
|
||||
if (!store || store === 'all') return 'needs_store';
|
||||
}
|
||||
return 'allow';
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import {
|
||||
TAB_POINTER_COOKIE,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
|
||||
/**
|
||||
* Set on <html> by the script below when the server rendered this document as
|
||||
* ANOTHER tab's user. CSS (globals.css) hides the body while it is present;
|
||||
* SessionProvider removes it once it has rendered this tab's own state.
|
||||
*/
|
||||
export const FOREIGN_SEED_ATTR = 'data-tab-seed-foreign';
|
||||
|
||||
/**
|
||||
* The inline script that gives each tab its own identity.
|
||||
*
|
||||
@@ -27,6 +34,9 @@ import {
|
||||
* 2. Writes the id into the `loyaly_tab` pointer cookie, so the NEXT document
|
||||
* navigation (which cannot carry a header) is server-rendered as this tab's
|
||||
* user rather than as whoever signed in last.
|
||||
* 3. Hides the page (FOREIGN_SEED_ATTR) when THIS document was rendered for a
|
||||
* different tab — `seedTabId` is the tab the server resolved the session
|
||||
* from. Otherwise that tab's user would paint until React took over.
|
||||
*
|
||||
* ── Why an inline script and not an effect ───────────────────────────────
|
||||
* A React effect runs after the first paint, so the first document of a tab
|
||||
@@ -44,14 +54,17 @@ import {
|
||||
* JavaScript cannot read, and a forged id names a cookie the browser either
|
||||
* already had or does not have at all.
|
||||
*/
|
||||
export function tabSessionScript(): string {
|
||||
export function tabSessionScript(seedTabId: string | null): string {
|
||||
// seedTabId has passed isValidTabId ([a-z0-9]), so it cannot break out of
|
||||
// the string literal or the <script> element.
|
||||
return `(function(){try{
|
||||
var K=${JSON.stringify(TAB_ID_STORAGE_KEY)},P=${JSON.stringify(TAB_POINTER_COOKIE)};
|
||||
var K=${JSON.stringify(TAB_ID_STORAGE_KEY)},P=${JSON.stringify(TAB_POINTER_COOKIE)},SEED=${JSON.stringify(seedTabId)};
|
||||
var s=window.sessionStorage,id=s.getItem(K);
|
||||
if(!id||!/^[a-z0-9]{8,32}$/.test(id)){
|
||||
id=(Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)).slice(0,16);
|
||||
s.setItem(K,id);
|
||||
}
|
||||
if(id!==SEED)document.documentElement.setAttribute(${JSON.stringify(FOREIGN_SEED_ATTR)},'');
|
||||
// Only a VISIBLE tab claims the pointer, and that condition is load-bearing.
|
||||
// A tab opened in the background (a middle-click, "open link in new tab")
|
||||
// starts hidden, and pointing from there would aim the pointer at a tab with
|
||||
@@ -59,9 +72,12 @@ s.setItem(K,id);
|
||||
// focused tab's very next navigation would be gated against the wrong cookie
|
||||
// and bounced to /login. A hidden tab simply waits: the visibilitychange
|
||||
// listener below points the moment it is actually looked at.
|
||||
var claim=function(){
|
||||
document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');
|
||||
};
|
||||
var point=function(){
|
||||
if(document.visibilityState!=='visible')return;
|
||||
document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');
|
||||
claim();
|
||||
};
|
||||
point();
|
||||
// Re-point on focus: only one tab is focused at a time, so this keeps the
|
||||
@@ -69,5 +85,17 @@ point();
|
||||
// next navigation the server has to render.
|
||||
window.addEventListener('visibilitychange',point);
|
||||
window.addEventListener('pageshow',point);
|
||||
// Claim the pointer the instant THIS tab is about to make a request, not only
|
||||
// when it last became visible. Visibility alone loses to any other tab that
|
||||
// became visible later — a second window side by side, a merchant tab — and a
|
||||
// reload here then went out under THAT tab's session: /admin 307 → /dashboard,
|
||||
// the merchant shell painted for a platform admin, and every data call 403'd.
|
||||
// beforeunload fires before the navigation request is sent (reload, typed URL,
|
||||
// full-page link), and an unloading tab is by definition the one asking.
|
||||
// Input covers client-side navigations, whose RSC fetches carry no tab header.
|
||||
window.addEventListener('beforeunload',claim);
|
||||
window.addEventListener('pointerdown',claim,true);
|
||||
window.addEventListener('keydown',claim,true);
|
||||
window.addEventListener('focus',claim);
|
||||
}catch(e){}})();`;
|
||||
}
|
||||
|
||||
14
src/features/auth/types/join.ts
Normal file
14
src/features/auth/types/join.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* What an invitation code is for, shown before anybody picks a password.
|
||||
*
|
||||
* `email` and `role` are fixed by the invitation and cannot be changed on the
|
||||
* join screen — the platform refuses a registration that names either.
|
||||
*/
|
||||
export interface InvitationPreview {
|
||||
companyName: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: Exclude<UserRole, 'admin'>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {formatPaise} from '@/features/commerce/services/money';
|
||||
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/**
|
||||
@@ -30,18 +31,18 @@ export function SaleDetailDialog({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sales/${encodeURIComponent(saleId)}`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setError(body?.error?.message ?? 'Could not load this sale.');
|
||||
return;
|
||||
}
|
||||
setSale(body.data as Sale);
|
||||
} catch {
|
||||
if (!cancelled) setError('Could not reach the platform.');
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await saleRepository.byId(saleId);
|
||||
if (cancelled) return;
|
||||
if (!res.ok || !res.data) {
|
||||
setError(
|
||||
res.status === 0
|
||||
? 'Could not reach the platform.'
|
||||
: (res.message ?? 'Could not load this sale.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSale(res.data);
|
||||
})();
|
||||
// A dialog closed mid-request must not write into an unmounted component.
|
||||
return () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
formatPaise,
|
||||
parseRupeesToPaise,
|
||||
} from '@/features/commerce/services/money';
|
||||
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
@@ -147,36 +148,36 @@ export function SaleEntryDialog({
|
||||
*/
|
||||
idempotencyKey.current ??= newIdempotencyKey();
|
||||
|
||||
const res = await fetch('/api/sales', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: idempotencyKey.current,
|
||||
// Taken from the floor context, never typed. §4: do not ask for a
|
||||
// visit id the flow already knows. Staff identity is not sent at
|
||||
// all — the platform derives it from the session.
|
||||
visitId: visit.visitId,
|
||||
visitorId: visit.visitorId ?? undefined,
|
||||
invoiceNo: invoiceNo.trim(),
|
||||
site: visit.siteId,
|
||||
lines: lines.map((l) => ({
|
||||
productName: l.productName,
|
||||
pricePaise: l.pricePaise,
|
||||
intent: l.intent,
|
||||
})),
|
||||
}),
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await saleRepository.create({
|
||||
idempotencyKey: idempotencyKey.current,
|
||||
// Taken from the floor context, never typed. §4: do not ask for a
|
||||
// visit id the flow already knows. Staff identity is not sent at
|
||||
// all — the platform derives it from the session.
|
||||
visitId: visit.visitId,
|
||||
visitorId: visit.visitorId ?? undefined,
|
||||
invoiceNo: invoiceNo.trim(),
|
||||
site: visit.siteId,
|
||||
lines: lines.map((l) => ({
|
||||
productName: l.productName,
|
||||
pricePaise: l.pricePaise,
|
||||
intent: l.intent,
|
||||
})),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(body?.error?.message ?? 'Could not record this sale.');
|
||||
setError(
|
||||
res.status === 0
|
||||
? 'Could not reach the platform. The sale was not recorded.'
|
||||
: (res.message ?? 'Could not record this sale.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The figure shown now is the SERVER's, computed by the database from
|
||||
// the purchased lines. The running total above is only what the merchant
|
||||
// watched while typing.
|
||||
const sale = body?.data?.sale;
|
||||
const sale = res.data?.sale;
|
||||
setDone({
|
||||
saleId: body?.data?.saleId ?? '',
|
||||
saleId: res.data?.saleId ?? '',
|
||||
total:
|
||||
typeof sale?.totalPaise === 'number'
|
||||
? formatPaise(sale.totalPaise)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import {getJson, postJson, scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
@@ -8,6 +8,17 @@ import type {Sale} from '@/features/commerce/types/sale';
|
||||
* Scoped like every other read, so the store switcher and the range picker
|
||||
* change what this returns without the Sales screen knowing how.
|
||||
*/
|
||||
/** What recording a sale answers with. A replay is a success carrying the original sale. */
|
||||
export interface SaleCreated {
|
||||
status: string;
|
||||
saleId: string;
|
||||
sale: Sale | null;
|
||||
}
|
||||
|
||||
export const saleRepository = {
|
||||
list: (scope: Scope): Endpoint<Sale[]> => scopedEndpoint('/api/sales', scope, {}),
|
||||
|
||||
create: (body: Record<string, unknown>) => postJson<SaleCreated>('/api/sales', body),
|
||||
|
||||
byId: (id: string) => getJson<Sale>(`/api/sales/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
189
src/features/customers/components/CustomerDialog.tsx
Normal file
189
src/features/customers/components/CustomerDialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import Image from 'next/image';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {hasRole} from '@/features/auth/services/roles';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import {useCustomerPhoto} from '@/features/customers/hooks/useCustomers';
|
||||
import {CustomerHistory} from '@/features/customers/components/CustomerHistory';
|
||||
import {RecordPurchaseDialog} from '@/features/customers/components/RecordPurchaseDialog';
|
||||
import {EraseCustomerDialog} from '@/features/customers/components/EraseCustomerDialog';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* One customer: who they are, when they came, and what staff can do about it.
|
||||
*
|
||||
* The photo is read ONCE here, not per component, because every read is a row
|
||||
* in the platform's "who looked at my customers" audit log. A missing photo is
|
||||
* the platform's own reason in plain text — most deployments store none.
|
||||
*
|
||||
* The profile save is a whole-object replace upstream, so the form holds only
|
||||
* what can be read back (name, phone, email, consent) and says what saving
|
||||
* clears. Unticking consent is a withdrawal the platform timestamps.
|
||||
*/
|
||||
export function CustomerDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
/** Something about this customer changed; the list should re-read. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const {user} = useSession();
|
||||
const canErase = hasRole(user?.role, 'manager');
|
||||
const photo = useCustomerPhoto(customer.id);
|
||||
|
||||
const [fullName, setFullName] = useState(customer.fullName);
|
||||
const [phone, setPhone] = useState(customer.phone);
|
||||
const [email, setEmail] = useState(customer.email);
|
||||
const [consent, setConsent] = useState(customer.hasConsent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ok: boolean; text: string} | null>(null);
|
||||
const [buying, setBuying] = useState(false);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
|
||||
const hasContact = fullName.trim() !== '' || phone.trim() !== '' || email.trim() !== '';
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const res = await customerRepository.saveProfile(customer.id, {
|
||||
fullName,
|
||||
phone,
|
||||
email,
|
||||
consent,
|
||||
});
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
setMessage({ok: false, text: res.message ?? 'Could not save these details.'});
|
||||
return;
|
||||
}
|
||||
setMessage({ok: true, text: 'Saved.'});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={560}
|
||||
aria-label={`Customer ${customer.label}`}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={customer.label}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
<HStack gap={4} vAlign="start">
|
||||
{photo.status === 'success' && photo.data.available && photo.data.url ? (
|
||||
<Image
|
||||
src={photo.data.url}
|
||||
alt={`Latest photo of ${customer.label}`}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
style={{borderRadius: 8, objectFit: 'cover'}}
|
||||
/>
|
||||
) : null}
|
||||
<VStack gap={1}>
|
||||
{customer.ref ? (
|
||||
<Text size="sm" color="secondary" className="font-mono">
|
||||
{customer.ref}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="sm">
|
||||
{customer.visitCount} {customer.visitCount === 1 ? 'visit' : 'visits'} · last seen{' '}
|
||||
<Timestamp value={customer.lastSeenAt} format="relative" />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
First seen <Timestamp value={customer.firstSeenAt} format="date" />
|
||||
</Text>
|
||||
{photo.status === 'success' && !photo.data.available && photo.data.reason ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{photo.data.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading level={4}>Details</Heading>
|
||||
<TextInput label="Name" value={fullName} onChange={setFullName} />
|
||||
<HStack gap={3} wrap="wrap">
|
||||
<TextInput label="Phone" value={phone} onChange={setPhone} isOptional />
|
||||
<TextInput label="Email" value={email} onChange={setEmail} isOptional />
|
||||
</HStack>
|
||||
<CheckboxInput
|
||||
label="They agreed to be recognised"
|
||||
value={consent}
|
||||
onChange={(v) => setConsent(v === true)}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Saving replaces their whole profile: any gender, date of birth or notes recorded
|
||||
elsewhere are cleared.
|
||||
</Text>
|
||||
{message ? (
|
||||
<Banner status={message.ok ? 'success' : 'error'} title={message.text} />
|
||||
) : null}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading level={4}>Visits</Heading>
|
||||
<CustomerHistory customerId={customer.id} />
|
||||
|
||||
<HStack gap={2} hAlign="between" wrap="wrap">
|
||||
{canErase ? (
|
||||
<Button variant="destructive" onClick={() => setErasing(true)} label="Erase…" />
|
||||
) : null}
|
||||
<HStack gap={2} wrap="wrap">
|
||||
<Button variant="secondary" onClick={() => setBuying(true)} label="Record purchase" />
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
isDisabled={!hasContact || saving}
|
||||
isLoading={saving}
|
||||
label="Save details"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{buying ? (
|
||||
<RecordPurchaseDialog
|
||||
customer={customer}
|
||||
onClose={() => setBuying(false)}
|
||||
onRecorded={() => {
|
||||
setBuying(false);
|
||||
setMessage({ok: true, text: 'Purchase recorded.'});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{erasing ? (
|
||||
<EraseCustomerDialog
|
||||
customer={customer}
|
||||
onClose={() => setErasing(false)}
|
||||
onErased={() => {
|
||||
setErasing(false);
|
||||
onChanged();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
137
src/features/customers/components/CustomerDirectory.tsx
Normal file
137
src/features/customers/components/CustomerDirectory.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
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 {useCustomerSearch} from '@/features/customers/hooks/useCustomers';
|
||||
import {CustomerDialog} from '@/features/customers/components/CustomerDialog';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/** Long enough that a word typed at speed is one request, short enough to feel live. */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Find a customer by name, phone, email or number (`42` or `V-42`).
|
||||
*
|
||||
* The term is debounced before it becomes the request: useResource keys on the
|
||||
* URL, so without it every keystroke would be a round trip to the platform.
|
||||
* With no term the list is the most recently seen — the useful default for a
|
||||
* person who has just walked in.
|
||||
*/
|
||||
export function CustomerDirectory() {
|
||||
const [input, setInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState<Customer | null>(null);
|
||||
const customers = useCustomerSearch(query);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(input), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [input]);
|
||||
|
||||
const columns: TableColumn<Customer>[] = [
|
||||
{
|
||||
key: 'label',
|
||||
header: 'Customer',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{/* An unnamed customer's label already IS their number ("Visitor 42"). */}
|
||||
{[row.fullName ? row.ref : null, row.phone || null].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visitCount',
|
||||
header: 'Visits',
|
||||
width: pixel(90),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.visitCount}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeenAt',
|
||||
header: 'Last seen',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Timestamp value={row.lastSeenAt} format="relative" />,
|
||||
},
|
||||
{
|
||||
key: 'hasConsent',
|
||||
header: 'Consent',
|
||||
width: pixel(110),
|
||||
renderCell: (row) =>
|
||||
row.hasConsent ? <Badge variant="success" label="Given" /> : <Text size="sm">—</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'center',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="center">
|
||||
<Button size="sm" variant="secondary" onClick={() => setOpen(row)} label="Open" />
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<TextInput
|
||||
label="Search customers"
|
||||
isLabelHidden
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="Name, phone, email or number (V-42)"
|
||||
hasClear
|
||||
/>
|
||||
|
||||
<PanelCard
|
||||
title={query ? `Results for “${query}”` : 'Recently seen'}
|
||||
resource={customers}
|
||||
loading={<SkeletonRows count={6} height={52} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="visitors"
|
||||
title={query ? 'No customer matches that' : 'No customers yet'}
|
||||
description={
|
||||
query
|
||||
? 'Try part of a name, a phone number, or their customer number.'
|
||||
: 'Customers appear here once a camera has recognised them.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="label" />
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{open ? (
|
||||
<CustomerDialog
|
||||
key={open.id}
|
||||
customer={open}
|
||||
onClose={() => setOpen(null)}
|
||||
onChanged={customers.refetch}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
49
src/features/customers/components/CustomerHistory.tsx
Normal file
49
src/features/customers/components/CustomerHistory.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useCustomerHistory} from '@/features/customers/hooks/useCustomers';
|
||||
|
||||
/** Their visits, newest first — the one that enrolled them is marked. */
|
||||
export function CustomerHistory({customerId}: {customerId: string}) {
|
||||
const history = useCustomerHistory(customerId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={history}
|
||||
loading={<SkeletonRows count={4} height={28} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="visitors"
|
||||
title="No visits recorded"
|
||||
description="Visits appear here once a camera recognises them."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(visits) => (
|
||||
<VStack gap={2}>
|
||||
{visits.map((v, i) => (
|
||||
<VStack key={v.id} gap={2}>
|
||||
{i > 0 ? <Divider /> : null}
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<VStack gap={0}>
|
||||
<Timestamp value={v.occurredAt} format="date_time" />
|
||||
<Text size="xsm" color="secondary">
|
||||
{v.siteName}
|
||||
</Text>
|
||||
</VStack>
|
||||
{v.isNewVisitor ? <Badge variant="neutral" label="First visit" /> : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
88
src/features/customers/components/EraseCustomerDialog.tsx
Normal file
88
src/features/customers/components/EraseCustomerDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Erase a customer — the right-to-be-forgotten path. Manager and above.
|
||||
*
|
||||
* Irreversible, so it asks for the customer number to be typed. What it does
|
||||
* is said plainly: the face and photo are destroyed; the visits stay as
|
||||
* anonymous footfall; the consent record stays, revoked.
|
||||
*
|
||||
* A failure is shown and never softened. The platform's 502 means the photo
|
||||
* could not be deleted and NOTHING was erased — telling the merchant otherwise
|
||||
* would leave them believing data is gone that is still there.
|
||||
*/
|
||||
export function EraseCustomerDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onErased,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
onErased: () => void;
|
||||
}) {
|
||||
const expected = customer.ref ?? customer.id;
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await customerRepository.erase(customer.id);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Nothing was erased. Please try again.');
|
||||
return;
|
||||
}
|
||||
onErased();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Erase customer"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`Erase ${customer.label}?`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<Banner
|
||||
status="error"
|
||||
title="This cannot be undone"
|
||||
description="Their face template and photo are deleted permanently, and their name and contact details are removed. Their visits stay as anonymous footfall, and the record of their consent stays, marked withdrawn. The cameras will not re-enrol them as a new customer."
|
||||
/>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<TextInput
|
||||
label="Type the customer number to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={expected}
|
||||
description={`Exactly: ${expected}`}
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isDisabled={confirm.trim() !== expected || busy}
|
||||
isLoading={busy}
|
||||
label="Erase customer"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
126
src/features/customers/components/RecordPurchaseDialog.tsx
Normal file
126
src/features/customers/components/RecordPurchaseDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
const LAST_SEEN = '__last_seen__';
|
||||
|
||||
/**
|
||||
* Link a sale to this customer, so the conversion report can say who bought.
|
||||
*
|
||||
* Lighter than the till's itemised sale on the Commerce page: an amount, and
|
||||
* optionally what they bought. Left on "where they were last seen", the
|
||||
* platform books it at that shop — what "somebody on the floor just sold them
|
||||
* something" means. Items are a comma-separated list of names, because that is
|
||||
* what the platform stores.
|
||||
*/
|
||||
export function RecordPurchaseDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onRecorded,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
onRecorded: () => void;
|
||||
}) {
|
||||
const {stores} = useWorkspace();
|
||||
const [amount, setAmount] = useState<number | null>(null);
|
||||
const [site, setSite] = useState(LAST_SEEN);
|
||||
const [items, setItems] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const shopOptions = [
|
||||
{value: LAST_SEEN, label: 'Where they were last seen'},
|
||||
...stores.filter((s) => s.id !== 'all').map((s) => ({value: s.id, label: s.name})),
|
||||
];
|
||||
|
||||
async function submit() {
|
||||
if (amount === null || amount <= 0) {
|
||||
setError('Enter the amount they paid.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await customerRepository.recordPurchase({
|
||||
visitorId: customer.id,
|
||||
site: site === LAST_SEEN ? undefined : site,
|
||||
amount,
|
||||
currency: 'INR',
|
||||
items: items
|
||||
.split(',')
|
||||
.map((i) => i.trim())
|
||||
.filter(Boolean),
|
||||
notes: notes.trim(),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Could not record that purchase.');
|
||||
return;
|
||||
}
|
||||
onRecorded();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={440}
|
||||
aria-label="Record a purchase"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`Record a purchase — ${customer.label}`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount (₹)"
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
hasClear
|
||||
min={0}
|
||||
/>
|
||||
<Selector
|
||||
label="Shop"
|
||||
value={site}
|
||||
onChange={(v) => setSite(v)}
|
||||
options={shopOptions}
|
||||
/>
|
||||
<TextInput
|
||||
label="Items"
|
||||
value={items}
|
||||
onChange={setItems}
|
||||
isOptional
|
||||
placeholder="Silk saree, Blouse"
|
||||
description="Separate items with commas."
|
||||
/>
|
||||
<TextInput label="Notes" value={notes} onChange={setNotes} isOptional />
|
||||
<Text size="xsm" color="secondary">
|
||||
For an itemised bill with enquiries, use New sale on the Commerce page.
|
||||
</Text>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={busy}
|
||||
isLoading={busy}
|
||||
label="Record purchase"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
32
src/features/customers/hooks/useCustomers.ts
Normal file
32
src/features/customers/hooks/useCustomers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Customer search. An empty term lists the most recently seen — the useful
|
||||
* default for somebody who has just walked in.
|
||||
*
|
||||
* Session-gated like every other hook here, so nothing is requested from a
|
||||
* tab that has signed out.
|
||||
*/
|
||||
export function useCustomerSearch(q: string): Resource<Customer[]> {
|
||||
const {isAuthenticated} = useSession();
|
||||
return useResource(isAuthenticated ? customerRepository.search(q) : null);
|
||||
}
|
||||
|
||||
export function useCustomerHistory(id: string): Resource<CustomerVisit[]> {
|
||||
return useResource(customerRepository.history(id));
|
||||
}
|
||||
|
||||
/** A missing photo is `available: false`, never 'empty' or an error. */
|
||||
export function useCustomerPhoto(id: string): Resource<CustomerPhoto> {
|
||||
return useResource(customerRepository.photo(id), {isEmpty: () => false});
|
||||
}
|
||||
46
src/features/customers/repositories/customerRepository.ts
Normal file
46
src/features/customers/repositories/customerRepository.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
deleteJson,
|
||||
postJson,
|
||||
putJson,
|
||||
type Endpoint,
|
||||
} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
ProfileDraft,
|
||||
PurchaseDraft,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* TRANSPORT ONLY — customers, their history and photo, and the writes.
|
||||
*
|
||||
* The reads are Endpoints so useResource keys on the URL: a new search term or
|
||||
* a different customer refetches on its own. `id` may be the uuid or `V-42`.
|
||||
*/
|
||||
export const customerRepository = {
|
||||
search: (q: string): Endpoint<Customer[]> => ({
|
||||
path: '/api/visitors',
|
||||
params: q.trim() ? {q: q.trim()} : {},
|
||||
}),
|
||||
|
||||
history: (id: string): Endpoint<CustomerVisit[]> => ({
|
||||
path: `/api/visitors/${encodeURIComponent(id)}/history`,
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Audited upstream on every read — fetch it once per screen. */
|
||||
photo: (id: string): Endpoint<CustomerPhoto> => ({
|
||||
path: `/api/visitors/${encodeURIComponent(id)}/image`,
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Whole-object replace; see ProfileDraft for why it is these four fields. */
|
||||
saveProfile: (id: string, draft: ProfileDraft) =>
|
||||
putJson<null>(`/api/visitors/${encodeURIComponent(id)}/profile`, draft),
|
||||
|
||||
recordPurchase: (draft: PurchaseDraft) => postJson<null>('/api/purchases', draft),
|
||||
|
||||
/** Irreversible erasure. Manager and above. */
|
||||
erase: (id: string) => deleteJson<null>(`/api/visitors/${encodeURIComponent(id)}`),
|
||||
};
|
||||
110
src/features/customers/services/mapCustomer.ts
Normal file
110
src/features/customers/services/mapCustomer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
ApiImageRef,
|
||||
ApiProfileInput,
|
||||
ApiPurchaseInput,
|
||||
ApiVisitor,
|
||||
ApiVisitorHistoryEntry,
|
||||
} from '@/services/api/types';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Platform shapes → what the customer screens consume, and request bodies →
|
||||
* what the platform accepts.
|
||||
*
|
||||
* Kept out of the route files so the list, the history, the save and the
|
||||
* purchase routes agree on one mapping, the way `mapTeam` does for the team.
|
||||
*/
|
||||
export function toCustomer(v: ApiVisitor): Customer {
|
||||
return {
|
||||
id: v.id,
|
||||
ref: v.ref || null,
|
||||
// Upstream `label` is the name given at ENROLMENT ("Visitor 42") and is
|
||||
// never changed by naming them — the chosen name is `full_name`. So the
|
||||
// name wins when there is one, as it does in the platform's own console.
|
||||
label: v.full_name || v.label || v.ref || 'Customer',
|
||||
fullName: v.full_name ?? '',
|
||||
phone: v.phone ?? '',
|
||||
email: v.email ?? '',
|
||||
visitCount: v.visit_count ?? 0,
|
||||
firstSeenAt: v.first_seen_at,
|
||||
lastSeenAt: v.last_seen_at,
|
||||
hasProfile: v.has_profile === true,
|
||||
hasConsent: v.has_consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCustomerVisit(h: ApiVisitorHistoryEntry): CustomerVisit {
|
||||
return {
|
||||
id: h.id,
|
||||
occurredAt: h.occurred_at,
|
||||
siteName: h.site,
|
||||
cameraId: h.camera_id,
|
||||
isNewVisitor: h.is_new_visitor === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A relative URL is served by the platform and needs the session's token,
|
||||
* which an <img> cannot send — so it goes through /api/faces. A presigned
|
||||
* object-storage link carries its own signature and is used as it is.
|
||||
*/
|
||||
export function toCustomerPhoto(img: ApiImageRef): CustomerPhoto {
|
||||
if (!img.available || !img.url) {
|
||||
return {available: false, url: null, reason: img.reason ?? null};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
url: img.url.startsWith('http')
|
||||
? img.url
|
||||
: `/api/faces?src=${encodeURIComponent(img.url)}`,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The save is a WHOLE-OBJECT replace upstream. Gender, date of birth and notes
|
||||
* are sent empty because this console cannot read them back to preserve them —
|
||||
* see ProfileDraft — and the form says so before anybody saves.
|
||||
*/
|
||||
export function toProfileInput(body: Record<string, unknown>): ApiProfileInput {
|
||||
return {
|
||||
full_name: text(body.fullName),
|
||||
phone: text(body.phone),
|
||||
email: text(body.email),
|
||||
gender: '',
|
||||
date_of_birth: '',
|
||||
notes: '',
|
||||
consent: body.consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A request body → the platform's `PurchaseInput`.
|
||||
*
|
||||
* `items` upstream is a list of item NAMES; anything that is not a non-empty
|
||||
* string is dropped rather than sent, because a number there fails the
|
||||
* platform's JSON decode for the whole request.
|
||||
*/
|
||||
export function toPurchaseInput(body: Record<string, unknown>): ApiPurchaseInput {
|
||||
const items = Array.isArray(body.items)
|
||||
? body.items.map(text).filter((i) => i !== '')
|
||||
: [];
|
||||
const site = text(body.site) || text(body.siteId);
|
||||
return {
|
||||
visitor_id: text(body.visitorId),
|
||||
site_id: site || undefined,
|
||||
amount: Number(body.amount),
|
||||
currency: text(body.currency).toUpperCase() || 'INR',
|
||||
items: items.length > 0 ? items : undefined,
|
||||
source: 'console',
|
||||
notes: text(body.notes) || undefined,
|
||||
};
|
||||
}
|
||||
74
src/features/customers/types/customer.ts
Normal file
74
src/features/customers/types/customer.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* A customer, as the console consumes it — `GET /api/visitors`.
|
||||
*
|
||||
* `label` is what to show: it reads "Visitor 42" until somebody names them,
|
||||
* then the name. `fullName` stays empty until then, which is how the screen
|
||||
* tells an unnamed regular from a named one without guessing from the label.
|
||||
*
|
||||
* `ref` ("V-42") is per tenant and immutable — safe to show, to search by and
|
||||
* to type into a till. It is null only on a deployment that predates numbering.
|
||||
*/
|
||||
export interface Customer extends Record<string, unknown> {
|
||||
id: string;
|
||||
ref: string | null;
|
||||
label: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
visitCount: number;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
hasProfile: boolean;
|
||||
/** Recorded agreement to be recognised. Withdrawn, never deleted, upstream. */
|
||||
hasConsent: boolean;
|
||||
}
|
||||
|
||||
/** One visit in a customer's history. Newest first. */
|
||||
export interface CustomerVisit {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
/** The shop's display name at the time of reading, not its slug. */
|
||||
siteName: string;
|
||||
cameraId: string;
|
||||
/** True on exactly one row — the visit that enrolled them. */
|
||||
isNewVisitor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's latest photo.
|
||||
*
|
||||
* `available: false` is DATA, not an error: images are off by default across
|
||||
* the product, so most deployments legitimately have none, and `reason` says
|
||||
* why. `url` is already proxied — a component never builds a platform URL.
|
||||
*/
|
||||
export interface CustomerPhoto {
|
||||
available: boolean;
|
||||
url: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the profile form edits.
|
||||
*
|
||||
* Deliberately only what the platform lets this console READ back. The save is
|
||||
* a whole-object replace, and gender, date of birth and notes are not on any
|
||||
* read endpoint — a form offering them would show them blank and could only
|
||||
* ever overwrite what somebody else had recorded.
|
||||
*/
|
||||
export interface ProfileDraft {
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
/** A sale linked to a customer, so the conversion report can say who bought. */
|
||||
export interface PurchaseDraft {
|
||||
visitorId: string;
|
||||
/** Slug or uuid. Omitted = where this customer was last seen. */
|
||||
site?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
items: string[];
|
||||
notes: string;
|
||||
}
|
||||
81
src/features/dashboard/hooks/useArrivalStream.ts
Normal file
81
src/features/dashboard/hooks/useArrivalStream.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useEffectEvent, useState} from 'react';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import {reportRepository} from '@/features/dashboard/repositories/reportRepository';
|
||||
|
||||
/** First retry after 5 s, doubling to a minute — a restarting server is not hammered. */
|
||||
const RETRY_MIN_MS = 5_000;
|
||||
const RETRY_MAX_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Listen for arrivals as they happen, and call `onArrivals` for each batch.
|
||||
*
|
||||
* The event is used as a SIGNAL — the caller re-reads the feed it already
|
||||
* renders — rather than as data to splice in. That keeps one mapping of an
|
||||
* arrival in the app and means a missed event costs nothing: the next re-read
|
||||
* returns everything, because the feed is cursor-based and lossless.
|
||||
*
|
||||
* ── Opened FROM the page's cursor, and only once there is one ────────────
|
||||
* Without a cursor the platform's first event is its backlog: the same rows
|
||||
* the page is already fetching. Treating that as news aborted the page's
|
||||
* in-flight read and sent it again — a "(canceled)" request and a duplicate on
|
||||
* every load. So the stream waits for the first page (`cursor` defined) and
|
||||
* resumes after it: every event is then genuinely new. The cursor is read
|
||||
* again at each reconnect, so rows that arrived while the stream was down
|
||||
* come through as one event rather than being lost.
|
||||
*
|
||||
* The connection follows the store switcher, closes on unmount, and reconnects
|
||||
* with backoff when the server ends it. `isLive` is true only while events can
|
||||
* actually arrive, so a screen never claims "Live" over a dead connection.
|
||||
*/
|
||||
export function useArrivalStream(
|
||||
onArrivals: () => void,
|
||||
/** The cursor of the page on screen; undefined until it has loaded. */
|
||||
cursor: string | undefined,
|
||||
): {isLive: boolean} {
|
||||
const {isAuthenticated} = useSession();
|
||||
const {storeId} = useScope();
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const notify = useEffectEvent(onArrivals);
|
||||
const currentCursor = useEffectEvent(() => cursor);
|
||||
const hasCursor = cursor !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !hasCursor) return;
|
||||
const controller = new AbortController();
|
||||
let delay = RETRY_MIN_MS;
|
||||
|
||||
async function run() {
|
||||
while (!controller.signal.aborted) {
|
||||
const res = await reportRepository.arrivalStream(
|
||||
storeId,
|
||||
currentCursor(),
|
||||
(e) => {
|
||||
if (e.event === 'arrivals') notify();
|
||||
},
|
||||
controller.signal,
|
||||
() => {
|
||||
setIsLive(true);
|
||||
delay = RETRY_MIN_MS;
|
||||
},
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setIsLive(false);
|
||||
// A refusal (signed out, forbidden) will not fix itself by retrying.
|
||||
if (!res.ok && (res.status === 401 || res.status === 403)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
controller.abort();
|
||||
setIsLive(false);
|
||||
};
|
||||
}, [isAuthenticated, storeId, hasCursor]);
|
||||
|
||||
return {isLive};
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from './useReports';
|
||||
import type {Kpi} from '@/features/dashboard/types/dashboard';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
|
||||
export {useConversionReport, useFootfallReport, useRecentVisits};
|
||||
|
||||
@@ -24,9 +28,20 @@ export {useConversionReport, useFootfallReport, useRecentVisits};
|
||||
* 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});
|
||||
/**
|
||||
* The KPI row, built from the page's OWN footfall and conversion reports.
|
||||
*
|
||||
* It used to fetch its own copies (with `compare`) while the charts fetched
|
||||
* the same reports again without it: four requests for two answers, since
|
||||
* useResource does not share results between hooks. The comparison response
|
||||
* carries the current window's buckets unchanged, so the page now fetches
|
||||
* each report once — `{bucket: 'day', compare: true}` — and hands it to both
|
||||
* the charts and this.
|
||||
*/
|
||||
export function useDashboardKpis(
|
||||
footfall: Resource<FootfallReport>,
|
||||
conversion: Resource<ConversionReport>,
|
||||
): Resource<Kpi[]> {
|
||||
|
||||
return useMemo<Resource<Kpi[]>>(() => {
|
||||
const refetch = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import {readEventStream, scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope, StreamEvent} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
@@ -13,6 +13,10 @@ import type {VisitsPage} from '@/features/dashboard/types/visits';
|
||||
* 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.
|
||||
*
|
||||
* (`/api/dashboard/summary` does exist, but it is the PLATFORM's endpoint —
|
||||
* the merchant app's "today" figures — and is read by the Floor page through
|
||||
* floorRepository, not by this dashboard.)
|
||||
*/
|
||||
export const reportRepository = {
|
||||
footfall: (scope: Scope, opts: {bucket?: string; compare?: boolean} = {}):
|
||||
@@ -35,4 +39,25 @@ export const reportRepository = {
|
||||
...(opts.limit ? {limit: String(opts.limit)} : {}),
|
||||
...(opts.cursor ? {cursor: opts.cursor} : {}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* The same arrivals, pushed. Scoped by shop only — a stream has no range.
|
||||
* Resolves when the stream ends; the caller decides whether to reconnect.
|
||||
*/
|
||||
arrivalStream: (
|
||||
storeId: string,
|
||||
/** Resume after this position; without it the first event is the backlog. */
|
||||
cursor: string | undefined,
|
||||
onEvent: (e: StreamEvent) => void,
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
) =>
|
||||
readEventStream(
|
||||
`/api/visits/stream?${new URLSearchParams(
|
||||
cursor ? {storeId, cursor} : {storeId},
|
||||
).toString()}`,
|
||||
onEvent,
|
||||
signal,
|
||||
onOpen,
|
||||
),
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user