Compare commits
7 Commits
dccb1beda5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d8739e9a4 | |||
| b36a30385c | |||
| 91e4db8217 | |||
| b60b8baef6 | |||
| 8456c5e852 | |||
| 02aadaf091 | |||
| ea3dbbeaf3 |
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.
|
||||
@@ -1,6 +1,9 @@
|
||||
# Behavision API ↔ Loyaly Merchant OS — gap analysis
|
||||
|
||||
Audit date: 2026-09-09 · API: `https://platform.loyaly.ai` · Frontend: this repo
|
||||
Audit date: 2026-09-09 · API: `https://mcp.loyaly.ai` · Frontend: this repo
|
||||
|
||||
(Host corrected 2026-09-21: this line read `https://platform.loyaly.ai`, which
|
||||
serves this console, not the API. See `src/shared/config/platformApi.ts:76-80`.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
19
src/app/(admin)/admin/page.tsx
Normal file
19
src/app/(admin)/admin/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {PlatformOverview} from '@/features/admin/components/PlatformOverview';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Overview',
|
||||
};
|
||||
|
||||
/**
|
||||
* The platform console's landing page — where a platform admin arrives after
|
||||
* sign-in (the proxy sends them to /admin).
|
||||
*
|
||||
* 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 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/(admin)/layout.tsx
Normal file
23
src/app/(admin)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import {AdminLayout} from '@/features/admin/components/AdminLayout';
|
||||
|
||||
/**
|
||||
* The route-group boundary for the platform console.
|
||||
*
|
||||
* Deliberately NOT `(workspace)`. That group wraps everything in
|
||||
* `ProtectedLayout` → `WorkspaceShell`: a site switcher, tenant navigation and
|
||||
* the Loyaly AI rail, every one of which is scoped to a company. A platform
|
||||
* operator has no company, so that shell would render a store picker with
|
||||
* nothing in it above a page about somebody else's stores.
|
||||
*
|
||||
* What the two groups DO share is the session: `AdminLayout` uses the same
|
||||
* `AuthGuard` as the workspace, reading the same cookie minted by the same
|
||||
* login route. There is one authentication system in this app, and this is not
|
||||
* a second one.
|
||||
*/
|
||||
export default function AdminRouteLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,13 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sits in the (public) group on purpose: no shell, no nav, no store scope, and
|
||||
* a GuestGuard instead of an AuthGuard — see PublicLayout.
|
||||
* Sits in the (public) group on purpose: no shell, no nav, no store scope and
|
||||
* no auth guard — see PublicLayout.
|
||||
*
|
||||
* Reaching this page with a live session is already impossible via a fresh
|
||||
* request (src/proxy.ts redirects it to /dashboard); the guard covers the
|
||||
* client-side navigation the proxy never sees.
|
||||
* This page renders whenever it is asked for, with or without a live session in
|
||||
* this browser. Sessions are per tab, so "somebody is signed in here" is not a
|
||||
* reason to refuse the sign-in form to a tab that has no session of its own —
|
||||
* and a page that always renders cannot take part in a redirect cycle.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
return <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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||
import {AccountCard} from '@/features/settings/components/AccountCard';
|
||||
import {SecurityManager} from '@/features/settings/components/SecurityManager';
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
@@ -7,6 +8,9 @@ export default function SecuritySettingsPage() {
|
||||
title="Security & Audit Logs"
|
||||
description="Two-Factor authentication, password management, active login sessions and security audit history."
|
||||
>
|
||||
{/* Who you are, then the devices signed in as you — both read from the
|
||||
platform. The panels below them are still local-only. */}
|
||||
<AccountCard />
|
||||
<SecurityManager />
|
||||
</SettingsPage>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {useSites} from '@/features/stores/hooks/useSites';
|
||||
import {formatPct} from '@/shared/utils/format';
|
||||
import {ShopSection} from '@/features/stores/components/ShopSection';
|
||||
|
||||
/**
|
||||
* The estate, from GET /api/sites.
|
||||
@@ -28,7 +23,7 @@ export default function StoresPage() {
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Store"
|
||||
description="Every shop in the network, with its camera health."
|
||||
description="Every shop, its cameras, and the shop PC that watches them."
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
@@ -43,52 +38,11 @@ export default function StoresPage() {
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
|
||||
<VStack gap={6}>
|
||||
{rows.map((site) => (
|
||||
<Card key={site.id}>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<Heading level={3}>{site.name}</Heading>
|
||||
{site.isOnline === null ? null : (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={site.isOnline ? 'success' : 'error'}
|
||||
label={site.isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{site.isOnline ? 'Online' : 'Offline'}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Text size="sm" color="secondary" className="font-mono">
|
||||
{site.id}
|
||||
</Text>
|
||||
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Cameras up"
|
||||
value={
|
||||
site.camerasUp === null || site.camerasTotal === null
|
||||
? '—'
|
||||
: `${site.camerasUp}/${site.camerasTotal}`
|
||||
}
|
||||
/>
|
||||
<StatPair
|
||||
label="Below gate"
|
||||
value={
|
||||
site.fractionBelowGate === null
|
||||
? '—'
|
||||
: formatPct(site.fractionBelowGate * 100, 0)
|
||||
}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
<ShopSection key={site.id} site={site} />
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
|
||||
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))) ?? [];
|
||||
});
|
||||
}
|
||||
34
src/app/api/activities/route.ts
Normal file
34
src/app/api/activities/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
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);
|
||||
try {
|
||||
const [activities, impact] = await Promise.all([
|
||||
engagementApi.activities(token, window, site),
|
||||
engagementApi.impact(token, window, site),
|
||||
]);
|
||||
return toActivityRows(activities, impact);
|
||||
} catch (err) {
|
||||
// The deployed platform predates this route: an empty panel, not a red
|
||||
// error. Any other failure, a real 404 included, still surfaces.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) return [];
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
36
src/app/api/admin/clients/[id]/owner-password/route.ts
Normal file
36
src/app/api/admin/clients/[id]/owner-password/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/admin/clients/{id}/owner-password — reset an owner's password.
|
||||
*
|
||||
* The support case: the owner has locked themselves out and there is nobody
|
||||
* above them in the company to reset it. The new password is GENERATED, never
|
||||
* chosen, and every session that owner held is revoked.
|
||||
*
|
||||
* `email` picks the owner when the company has more than one; with exactly one
|
||||
* it may be omitted, and the UI omits it first. With several owners and no
|
||||
* address the platform answers 400 listing them — that message travels through
|
||||
* `failResponse` intact, which is what lets the dialog ask "which owner?"
|
||||
* without this console needing its own endpoint to enumerate them.
|
||||
*
|
||||
* ── The response body is a credential ────────────────────────────────────
|
||||
* It is shown once and cannot be fetched again. Nothing on this path may cache
|
||||
* it: `proxyUpstream` sets `cache-control: no-store` on every response it
|
||||
* writes, which is what keeps it out of a CDN, a browser disk cache and the
|
||||
* back button. It is never logged here, and never reaches a URL — it travels in
|
||||
* a POST response body and nowhere else.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, body) => {
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
return adminApi.resetOwnerPassword(token, id, email || undefined);
|
||||
});
|
||||
}
|
||||
91
src/app/api/admin/clients/[id]/route.ts
Normal file
91
src/app/api/admin/clients/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toCompany, toCompanyDetail} from '@/features/admin/services/mapCompany';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/admin/clients/{id} — one company, with its owner.
|
||||
*
|
||||
* Suspended companies still resolve: suspension is exactly when an operator
|
||||
* opens the page. A non-uuid or unknown id is the platform's 404.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return serveUpstream(req, (token) => adminApi.getClient(token, id), toCompanyDetail);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/clients/{id} — suspend or reinstate a company.
|
||||
*
|
||||
* Suspension is complete the moment this returns: the company's users cannot
|
||||
* sign in, every session they hold is revoked in the same transaction, and
|
||||
* visits from its shop PCs are dropped at ingest. Reinstating does not restore
|
||||
* sessions — people sign in again.
|
||||
*
|
||||
* The count of revoked sessions is surfaced rather than swallowed. "Suspended"
|
||||
* alone leaves an operator wondering whether somebody is still signed in on a
|
||||
* shop PC; "suspended, 3 sessions ended" answers it.
|
||||
*
|
||||
* `active` is read strictly as a boolean. A missing or non-boolean value is
|
||||
* forwarded as-is so the platform's own 400 (`"active" is required: true to
|
||||
* reinstate, false to suspend`) is what the operator reads, rather than a
|
||||
* second, differently-worded validation invented here.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => adminApi.setClientActive(token, id, body.active as boolean),
|
||||
{
|
||||
map: (res) => ({
|
||||
company: toCompany(res.client),
|
||||
sessionsRevoked: res.sessions_revoked,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/clients/{id} — permanent, and the data is biometric.
|
||||
*
|
||||
* Two conditions, both the PLATFORM's and neither enforced here: the company
|
||||
* must already be suspended (`409 still_active` otherwise) and the body must
|
||||
* repeat its slug. The dialog mirrors them so nobody is surprised, but this
|
||||
* route forwards whatever it is given — an active company is sent and the real
|
||||
* 409 comes back. A console that pre-empted the check would eventually disagree
|
||||
* with the server about what is deletable, and the disagreement would surface
|
||||
* as a delete that "worked" in the UI and did not happen.
|
||||
*
|
||||
* Upstream order matters if this fails: face images go from object storage
|
||||
* first (`502 storage_error` leaves everything else untouched), then the shop
|
||||
* PCs' broker logins, then every row by cascade.
|
||||
*
|
||||
* ── Why `confirm` arrives as a query parameter ───────────────────────────
|
||||
* The platform wants it in the body, and this route puts it there. It cannot
|
||||
* arrive that way, though: `proxyUpstream` does not read a body on DELETE (a
|
||||
* DELETE legitimately has none), and the browser-side `deleteJson` cannot send
|
||||
* one either. So it travels as a query parameter on THIS origin's request and
|
||||
* is moved into the body on the way upstream.
|
||||
*
|
||||
* Safe to put in a URL, unlike anything else on this screen: a slug is the
|
||||
* company's public identifier, already visible in the list and in every broker
|
||||
* topic. It is a confirmation, not a credential — it proves the operator typed
|
||||
* the right name, and it protects nothing on its own.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, _body, query) =>
|
||||
adminApi.deleteClient(token, id, query.get('confirm') ?? ''),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toAdminCamera} from '@/features/admin/services/mapMonitoring';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/admin/clients/{id}/sites/{site}/cameras/{camera} — one camera. The
|
||||
* platform checks company → shop → camera in one query.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string; site: string; camera: string}>},
|
||||
) {
|
||||
const {id, site, camera} = await params;
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.getSiteCamera(token, id, site, camera),
|
||||
toAdminCamera,
|
||||
);
|
||||
}
|
||||
22
src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts
Normal file
22
src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toAdminCamera} from '@/features/admin/services/mapMonitoring';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/admin/clients/{id}/sites/{site}/cameras — the shop's cameras,
|
||||
* redacted upstream: no host, port, path or credentials reach this console.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string; site: string}>},
|
||||
) {
|
||||
const {id, site} = await params;
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.listSiteCameras(token, id, site),
|
||||
(rows) => rows.map(toAdminCamera),
|
||||
);
|
||||
}
|
||||
24
src/app/api/admin/clients/[id]/sites/[site]/route.ts
Normal file
24
src/app/api/admin/clients/[id]/sites/[site]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toAdminStore} from '@/features/admin/services/mapMonitoring';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/admin/clients/{id}/sites/{site} — one shop of one company.
|
||||
*
|
||||
* The platform checks the pair: a shop that belongs to another company is a
|
||||
* 404, never that company's row.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string; site: string}>},
|
||||
) {
|
||||
const {id, site} = await params;
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.getClientSite(token, id, site),
|
||||
toAdminStore,
|
||||
);
|
||||
}
|
||||
22
src/app/api/admin/clients/[id]/sites/route.ts
Normal file
22
src/app/api/admin/clients/[id]/sites/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toAdminStore} from '@/features/admin/services/mapMonitoring';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/admin/clients/{id}/sites — the company's active shops, with the
|
||||
* shop PC's liveness and camera counts. Read-only.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.listClientSites(token, id),
|
||||
(rows) => rows.map(toAdminStore),
|
||||
);
|
||||
}
|
||||
62
src/app/api/admin/clients/route.ts
Normal file
62
src/app/api/admin/clients/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toCompany} from '@/features/admin/services/mapCompany';
|
||||
import type {ApiNewClientInput} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* The companies on this platform.
|
||||
*
|
||||
* ── Why this proxy exists at all ─────────────────────────────────────────
|
||||
* The browser could not call `mcp.loyaly.ai/api/admin/clients` directly even if
|
||||
* we wanted it to: the platform sends no CORS headers, so a cross-origin fetch
|
||||
* from this console is blocked before it leaves. Routing through the BFF is not
|
||||
* a workaround for that — it is the reason the platform can afford to send no
|
||||
* CORS headers. The access token stays in an httpOnly cookie this page's
|
||||
* JavaScript cannot read, so an XSS on this origin cannot steal a platform
|
||||
* session.
|
||||
*
|
||||
* Authorisation is NOT re-implemented here. `withUpstream` attaches whatever
|
||||
* token the session holds and the platform decides: `adminOnly` answers 404 to
|
||||
* anyone who is not a platform operator. A merchant who reached this route
|
||||
* would get that 404 translated into `not_found`, not a list of tenants.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.listClients(token),
|
||||
(rows) => rows.map(toCompany),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/clients — create a company and its owner, in one transaction.
|
||||
*
|
||||
* Answers **201**, and the body carries the owner's generated password. That is
|
||||
* the only time it exists in readable form: it is bcrypt-hashed on the way in
|
||||
* and cannot be fetched again.
|
||||
*
|
||||
* `password` is never forwarded from the client, even if one were sent. Empty
|
||||
* means "generate one", which is the better default — an operator typing a
|
||||
* password for somebody else invents a weak one and then sends it over chat.
|
||||
* `slug` is forwarded only when non-empty; the platform derives it from the
|
||||
* name otherwise, and it can never be changed afterwards.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => {
|
||||
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
|
||||
const input: ApiNewClientInput = {
|
||||
company_name: String(body.company_name ?? '').trim(),
|
||||
owner_email: String(body.owner_email ?? '').trim(),
|
||||
owner_name: String(body.owner_name ?? '').trim(),
|
||||
};
|
||||
if (slug) input.slug = slug;
|
||||
return adminApi.createClient(token, input);
|
||||
},
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
15
src/app/api/admin/monitoring/summary/route.ts
Normal file
15
src/app/api/admin/monitoring/summary/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toPlatformMonitoring} from '@/features/admin/services/mapMonitoring';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/admin/monitoring/summary — estate-wide counts for the Overview. */
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.monitoringSummary(token),
|
||||
toPlatformMonitoring,
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,28 @@ import {UpstreamError} from '@/services/api/apiClient';
|
||||
import {ConfigError} from '@/shared/errors/configError';
|
||||
import {
|
||||
LOGIN_ERROR_PARAM,
|
||||
NOT_PLATFORM_ADMIN_MESSAGE,
|
||||
type LoginErrorCode,
|
||||
} from '@/features/auth/services/loginErrorCodes';
|
||||
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
||||
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
|
||||
import {
|
||||
REMEMBERED_MAX_AGE_SECONDS,
|
||||
SESSION_COOKIE,
|
||||
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 {isPlatformAdmin, toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import {destinationForRole} from '@/features/auth/services/roleDestination';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
import type {ApiSuccess} from '@/shared/types/api';
|
||||
|
||||
@@ -171,50 +180,40 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A platform admin authenticates correctly and still gets no session HERE.
|
||||
* A platform admin gets a session here, exactly like a merchant does.
|
||||
*
|
||||
* `isPlatformAdmin` is role AND empty client_id together, which is the
|
||||
* pairing the platform documents — checking the role alone would misread a
|
||||
* tenant-scoped account that happens to carry an admin-shaped role.
|
||||
* ── What this used to do, and why it no longer does ──────────────────────
|
||||
* This route used to detect a platform admin, revoke the upstream session it
|
||||
* had just created, and answer 403 `platform_account`. The reasoning was
|
||||
* sound at the time: every screen in this console was tenant-scoped, an admin
|
||||
* has no tenant, and a cookie would have bought that person a dashboard of
|
||||
* 500s. Refusing the session was the honest answer.
|
||||
*
|
||||
* Every endpoint behind this console is tenant-scoped, and an admin has no
|
||||
* tenant. Measured on the live local platform with a real admin token:
|
||||
* /api/sites 500, /api/visits 500, /api/visitors 500, /api/team 403 "This
|
||||
* account does not belong to a company." Minting a cookie here would buy
|
||||
* that person nothing but a dashboard of server errors, so the session is
|
||||
* refused at the only place that can refuse it — before the cookie is set.
|
||||
* There is now somewhere for them to go — /admin, reading the platform's own
|
||||
* `/api/admin/*` surface, which is the one part of the platform that is NOT
|
||||
* tenant-scoped. So the refusal is gone, and the ONLY thing that differs for
|
||||
* an admin is the destination. Nothing about how the session is minted
|
||||
* changes: same `storeTokens`, same `createSessionToken`, same cookies, same
|
||||
* lifetimes. There is no second authentication path in this app.
|
||||
*
|
||||
* This is not a client-side authorisation check standing in for a server
|
||||
* one. It runs on the server, it mirrors the platform's own rule rather
|
||||
* than inventing a second one, and the platform still enforces its own on
|
||||
* every request regardless of what this route decides.
|
||||
* ── What is emphatically NOT delegated to the client ─────────────────────
|
||||
* `isPlatformAdmin` is role AND empty `client_id` together — the pairing the
|
||||
* platform documents. Checking the role alone would promote a tenant-scoped
|
||||
* account that happens to carry an admin-shaped role, and that account is an
|
||||
* ordinary merchant user. The answer is computed here, from a field the
|
||||
* browser never receives, and signed into the cookie (see userMapper and
|
||||
* sessionToken), so the client cannot assert it.
|
||||
*
|
||||
* The upstream session created moments ago by `authApi.login` is revoked
|
||||
* rather than abandoned: it is a live refresh token nobody will ever use,
|
||||
* and leaving it to expire on its own is a credential left lying around.
|
||||
* Best-effort — a failure to revoke must not turn into a 500 on a sign-in
|
||||
* that this console was going to decline anyway.
|
||||
* And it decides ROUTING, never authority. Every admin read this console
|
||||
* makes is authorised by the platform's own `adminOnly`, which answers 404 to
|
||||
* anyone who is not a platform operator regardless of what this cookie says.
|
||||
*
|
||||
* ── Note what is absent: no `authApi.logout` call ────────────────────────
|
||||
* Revoking was correct while no session followed — an unused refresh token is
|
||||
* a credential left lying around. Now the session DOES follow, and that same
|
||||
* token is what `storeTokens` seals for every subsequent request. Revoking it
|
||||
* here would sign the admin straight back out.
|
||||
*/
|
||||
if (isPlatformAdmin(bundle.user)) {
|
||||
try {
|
||||
await authApi.logout(bundle.access_token);
|
||||
} catch {
|
||||
/* deliberately ignored — see above */
|
||||
}
|
||||
|
||||
const code: LoginErrorCode = 'platform_account';
|
||||
if (isForm) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
|
||||
303,
|
||||
);
|
||||
}
|
||||
return failJson(
|
||||
code,
|
||||
'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minting the local session, which is where AUTH_SECRET is first read.
|
||||
@@ -236,11 +235,72 @@ export async function POST(req: NextRequest) {
|
||||
* a session it is unable to verify on the next request.
|
||||
*/
|
||||
const user = toAuthUser(bundle.user);
|
||||
const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;
|
||||
|
||||
/**
|
||||
* This is the Platform Admin console: it admits platform admins and nobody else.
|
||||
*
|
||||
* Correct merchant or staff credentials are still not Platform Admin
|
||||
* credentials. Refused HERE, before `storeTokens` and before any cookie, so
|
||||
* no session of any kind exists on this domain for them — not a merchant
|
||||
* session that the proxy then has to route away from. The platform session
|
||||
* `authApi.login` just minted is revoked for the same reason the
|
||||
* misconfigured branch below revokes it: an unused refresh token is a
|
||||
* credential left lying around. No redirect to the merchant app either; the
|
||||
* form stays put and says why.
|
||||
*/
|
||||
if (!user.isPlatformAdmin) {
|
||||
try {
|
||||
await authApi.logout(bundle.access_token);
|
||||
} catch {
|
||||
/* best-effort: the refusal must not depend on the revoke succeeding */
|
||||
}
|
||||
const code: LoginErrorCode = 'not_platform_admin';
|
||||
if (isForm) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
|
||||
303,
|
||||
);
|
||||
}
|
||||
return failJson(code, NOT_PLATFORM_ADMIN_MESSAGE, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two different lifetimes, and conflating them was the bug.
|
||||
*
|
||||
* `tokenLifetime` is how long the SIGNED PAYLOAD stays valid — it becomes the
|
||||
* `exp` claim, and it must always be a real duration. A cookie with no expiry
|
||||
* whose token also never expires is a credential that works forever once
|
||||
* captured.
|
||||
*
|
||||
* `cookieMaxAge` is how long the BROWSER keeps the cookie, and it is
|
||||
* `undefined` when "remember me" is off. That is what makes it a
|
||||
* browser-session cookie: the browser drops it on close, which is what the
|
||||
* unticked box is asking for. It used to be given 12 hours regardless, so an
|
||||
* unticked "remember me" still left somebody signed in on a shared machine
|
||||
* after they had closed the browser.
|
||||
*
|
||||
* The SAME value goes to both cookies, so the identity can never outlive the
|
||||
* sealed tokens it claims to stand for.
|
||||
*/
|
||||
const tokenLifetime = rememberMe
|
||||
? REMEMBERED_MAX_AGE_SECONDS
|
||||
: SESSION_MAX_AGE_SECONDS;
|
||||
const cookieMaxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : undefined;
|
||||
|
||||
/**
|
||||
* Which tab this session belongs to.
|
||||
*
|
||||
* The tab sends its own id in `X-Tab-Id`; signing in again in the same tab
|
||||
* REPLACES that tab's session and leaves every other tab alone. When there is
|
||||
* no id — the no-JavaScript form POST, which cannot set a header — one is
|
||||
* minted here and handed back in the pointer cookie, so that path ends up
|
||||
* with a properly scoped session too rather than a special unscoped one.
|
||||
*/
|
||||
const tabId = (await resolveTabId()) ?? newTabId();
|
||||
|
||||
let sessionCookie: string;
|
||||
try {
|
||||
await storeTokens(bundle);
|
||||
await storeTokens(bundle, cookieMaxAge, tabId);
|
||||
sessionCookie = createSessionToken(
|
||||
{
|
||||
sub: user.id,
|
||||
@@ -248,8 +308,14 @@ export async function POST(req: NextRequest) {
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
organisation: user.organisation,
|
||||
// Signed into the cookie so `proxy.ts` can decide which console to
|
||||
// serve without a round trip, and so the browser cannot edit the
|
||||
// answer: a tampered payload fails verifySessionToken and reads as no
|
||||
// session at all. Still routing, never authority — the platform
|
||||
// re-checks on every /api/admin/* call.
|
||||
isPlatformAdmin: user.isPlatformAdmin,
|
||||
},
|
||||
maxAge,
|
||||
tokenLifetime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ConfigError)) throw err;
|
||||
@@ -278,12 +344,15 @@ export async function POST(req: NextRequest) {
|
||||
const session: AuthSession = {user, expiresAt: bundle.expires_at};
|
||||
|
||||
// The no-JavaScript path lands in the SAME place the hydrated one does: an
|
||||
// explicit `next` wins, otherwise the role the platform just returned decides.
|
||||
// Both paths read one map, so a browser with JS disabled cannot end up
|
||||
// somewhere else.
|
||||
const landing = next
|
||||
? resolveRedirectTarget(next)
|
||||
: destinationForRole(user.role);
|
||||
// explicit `next` wins, otherwise what the platform just returned decides —
|
||||
// /admin for a platform operator, the role's route for a merchant. Both paths
|
||||
// read one function, so a browser with JS disabled cannot end up somewhere
|
||||
// else, and neither can walk into the wrong console.
|
||||
const landing = resolveRedirectTargetFor(
|
||||
next,
|
||||
user.isPlatformAdmin,
|
||||
destinationForUser(user),
|
||||
);
|
||||
|
||||
const res = isForm
|
||||
? NextResponse.redirect(new URL(landing, req.url), 303)
|
||||
@@ -292,6 +361,14 @@ export async function POST(req: NextRequest) {
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
|
||||
res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(maxAge));
|
||||
res.cookies.set(
|
||||
sessionCookieFor(tabId),
|
||||
sessionCookie,
|
||||
sessionCookieOptions(cookieMaxAge),
|
||||
);
|
||||
// Points server rendering and the proxy at the tab that just signed in. The
|
||||
// tab rewrites this on focus, so it follows whichever tab is in use; it is a
|
||||
// hint for the first paint, never the authority on who anyone is.
|
||||
res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions());
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import {NextResponse} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
|
||||
import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
sessionCookieFor,
|
||||
tokenCookieFor,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {peekAccessToken} from '@/features/auth/services/upstreamSession';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* POST /api/auth/logout — sign THIS TAB out.
|
||||
*
|
||||
* Revokes the session upstream first, then clears both cookies. The order is
|
||||
* deliberate, and so is the fact that an upstream failure does NOT abort the
|
||||
* local clear: if the platform is unreachable, the least bad outcome is that
|
||||
* this browser is signed out immediately and the server-side session lapses on
|
||||
* its own expiry. Leaving the user apparently signed in because a revoke call
|
||||
* failed is the one outcome nobody expects from pressing Sign out.
|
||||
* Revokes the session upstream first, then clears that tab's two cookies. The
|
||||
* order is deliberate, and so is the fact that an upstream failure does NOT
|
||||
* abort the local clear: if the platform is unreachable, the least bad outcome
|
||||
* is that this tab is signed out immediately and the server-side session lapses
|
||||
* on its own expiry. Leaving somebody apparently signed in because a revoke
|
||||
* call failed is the one outcome nobody expects from pressing Sign out.
|
||||
*
|
||||
* No refresh attempt: the token is about to be thrown away, so spending a
|
||||
* refresh token to revoke it is pure waste.
|
||||
*
|
||||
* ── Only this tab, and the upstream revoke is still real ─────────────────
|
||||
* `peekAccessToken` resolves through the tab scope, so the token revoked
|
||||
* upstream is THIS tab's session and no other. Signing out of the manager tab
|
||||
* ends the manager's platform session — genuinely, server-side, as before — and
|
||||
* leaves the admin and staff tabs holding their own untouched sessions in their
|
||||
* own cookies. Nothing here weakens server-side invalidation; it narrows what
|
||||
* gets invalidated to what the person actually asked to sign out of.
|
||||
*
|
||||
* A request with no resolvable tab clears nothing and still answers 200. There
|
||||
* is no session to end, and guessing at one would sign out a tab that never
|
||||
* asked.
|
||||
*/
|
||||
export async function POST() {
|
||||
const accessToken = await peekAccessToken();
|
||||
@@ -27,7 +43,7 @@ export async function POST() {
|
||||
await authApi.logout(accessToken);
|
||||
} catch {
|
||||
// Already-expired, revoked, or unreachable — all fine. The cookies below
|
||||
// are what actually ends this browser's session.
|
||||
// are what actually ends this tab's session.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +52,23 @@ export async function POST() {
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
|
||||
// Overwrite with an expired cookie rather than only deleting: a delete that
|
||||
// misses on `path` leaves a live session behind.
|
||||
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
|
||||
res.cookies.set(TOKEN_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
const tabId = await resolveTabId();
|
||||
if (tabId) {
|
||||
// Overwrite with an expired cookie rather than only deleting: a delete that
|
||||
// misses on `path` leaves a live session behind.
|
||||
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
|
||||
res.cookies.set(tokenCookieFor(tabId), '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// The pointer is deliberately left alone. It names a tab, not a session, and
|
||||
// the signed-out tab rewrites it on its next load anyway — clearing it here
|
||||
// would only blank the server-rendered first paint of whichever OTHER tab the
|
||||
// person switches to next.
|
||||
return res;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import {NextResponse} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
|
||||
import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
sessionCookieFor,
|
||||
tokenCookieFor,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
@@ -30,6 +34,13 @@ export async function GET() {
|
||||
try {
|
||||
const user = await withUpstream((token) => authApi.me(token));
|
||||
|
||||
// This console knows only platform admins: a merchant or staff session is
|
||||
// answered as nobody signed in, and its cookies are cleared, exactly like a
|
||||
// session the platform rejected.
|
||||
if (!toAuthUser(user).isPlatformAdmin) {
|
||||
return anonymous();
|
||||
}
|
||||
|
||||
const session: AuthSession = {
|
||||
user: toAuthUser(user),
|
||||
// The cookie's own expiry is the browser-side lifetime; the platform's
|
||||
@@ -60,18 +71,46 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
const res = NextResponse.json(
|
||||
{data: null, meta: {generatedAt: new Date().toISOString()}},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
|
||||
res.cookies.set(TOKEN_COOKIE, '', {
|
||||
return anonymous();
|
||||
}
|
||||
}
|
||||
|
||||
/** Nobody signed in: `data: null`, and this tab's session cookies cleared. */
|
||||
async function anonymous() {
|
||||
const res = NextResponse.json(
|
||||
{data: null, meta: {generatedAt: new Date().toISOString()}},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear THIS TAB's cookies, by their tab-scoped names.
|
||||
*
|
||||
* This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped
|
||||
* names from before sessions were per-tab. Those cookies do not exist any
|
||||
* more, so the clear silently did nothing and a confirmed 401 left the
|
||||
* tab's real `loyaly_session_<tabId>` in place. The result was the
|
||||
* half-authenticated state upstreamSession warns about, with a twist: the
|
||||
* client set itself unauthenticated and went to /login, the proxy saw a
|
||||
* still-valid session cookie and sent it straight back, and the two flapped.
|
||||
*
|
||||
* Same two helpers the logout route uses, so there is one naming scheme and
|
||||
* the two paths cannot drift. Scoped to the resolved tab and no other: a
|
||||
* dead session in one tab says nothing about the others, and clearing more
|
||||
* than asked would sign out a tab that is working fine.
|
||||
*
|
||||
* A request with no resolvable tab clears nothing. There is no cookie to
|
||||
* name, and guessing would reach into somebody else's session.
|
||||
*/
|
||||
const tabId = await resolveTabId();
|
||||
if (tabId) {
|
||||
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
|
||||
res.cookies.set(tokenCookieFor(tabId), '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
23
src/app/api/auth/sessions/[id]/route.ts
Normal file
23
src/app/api/auth/sessions/[id]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {refuseOffConsole} from '@/features/auth/services/serverSession';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/sessions/{id} — sign one device out.
|
||||
*
|
||||
* Revoking the CURRENT session is allowed and signs this browser out — which is
|
||||
* a legitimate thing to want and a surprising thing to do by accident, so the
|
||||
* screen warns before calling it rather than this route refusing.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const refused = await refuseOffConsole();
|
||||
if (refused) return refused;
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
|
||||
}
|
||||
19
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
19
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {refuseOffConsole} from '@/features/auth/services/serverSession';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/auth/sessions/revoke-others — sign out everywhere else.
|
||||
*
|
||||
* Keeps the caller's own session alive by design, so somebody who suspects a
|
||||
* leak can clear every other device without locking themselves out of the
|
||||
* screen they are doing it from.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const refused = await refuseOffConsole();
|
||||
if (refused) return refused;
|
||||
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
|
||||
}
|
||||
23
src/app/api/auth/sessions/route.ts
Normal file
23
src/app/api/auth/sessions/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {refuseOffConsole} from '@/features/auth/services/serverSession';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toDeviceSession} from '@/features/settings/services/mapSession';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/auth/sessions — every device currently signed in as this person.
|
||||
*
|
||||
* `current: true` marks the one making this request. It is the reason this list
|
||||
* is worth showing at all: a session the user does not recognise is how they
|
||||
* find out a password has leaked, and they need to be able to tell it apart
|
||||
* from the browser they are reading the page in.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const refused = await refuseOffConsole();
|
||||
if (refused) return refused;
|
||||
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
|
||||
list.map(toDeviceSession),
|
||||
);
|
||||
}
|
||||
34
src/app/api/cameras/[id]/check/route.ts
Normal file
34
src/app/api/cameras/[id]/check/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/cameras/{id}/check — ask the shop PC to prove this camera works.
|
||||
*
|
||||
* Two kinds: `connection` (can it be reached at all) and `placement` (is the
|
||||
* view usable for recognition). Anything else the platform rejects, so the
|
||||
* union is narrowed here rather than passed through as a free string.
|
||||
*
|
||||
* The platform answers 202 and the camera it returns still carries the PREVIOUS
|
||||
* check — the edge has not run the new one yet. The caller re-reads; it must
|
||||
* not render this response as the verdict.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
sitesApi.checkCamera(
|
||||
token,
|
||||
id,
|
||||
body.kind === 'placement' ? 'placement' : 'connection',
|
||||
),
|
||||
{map: toCamera, status: 202},
|
||||
);
|
||||
}
|
||||
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));
|
||||
}
|
||||
36
src/app/api/cameras/[id]/route.ts
Normal file
36
src/app/api/cameras/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
import type {ApiCameraInput} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/cameras/{id} — edit one camera.
|
||||
* DELETE /api/cameras/{id} — remove it.
|
||||
*
|
||||
* PATCH rather than PUT, matching the platform: a form that leaves the password
|
||||
* blank means "keep the stored one", and a PUT would read that as "clear it".
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput),
|
||||
{map: toCamera},
|
||||
);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
// The platform answers 204 with no body; proxyUpstream sends `data: null`
|
||||
// rather than an empty object, so the client can tell "done" from "malformed".
|
||||
return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
|
||||
}
|
||||
39
src/app/api/cameras/route.ts
Normal file
39
src/app/api/cameras/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
import type {ApiCameraInput} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/cameras?site=<slug> — the cameras on one shop, or all of them.
|
||||
* POST /api/cameras?site=<slug> — add one to that shop.
|
||||
*
|
||||
* The POST carries the shop in the QUERY rather than the path because the
|
||||
* platform creates under /api/sites/{site}/cameras while it reads from
|
||||
* /api/cameras — two different shapes for one resource. Collapsing them here
|
||||
* keeps that asymmetry out of every component.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const site = req.nextUrl.searchParams.get('site') ?? undefined;
|
||||
return serveUpstream(req, (token) => sitesApi.cameras(token, site), (cams) =>
|
||||
cams.map(toCamera),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const site = req.nextUrl.searchParams.get('site') ?? '';
|
||||
if (!site) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Which shop is this camera in?'}},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => sitesApi.addCamera(token, site, body as ApiCameraInput),
|
||||
{map: toCamera, status: 201},
|
||||
);
|
||||
}
|
||||
24
src/app/api/campaigns/route.ts
Normal file
24
src/app/api/campaigns/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
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));
|
||||
try {
|
||||
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
||||
return (list ?? []).map(toCampaign);
|
||||
} catch (err) {
|
||||
// The deployed platform predates this route: an empty panel, not a red
|
||||
// error. Any other failure, a real 404 included, still surfaces.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) return [];
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
import type {ApiFloorVisit} from '@/services/api/types';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
@@ -41,7 +42,20 @@ export function toFloorVisit(v: ApiFloorVisit): FloorVisit {
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) => floorApi.list(token, {site: toSiteParam(query.storeId)}),
|
||||
async (token, query) => {
|
||||
try {
|
||||
return await floorApi.list(token, {site: toSiteParam(query.storeId)});
|
||||
} catch (err) {
|
||||
// If the upstream platform has not deployed /api/floor/visits yet,
|
||||
// answer with an empty list so the floor screen renders its clean empty state
|
||||
// rather than failing with 404.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return {items: []};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
(page) => (page.items ?? []).map(toFloorVisit),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
67
src/app/api/images/route.ts
Normal file
67
src/app/api/images/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {upstreamRaw} from '@/services/api/apiClient';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/images?src=<platform image path> — any authenticated picture, proxied.
|
||||
*
|
||||
* The same hop as /api/faces and for the same reason: a browser `<img>` cannot
|
||||
* send an Authorization header, and every platform image URL requires one.
|
||||
*
|
||||
* This exists alongside /api/faces rather than replacing it. That route accepts
|
||||
* exactly one namespace, which was right while faces were the only pictures in
|
||||
* the product; camera snapshots are not under /api/faces/, so they could not be
|
||||
* displayed through it at all. /api/faces is left untouched so nothing that
|
||||
* works today changes, and new callers use this.
|
||||
*
|
||||
* ── Why an allowlist of shapes, not a prefix test ────────────────────────
|
||||
* An unchecked pass-through is an open proxy that attaches the merchant's
|
||||
* bearer token to whatever URL an attacker can get into a page. Each pattern
|
||||
* below is anchored at both ends and permits no slash inside the id segment, so
|
||||
* `/api/faces/../../admin/clients` cannot masquerade as a face. The `..` test is
|
||||
* belt and braces on top of that.
|
||||
*
|
||||
* Adding a fourth kind of image means adding a line here, deliberately.
|
||||
*/
|
||||
const ALLOWED = [
|
||||
/^\/api\/faces\/[^/?]+$/,
|
||||
/^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/,
|
||||
/^\/api\/visitors\/[^/?]+\/image$/,
|
||||
];
|
||||
|
||||
/** Exported so a caller can decide whether to render an <img> at all. */
|
||||
export function isProxyableImage(src: string): boolean {
|
||||
return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0]));
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const src = req.nextUrl.searchParams.get('src') ?? '';
|
||||
|
||||
if (!isProxyableImage(src)) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await withUpstream((token) =>
|
||||
upstreamRaw({path: src, accessToken: token}),
|
||||
);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
|
||||
// Private: this is one merchant's shop floor, and a shared cache
|
||||
// holding it would serve it across tenants.
|
||||
'cache-control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
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},
|
||||
);
|
||||
}
|
||||
31
src/app/api/reports/journey/route.ts
Normal file
31
src/app/api/reports/journey/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
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));
|
||||
try {
|
||||
return toJourney(
|
||||
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
||||
);
|
||||
} catch (err) {
|
||||
// The deployed platform predates this route: an empty panel, not a red
|
||||
// error. Any other failure, a real 404 included, still surfaces.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return {stages: [], attribution: 'observed'};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {salesApi} from '@/services/api/salesApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream, failureFrom} from '@/shared/services/bff';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {UpstreamError} from '@/services/api/apiClient';
|
||||
import type {ApiSale} from '@/services/api/types';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
@@ -44,11 +45,26 @@ export function toSale(s: ApiSale): Sale {
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) =>
|
||||
salesApi.list(token, {
|
||||
site: toSiteParam(query.storeId),
|
||||
limit: 50,
|
||||
}),
|
||||
async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||
try {
|
||||
return await salesApi.list(token, {
|
||||
site: toSiteParam(query.storeId),
|
||||
from: window.from,
|
||||
to: window.to,
|
||||
limit: 50,
|
||||
});
|
||||
} catch (err) {
|
||||
// If the upstream platform has not deployed /api/sales yet,
|
||||
// answer with an empty list so the sales screen renders cleanly
|
||||
// rather than failing. Only a missing ROUTE — a 404 for a shop that
|
||||
// does not exist is a real answer and still surfaces.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return {items: []};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
(page) => (page.items ?? []).map(toSale),
|
||||
);
|
||||
}
|
||||
@@ -72,6 +88,7 @@ export async function GET(req: NextRequest) {
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: {
|
||||
idempotencyKey?: unknown;
|
||||
clientCreatedAt?: unknown;
|
||||
visitId?: unknown;
|
||||
visitorId?: unknown;
|
||||
invoiceNo?: unknown;
|
||||
@@ -110,7 +127,7 @@ export async function POST(req: NextRequest) {
|
||||
site: typeof body.site === 'string' ? body.site : undefined,
|
||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
||||
client_created_at: new Date().toISOString(),
|
||||
client_created_at: draftTime(body.clientCreatedAt),
|
||||
lines,
|
||||
}),
|
||||
);
|
||||
@@ -124,9 +141,27 @@ export async function POST(req: NextRequest) {
|
||||
sale: result.sale ? toSale(result.sale) : null,
|
||||
},
|
||||
},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
// The platform's own status: 201 for a new sale, 200 for a replay.
|
||||
{
|
||||
status: result.status === 'already_processed' ? 200 : 201,
|
||||
headers: {'cache-control': 'no-store'},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// 404 "No such endpoint." or 405 — the deployed platform has no sale
|
||||
// writer yet. Any other 404 (a visit that is not this shop's) is real.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 'bad_request',
|
||||
message: 'Sale recording is not available on this server version yet.',
|
||||
},
|
||||
reason: 'not_implemented',
|
||||
},
|
||||
{status: 501, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
@@ -134,3 +169,17 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the sale was drafted on the device, as the dialog stamped it. Falls
|
||||
* back to now for a missing or unparseable value, and for one in the future —
|
||||
* a device clock ahead of the server must not date a sale tomorrow.
|
||||
*/
|
||||
function draftTime(value: unknown): string {
|
||||
const now = Date.now();
|
||||
if (typeof value !== 'string') return new Date(now).toISOString();
|
||||
const t = Date.parse(value);
|
||||
return Number.isFinite(t) && t <= now
|
||||
? new Date(t).toISOString()
|
||||
: new Date(now).toISOString();
|
||||
}
|
||||
|
||||
23
src/app/api/settings/profile/route.ts
Normal file
23
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {fail} from '@/shared/services/apiRoute';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET / PATCH /api/settings/profile — the merchant's business profile.
|
||||
*
|
||||
* The platform has no profile endpoint yet (docs/API-STATUS.md), so there is
|
||||
* nothing to forward to. Without this file Next.js answered its own HTML 404
|
||||
* page, which the settings screen could only report as "Unexpected response".
|
||||
* This says what is actually true, in the envelope the screen reads. Once the
|
||||
* platform ships the route, replace both handlers with `serveUpstream` /
|
||||
* `proxyUpstream` calls against it.
|
||||
*/
|
||||
const NOT_YET = 'Business profile is not available on the platform yet.';
|
||||
|
||||
export function GET() {
|
||||
return fail('not_deployed', NOT_YET, 501);
|
||||
}
|
||||
|
||||
export function PATCH() {
|
||||
return fail('not_deployed', NOT_YET, 501);
|
||||
}
|
||||
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);
|
||||
}
|
||||
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/sites/{site}/enrolment-code — a one-time code that enrols a shop PC.
|
||||
*
|
||||
* The code comes back ONCE and is not recoverable: the platform stores a hash,
|
||||
* exactly as it does for a team invitation. So this is a POST even though it
|
||||
* reads like a fetch — asking twice mints two codes rather than showing the
|
||||
* same one, and a GET would invite a browser or a prefetch to do that silently.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{site: string}>},
|
||||
) {
|
||||
const {site} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
sitesApi.enrolmentCode(
|
||||
token,
|
||||
site,
|
||||
typeof body.label === 'string' ? body.label : undefined,
|
||||
),
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
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},
|
||||
);
|
||||
}
|
||||
|
||||
32
src/app/api/team/[id]/password/route.ts
Normal file
32
src/app/api/team/[id]/password/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/team/{id}/password — set a new password for somebody.
|
||||
*
|
||||
* The response carries the password ONCE. It is bcrypt-hashed on the way in and
|
||||
* is not recoverable afterwards, so the screen must show it immediately and
|
||||
* must not stash it anywhere it could be read back.
|
||||
*
|
||||
* Omitting `password` has the platform generate a strong one, which is the
|
||||
* better default — a password an operator invents for somebody else is weak and
|
||||
* ends up in a chat message.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, body) =>
|
||||
teamApi.resetPassword(
|
||||
token,
|
||||
id,
|
||||
typeof body.password === 'string' && body.password !== ''
|
||||
? body.password
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/[id]/route.ts
Normal file
34
src/app/api/team/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toMember} from '@/features/team/services/mapTeam';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/team/{id} — change somebody's role, or switch their access off.
|
||||
*
|
||||
* Deactivating revokes every session that person holds IMMEDIATELY; it is not a
|
||||
* soft flag that takes effect at next sign-in. The UI is expected to confirm
|
||||
* before calling this.
|
||||
*
|
||||
* The platform answers 409 `last_owner` when this would leave the company with
|
||||
* no active owner. That travels through `failResponse` with its reason intact,
|
||||
* so the screen can say which rule was hit rather than "something went wrong".
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.update(token, id, {
|
||||
role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined,
|
||||
active: typeof body.active === 'boolean' ? body.active : undefined,
|
||||
}),
|
||||
{map: toMember},
|
||||
);
|
||||
}
|
||||
19
src/app/api/team/invitations/[id]/route.ts
Normal file
19
src/app/api/team/invitations/[id]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* DELETE /api/team/invitations/{id} — withdraw an invitation.
|
||||
*
|
||||
* The code stops working immediately. There is no way to un-withdraw it; a
|
||||
* change of mind means minting a new one.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
|
||||
}
|
||||
39
src/app/api/team/invitations/route.ts
Normal file
39
src/app/api/team/invitations/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toInvitation} from '@/features/team/services/mapTeam';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/team/invitations — who has been invited and not yet joined.
|
||||
* POST /api/team/invitations — invite somebody.
|
||||
*
|
||||
* The invitation is the PREFERRED way to add a person: they redeem the code and
|
||||
* choose their own password, so the merchant never handles it. The code comes
|
||||
* back once on the POST and never again.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, (token) => teamApi.invitations(token), (list) =>
|
||||
list.map(toInvitation),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.invite(token, {
|
||||
email: String(body.email ?? '').trim(),
|
||||
full_name:
|
||||
typeof body.full_name === 'string' ? body.full_name : undefined,
|
||||
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
|
||||
expires_in_days:
|
||||
typeof body.expires_in_days === 'number'
|
||||
? body.expires_in_days
|
||||
: undefined,
|
||||
}),
|
||||
{map: toInvitation, status: 201},
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/members/route.ts
Normal file
34
src/app/api/team/members/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/team/members — create a login directly and hand the password over.
|
||||
*
|
||||
* The other way in is an invitation, where the person chooses their own
|
||||
* password and the merchant never sees it. That is the better path and the UI
|
||||
* offers it first; this exists for somebody standing at the counter with no
|
||||
* phone to redeem a code on.
|
||||
*
|
||||
* Answers 201 with the member AND the generated password, shown once.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.createMember(token, {
|
||||
email: String(body.email ?? '').trim(),
|
||||
full_name:
|
||||
typeof body.full_name === 'string' ? body.full_name : undefined,
|
||||
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
|
||||
password:
|
||||
typeof body.password === 'string' && body.password !== ''
|
||||
? body.password
|
||||
: undefined,
|
||||
}),
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import type {ApiTeamMember} from '@/services/api/types';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
import {toMember} from '@/features/team/services/mapTeam';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -20,22 +18,6 @@ export const dynamic = 'force-dynamic';
|
||||
* row, while `active`, `last_login_at` and `created_at` were discarded. The
|
||||
* one field the team screen needs — who still has access — never arrived.
|
||||
*/
|
||||
function toMember(m: ApiTeamMember): TeamMember {
|
||||
return {
|
||||
id: m.id,
|
||||
// Falls back to the address rather than rendering a blank cell: somebody
|
||||
// invited but not yet named still has to be identifiable.
|
||||
name: m.full_name || m.email,
|
||||
email: m.email,
|
||||
role: m.role as UserRole,
|
||||
active: m.active,
|
||||
// Null rather than '' — "has never signed in" and "signed in at an unknown
|
||||
// time" are different facts, and the screen says so.
|
||||
lastLoginAt: m.last_login_at || null,
|
||||
createdAt: m.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
|
||||
members.map(toMember),
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {cookies} from 'next/headers';
|
||||
import {Sora, Inter} from 'next/font/google';
|
||||
import './globals.css';
|
||||
import {getServerSession} from '@/features/auth/services/serverSession';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {tabSessionScript} from '@/features/auth/services/tabSession';
|
||||
import {
|
||||
htmlThemeAttr,
|
||||
parseThemeMode,
|
||||
@@ -87,6 +89,22 @@ export default async function RootLayout({
|
||||
* already answered in this very request.
|
||||
*/
|
||||
const session = await getServerSession();
|
||||
/**
|
||||
* WHICH tab that session was resolved from, handed to the client alongside it.
|
||||
*
|
||||
* A document navigation carries no `X-Tab-Id`, so `getServerSession` resolves
|
||||
* through the `loyaly_tab` pointer — the tab that was last FOCUSED, which for
|
||||
* a newly opened tab is somebody else. The seed is still worth sending (it is
|
||||
* what saves a guard spinner on every load), but the client has to be able to
|
||||
* tell whether the seed is its own. Without this it could not: it received an
|
||||
* identity with nothing attached saying who it belonged to, so a second tab
|
||||
* rendered the first tab's user in the shell and only found out when a data
|
||||
* call answered 401.
|
||||
*
|
||||
* Not a credential and not a decision — just the label that lets the client
|
||||
* recognise a seed that is not about it. See SessionProvider.
|
||||
*/
|
||||
const tabId = await resolveTabId();
|
||||
const themeMode = await readThemeMode();
|
||||
|
||||
return (
|
||||
@@ -105,7 +123,22 @@ export default async function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body>
|
||||
<Providers initialSession={session} initialThemeMode={themeMode}>
|
||||
{/*
|
||||
FIRST child of <body>, and that position is the whole point: it gives
|
||||
this tab its id and points the cookie at it before the markup below is
|
||||
parsed, so the very first navigation is rendered as the right user. An
|
||||
effect inside Providers would run after the first paint instead.
|
||||
|
||||
It no longer takes the session: it does not decide anything about
|
||||
being signed in, and nothing in it signs anybody out. See
|
||||
services/tabSession.ts for what it replaced and why.
|
||||
*/}
|
||||
<script dangerouslySetInnerHTML={{__html: tabSessionScript(tabId)}} />
|
||||
<Providers
|
||||
initialSession={session}
|
||||
initialTabId={tabId}
|
||||
initialThemeMode={themeMode}
|
||||
>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -32,11 +32,14 @@ import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider'
|
||||
export function Providers({
|
||||
children,
|
||||
initialSession,
|
||||
initialTabId,
|
||||
initialThemeMode,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
|
||||
initialSession: AuthSession | null;
|
||||
/** Which tab that session was read from — see SessionProvider. */
|
||||
initialTabId: string | null;
|
||||
/** Resolved from the theme-mode cookie in the root layout. */
|
||||
initialThemeMode: ThemeMode;
|
||||
}) {
|
||||
@@ -44,7 +47,10 @@ export function Providers({
|
||||
// Outside <Theme>, because <Theme> takes the mode as a prop — a context
|
||||
// rendered inside it could never reach it.
|
||||
<ThemeModeProvider initialMode={initialThemeMode}>
|
||||
<ThemedProviders initialSession={initialSession}>
|
||||
<ThemedProviders
|
||||
initialSession={initialSession}
|
||||
initialTabId={initialTabId}
|
||||
>
|
||||
{children}
|
||||
</ThemedProviders>
|
||||
</ThemeModeProvider>
|
||||
@@ -54,9 +60,11 @@ export function Providers({
|
||||
function ThemedProviders({
|
||||
children,
|
||||
initialSession,
|
||||
initialTabId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialSession: AuthSession | null;
|
||||
initialTabId: string | null;
|
||||
}) {
|
||||
const {mode} = useThemeMode();
|
||||
|
||||
@@ -67,7 +75,10 @@ function ThemedProviders({
|
||||
<MotionConfig reducedMotion="user">
|
||||
{/* Above the route tree AND outside (workspace), because /login
|
||||
establishes the session that the workspace then reads. */}
|
||||
<SessionProvider initialSession={initialSession}>
|
||||
<SessionProvider
|
||||
initialSession={initialSession}
|
||||
initialTabId={initialTabId}
|
||||
>
|
||||
<WorkspaceProvider>
|
||||
<LoyalyAiProvider>{children}</LoyalyAiProvider>
|
||||
</WorkspaceProvider>
|
||||
|
||||
213
src/features/admin/components/AdminAccount.tsx
Normal file
213
src/features/admin/components/AdminAccount.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'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 {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {summarise} from '@/features/admin/hooks/useMonitoring';
|
||||
import {adminHref} from './MonitoringTables';
|
||||
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 the
|
||||
* merchant activity status list (`GET /api/admin/clients`). 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, merchant activity 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>
|
||||
|
||||
<MerchantActivityStatus />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every merchant and whether it is active or suspended.
|
||||
*
|
||||
* Only what `GET /api/admin/clients` returns: `isActive`, `sites`, `users`,
|
||||
* `createdAt`. The platform has no last-seen or last-activity field, so none
|
||||
* is shown. Suspended merchants sort first — they are the rows an admin opens
|
||||
* this list to find.
|
||||
*/
|
||||
function MerchantActivityStatus() {
|
||||
const companies = useCompanies();
|
||||
|
||||
return (
|
||||
<AsyncBoundary resource={companies} loading={<SkeletonRows count={3} />}>
|
||||
{(rows) => {
|
||||
const s = summarise(rows);
|
||||
const sorted = [...rows].sort(
|
||||
(a, b) =>
|
||||
Number(a.isActive) - Number(b.isActive) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Merchant activity status"
|
||||
subtitle={`${s.active} active · ${s.suspended} suspended`}
|
||||
actions={<Link href={adminHref.merchants}>View all</Link>}
|
||||
>
|
||||
<List>
|
||||
{sorted.map((c) => (
|
||||
<ListItem
|
||||
key={c.id}
|
||||
href={adminHref.company(c.id)}
|
||||
label={c.name}
|
||||
description={`${c.sites} ${c.sites === 1 ? 'store' : 'stores'} · ${c.users} ${c.users === 1 ? 'account' : 'accounts'} · Added ${formatDate(c.createdAt)}`}
|
||||
startContent={<Avatar name={c.name} size="sm" tooltip={false} />}
|
||||
endContent={
|
||||
<HStack gap={3} vAlign="center">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={c.isActive ? 'success' : 'error'}
|
||||
label={c.isActive ? 'Active' : 'Suspended'}
|
||||
/>
|
||||
<Text size="sm">{c.isActive ? 'Active' : 'Suspended'}</Text>
|
||||
</HStack>
|
||||
<Icon icon={ICONS.arrowRight} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</StaticPanel>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? iso
|
||||
: d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
68
src/features/admin/components/AdminLayout.tsx
Normal file
68
src/features/admin/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {Center} from '@astryxdesign/core/Center';
|
||||
import {Spinner} from '@astryxdesign/core/Spinner';
|
||||
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
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. 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>
|
||||
<OperatorsOnly>
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</OperatorsOnly>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform admins only. A merchant or staff session never sees this console.
|
||||
*
|
||||
* The proxy redirects them before this renders, but it resolves a DOCUMENT
|
||||
* request 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 /admin for the
|
||||
* operator's pointer while this tab holds the merchant.
|
||||
*
|
||||
* This used to show an explanation with a "Go to dashboard" link. Now it sends
|
||||
* the tab straight to its own home — /dashboard for a merchant, /floor for
|
||||
* staff — and renders nothing of the admin console in between.
|
||||
*
|
||||
* A full navigation rather than `router.replace`: the tab session script claims
|
||||
* the pointer on `beforeunload`, so the next document request is gated as THIS
|
||||
* tab. A client-side RSC fetch could be bounced back to /admin by the
|
||||
* operator's pointer — the loop that made the old version give up and explain.
|
||||
*
|
||||
* Routing only, like the proxy. Every admin read carries this tab's id, and the
|
||||
* proxy and platform answer 404 to a non-admin either way.
|
||||
*/
|
||||
function OperatorsOnly({children}: {children: React.ReactNode}) {
|
||||
const {user} = useSession();
|
||||
const isOperator = user?.isPlatformAdmin === true;
|
||||
const home = user && !isOperator ? destinationForUser(user) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (home) window.location.replace(home);
|
||||
}, [home]);
|
||||
|
||||
if (isOperator) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<Center height="100vh" role="status" aria-label="Leaving the admin console">
|
||||
<Spinner size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
219
src/features/admin/components/CameraDetail.tsx
Normal file
219
src/features/admin/components/CameraDetail.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'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.shops(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, last-seen and snapshot time need the platform admin camera-detail API.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(c) => (
|
||||
<List>
|
||||
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
|
||||
<ListItem
|
||||
label="Camera ID"
|
||||
description="What the engine knows it by."
|
||||
endContent={<Text size="sm">{c.cameraId}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Enabled"
|
||||
endContent={<Text size="sm">{c.isEnabled ? 'Yes' : 'No'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Last seen"
|
||||
endContent={
|
||||
<Text size="sm">
|
||||
{c.lastSeenAt ? new Date(c.lastSeenAt).toLocaleString() : '—'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
label="Last snapshot"
|
||||
endContent={
|
||||
<Text size="sm">
|
||||
{c.snapshotAt ? new Date(c.snapshotAt).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>
|
||||
);
|
||||
}
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
511
src/features/admin/components/CompanyDetail.tsx
Normal file
511
src/features/admin/components/CompanyDetail.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
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 {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,
|
||||
useCompanyOwner,
|
||||
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 {CompanyActions} from './CompanyActions';
|
||||
import {adminHref} from './MonitoringTables';
|
||||
import {ShopCard, ShopList} from './ShopCards';
|
||||
import {PendingIntegration} from './common/PendingIntegration';
|
||||
import {
|
||||
ADMIN_CAPABILITIES,
|
||||
MERCHANT_AREAS,
|
||||
} from '@/features/admin/config/capabilities';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import type {AdminStore} from '@/features/admin/types/monitoring';
|
||||
|
||||
/**
|
||||
* 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 — from the merchant
|
||||
* row — plus the owner's name and email from the detail read, when the
|
||||
* platform serves it. There is no plan or contact beyond that. If the shops
|
||||
* cannot be listed, the Shops tab says so 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 router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// The tab lives in the URL, so "back" from a shop lands on Shops, and a
|
||||
// Shops link can be shared.
|
||||
const tab: MerchantTab =
|
||||
searchParams.get('tab') === 'shops' ? 'shops' : 'general';
|
||||
const setTab = (t: MerchantTab) =>
|
||||
router.replace(
|
||||
t === 'shops'
|
||||
? adminHref.shops(company.id)
|
||||
: adminHref.company(company.id),
|
||||
{scroll: false},
|
||||
);
|
||||
const status = company.isActive ? 'Active' : 'Suspended';
|
||||
// One request feeds the tab badge, the General preview and the Shops tab,
|
||||
// so the count shown and the shops listed can never disagree. Until the
|
||||
// list exists, the merchant row's own `sites` is the only count there is.
|
||||
const stores = useCompanyStores(company.id);
|
||||
const listed =
|
||||
stores.isAvailable &&
|
||||
(stores.resource.status === 'success' || stores.resource.status === 'empty')
|
||||
? stores.resource.data
|
||||
: undefined;
|
||||
const shopCount = listed ? listed.length : company.sites;
|
||||
|
||||
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={shopCount.toLocaleString()} />}
|
||||
/>
|
||||
</TabList>
|
||||
|
||||
{tab === 'general' ? (
|
||||
<MerchantGeneral
|
||||
company={company}
|
||||
stores={stores}
|
||||
shopCount={shopCount}
|
||||
onOpenShops={() => setTab('shops')}
|
||||
/>
|
||||
) : (
|
||||
<MerchantShops
|
||||
company={company}
|
||||
stores={stores}
|
||||
shopCount={shopCount}
|
||||
/>
|
||||
)}
|
||||
</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,
|
||||
stores,
|
||||
shopCount,
|
||||
onOpenShops,
|
||||
}: {
|
||||
company: Company;
|
||||
stores: AdminData<AdminStore[]>;
|
||||
shopCount: number;
|
||||
onOpenShops: () => void;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const owner = useCompanyOwner(company.id);
|
||||
// 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());
|
||||
// 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),
|
||||
);
|
||||
// Merchant-level areas the platform cannot answer yet. Cameras and their
|
||||
// heartbeat live inside a shop, so they are not listed here.
|
||||
const upcoming = MERCHANT_AREAS.filter(
|
||||
(a) =>
|
||||
!['shops', 'cameras', 'heartbeat'].includes(a.key) &&
|
||||
!ADMIN_CAPABILITIES[a.capability],
|
||||
).map((a) => a.label);
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4} gap={4}>
|
||||
<Fact
|
||||
icon="stores"
|
||||
label="Shops"
|
||||
value={shopCount.toLocaleString()}
|
||||
caption="Registered to this merchant"
|
||||
action={{label: 'View shops →', onClick: onOpenShops}}
|
||||
/>
|
||||
<Fact
|
||||
icon="staff"
|
||||
label="Merchant accounts"
|
||||
value={company.users.toLocaleString()}
|
||||
caption={
|
||||
owner
|
||||
? `Owner: ${[owner.name, owner.email].filter(Boolean).join(' · ')}`
|
||||
: '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="Shops"
|
||||
subtitle={`The shops and businesses ${company.name} runs. Open one to see its cameras, footfall and sales.`}
|
||||
actions={
|
||||
shopCount > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label="View all shops"
|
||||
onClick={onOpenShops}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{stores.isAvailable ? (
|
||||
<AsyncBoundary
|
||||
resource={stores.resource}
|
||||
loading={<SkeletonRows count={2} />}
|
||||
empty={
|
||||
<Text size="sm" color="secondary">
|
||||
{company.name} has not opened a shop yet.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<Grid
|
||||
columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3}
|
||||
gap={4}
|
||||
>
|
||||
{[...rows]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, PREVIEW)
|
||||
.map((s) => (
|
||||
<ShopCard key={s.id} companyId={company.id} shop={s} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<ShopsPending company={company} />
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{upcoming.length > 0 ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
Also coming to this merchant as the platform exposes them:{' '}
|
||||
{upcoming.join(' · ')}.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const PREVIEW = 3;
|
||||
|
||||
/** Why the shops cannot be opened yet — said once, in engineering terms. */
|
||||
function ShopsPending({company}: {company: Company}) {
|
||||
const n = company.sites;
|
||||
return (
|
||||
<PendingIntegration
|
||||
icon="stores"
|
||||
title="Shops cannot be listed yet"
|
||||
description={
|
||||
n === 0
|
||||
? `${company.name} has not opened a shop.`
|
||||
: `The platform reports ${n} ${n === 1 ? 'shop' : 'shops'} for ${company.name} but has no platform-admin endpoint to list them. Once GET /api/admin/clients/{id}/sites ships, every shop opens from here.`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MerchantShops({
|
||||
company,
|
||||
stores,
|
||||
shopCount,
|
||||
}: {
|
||||
company: Company;
|
||||
stores: AdminData<AdminStore[]>;
|
||||
shopCount: number;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={6}>
|
||||
<VStack gap={5} width="100%">
|
||||
<SectionHeader
|
||||
title="Shops"
|
||||
subtitle={`${shopCount.toLocaleString()} ${
|
||||
shopCount === 1 ? 'shop' : 'shops'
|
||||
} registered to ${company.name}.`}
|
||||
/>
|
||||
{stores.isAvailable ? (
|
||||
<AsyncBoundary
|
||||
resource={stores.resource}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No shops"
|
||||
description={`${company.name} has not opened a shop yet.`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <ShopList companyId={company.id} shops={rows} />}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<ShopsPending company={company} />
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled fact with its icon. Not a KPI card: these do not trend. */
|
||||
function Fact({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
action,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
/** Makes the whole card a way in — e.g. Shops → the Shops tab. */
|
||||
action?: {label: string; onClick: () => void};
|
||||
}) {
|
||||
const body = (
|
||||
<FactBody icon={icon} label={label} value={value} caption={caption}>
|
||||
{action ? (
|
||||
<Text size="sm" weight="medium">
|
||||
{action.label}
|
||||
</Text>
|
||||
) : null}
|
||||
</FactBody>
|
||||
);
|
||||
return action ? (
|
||||
<ClickableCard
|
||||
label={`${label}: ${action.label}`}
|
||||
onClick={action.onClick}
|
||||
padding={4}
|
||||
className="h-full"
|
||||
>
|
||||
{body}
|
||||
</ClickableCard>
|
||||
) : (
|
||||
<Card padding={4} className="h-full">
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FactBody({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
children,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
{children}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
176
src/features/admin/components/CreateCompanyDialog.tsx
Normal file
176
src/features/admin/components/CreateCompanyDialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'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 {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
|
||||
/**
|
||||
* Create a company and its owner, in one transaction.
|
||||
*
|
||||
* ── Why the owner is not optional ────────────────────────────────────────
|
||||
* The platform creates both together on purpose: a company with no owner is a
|
||||
* tenant nobody can sign into, and it looks entirely normal in the list. The
|
||||
* operator finds out weeks later, when the customer says their login does not
|
||||
* work. So this form asks for the owner up front rather than offering a
|
||||
* "add the owner later" path that upstream would not honour anyway.
|
||||
*
|
||||
* ── The password is not ours to choose ───────────────────────────────────
|
||||
* There is no password field, and adding one would be a mistake even though
|
||||
* the API accepts it. An operator inventing a password for somebody else
|
||||
* invents a weak one and then sends it over chat. The server generates it and
|
||||
* returns it exactly once — it is bcrypt-hashed on the way in and cannot be
|
||||
* fetched again — so the reveal below is the only chance to copy it.
|
||||
*
|
||||
* ── Slug ─────────────────────────────────────────────────────────────────
|
||||
* Optional, and left blank by default. The platform derives it from the name,
|
||||
* strips the characters that would break a broker topic, and then it can NEVER
|
||||
* be changed. A field that permanent deserves to be a deliberate act, not a
|
||||
* box somebody fills in because it is there.
|
||||
*/
|
||||
export function CreateCompanyDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Fired once the list should refetch — on success, and on close after one. */
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [ownerName, setOwnerName] = useState('');
|
||||
const [ownerEmail, setOwnerEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
const canSubmit =
|
||||
companyName.trim() !== '' && ownerEmail.trim() !== '' && !busy;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.create({
|
||||
company_name: companyName.trim(),
|
||||
owner_email: ownerEmail.trim(),
|
||||
owner_name: ownerName.trim(),
|
||||
...(slug.trim() ? {slug: slug.trim()} : {}),
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
// 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 merchant.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreated({email: res.data.owner_email, password: res.data.password});
|
||||
// The company exists now, whether or not they close this dialog politely.
|
||||
onCreated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing is what discards the password.
|
||||
*
|
||||
* It lives in this component's state and nowhere else: not in the URL, not in
|
||||
* localStorage, not in a toast that outlives the dialog, and never in a log.
|
||||
* Unmounting drops it, which is the strongest guarantee available on a page
|
||||
* — and the reason the warning says to copy it first.
|
||||
*/
|
||||
function close() {
|
||||
setCreated(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Create merchant"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={created ? 'Merchant created' : 'Create merchant'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
{created ? (
|
||||
<VStack gap={4} width="100%">
|
||||
<Banner
|
||||
status="success"
|
||||
title={companyName.trim()}
|
||||
description={`Owner account created for ${created.email}.`}
|
||||
/>
|
||||
<SecretOnce
|
||||
value={created.password}
|
||||
label={`Password for ${created.email}`}
|
||||
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={close} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={4} width="100%">
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<TextInput
|
||||
label="Merchant name"
|
||||
value={companyName}
|
||||
onChange={setCompanyName}
|
||||
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 merchant's broker topic."
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={ownerEmail}
|
||||
onChange={setOwnerEmail}
|
||||
placeholder="owner@merchant.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner name"
|
||||
value={ownerName}
|
||||
onChange={setOwnerName}
|
||||
placeholder="Their full name"
|
||||
/>
|
||||
|
||||
<Text size="xsm" color="secondary">
|
||||
The owner's password is generated by the server and shown
|
||||
once, here, immediately after this succeeds.
|
||||
</Text>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={close} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={busy}
|
||||
label="Create merchant"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
src/features/admin/components/DeleteCompanyDialog.tsx
Normal file
125
src/features/admin/components/DeleteCompanyDialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'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 {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Delete a company. Permanently.
|
||||
*
|
||||
* ── Why the typed slug ───────────────────────────────────────────────────
|
||||
* The platform requires the body to repeat the company's slug, and this dialog
|
||||
* asks for it rather than filling it in. That is the whole safeguard: a
|
||||
* confirm button can be clicked by muscle memory on the wrong row, and typing
|
||||
* "tenext-retail" cannot. The button stays disabled until it matches exactly —
|
||||
* no trimming of case, because the slug is lowercase upstream and a console
|
||||
* that quietly "fixes" what was typed is not confirming anything.
|
||||
*
|
||||
* ── Why the suspended check is NOT enforced here ─────────────────────────
|
||||
* The platform refuses to delete an active company (`409 still_active`), and
|
||||
* this dialog does not pre-empt that. It says so plainly, and the button is
|
||||
* still live: an active company is sent, and the platform's real 409 is what
|
||||
* appears. A console that blocked the request itself would eventually disagree
|
||||
* with the server about what is deletable, and that disagreement surfaces as a
|
||||
* delete that looked like it worked and did not happen.
|
||||
*
|
||||
* ── What is actually destroyed ───────────────────────────────────────────
|
||||
* Stored face images go from object storage first — a failure there is
|
||||
* `502 storage_error` and nothing else is touched — then the shop PCs' broker
|
||||
* logins, then every row by cascade: face templates, visits, users, sessions,
|
||||
* cameras. The data is biometric. There is no undo and no backup to ask for.
|
||||
*/
|
||||
export function DeleteCompanyDialog({
|
||||
company,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const matches = confirm === company.slug;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.remove(company.id, confirm);
|
||||
setBusy(false);
|
||||
|
||||
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 merchant.');
|
||||
return;
|
||||
}
|
||||
|
||||
onDeleted();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Delete merchant"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Delete merchant?"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<Banner
|
||||
status="error"
|
||||
title="This cannot be undone"
|
||||
description={`Deletes ${company.name}, its ${company.sites} ${
|
||||
company.sites === 1 ? 'shop' : 'shops'
|
||||
}, its ${company.users} ${
|
||||
company.users === 1 ? 'account' : 'accounts'
|
||||
}, and every stored face image, visit and camera. The data is biometric and there is no backup to restore from.`}
|
||||
/>
|
||||
|
||||
{company.isActive ? (
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Still active"
|
||||
description="The platform will refuse this until the merchant is suspended. Suspend it first, then come back."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Type the merchant slug to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={company.slug}
|
||||
description={`Exactly: ${company.slug}`}
|
||||
/>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!matches || busy}
|
||||
isLoading={busy}
|
||||
label="Delete merchant"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
171
src/features/admin/components/MonitoringTables.tsx
Normal file
171
src/features/admin/components/MonitoringTables.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'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 {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {
|
||||
AdminAlert,
|
||||
AdminCameraStatus,
|
||||
AdminEvent,
|
||||
AdminSeverity,
|
||||
} 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)}`,
|
||||
/** The merchant page, opened on its Shops tab — where "back" from a shop goes. */
|
||||
shops: (companyId: string) => `${adminHref.company(companyId)}?tab=shops`,
|
||||
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)}`,
|
||||
};
|
||||
|
||||
/* ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
742
src/features/admin/components/PlatformOverview.tsx
Normal file
742
src/features/admin/components/PlatformOverview.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
'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 {
|
||||
SkeletonMetricGrid,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import type {BrandAccent} from '@/shared/utils/accent';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {
|
||||
summarise,
|
||||
usePlatformMonitoring,
|
||||
} 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 {AdminKpiCard, KpiCaption} from './common/AdminKpiCard';
|
||||
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.
|
||||
*/
|
||||
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 : 3;
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
<AdminKpiCard
|
||||
label="Merchants"
|
||||
value={s.companies}
|
||||
unit="tenants"
|
||||
icon={ICONS.companies}
|
||||
tone="warm"
|
||||
isFooterDivided={false}
|
||||
footer={
|
||||
<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" />
|
||||
<KpiCaption>{s.active} active</KpiCaption>
|
||||
</HStack>
|
||||
<KpiCaption>{s.suspended} suspended</KpiCaption>
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="Active merchants"
|
||||
value={s.active}
|
||||
unit="active"
|
||||
icon={ICONS.present}
|
||||
tone="success"
|
||||
footer={
|
||||
<>
|
||||
<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>
|
||||
<KpiCaption>
|
||||
{s.companies - s.active === 0
|
||||
? 'All nominal'
|
||||
: `${s.companies - s.active} inactive`}
|
||||
</KpiCaption>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="Stores"
|
||||
value={s.stores}
|
||||
unit="locations"
|
||||
icon={ICONS.stores}
|
||||
tone="warm"
|
||||
footer={
|
||||
<>
|
||||
<KpiCaption>
|
||||
{s.active} active {s.active === 1 ? 'client' : 'clients'}
|
||||
</KpiCaption>
|
||||
<Token size="sm" label={`${avgStores} avg / tenant`} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
|
||||
<EstateKpis columns={columns} />
|
||||
|
||||
<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-success-muted text-success shrink-0"
|
||||
>
|
||||
<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"
|
||||
className={ACCENT.warm.ink}
|
||||
/>
|
||||
<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"
|
||||
className={ACCENT.cool.ink}
|
||||
/>
|
||||
<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;
|
||||
/** warm = what a merchant runs; cool = analytics and AI. */
|
||||
accent: BrandAccent;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const COVERAGE: CoverageItem[] = [
|
||||
{
|
||||
label: 'Merchants',
|
||||
capability: 'companies',
|
||||
description:
|
||||
'Tenant isolation, company lifecycle, and operator credential provisioning.',
|
||||
icon: ICONS.companies,
|
||||
accent: 'warm',
|
||||
href: adminHref.merchants,
|
||||
},
|
||||
{
|
||||
label: 'Merchant shops',
|
||||
capability: 'companyStores',
|
||||
description:
|
||||
'Storefront network topology, physical address mapping, and device linkages.',
|
||||
icon: ICONS.stores,
|
||||
accent: 'warm',
|
||||
},
|
||||
{
|
||||
label: 'Footfall',
|
||||
capability: 'footfall',
|
||||
description:
|
||||
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
|
||||
icon: ICONS.visitors,
|
||||
accent: 'cool',
|
||||
href: '/admin/footfall',
|
||||
},
|
||||
{
|
||||
label: 'Commerce',
|
||||
capability: 'commerce',
|
||||
description:
|
||||
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
|
||||
icon: ICONS.commerce,
|
||||
accent: 'warm',
|
||||
href: '/admin/commerce',
|
||||
},
|
||||
{
|
||||
label: 'Platform Loyaly AI',
|
||||
capability: 'platformAssistant',
|
||||
description:
|
||||
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
|
||||
icon: ICONS.ai,
|
||||
accent: 'cool',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Camera and visit totals across every merchant, from the platform's
|
||||
* monitoring summary. Rendered only once it has real numbers: while loading,
|
||||
* on a platform that predates the route, or on a failure, the row is simply
|
||||
* absent — the merchant row above is the page, this is an extra, and a
|
||||
* skeleton or error here would read as the overview being broken.
|
||||
*/
|
||||
function EstateKpis({columns}: {columns: number}) {
|
||||
const monitoring = usePlatformMonitoring();
|
||||
if (!monitoring.isAvailable || monitoring.resource.status !== 'success') {
|
||||
return null;
|
||||
}
|
||||
const m = monitoring.resource.data;
|
||||
const offline = m.camerasTotal - m.camerasOnline;
|
||||
const onlinePercent =
|
||||
m.camerasTotal > 0 ? Math.round((m.camerasOnline / m.camerasTotal) * 100) : 100;
|
||||
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
<AdminKpiCard
|
||||
label="Cameras online"
|
||||
value={m.camerasOnline}
|
||||
unit={`of ${m.camerasTotal.toLocaleString()}`}
|
||||
icon={ICONS.camera}
|
||||
tone={offline === 0 ? 'success' : 'warning'}
|
||||
isFooterDivided={false}
|
||||
footer={
|
||||
<VStack gap={2} width="100%">
|
||||
<ProgressBar
|
||||
value={onlinePercent}
|
||||
label="Cameras online"
|
||||
isLabelHidden
|
||||
variant={offline === 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
<KpiCaption>
|
||||
{offline === 0 ? 'Every camera reporting' : `${offline} not reporting`}
|
||||
</KpiCaption>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="Visits today"
|
||||
value={m.eventsToday}
|
||||
unit="walk-ins"
|
||||
icon={ICONS.visitors}
|
||||
tone="cool"
|
||||
footer={
|
||||
<KpiCaption>
|
||||
Across {m.storesTotal.toLocaleString()}{' '}
|
||||
{m.storesTotal === 1 ? 'shop' : 'shops'}
|
||||
</KpiCaption>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
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, accent, 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 shrink-0 ${ACCENT[accent].chip}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
397
src/features/admin/components/PlatformPages.tsx
Normal file
397
src/features/admin/components/PlatformPages.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import type {SelectorOptionType} from '@astryxdesign/core/Selector';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
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 {AdminKpiCard, KpiCaption} from './common/AdminKpiCard';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
// Same budget as the Overview row, so a card is the same width everywhere.
|
||||
const columns = bp === 'mobile' ? 1 : 3;
|
||||
|
||||
// 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={columns} gap={4}>
|
||||
<AdminKpiCard
|
||||
label="Stores"
|
||||
value={merchant.sites}
|
||||
unit={merchant.sites === 1 ? 'location' : 'locations'}
|
||||
icon={ICONS.stores}
|
||||
tone="warm"
|
||||
footer={<KpiCaption>Registered to {merchant.name}</KpiCaption>}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="Status"
|
||||
value={merchant.isActive ? 'Active' : 'Suspended'}
|
||||
icon={ICONS.present}
|
||||
tone={merchant.isActive ? 'success' : 'error'}
|
||||
footer={
|
||||
<HStack gap={1.5} vAlign="center" className="min-w-0">
|
||||
<StatusDot
|
||||
variant={merchant.isActive ? 'success' : 'error'}
|
||||
label={merchant.isActive ? 'Active' : 'Suspended'}
|
||||
/>
|
||||
<KpiCaption>
|
||||
{merchant.isActive
|
||||
? 'Accounts can sign in'
|
||||
: 'Sign-in is switched off'}
|
||||
</KpiCaption>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="On the platform since"
|
||||
value={formatDate(merchant.createdAt)}
|
||||
icon={ICONS.companies}
|
||||
tone="warm"
|
||||
footer={<KpiCaption>@{merchant.slug}</KpiCaption>}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const active = merchants.filter((m) => m.isActive);
|
||||
const shops = active.reduce((n, m) => n + m.sites, 0);
|
||||
const withoutShop = active.filter((m) => m.sites === 0).length;
|
||||
|
||||
return (
|
||||
<Grid key="all" columns={columns} gap={4}>
|
||||
<AdminKpiCard
|
||||
label="Active merchants"
|
||||
value={active.length}
|
||||
unit="active"
|
||||
icon={ICONS.present}
|
||||
tone="success"
|
||||
footer={
|
||||
<>
|
||||
<KpiCaption>{merchants.length} on the platform</KpiCaption>
|
||||
<Token
|
||||
size="sm"
|
||||
label={`${merchants.length - active.length} suspended`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="Stores"
|
||||
value={shops}
|
||||
unit="locations"
|
||||
icon={ICONS.stores}
|
||||
tone="warm"
|
||||
footer={
|
||||
<>
|
||||
<KpiCaption>Registered to active merchants</KpiCaption>
|
||||
<Token
|
||||
size="sm"
|
||||
label={`${(shops / (active.length || 1)).toFixed(1)} avg`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<AdminKpiCard
|
||||
label="Merchants without a store"
|
||||
value={withoutShop}
|
||||
unit="pending"
|
||||
icon={ICONS.stores}
|
||||
tone={withoutShop > 0 ? 'warning' : 'success'}
|
||||
footer={
|
||||
<HStack gap={1.5} vAlign="center" className="min-w-0">
|
||||
<StatusDot
|
||||
variant={withoutShop > 0 ? 'warning' : 'success'}
|
||||
label={withoutShop > 0 ? 'Needs setup' : 'All set up'}
|
||||
/>
|
||||
<KpiCaption>
|
||||
{withoutShop > 0
|
||||
? 'Active, with nothing to report yet'
|
||||
: 'Every active merchant has a store'}
|
||||
</KpiCaption>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? iso
|
||||
: d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
149
src/features/admin/components/ResetOwnerPasswordDialog.tsx
Normal file
149
src/features/admin/components/ResetOwnerPasswordDialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'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 {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Generate a new password for a company's owner.
|
||||
*
|
||||
* ── The support case this exists for ─────────────────────────────────────
|
||||
* The owner has locked themselves out and there is nobody above them inside
|
||||
* the company to reset it. Every other account is reset by their own owner;
|
||||
* this is the one that cannot be.
|
||||
*
|
||||
* ── Why the email box starts empty and hidden ────────────────────────────
|
||||
* With exactly one owner the platform does not need to be told which — and
|
||||
* that is the common case, so asking would be a field nobody can answer
|
||||
* usefully. With several owners it answers 400 and NAMES them. That message is
|
||||
* shown verbatim and the box appears, so the operator picks from the
|
||||
* platform's own list. This console never enumerates owners itself: there is
|
||||
* no endpoint for it, and inventing one would mean guessing.
|
||||
*
|
||||
* ── The password ─────────────────────────────────────────────────────────
|
||||
* Generated, never chosen. Shown once — it is bcrypt-hashed upstream and
|
||||
* cannot be fetched again. It lives in this component's state and nowhere
|
||||
* else, and closing the dialog discards it. Resetting also revokes every
|
||||
* session that owner held, so they are signed out wherever they were.
|
||||
*/
|
||||
export function ResetOwnerPasswordDialog({
|
||||
company,
|
||||
onClose,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [needsEmail, setNeedsEmail] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.resetOwnerPassword(
|
||||
company.id,
|
||||
email.trim() || undefined,
|
||||
);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
// A 400 here is the platform saying "which owner?" and listing them.
|
||||
// Surface it word for word and reveal the box — a generic failure would
|
||||
// hide the one piece of information needed to succeed on the retry.
|
||||
if (res.status === 400) setNeedsEmail(true);
|
||||
setError(res.message ?? 'Could not reset that password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({email: res.data.email, password: res.data.password});
|
||||
}
|
||||
|
||||
function close() {
|
||||
setResult(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Reset owner password"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={result ? 'Password reset' : 'Reset owner password?'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
{result ? (
|
||||
<VStack gap={4} width="100%">
|
||||
<SecretOnce
|
||||
value={result.password}
|
||||
label={`New password for ${result.email}`}
|
||||
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Every session that owner held has been revoked. They are signed
|
||||
out everywhere and will need this password to get back in.
|
||||
</Text>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={close} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={4} width="100%">
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
A new password is generated for the owner and shown once. Their
|
||||
existing sessions are revoked, so they are signed out
|
||||
immediately.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{needsEmail ? (
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="owner@merchant.com"
|
||||
description="This merchant has more than one owner — name the one to reset."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={close} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
isDisabled={busy || (needsEmail && email.trim() === '')}
|
||||
label="Reset password"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
236
src/features/admin/components/ShopCards.tsx
Normal file
236
src/features/admin/components/ShopCards.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Pagination} from '@astryxdesign/core/Pagination';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {AdminCamera, AdminStore} from '@/features/admin/types/monitoring';
|
||||
import {CameraStatus, adminHref} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* A merchant's shops, and a shop's cameras, as cards that open.
|
||||
*
|
||||
* Both render exactly the rows the platform returned for THAT merchant /
|
||||
* shop — the list endpoint nests under the merchant's id, so another
|
||||
* merchant's shops cannot arrive here, and nothing is filtered client-side for
|
||||
* isolation (search and the status filter only narrow what was returned).
|
||||
*
|
||||
* Every field shown is on the platform's own row: name, slug, timezone, shop
|
||||
* PC online, last heartbeat, cameras up/total. There is no location or
|
||||
* created date on a site, so no card claims one.
|
||||
*/
|
||||
|
||||
const when = (iso?: string) => (iso ? new Date(iso).toLocaleString() : '—');
|
||||
|
||||
function columnsFor(bp: ReturnType<typeof useBreakpoint>) {
|
||||
return bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
type StatusFilter = 'all' | 'online' | 'offline';
|
||||
|
||||
export function ShopList({
|
||||
companyId,
|
||||
shops,
|
||||
}: {
|
||||
companyId: string;
|
||||
shops: AdminStore[];
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const [query, setQuery] = useState('');
|
||||
const [status, setStatus] = useState<StatusFilter>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return shops
|
||||
.filter((s) =>
|
||||
status === 'all' ? true : status === 'online' ? s.isOnline : !s.isOnline,
|
||||
)
|
||||
.filter(
|
||||
(s) =>
|
||||
!q ||
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.slug.toLowerCase().includes(q),
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [shops, query, status]);
|
||||
|
||||
const pageRows = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-2">
|
||||
<TextInput
|
||||
label="Search shops"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search shops…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
label="Filter by shop status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="online" label="Online" />
|
||||
<SegmentedControlItem value="offline" label="Offline" />
|
||||
</SegmentedControl>
|
||||
<Text size="sm" color="secondary">
|
||||
{visible.length.toLocaleString()} of {shops.length.toLocaleString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<Text size="sm" color="secondary">
|
||||
No shops match these filters.
|
||||
</Text>
|
||||
) : (
|
||||
<Grid columns={columnsFor(bp)} gap={4}>
|
||||
{pageRows.map((s) => (
|
||||
<ShopCard key={s.id} companyId={companyId} shop={s} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{visible.length > PAGE_SIZE ? (
|
||||
<HStack hAlign="end">
|
||||
<Pagination
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
totalItems={visible.length}
|
||||
pageSize={PAGE_SIZE}
|
||||
variant="count"
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShopCard({companyId, shop}: {companyId: string; shop: AdminStore}) {
|
||||
return (
|
||||
<ClickableCard
|
||||
label={`Open ${shop.name}`}
|
||||
href={adminHref.store(companyId, shop.id)}
|
||||
padding={4}
|
||||
className="h-full"
|
||||
>
|
||||
<VStack gap={4} width="100%" height="100%" vAlign="between">
|
||||
<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.stores} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={0} className="min-w-0">
|
||||
<Text weight="medium" className="truncate">
|
||||
{shop.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{shop.slug} · {shop.timezone}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={shop.isOnline ? 'success' : 'error'}
|
||||
label={shop.isOnline ? 'Shop PC online' : 'Shop PC offline'}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{shop.isOnline ? 'Online' : 'Offline'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{shop.camerasUp.toLocaleString()} of{' '}
|
||||
{shop.camerasTotal.toLocaleString()}{' '}
|
||||
{shop.camerasTotal === 1 ? 'camera' : 'cameras'} up · last heartbeat{' '}
|
||||
{when(shop.lastHeartbeatAt)}
|
||||
</Text>
|
||||
<Text size="sm" weight="medium">
|
||||
View shop →
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ClickableCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function CameraGrid({
|
||||
companyId,
|
||||
storeId,
|
||||
cameras,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
cameras: AdminCamera[];
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const sorted = [...cameras].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<Grid columns={columnsFor(bp)} gap={4}>
|
||||
{sorted.map((c) => (
|
||||
<ClickableCard
|
||||
key={c.id}
|
||||
label={`Open camera ${c.name}`}
|
||||
href={adminHref.camera(companyId, storeId, c.id)}
|
||||
padding={4}
|
||||
className="h-full"
|
||||
>
|
||||
<VStack gap={3} 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.camera} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={0} className="min-w-0">
|
||||
<Text weight="medium" className="truncate">
|
||||
{c.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{c.cameraId}
|
||||
{c.isEnabled ? '' : ' · disabled'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<CameraStatus status={c.status} />
|
||||
<Text size="xsm" color="secondary">
|
||||
Last seen {when(c.lastSeenAt)}
|
||||
</Text>
|
||||
<Text size="sm" weight="medium">
|
||||
View camera →
|
||||
</Text>
|
||||
</VStack>
|
||||
</ClickableCard>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
402
src/features/admin/components/StoreDetail.tsx
Normal file
402
src/features/admin/components/StoreDetail.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'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 {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
formatCount,
|
||||
formatDayLabel,
|
||||
formatInr,
|
||||
formatInrCompact,
|
||||
} from '@/shared/utils/format';
|
||||
import {
|
||||
useAlerts,
|
||||
useCompany,
|
||||
useCompanyStores,
|
||||
useEvents,
|
||||
useStore,
|
||||
useStoreCameras,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import {
|
||||
useCommerceAnalytics,
|
||||
useFootfallAnalytics,
|
||||
} from '@/features/admin/hooks/useAnalytics';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import type {AdminStore} from '@/features/admin/types/monitoring';
|
||||
import type {AnalyticsFilters} from '@/features/admin/types/analytics';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {CompanyNotFound} from './CompanyDetail';
|
||||
import {AlertTable, EventTable, adminHref} from './MonitoringTables';
|
||||
import {CameraGrid} from './ShopCards';
|
||||
import {AnalyticsPanel, StatPanel} from './analytics/AnalyticsPanel';
|
||||
import {FootfallTable} from './analytics/FootfallDashboard';
|
||||
|
||||
/**
|
||||
* One shop of one merchant, and everything the platform can say about it.
|
||||
*
|
||||
* ── The pair, never the shop alone ───────────────────────────────────────
|
||||
* The route is /admin/merchants/{merchantId}/shops/{shopId}, and every
|
||||
* request this page makes nests the same way — shop detail, cameras, events,
|
||||
* alerts, and the footfall / commerce reads (merchant + shop as filters). The
|
||||
* merchant is resolved first from the real list; whether the shop belongs to
|
||||
* it is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §2.2).
|
||||
*
|
||||
* ── Only what the platform returns ───────────────────────────────────────
|
||||
* A site carries name, slug, timezone, shop-PC liveness, heartbeat and
|
||||
* camera counts — no address, area or created date — so none is shown. Each
|
||||
* section renders real rows when its endpoint exists and its own honest
|
||||
* empty state until then.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const isoDay = (t: number) => new Date(t).toISOString().slice(0, 10);
|
||||
const when = (iso?: string) => (iso ? new Date(iso).toLocaleString() : '—');
|
||||
|
||||
/** Nothing for this area exists on the platform yet — no repository at all. */
|
||||
const NOT_BUILT = {isAvailable: false} as const satisfies AdminData<never>;
|
||||
|
||||
function StoreBody({company, storeId}: {company: Company; storeId: string}) {
|
||||
const bp = useBreakpoint();
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
const store = useStore(company.id, storeId);
|
||||
// The merchant's shop list, when it exists, also names this shop — and is
|
||||
// how a shop id that is not this merchant's is caught before anything else.
|
||||
const list = useCompanyStores(company.id);
|
||||
const cameras = useStoreCameras(company.id, storeId);
|
||||
const events = useEvents(company.id, storeId);
|
||||
const alerts = useAlerts(company.id, storeId);
|
||||
|
||||
const [now] = useState(() => Date.now());
|
||||
const filters: AnalyticsFilters = useMemo(
|
||||
() => ({
|
||||
merchantId: company.id,
|
||||
shopId: storeId,
|
||||
range: {from: isoDay(now - 29 * DAY_MS), to: isoDay(now)},
|
||||
}),
|
||||
[company.id, storeId, now],
|
||||
);
|
||||
const footfall = useFootfallAnalytics(filters);
|
||||
const commerce = useCommerceAnalytics(filters);
|
||||
|
||||
const fromDetail =
|
||||
store.isAvailable && store.resource.status === 'success'
|
||||
? store.resource.data
|
||||
: undefined;
|
||||
const listRows =
|
||||
list.isAvailable &&
|
||||
(list.resource.status === 'success' || list.resource.status === 'empty')
|
||||
? list.resource.data
|
||||
: undefined;
|
||||
const listLoaded = listRows !== undefined;
|
||||
const fromList = listRows?.find((s) => s.id === storeId);
|
||||
const shop: AdminStore | undefined = fromDetail ?? fromList;
|
||||
|
||||
// The name exists only once an endpoint answers. Until then "Shop", never a
|
||||
// raw id printed as if it were a name.
|
||||
const name = shop?.name ?? 'Shop';
|
||||
|
||||
useAiContext({
|
||||
level: 'store',
|
||||
label: `${name} · ${company.name}`,
|
||||
companyId: company.id,
|
||||
storeId,
|
||||
});
|
||||
|
||||
if (listLoaded && !fromList && !fromDetail) {
|
||||
return <ShopNotFound company={company} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title={name}
|
||||
subtitle={`A shop of ${company.name}.`}
|
||||
back={{label: company.name, href: adminHref.shops(company.id)}}
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: company.name, href: adminHref.shops(company.id)},
|
||||
{label: name},
|
||||
]}
|
||||
meta={
|
||||
shop ? (
|
||||
<>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={shop.isOnline ? 'success' : 'error'}
|
||||
label={shop.isOnline ? 'Shop PC online' : 'Shop PC offline'}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{shop.isOnline ? 'Online' : 'Offline'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
@{shop.slug}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{shop.timezone}
|
||||
</Text>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<StatPanel
|
||||
title="Shop status"
|
||||
subtitle="Cameras and the shop PC, as the platform last heard from them."
|
||||
data={store}
|
||||
placeholders={STATUS_TILES}
|
||||
toStats={(s) => [
|
||||
{...STATUS_TILES[0], value: formatCount(s.camerasTotal), caption: 'Registered'},
|
||||
{...STATUS_TILES[1], value: formatCount(s.camerasUp), caption: 'Reporting now'},
|
||||
{
|
||||
...STATUS_TILES[2],
|
||||
value: formatCount(Math.max(0, s.camerasTotal - s.camerasUp)),
|
||||
caption: 'Not reporting',
|
||||
},
|
||||
{...STATUS_TILES[3], value: s.isOnline ? 'Online' : 'Offline', caption: s.agentVersion ? `Agent ${s.agentVersion}` : 'Shop PC'},
|
||||
{...STATUS_TILES[4], value: when(s.lastHeartbeatAt), caption: 'Last heartbeat'},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Cameras"
|
||||
subtitle="Every camera registered to this shop. Open one for its status and activity."
|
||||
icon="camera"
|
||||
data={cameras}
|
||||
variant="table"
|
||||
height={200}
|
||||
pendingTitle="Cameras cannot be listed yet"
|
||||
pendingDescription="Needs GET /api/admin/clients/{id}/sites/{siteId}/cameras on the platform."
|
||||
emptyTitle="No cameras"
|
||||
emptyDescription="No camera is registered to this shop."
|
||||
>
|
||||
{(rows) => (
|
||||
<CameraGrid companyId={company.id} storeId={storeId} cameras={rows} />
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<StatPanel
|
||||
title="Footfall · last 30 days"
|
||||
data={footfall.overview}
|
||||
placeholders={FOOTFALL_TILES}
|
||||
toStats={(o) => [
|
||||
{...FOOTFALL_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins'},
|
||||
{...FOOTFALL_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
|
||||
{
|
||||
...FOOTFALL_TILES[2],
|
||||
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
|
||||
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<StatPanel
|
||||
title="Sales · last 30 days"
|
||||
data={commerce.overview}
|
||||
placeholders={SALES_TILES}
|
||||
toStats={(o) => [
|
||||
{...SALES_TILES[0], value: formatInrCompact(o.salesInr), caption: formatInr(o.salesInr)},
|
||||
{...SALES_TILES[1], value: formatCount(o.transactions), caption: 'Recorded sales'},
|
||||
{...SALES_TILES[2], value: formatInr(o.averageTransactionInr), caption: 'Per transaction'},
|
||||
]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Daily footfall"
|
||||
subtitle="Walk-ins per day at this shop."
|
||||
icon="visitors"
|
||||
data={footfall.daily}
|
||||
pendingTitle="No footfall data yet"
|
||||
emptyDescription="This shop recorded no walk-ins in the last 30 days."
|
||||
>
|
||||
{(rows) => (
|
||||
<AreaChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
<AnalyticsPanel
|
||||
title="Sales over time"
|
||||
subtitle="Daily sales at this shop."
|
||||
icon="revenue"
|
||||
data={commerce.daily}
|
||||
pendingTitle="No sales data yet"
|
||||
emptyDescription="This shop recorded no sales in the last 30 days."
|
||||
>
|
||||
{(rows) => (
|
||||
<LineChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Recent footfall"
|
||||
subtitle="Day-by-day rows for this shop, as the platform reports them."
|
||||
icon="events"
|
||||
data={footfall.details}
|
||||
variant="table"
|
||||
height={180}
|
||||
pendingTitle="No footfall rows yet"
|
||||
pendingDescription="Fills in when the platform exposes admin footfall details."
|
||||
emptyDescription="No footfall was recorded at this shop in the last 30 days."
|
||||
>
|
||||
{(rows) => <FootfallTable rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Recent events"
|
||||
icon="events"
|
||||
data={events}
|
||||
variant="table"
|
||||
height={180}
|
||||
pendingTitle="Event monitoring unavailable"
|
||||
pendingDescription="The platform has no event endpoint for platform admin."
|
||||
emptyTitle="No events"
|
||||
emptyDescription="This shop has not reported any events yet."
|
||||
>
|
||||
{(rows) => <EventTable events={rows} />}
|
||||
</AnalyticsPanel>
|
||||
<AnalyticsPanel
|
||||
title="Active alerts"
|
||||
icon="notifications"
|
||||
data={alerts}
|
||||
variant="table"
|
||||
height={180}
|
||||
pendingTitle="Alert monitoring unavailable"
|
||||
pendingDescription="The platform has no alert endpoint for platform admin."
|
||||
emptyTitle="No open alerts"
|
||||
emptyDescription="Nothing needs attention at this shop."
|
||||
>
|
||||
{(rows) => <AlertTable alerts={rows} />}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Staff"
|
||||
icon="staff"
|
||||
data={NOT_BUILT}
|
||||
variant="table"
|
||||
height={140}
|
||||
pendingTitle="Staff unavailable"
|
||||
pendingDescription="Sales persons for a merchant's shop need a platform-admin team endpoint."
|
||||
>
|
||||
{() => null}
|
||||
</AnalyticsPanel>
|
||||
<AnalyticsPanel
|
||||
title="Customers"
|
||||
icon="profile"
|
||||
data={NOT_BUILT}
|
||||
variant="table"
|
||||
height={140}
|
||||
pendingTitle="Customers unavailable"
|
||||
pendingDescription="A shop's customers need a platform-admin customers endpoint."
|
||||
>
|
||||
{() => null}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_TILES = [
|
||||
{label: 'Cameras', icon: 'camera'},
|
||||
{label: 'Online', icon: 'present'},
|
||||
{label: 'Offline', icon: 'absent'},
|
||||
{label: 'Shop PC', icon: 'stores'},
|
||||
{label: 'Last heartbeat', icon: 'sessions'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
const FOOTFALL_TILES = [
|
||||
{label: 'Total footfall', icon: 'visitors'},
|
||||
{label: 'Average daily', icon: 'analytics'},
|
||||
{label: 'Peak day', icon: 'up'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
const SALES_TILES = [
|
||||
{label: 'Sales', icon: 'revenue'},
|
||||
{label: 'Transactions', icon: 'purchases'},
|
||||
{label: 'Avg transaction', icon: 'conversion'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
/**
|
||||
* The merchant's shop list loaded, and this id is not in it: another
|
||||
* merchant's shop, or a mistyped link. Said plainly, with no data from either.
|
||||
*/
|
||||
function ShopNotFound({company}: {company: Company}) {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Shop not found"
|
||||
back={{label: company.name, href: adminHref.shops(company.id)}}
|
||||
/>
|
||||
<Card>
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title={`No shop with this id at ${company.name}`}
|
||||
description="It may belong to another merchant, have been removed, or the link is wrong."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
href={adminHref.shops(company.id)}
|
||||
label={`${company.name}'s shops`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
112
src/features/admin/components/SuspendCompanyDialog.tsx
Normal file
112
src/features/admin/components/SuspendCompanyDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Suspend or reinstate a company.
|
||||
*
|
||||
* ── Why suspension is confirmed and reinstatement is too ─────────────────
|
||||
* Suspension is not a flag that takes effect at next sign-in: the platform
|
||||
* revokes every session the company holds in the same transaction, so a
|
||||
* manager halfway through a shift on a shop PC is signed out mid-task, and
|
||||
* visits from its shop PCs are dropped at ingest from that moment. That is
|
||||
* worth a sentence and a second click.
|
||||
*
|
||||
* Reinstatement is confirmed as well, for a quieter reason: it does NOT restore
|
||||
* the sessions it ended. Everybody signs in again. An operator who expects
|
||||
* "undo" should be told that before they click, not after the support call.
|
||||
*
|
||||
* ── The count is reported, not swallowed ─────────────────────────────────
|
||||
* "Suspended" on its own leaves the operator wondering whether somebody is
|
||||
* still signed in somewhere. `sessions_revoked` answers it, and it comes from
|
||||
* the platform's own transaction — this console does not count anything itself
|
||||
* and must not: a local tally would be a guess dressed as a fact.
|
||||
*/
|
||||
export function SuspendCompanyDialog({
|
||||
company,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const suspending = company.isActive;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.setActive(company.id, !company.isActive);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
setError(
|
||||
res.message ??
|
||||
(suspending
|
||||
? 'Could not suspend that merchant.'
|
||||
: 'Could not reinstate that merchant.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onDone();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose={suspending ? 'required' : 'info'}
|
||||
width={440}
|
||||
aria-label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={suspending ? 'Suspend merchant?' : 'Reinstate merchant?'}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{suspending
|
||||
? `Nobody at ${company.name} will be able to sign in. Every session they hold is ended immediately — including anyone signed in on a shop PC right now — and visits from their shops stop being recorded.`
|
||||
: `${company.name} will be able to sign in again. Sessions ended by the suspension are not restored, so everyone signs in fresh.`}
|
||||
</Text>
|
||||
{suspending && company.users > 0 ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{company.users} {company.users === 1 ? 'person' : 'people'} and{' '}
|
||||
{company.sites} {company.sites === 1 ? 'shop' : 'shops'} are
|
||||
affected.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant={suspending ? 'destructive' : 'primary'}
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
347
src/features/admin/components/analytics/AnalyticsPanel.tsx
Normal file
347
src/features/admin/components/analytics/AnalyticsPanel.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'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,
|
||||
pendingDescription = 'The chart is ready. It fills in as soon as the platform exposes this admin endpoint.',
|
||||
emptyTitle = 'No data for the selected filters',
|
||||
emptyDescription = 'Try a wider date range, or a different merchant, area or shop.',
|
||||
height = 260,
|
||||
variant = 'chart',
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: IconKey;
|
||||
data: AdminData<T>;
|
||||
/** e.g. "No area footfall data yet". */
|
||||
pendingTitle: string;
|
||||
pendingDescription?: string;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: 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={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<ChartEmpty
|
||||
icon={icon}
|
||||
height={height}
|
||||
title={pendingTitle}
|
||||
description={pendingDescription}
|
||||
/>
|
||||
)}
|
||||
</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)},
|
||||
];
|
||||
|
||||
export 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
114
src/features/admin/components/common/AdminKpiCard.tsx
Normal file
114
src/features/admin/components/common/AdminKpiCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import type {BrandAccent} from '@/shared/utils/accent';
|
||||
import type {ICONS} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* The platform console's KPI card — one shape for Overview, Footfall and
|
||||
* Commerce, so the three rows read as the same system.
|
||||
*
|
||||
* Tone is the icon chip only, never the card: `warm`/`cool` are the two brand
|
||||
* accents (see accent.ts), `success`/`warning`/`error` are semantic. The card
|
||||
* stays white.
|
||||
*/
|
||||
export type KpiTone = BrandAccent | 'success' | 'warning' | 'error';
|
||||
|
||||
const TONE_CHIP: Record<KpiTone, string> = {
|
||||
warm: ACCENT.warm.chip,
|
||||
cool: ACCENT.cool.chip,
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
error: 'bg-error-muted text-error',
|
||||
};
|
||||
|
||||
// Rounded: AnimatedNumber hands every in-between frame to `format`.
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString();
|
||||
|
||||
export function AdminKpiCard({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
icon,
|
||||
tone,
|
||||
footer,
|
||||
isFooterDivided = true,
|
||||
}: {
|
||||
label: string;
|
||||
/** A number animates; a string (a date, a status) is shown as is. */
|
||||
value: number | string;
|
||||
/** The small word beside the figure: "tenants", "locations". */
|
||||
unit?: string;
|
||||
icon: typeof ICONS.companies;
|
||||
tone: KpiTone;
|
||||
footer: React.ReactNode;
|
||||
/** false when the footer draws its own separation, e.g. a ProgressBar. */
|
||||
isFooterDivided?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
{label}
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className={`size-8 rounded-lg shrink-0 ${TONE_CHIP[tone]}`}
|
||||
>
|
||||
<Icon icon={icon} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text size="2xl" weight="semibold" className="tracking-tight">
|
||||
{typeof value === 'number' ? (
|
||||
<AnimatedNumber value={value} format={fmt} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</Text>
|
||||
{unit ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{unit}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{isFooterDivided ? (
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
gap={2}
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
{footer}
|
||||
</HStack>
|
||||
) : (
|
||||
footer
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
|
||||
/** The footer's left-hand caption, truncating rather than wrapping. */
|
||||
export function KpiCaption({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
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}/`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user