first commit

This commit is contained in:
2026-09-25 16:31:10 +05:30
parent e43cebb43e
commit 88f658fba9
378 changed files with 33242 additions and 115 deletions

16
.claude/launch.json Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "loyaly-staff-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3200
},
{
"name": "loyaly-staff-attach",
"url": "http://localhost:3200",
"port": 3200
}
]
}

57
.dockerignore Normal file
View File

@@ -0,0 +1,57 @@
# Docker build context excludes.
#
# WITHOUT this file, the `COPY . .` in the builder stage ships the host's
# .next/ (2.1 GB, almost all of it the Turbopack dev cache) and node_modules/
# (639 MB, with darwin-arm64 sharp binaries that are wrong for Alpine) into
# the build context. That is what filled the production disk.
# Dependencies — reinstalled from the lockfile in the deps stage
node_modules
.pnp
.pnp.*
.yarn
# Build output — regenerated by `npm run build` in the builder stage.
# .next/dev alone is 1.8 GB of dev-server cache that must never leave the host.
.next
out
build
dist
# VCS + local tooling
.git
.gitignore
.github
.claude
.vscode
.idea
# Incremental compiler state (253 KB and host-specific)
*.tsbuildinfo
next-env.d.ts
# Environment.
#
# `.env` IS copied in (see the Dockerfile's runner stage) — it holds the
# production platform host, which is not a secret, and is what the standalone
# server reads at boot. Excluding it is what shipped an image with no
# LOYALY_API_BASE and made every BFF call fail.
#
# `.env.*` stays out, and that exclusion is load-bearing: a developer's
# `.env.local` points LOYALY_API_BASE at http://127.0.0.1:8088, and @next/env
# loads `.env.local` AHEAD of `.env`. One leaked into the image and the
# deployed console calls localhost — silently, with no error to read.
.env.*
*.pem
# Docs and infra that the build does not read
*.md
Dockerfile
.dockerignore
nginx.conf
# Noise
.DS_Store
coverage
npm-debug.log*
yarn-error.log*

79
.env Normal file
View File

@@ -0,0 +1,79 @@
# ---------------------------------------------------------------------------
# Production runtime configuration. COMMITTED ON PURPOSE — carries no secret.
# ---------------------------------------------------------------------------
#
# This file is the production environment. It is read by `next build` and, more
# importantly, by the standalone `server.js` at boot (Next calls loadEnvConfig
# on the server's working directory), so the deployed container knows the
# platform host without anyone remembering to type it into a dashboard.
#
# ── Precedence, exactly as @next/env resolves it ────────────────────────────
#
# 1. real process.env (Dokploy / docker -e / systemd) ← always wins
# 2. .env.production.local
# 3. .env.local ← LOCAL DEV ONLY. Never enters the image.
# 4. .env.production
# 5. .env ← this file, the floor everything falls back to
#
# A value already present in process.env is never overwritten by a file, so
# setting LOYALY_API_BASE in Dokploy still overrides this — nothing here locks
# the deployment in. It only removes "unset" as a possible state.
#
# ── Working on this locally? ────────────────────────────────────────────────
# Put your overrides in `.env.local` (gitignored, loaded ahead of this file).
# Without one, `npm run dev` will talk to the PRODUCTION platform, because that
# is what this file says. `.env.example` has the local values to copy.
# The one shared Loyaly platform API (Behavision). Server-side only and
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
# the BFF, which is what keeps the access token out of JavaScript.
#
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
# the variable there makes the BFF call its own origin, which fails in a way
# that looks like a broken login form rather than a misconfiguration.
# apiClient.ts rejects that hostname by name for exactly this reason.
#
# NOT REQUIRED in production any more. Production accepts exactly one origin, so
# an unset variable could never have meant another one, and platformApi resolves
# it to that origin on its own. It stays here so `docker run` is self-describing
# and so development has something to read.
#
# Why that change was needed: @next/env only fills a variable that is ABSENT.
# Verified against the installed copy — a real environment variable set to the
# EMPTY STRING stays empty and this file is NOT consulted. So one blank field in
# a dashboard silently defeated the value below and took production down with
# "LOYALY_API_BASE is required in production".
LOYALY_API_BASE=https://mcp.loyaly.ai
# Browser → this app's own BFF routes, which are same-origin. Empty is correct
# and is what makes the console work on any hostname it is served from:
# requests go to /api/... on whatever origin loaded the page (localhost:3100 in
# dev, platform.loyaly.ai in production) and the server hop above reaches the
# platform. Setting this to the platform host would send the browser straight
# at the API with no session cookie and no token — do not.
#
# It is NEXT_PUBLIC, so it is inlined at BUILD time, not read at runtime.
# Changing it in Dokploy's environment panel would do nothing without a rebuild.
NEXT_PUBLIC_API_BASE=
# AUTH_SECRET is deliberately NOT in this file. It is the ONLY variable this
# deployment requires, and the only one that cannot ship.
#
# It signs the session cookie and encrypts the platform token bundle, so a
# value committed here is a session-forging key in git — anyone who can read
# the repo could mint a cookie for any user. It was already removed from the
# Dockerfile once for that reason; do not reintroduce it here.
#
# Set it as a Dokploy environment variable in the RUNTIME panel — a value set as
# a BUILD argument is not present when the server runs, which looks exactly like
# never having set it. Alternatively mount the value and set AUTH_SECRET_FILE to
# its path (the Docker/Swarm secret convention); AUTH_SECRET wins if both exist.
#
# Production refuses to sign sessions without it. Generate with:
#
# openssl rand -hex 32
#
# Hex, not base64: a base64 value ends in '=' and can contain '+' and '/', and
# an environment editor that splits a line on the first '=' can store that
# truncated or empty. A silently-empty AUTH_SECRET looks exactly like an unset
# one, which is a slow afternoon. Hex has nothing a parser can mangle.

42
.env.example Normal file
View File

@@ -0,0 +1,42 @@
# ---------------------------------------------------------------------------
# Template for `.env.local` — your LOCAL overrides. Copy it:
#
# cp .env.example .env.local
#
# Do not copy it to `.env`. `.env` is committed and already holds the
# production values; `.env.local` is loaded ahead of it and is gitignored.
# ---------------------------------------------------------------------------
# The one shared Loyaly platform API (Behavision). Server-side only and
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
# the BFF, which is what keeps the access token out of JavaScript.
#
# local dev http://127.0.0.1:8088 ← what belongs in .env.local
# production https://mcp.loyaly.ai ← already set in the committed .env
#
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
# the variable there makes the BFF call its own origin, which fails in a way
# that looks like a broken login form rather than a misconfiguration.
#
# Production no longer requires this: it accepts exactly one origin, so an unset
# value can only have meant that one, and platformApi resolves it. Any OTHER
# host set explicitly is still rejected. Locally it is worth setting, because a
# dev machine legitimately means a different address.
LOYALY_API_BASE=http://127.0.0.1:8088
# Signs the session cookie and encrypts the platform token bundle.
#
# The ONLY variable production requires, the only real secret, and the only one
# taken solely from the environment — it is in no committed file, by design.
# Set it as a Dokploy environment variable in the RUNTIME panel (a build
# argument is not present at runtime), or mount it and set AUTH_SECRET_FILE to
# its path. Locally, any string works; leave it blank and a development key is
# used.
#
# Generate with: openssl rand -hex 32 (hex, not base64 — a trailing '=' can be
# mangled by a dashboard env editor that splits on the first '=')
AUTH_SECRET=
# Browser → this app's own BFF routes. Same origin, so leave it empty. Inlined
# at BUILD time (NEXT_PUBLIC), so changing it at runtime does nothing.
NEXT_PUBLIC_API_BASE=

20
.gitignore vendored
View File

@@ -19,6 +19,7 @@
# production
/build
/dist
# misc
.DS_Store
@@ -30,8 +31,23 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# env files.
#
# `.env*` used to be blanket-ignored, and that was the deploy bug: the image
# shipped with no LOYALY_API_BASE at all, so every BFF call died on "required
# in production — refusing to guess the Loyaly platform host" and the console
# looked like a broken login form. The production host is not a secret, so it
# now lives in a committed `.env` and ships with the build.
#
# What stays ignored is the per-machine and per-secret layer:
.env.local
.env.*.local
#
# What is committed:
# .env production defaults (no secret) — loaded by the running server
# .env.example the template, the record of which variables exist
#
# AUTH_SECRET belongs in neither. It is injected by Dokploy at runtime.
# vercel
.vercel

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.23.1

179
AGENTS.md
View File

@@ -7,3 +7,182 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
<!-- ASTRYX:START -->
Astryx v0.2.0 · 154 components
CLI: run every command as `npx astryx <cmd>` (shown below as `astryx ...`).
SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled:
import "@astryxdesign/core/reset.css";
import "@astryxdesign/core/astryx.css";
WORKFLOW — discover, don't guess. Before writing UI:
1. `astryx build "<idea>"` — START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook.
2. `astryx template <name> [--skeleton]` — scaffold the [page]/[block]s it named, or study their layout. Templates are reference code.
3. `astryx component <Name>` — props + examples for every component you use.
RULES:
- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).
- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
- Custom styling: component props first; else Tailwind utilities backed by tokens (bg-surface, text-primary, rounded-lg) via tailwind-theme.css. No raw hex/px.
- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root.
- SELF-CHECK before you finish: re-read the file and replace any style={{…}}, raw <div>/<span> layout, imported .css/@apply, or hardcoded/arbitrary value (e.g. bg-[#fff], p-[13px]) with the component or a token-backed utility. If unsure a component/prop exists, run `astryx component <Name>` / `astryx search "<thing>"`; don't hand-roll CSS.
MORE CLI:
search "<query>" find any component / hook / doc / template / block
component --list 154 components by category
template --list page + block recipes
docs <topic> color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography
swizzle <Name> eject component source for deep customization
upgrade --apply run after any @astryxdesign/core bump
<!-- ASTRYX:END -->
<!-- LOYALY:START -->
# Loyaly Merchant Dashboard — project rules
## Toolchain
- **Node 22 is required** (`.nvmrc` → 22.23.1). The astryx CLI hard-fails on Node 20.
`nvm use` before running any `npx astryx` command or `npm run theme:build`.
- Dev server runs on **port 3100** (`lytsup-site` owns 3000).
## Theme
- All design values live in `src/theme/loyalyTheme.ts`. Edit that, then run
`npm run theme:build`, then **commit the generated** `src/theme/loyaly.{css,js,d.ts,variants.d.ts}`.
- `theme:build` is deliberately NOT wired to `prebuild` — it would break `npm run build` on Node 20.
- Import the theme from **`@/theme`**, never `@/theme/loyaly` directly. `@/theme` re-attaches
the Lucide icon registry, which `astryx theme build` cannot serialise (icons are React
components). Skipping it silently degrades every `<Icon>` to Astryx's default glyph set.
- `globals.css` imports `../theme/loyaly.css`, NOT `@astryxdesign/theme-neutral/theme.css`.
Astryx theme CSS is `@scope`-gated on the theme *name*, so neutral's rules match nothing
under `[data-astryx-theme="loyaly"]`.
## The monochrome rule
Gray is the accent. The **only** colour permitted is semantic: success, warning, error.
- Astryx's categorical hues (blue/cyan/teal/purple/pink/orange) are aliased to gray in
`aliasHueToGray()`. green/red/yellow deliberately keep hue — Banner's semantic statuses
resolve *through* those hue tokens.
- Some neutral-theme component styles hardcode colour instead of reading a token
(`badge variant:info`, `progressbar variant:accent`). Those need an explicit `components`
override; `aliasHueToGray()` cannot reach them.
- `/tokens` (dev-only route) is the audit page. After any theme change, load it and run the
chromatic sweep in the browser console — it should report **0** non-semantic colour nodes.
## The two brand accents (exception to the above)
Store intelligence carries exactly **two** non-semantic hues, and nothing else may add a third.
| | token | light | dark | used for |
|---|---|---|---|---|
| warm | `--color-brand-warm` | `#F4C430` | `#F4C430` | brand, rewards, the activities a merchant runs |
| cool | `--color-brand-cool` | `#7C3AED` | `#A78BFA` | analytics, journeys, AI insight |
- Defined in `src/app/globals.css` in a Tailwind `@theme` block, **not** in `loyalyTheme.ts`.
They are not Astryx tokens: no component variant resolves through them, and putting them in
the theme would let Badge/Banner pick them up as part of the semantic language.
- Three slots per hue. `-ink` is the readable one for text and icons (light mode darkens warm
to `#8A6300`; raw `#F4C430` on white is ~1.7:1). `-soft` is an icon-chip tint only — never a
card background. The base is for non-text marks: chart fills, dots, bars.
- `src/shared/utils/accent.ts` is the only place a hue becomes a class name. Feature files use
`ACCENT[accent].ink` / `.soft`, never `text-brand-warm-ink` by hand.
- Charts stay monochrome by default. `CHART.brand.{warm,cool}` is opt-in per series and is used
on exactly two charts (dashboard Footfall = cool, Revenue = warm). Never make it a ramp
default — `Sparkline` reads `seriesAt(0)`, so that would turn every KPI card's trend line.
- An activity's accent travels on its API payload (`ActivityMetric.accent`), not from grid
position, so an activity is the same colour on every screen.
## Attribution is estimated — say so
Everything downstream of `ActivityImpact.customers` (repeat visits, purchases, revenue) is
**modelled**, not measured. No purchase is joined to a specific spin, selfie or challenge.
- The field is `attributedRevenueInr`, never `revenueInr`, and every payload carries
`attribution: 'estimated' | 'observed'`.
- Copy says *attributed*. Never "generated", "earned" or "drove". A merchant who reads
"Selfie generated ₹3.4L" and spends against it is the failure this rule prevents.
- `<AttributionNote basis={…} />` sits in the header of every panel that shows an attributed
figure, and renders **nothing** when the basis is `'observed'` — so a real attribution
backend retires the disclosure with no copy edit. Read the basis off the payload, never
hardcode it.
- The single sentence lives in `ATTRIBUTION_NOTE` (types/intelligence.ts) so three panels
cannot drift.
## Dashboard supporting analytics are collapsed by default
The six preserved supporting panels (conversion pair, peak hours, reward usage, period rollup,
store comparison) sit inside a `Collapsible`, closed by default, state in
`loyaly.dashboard.supporting-analytics`. Expanded they add ~1,700px desktop / ~3,100px mobile
and push "What needs your attention" — the only actionable section — to 81% of the scroll.
They are evidence, not the finding. Do not re-expand by default; do not delete them either.
## Known upstream issues (Astryx 0.2.0)
- **`useEntryAnimation` breaks SSR hydration.** Its source assumes `'use client'` modules never
run on the server; in the App Router they do. Any `FieldStatus` (i.e. `TextInput`/`TextArea`
`status={...}`) present at *initial paint* renders without the slide-down class on the server
and with it on the client → an unpatchable class mismatch. Setting status on blur/submit
(the normal path) is unaffected. Don't server-render a field that already has a status.
- Component keys in `defineTheme({components})` are inconsistently cased: `text-input` but
`progressbar`. The CLI warns on unknown keys, but that warning is **not** reliable in
either direction:
- A *misspelled* key (`textinput`) is dropped silently — the CSS is never emitted.
- A key the CLI doesn't know but Astryx *does* use (`table-cell`, `table-header-cell`)
warns yet still emits correctly.
So never trust the warning alone. After any `components` change, grep the generated
`src/theme/loyaly.css` for the rule, then confirm the computed style in the browser.
Valid targets are whatever `themeProps('...')` is called with in `dist/<Component>/*.js`.
- `StyleOverrides` supports structural pseudo-classes, not just interaction ones —
`':first-child'` emits correctly (used for the table first-column lead-in).
## Deployment — never ship `.next/` wholesale
This filled the production disk and took the server down once. A working tree's `.next/`
reaches **2+ GB**, but almost none of it is runtime state:
| path | size (typical) | ships? |
|---|---|---|
| `.next/dev` | 1.8 GB | **no** — Turbopack dev-server cache from `npm run dev` |
| `.next/cache` | 120–160 MB | **no** — incremental build cache, build host only |
| `.next/server`, `types`, `trace` | ~24 MB | **no** — inputs to the standalone trace, not read at runtime |
| `.next/standalone` | 54 MB | **yes** — server.js + traced node_modules |
| `.next/static` | 3 MB | **yes** — hashed client assets (nginx serves at `/_next/static/`) |
| `public` | 344 KB | **yes** |
Those last three are the entire payload: **~55 MB unpacked, 18 MB gzipped.**
- Non-Docker deploy: `npm run bundle` → `dist/loyaly-mer-login.tar.gz`. Never `scp -r .next`.
- Docker: `.dockerignore` excludes `node_modules` and `.next`. **Do not delete it** — without
it `COPY . .` pushes the host's 2 GB `.next` and 639 MB `node_modules` into the build
context, including darwin-arm64 sharp binaries that are wrong for Alpine.
- `npm run clean` drops `.next`, `dist` and the tsbuildinfo when the tree gets heavy.
**`CI_BUILD=1` in container builds.** Next 16.3 enables `turbopackFileSystemCacheForBuild`
by default; it writes 117 MB to `.next/cache` that only pays off when that directory is
restored between builds. Docker/Dokploy starts from a clean layer every time, so it is
written and never read. The Dockerfile sets `CI_BUILD=1`, which flips the flag off in
`next.config.ts`: **~20% less build CPU** (32s vs 40–43s measured) and `.next` drops
202 MB → 86 MB. Leave it unset locally — repeat `npm run build` there does reuse the cache.
It changes no output: the deployable payload is 58 MB either way.
## Conventions
- Follow the Astryx rules above: no raw `<div>` for layout, no hardcoded hex/px, component
props first then token-backed Tailwind utilities.
- `src/lib/icons.ts` is the single icon map. Only the 26 semantic names resolve via
`<Icon icon="search"/>`; everything else is `<Icon icon={ICONS.stores}/>`. Never import a
lucide icon directly into a feature file.
## Settings spacing contract
Every Settings screen renders inside `src/features/settings/SettingsPage.tsx`. Do not add
page padding in an individual settings page — change the container instead.
| | value | why |
|---|---|---|
| Horizontal | 32px (`paddingInline={8}`) | clears the sub-nav and the Loyaly AI rail |
| Top | 32px (`paddingBlock={8}`) | title never touches the header |
| Bottom | 48px (`className="pb-12"`) | last card is never flush to the fold |
| Header → body | 32px (`gap={8}`) | title block reads as its own layer |
| Between body blocks | 24px (`gap={6}`) | cards, tables, toolbars |
`SpacingStep` stops at 10 (40px) and Stack has no `paddingBlockEnd`, which is why the 48px
bottom goes through the Tailwind bridge — `pb-12` still resolves to `--spacing-12`, so no
raw pixel value enters the codebase. It sits in the `utilities` layer, which the cascade
puts after `astryx-base`, so it wins over `paddingBlock` without `!important`.
Card padding (24px) and table cell padding (12px, 20px first-column lead-in) are set once
in `loyalyTheme.ts` so every module agrees — not per page.
<!-- LOYALY:END -->

120
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,120 @@
# Architecture
How this codebase is organised, and the rules that keep it that way. Read this
before adding a feature; it should take about five minutes.
## The layers
Data flows in one direction, and each layer is allowed to know only the one
below it:
```
component / page renders state, owns no rules
↓
features/<f>/hooks binds a service or repository to React
↓
features/<f>/services domain rules: validation, interpretation, defaults
↓
features/<f>/repositories TRANSPORT ONLY — the single place that knows a URL
↓
app/api/<f>/route.ts HTTP boundary: authorises, parses, responds
↓
features/<f>/mock/*.mock fixtures, server-side only
```
**Connecting a real backend touches the repository layer and nothing else.**
Point `NEXT_PUBLIC_API_BASE` at a host, or rewrite the four lines in a
repository, and every component above it is untouched. That is the property the
whole structure exists to protect, so:
- **Never** `import` from a `mock/` module in a component, hook or page. Server
Components read through a `*ServerRepository`; clients go over HTTP.
- **Never** call `fetch` in a component. `shared/services/httpClient` is the
only module that does.
- A repository never interprets a response, and a service never builds a URL.
## Where things live
```
src/
app/ routing only — thin files, no logic
(public)/login unauthenticated routes → PublicLayout → GuestGuard
(workspace)/… authenticated routes → ProtectedLayout → AuthGuard
api/ route handlers
proxy.ts the server-side auth gate (Next 16 renamed middleware → proxy)
features/<feature>/
components/ UI for this feature only
hooks/ data access + view state
services/ domain rules, framework-free, unit-testable
repositories/ URLs and verbs
types/ the wire contract for this feature
mock/ fixtures (server-side)
utils/ · config/ · guards/ · providers/ as needed
shared/
components/ brand · charts · patterns · primitives · scope · data · motion
hooks/ useResource · useScope · useBreakpoint · usePersistentFlag
layouts/ ProtectedLayout · PublicLayout · workspace shell
providers/ app-wide state (workspace scope)
services/ httpClient (transport) · apiRoute (handler helpers)
types/ the response envelope
mock/ seeded RNG shared by every fixture
theme/ design tokens; edit loyalyTheme.ts, run `npm run theme:build`
```
A feature owns everything about itself. If two features need the same thing, it
moves to `shared/` — it does not get imported across features, with one
deliberate exception: `features/dashboard/types` holds the analytics primitives
(`TimePoint`, `ActivityEvent`) that LYTs and Stores genuinely share, because
duplicating them would let two modules disagree about the same wire shape.
## Authentication
Three independent gates, in order of authority:
1. **`src/proxy.ts`** — runs before any protected route renders. No valid
session cookie, no page. API paths get a 401 JSON; pages get a redirect to
`/login?next=…`. This is the one that matters.
2. **`requireApiSession()`** — every data route handler calls it. Next's own
docs warn that proxy coverage can be silently lost by a matcher change or a
route move, so the check is repeated where the data is.
3. **`AuthGuard` / `GuestGuard`** — client-side. Covers what the server never
sees: a session expiring in an open tab, a logout in another tab, a
client-side navigation. **Not a security boundary.**
The session is a signed httpOnly cookie (`features/auth/services/sessionToken`),
so the browser cannot read or forge it. `SessionProvider` holds only what to
draw, never what to permit — it asks `GET /api/auth/session` and believes the
answer.
Swapping the mock for a real identity provider means changing
`verifyCredentials` in `features/auth/mock/users.mock.ts` and the three URLs in
`authRepository`. Nothing else, including the login form, is aware.
## Conventions
- **No `any`.** The codebase has zero, and `tsc --noEmit` is expected to pass
before every commit.
- **Components stay under ~300 lines.** When one grows past that, the split is
usually behaviour-into-a-hook, not markup-into-more-markup.
- **Names say what the thing is**: `DashboardKpiCard`, `StaffAttendanceTable`.
Never `Card.tsx`, `utils.ts`, `NewFile.tsx`.
- **Styling comes from the design system**, in this order: component props →
token-backed Tailwind utilities → a theme override in `loyalyTheme.ts`. There
are no per-feature CSS files; see the note below.
### On CSS
The brief that produced this refactor asked for per-feature stylesheets
(`dashboard/styles/kpi.css`, and so on) so that changing one module cannot
affect another. That isolation is already total here, by construction rather
than by convention: styling lives in component props and utility classes scoped
to the element they are written on, so there is no selector in the codebase
that *can* reach another feature. Adding stylesheets would introduce global
selectors — the one mechanism that can leak — and they would have to fight
StyleX's `@layer astryx-base` for the cascade. Shared visual decisions belong in
`theme/loyalyTheme.ts`, which is the single place a change is meant to be
system-wide.
If a future component genuinely needs CSS that props and utilities cannot
express, `astryx swizzle <Component>` ejects the source for that one component
rather than opening a stylesheet the whole app can be edited through.

View File

@@ -1 +1,32 @@
@AGENTS.md
<!-- ASTRYX:START -->
Astryx v0.2.0 · 154 components
CLI: run every command as `npx astryx <cmd>` (shown below as `astryx ...`).
SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled:
import "@astryxdesign/core/reset.css";
import "@astryxdesign/core/astryx.css";
WORKFLOW — discover, don't guess. Before writing UI:
1. `astryx build "<idea>"` — START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook.
2. `astryx template <name> [--skeleton]` — scaffold the [page]/[block]s it named, or study their layout. Templates are reference code.
3. `astryx component <Name>` — props + examples for every component you use.
RULES:
- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).
- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
- Custom styling: component props first; else Tailwind utilities backed by tokens (bg-surface, text-primary, rounded-lg) via tailwind-theme.css. No raw hex/px.
- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root.
- SELF-CHECK before you finish: re-read the file and replace any style={{…}}, raw <div>/<span> layout, imported .css/@apply, or hardcoded/arbitrary value (e.g. bg-[#fff], p-[13px]) with the component or a token-backed utility. If unsure a component/prop exists, run `astryx component <Name>` / `astryx search "<thing>"`; don't hand-roll CSS.
MORE CLI:
search "<query>" find any component / hook / doc / template / block
component --list 154 components by category
template --list page + block recipes
docs <topic> color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography
swizzle <Name> eject component source for deep customization
upgrade --apply run after any @astryxdesign/core bump
<!-- ASTRYX:END -->

154
Dockerfile Normal file
View File

@@ -0,0 +1,154 @@
# syntax=docker/dockerfile:1
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
# devDeps are required to build (typescript, tailwind, eslint-config-next).
# This whole stage is discarded — none of it reaches the runner.
RUN npm ci --no-audit --no-fund
# Stage 2: Build the Next.js application
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Each Docker build starts from a clean layer, so Turbopack's .next/cache is
# written but never restored. Skipping it cuts ~20% of build CPU (the metric
# that matters on a 1-vCPU host) and 116 MB off this layer.
ENV CI_BUILD=1
RUN npm run build
# Stage 3: Production runner with Next.js Standalone
FROM node:22-alpine AS runner
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# ── Runtime configuration ────────────────────────────────────────────────
#
# EXACTLY ONE variable must be supplied to this container. That is the whole
# deployment contract, and it is one because everything else either ships in
# the image or can only have one legal value.
#
# AUTH_SECRET signs the session cookie and encrypts the platform token
# bundle. Generate with: openssl rand -hex 32
# → NOT shipped, and never can be: a secret in the image is
# readable with `docker history`, and a secret in git is a
# session-forging key for anyone who can read the repo.
# → Set it in Dokploy → Environment (the RUNTIME panel — a
# BUILD argument is not present when the server runs), or
# mount it and set AUTH_SECRET_FILE to its path.
#
# AUTH_SECRET_FILE optional alternative: a path to read the secret from, the
# standard Docker/Swarm secret convention. AUTH_SECRET wins
# when both are set. Use this when a dashboard field mangles
# the value.
#
# LOYALY_API_BASE no longer required. Production accepts exactly one origin
# (https://mcp.loyaly.ai), so an unset variable could never
# have meant anything else; shared/config/platformApi now
# resolves it to that origin. Setting it to any OTHER host
# is still rejected by name. It also still ships in the .env
# copied below, which keeps `docker run` self-describing.
#
# Hex rather than base64 for the secret, on purpose. `openssl rand -base64 48`
# ends in '=' and may contain '+' and '/'. Pasted into a dashboard field or a
# KEY=VALUE editor that splits on the first '=', that value can be stored
# truncated — or not at all — and the result is indistinguishable from never
# having set it. Hex is [0-9a-f] only, so there is nothing for a parser to
# mangle. 32 bytes is 256 bits, more than the HMAC and the AES-256 key derived
# from it need.
#
# A container started without the secret does not die and does not 502. It
# boots, names the missing variable on stderr (including any environment
# variable whose NAME looks like a near-miss for AUTH_SECRET, which is the one
# cause invisible from a dashboard), and answers 503 with
# `x-loyaly-config: misconfigured` on every gated request.
#
# ── Where the secret must be set in Dokploy ──────────────────────────────
# The "Environment Variables" tab. NOT "Build Arguments" and NOT "Build
# Secrets": Dokploy's own documentation is explicit that both of those are
# build-time only and are absent from the running container, so a secret placed
# there is indistinguishable, from inside the container, from never having been
# set at all. The boot log says which of the two happened.
#
# ── Two probe endpoints, deliberately separate ───────────────────────────
# /api/health LIVENESS — 200 whenever the process answers. Safe to probe
# unconditionally; can never remove a serving
# container from rotation.
# /api/ready READINESS — 503 while a required variable is missing. Meant
# for a DEPLOY gate, in Dokploy → Advanced → Swarm
# Settings, paired with Update Config
# `Order: start-first` + `FailureAction: rollback`
# so a misconfigured new task is rolled back while
# the previous good one keeps serving.
# Run as a non-root user; nextjs owns nothing it does not need to write.
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
# Copy public static assets and standalone build output.
# These three paths are the ENTIRE runtime payload (~57 MB). Never copy the
# whole .next/ directory here — .next/dev and .next/cache are build-host-only
# and account for ~1.96 GB.
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# The production environment, as a file the server reads at boot.
#
# Deliberately redundant, and worth keeping. `next build` already copies .env
# (and .env.production, and nothing else — see writeStandaloneDirectory in
# next/dist/build/index.js) into .next/standalone, so the line above lands one
# at /app/.env on its own. But it only does that when .env was in the BUILD
# CONTEXT, and .dockerignore excluded it until recently — which is precisely
# how images shipped with no LOYALY_API_BASE at all.
#
# This line turns that silent outcome into a loud one: exclude .env again and
# the Docker build FAILS here with "file not found" instead of producing an
# unconfigured image that starts and then rejects every sign-in.
#
# It does not pin the deployment either way: @next/env never overwrites a
# variable already present in process.env, so anything set in Dokploy wins.
COPY --chown=nextjs:nodejs .env ./.env
USER nextjs
EXPOSE 3000
# NO HEALTHCHECK, on purpose.
#
# One was added here and removed within the hour, because it recreated the
# exact 502 it was meant to replace. Dokploy runs applications as Docker Swarm
# services, and Swarm does not merely REPORT an unhealthy task — it pulls it
# out of the service load balancer and reschedules it. So a healthcheck wired
# to /api/health, which answers 503 while a required variable is missing, meant:
#
# AUTH_SECRET unset -> /api/health 503 -> task unhealthy -> removed from the
# load balancer and restarted -> Traefik has no backend -> 502 Bad Gateway on
# every url, which is precisely the symptom this whole change exists to end.
#
# The container would have been up, serving a 503 that names the fault, and
# nobody could have reached it. "A broken deploy must not look healthy" is a
# real concern, but enforcing it in the orchestrator destroys the diagnostics —
# and an outage you cannot see the reason for is the more expensive failure.
#
# So: the container stays in rotation whenever it can serve HTTP at all, and
# the configuration state is reported where it can actually be read — 503 with
# `x-loyaly-config: misconfigured` on every gated request, /api/health for a
# direct answer, and the named variable in the boot log.
#
# If a healthcheck is ever added back, it must probe LIVENESS (is the server
# answering?) and never configuration, or this comment is being relearned.
CMD ["node", "server.js"]

41
docs/API-GAP-REPORT.md Normal file
View File

@@ -0,0 +1,41 @@
# API gap report — after backend integration
Generated 2026-09-09. Format: `Feature | Existing API | Frontend status | Missing API`
## Wired to the platform
| Feature | Existing API | Frontend status | Missing API |
|---|---|---|---|
| Login | `POST /api/auth/login` | Wired | — |
| Session restore | `GET /api/auth/me` | Wired — confirmed on every load | — |
| Token refresh | `POST /api/auth/refresh` | Wired — single-flight, persist-before-use | — |
| Logout | `POST /api/auth/logout` | Wired — revokes upstream, then clears | — |
| Site switcher / Store page | `GET /api/sites` | Wired | — |
| Dashboard Visitors | `GET /api/reports/footfall` | Wired — server `total` | — |
| Dashboard Purchases / Revenue / Conversion | `GET /api/reports/conversion` | Wired | — |
| Footfall chart | `GET /api/reports/footfall?bucket=day` | Wired | — |
| Revenue chart · Sales page | `GET /api/reports/conversion?bucket=day` | Wired | — |
| Recent arrivals · Activity page | `GET /api/visits` | Wired — cursor echoed, deduped on `visit_id` | — |
| Customer photos | `GET /api/faces/…` | Wired — proxied so `<img>` works | — |
| Team | `GET /api/team` | Wired | — |
| Mobile → dashboard purchases | `POST /api/purchases` | Wired via `POST /api/visits` | `GET /api/purchases` |
## Cannot be wired — backend required
| Feature | Existing API | Frontend status | Missing API |
|---|---|---|---|
| LYT programme | none | Unavailable panel | `GET /api/rewards`, `/api/rewards/redemptions`, `/api/lyts/ledger` |
| Engagement activities | none | Unavailable panel | `GET /api/activities`, `/api/activities/impact` |
| Campaigns | none | Unavailable panel | `GET /api/campaigns` |
| Customer journey | partial (visit/purchase/return only) | Unavailable panel | `GET /api/reports/journey` |
| Orders / products / stock | none | Unavailable panel | `GET /api/purchases`, `/api/products` |
| Payment split / refunds | none | Unavailable panel | `GET /api/reports/payments`, `/api/reports/refunds` |
| Staff attendance & ranking | `/api/team` is console accounts | Unavailable panel | `GET /api/staff`, `/api/staff/attendance`, `/api/reports/staff-sales` |
| Business profile | none | Unavailable panel | `GET/PATCH /api/settings/profile` |
| Roles matrix · Integrations · API keys · Billing | none | Still local state | `GET /api/settings/{roles,integrations,api-keys,billing}` |
| Security → sessions | `GET/DELETE /api/auth/sessions` | **Not yet wired** — endpoint exists | — |
| Invitations / join flow | `POST /api/team/invitations`, `GET /api/auth/invitation`, `POST /api/auth/register` | **Not yet wired** — endpoints exist, service written | — |
| Loyaly AI chat | none | Still mock replies | `POST /api/ai/chat` |
| Live arrivals stream | `GET /api/visits/stream` | **Not yet wired** — polling only | — |
| Cameras / site health | `GET /api/cameras`, `/api/sites/{id}/check` | **Not yet wired** — service written | — |
| Visitor directory | `GET /api/visitors`, `/history`, `PUT /profile`, `DELETE` | **Not yet wired** — service written | — |

279
docs/API-INVENTORY.md Normal file
View File

@@ -0,0 +1,279 @@
# Loyaly Merchant OS — API inventory & backend wiring plan
Audit date: 2026-09-09 · Branch `main` · Audited against the running app on :3100
Purpose: establish exactly what data the frontend consumes today, which of it is
fake, and what a real backend must expose. Compare this against your API spec and
mark each row **match / rename / missing / extra**.
---
## 1. How data flows today
```
component → hook (useResource) → repository (owns the URL) → httpClient
│
NEXT_PUBLIC_API_BASE ────┤
│
(unset) → Next route handler → fixture generator
(set) → YOUR BACKEND
```
**The seam already exists and works.** `src/shared/services/httpClient.ts` line 22:
```ts
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
```
Set that to your API host and every endpoint in §2 bypasses the local route
handlers entirely. No component changes. This is the intended cutover.
**Response envelope** — every endpoint must return this shape (`src/shared/types/api.ts`):
```jsonc
// success
{ "data": <T>, "meta": { "generatedAt": "ISO-8601", "range": "30d", "storeId": "all" } }
// failure (any non-2xx, or 2xx with error present)
{ "error": { "code": "internal|not_found|bad_request|unauthorized", "message": "…" } }
```
`meta.generatedAt` is **required**: the UI measures every relative time
("2 hours ago", days-to-expiry, the greeting) against the server clock, never
`Date.now()`.
**Scope query** — every analytics endpoint is filtered by the same two params:
| param | values |
|---|---|
| `storeId` | `all` \| a store id |
| `range` | `7d` \| `30d` \| `90d` \| `mtd` \| `ytd` \| `custom` |
**Auth** — session is an httpOnly cookie. `credentials: 'same-origin'` is sent on
every request. A cross-origin backend needs CORS + `SameSite=None; Secure`, or
keep the Next routes as a thin proxy.
---
## 2. Endpoints that EXIST (24) — all fixture-backed
Legend: **T** = TypeScript response type, defined in the file noted.
### Auth — `src/features/auth/types/auth.ts`
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | `/api/auth/login` | `{email, password, rememberMe}` (JSON **and** form-encoded) | `AuthSession` |
| POST | `/api/auth/logout` | `{}` | `{ok: boolean}` |
| GET | `/api/auth/session` | — | `AuthSession \| null` |
`AuthSession = {user: {id, email, name, role: 'owner'|'manager'|'analyst', organisation}, expiresAt}`
### Dashboard — `src/features/dashboard/types/dashboard.ts` + `intelligence.ts`
| Method | Path | Extra query | Returns |
|---|---|---|---|
| GET | `/api/dashboard/kpis` | scope | `Kpi[]` |
| GET | `/api/dashboard/timeseries` | scope | `TimePoint[]` |
| GET | `/api/dashboard/peak-hours` | scope | `HourCell[]` |
| GET | `/api/dashboard/activity` | scope | `ActivityEvent[]` |
| GET | `/api/dashboard/store-comparison` | scope | `StoreComparison[]` |
| GET | `/api/dashboard/reward-usage` | scope | `RewardUsagePoint[]` |
| GET | `/api/dashboard/performance` | scope + `granularity=weekly\|monthly` | `PeriodPoint[]` |
| GET | `/api/dashboard/briefing` | scope | `DashboardBriefing` |
| GET | `/api/dashboard/activity-metrics` | scope | `ActivityMetric[]` |
| GET | `/api/dashboard/journey` | scope | `JourneyStage[]` |
| GET | `/api/dashboard/campaigns` | scope | `CampaignSummary[]` |
| GET | `/api/dashboard/insights` | scope | `Insight[]` |
Key shapes:
```ts
Kpi { id:'visitors'|'purchases'|'revenue'|'activeRewards', label, value,
unit:'count'|'inr'|'lyt'|'pct', deltaPct, isRiseGood, trend:{t,v}[] }
TimePoint { t:'YYYY-MM-DD', visitors, purchases, revenue, conversion }
HourCell { day:0-6 (0=Mon), hour:0-23, value }
ActivityEvent{ id, at:ISO, kind:'reward_redeemed'|'staff_checked_in'|'purchase'
|'reward_expired'|'store_opened', title, detail?, storeId }
PeriodPoint { label:'W32'|'Aug', visitors, purchases, revenue }
Insight { id, severity:'info'|'success'|'warning'|'error', title, body,
action?:{label, href} }
```
**Activity intelligence** (`intelligence.ts`) — the model shared by Dashboard and Lyts:
```ts
ActivityMetric {
id: 'walk'|'visit'|'selfie'|'spin'|'scratch'|'brand'|'challenge'|'friend'|'shop'|'event'
label, description
group: 'engagement'|'growth'|'commerce'
accent: 'warm'|'cool' // fixed per activity, travels on the payload
count, deltaPct
status: 'live'|'paused'|'draft'
lytsIssued // MEASURED (ledger). 1 LYT = ₹1
isFeatured // the 6 the dashboard summarises
impact: {
customers // MEASURED
rewardClaims? // omitted, never 0, when activity grants none
repeatVisits, purchases // ATTRIBUTED
attributedRevenueInr // ATTRIBUTED — never name this `revenue`
attribution: 'estimated'|'observed'
}
}
JourneyStage { id:'visit'|'engage'|'purchase'|'return'|'refer', label, value, conversionPct? }
CampaignSummary { id, name, activityId, accent, status:'live'|'ended'|'scheduled',
steps:{label,value}[], attributedRevenueInr, attribution }
```
> **Contract rule.** `attribution` is not decorative. When it is `'estimated'` the
> UI shows a disclosure ("Attribution is estimated…") beside every attributed
> figure; when your backend can join purchases to activity events, return
> `'observed'` and the disclosure disappears with no frontend change.
> Invariants the UI assumes: `purchases ≤ repeatVisits ≤ customers ≤ count`.
### Lyts — `src/features/lyts/types/reward.ts`
| Method | Path | Returns |
|---|---|---|
| GET | `/api/lyts/rewards` | `Reward[]` |
| GET | `/api/lyts/redemptions` | `TimePoint[]` ⚠️ |
| GET | `/api/lyts/activity` | `ActivityEvent[]` |
`Reward { id, name, costLyt, claimed, used, expiresAt: ISO|null, status: 'active'|'paused'|'expiring'|'expired' }`
⚠️ **Known contract smell:** redemptions reuses `TimePoint`, and the Lyts charts
read `visitors` as "Issued" and `purchases` as "Redeemed". Your backend should
return a purpose-built shape — `{t, issued, redeemed}` — and I'll update the two
chart call sites.
### Stores — `src/features/stores/types/store.ts`
| Method | Path | Returns |
|---|---|---|
| GET | `/api/stores` | `Store[]` |
| GET | `/api/stores/:storeId` | `Store` (404 → `not_found`) |
`Store { id, name, status:'open'|'closed'|'maintenance', visitors, purchases, revenueInr, conversionPct, staffCount }`
### Staff — `src/features/staff/types/staff.ts`
| Method | Path | Returns |
|---|---|---|
| GET | `/api/staff` | `StaffMember[]` |
| GET | `/api/staff/summary` | `StaffSummary` |
| GET | `/api/staff/attendance` | `AttendancePoint[]` |
### Settings — `src/features/settings/types/settings.ts`
| Method | Path | Returns |
|---|---|---|
| GET | `/api/settings/profile` | `MerchantProfile` |
| PATCH | `/api/settings/profile` | `MerchantProfile` (merged) — **does not persist today** |
---
## 3. Endpoints that DO NOT EXIST — must be created
These screens are live in the UI with **no API, no repository, and no persistence**.
Mutations are `useState` only: refresh the page and every change is gone.
### 3a. Commerce — the worst-wired module
`/commerce` imports `commerceService.ts` **directly and synchronously** from 10
component files. No repository, no route handler, no loading or error state.
Needed:
| Method | Path | Returns |
|---|---|---|
| GET | `/api/commerce/kpis` | revenue, orders, AOV, net sales, refunds, conversion (value + delta each) |
| GET | `/api/commerce/revenue-trend` | `{t, revenue, target}[]` |
| GET | `/api/commerce/orders-trend` | `{t, orders}[]` |
| GET | `/api/commerce/payment-breakdown` | `{method, amount, sharePct}[]` |
| GET | `/api/commerce/store-performance` | `{storeId, name, revenueInr}[]` |
| GET | `/api/commerce/top-products` | `{id, name, category, unitsSold, revenueInr, growthPct, stockCount, stockStatus}[]` |
| GET | `/api/commerce/alerts` | `{id, severity, title, body}[]` |
| GET | `/api/commerce/orders` | recent orders feed |
### 3b. Settings — 7 screens, all local-state fakes
| Screen | Needed endpoints |
|---|---|
| Team & Staff | `GET/POST/PATCH/DELETE /api/settings/team` (+ suspend, password reset, invite) |
| Stores | `GET/POST/PATCH/DELETE /api/settings/stores` |
| Roles & Permissions | `GET/PUT /api/settings/roles` (permission matrix) |
| Integrations | `GET /api/settings/integrations`, `POST/DELETE .../:id/connection` |
| API & Webhooks | `GET/POST/DELETE /api/settings/api-keys`, `GET/POST/DELETE /api/settings/webhooks`, `GET /api/settings/webhooks/logs` |
| Security | `GET /api/settings/sessions`, `DELETE /api/settings/sessions/:id`, `GET /api/settings/audit-log`, `POST /api/auth/password`, `POST/DELETE /api/auth/2fa` |
| Billing | `GET /api/settings/billing`, `GET /api/settings/invoices`, `PATCH /api/settings/payout-account` |
### 3c. Loyaly AI
The chat panel streams from `services/ai/mockAi.ts` — a local prompt classifier
picking canned templates. `loyalyAiRepository` is the only repository in the app
that imports a mock directly.
Needed: `POST /api/ai/chat` (streaming — SSE or chunked), plus
`GET/POST/DELETE /api/ai/conversations` if history is to survive reload.
### 3d. Store switcher — **critical**
`STORE_OPTIONS` is a **hardcoded array in `src/shared/providers/WorkspaceProvider.tsx`**
(line 45), duplicated from the fixture roster. This array scopes *every request in
the app*. It must be fed from `GET /api/stores`, or a real merchant sees fixture
store names in the global switcher.
---
## 4. Hardcoded / dummy data inventory
| Location | What | Removal |
|---|---|---|
| `src/features/*/mock/*.ts` (11 files, ~1,970 lines) | all fixture generators | delete at cutover |
| `src/shared/mock/rng.ts` | seeded RNG | delete at cutover |
| `src/features/commerce/services/commerceService.ts` | KPIs, trends, products, alerts, store multipliers | replace with repository |
| `src/features/settings/components/*.tsx` × 7 | `INITIAL_STAFF`, `INITIAL_STORES`, `INITIAL_MATRIX`, `INITIAL_APPS`, `INITIAL_KEYS`, `INITIAL_WEBHOOKS`, `WEBHOOK_LOGS`, `INITIAL_SESSIONS`, `AUDIT_LOGS`, `INVOICES` | replace with hooks |
| `src/shared/providers/WorkspaceProvider.tsx` | `STORE_OPTIONS` | feed from `/api/stores` |
| `src/features/loyaly-ai/services/ai/**` (~570 lines) | prompt router + reply templates | replace with real AI endpoint |
| `src/features/auth/mock/users.mock.ts` | 5 users, **plaintext passwords** | replace `verifyCredentials()` body |
| `src/app/api/**/route.ts` (24 files) | fixture wiring + `?_state=` simulation | delete or keep as proxy — see §6 |
**Auth note.** `verifyCredentials()` is already the single credential seam — its
body becomes an HTTP call and nothing else changes. But it currently returns
`unknown_email` vs `wrong_password` separately, which is a **user-enumeration
oracle**. Collapse both to one message at the route when you go live.
`AUTH_SECRET` must be set in production (`sessionToken.ts` throws without it).
---
## 5. What I recommend NOT doing yet
Deleting the fixtures before the real endpoints exist leaves every screen in an
error state and removes the only way to verify the wiring. The fixtures are also
the **executable spec** — `intelligence.mock.ts` encodes the funnel invariants
your backend has to honour.
Order that keeps the app working at every step:
1. **Now (backend-independent):** add the missing seams — commerce repository +
route, settings hooks + routes, `STORE_OPTIONS` from `/api/stores`, AI
repository seam. Fixtures stay behind them. Every module then has one file to
swap.
2. **You send the API spec MD.** I diff it against §2/§3 and report
match / rename / missing / extra per endpoint.
3. **Cutover:** point `NEXT_PUBLIC_API_BASE` at the real host, adapt any shape
mismatches in the repository layer only, delete `mock/` + `src/app/api/**`.
4. **Verify:** typecheck, build, and walk every screen with the network tab.
---
## 6. Decision needed from you
**Do the Next.js route handlers stay?**
- **A — Direct:** frontend calls your backend. Delete `src/app/api/**`. Needs CORS
and a cross-origin-safe session cookie.
- **B — Proxy (recommended):** keep the route handlers, replace each fixture call
with a `fetch` to your backend. Session cookie stays first-party, your API host
is never exposed to the browser, and the `?_state=error|empty|loading` dev
harness keeps working.

View File

@@ -0,0 +1,280 @@
# Behavision API ↔ Loyaly Merchant OS — gap analysis
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`.)
---
## Headline finding
**These are two different products.**
This frontend was built as a **loyalty & rewards console**: LYT points, reward
catalogues, engagement activities (spin / selfie / scratch / challenge /
referral), campaigns, redemption liability, commerce orders.
Behavision is a **camera-based footfall & visitor-recognition platform**: sites,
cameras, face templates, arrivals, visitor identity, footfall and conversion
reports.
They overlap on roughly **one third** of the surface — the part that is genuinely
about *people arriving at a shop and buying something*. The rest of the UI has no
data source in this API and never will until a loyalty backend exists.
So "remove all hardcodes" has a consequence that needs a decision, not a
guess: **removing the fixtures makes about half the current UI go blank.** §4
lays out the options.
The reverse is also true and more interesting: **Behavision exposes a lot of real
product this console does not surface at all** — a visitor directory, live camera
views, site health checks, an invitation/join flow, device management, GDPR
erasure. See §5.
---
## 1. What maps — build these for real
| Screen / panel | Behavision endpoint | Notes |
|---|---|---|
| Login | `POST /api/auth/login` | different token model — §3 |
| Session restore | `GET /api/auth/me` | |
| Logout | `POST /api/auth/logout` | |
| Dashboard · Visitors KPI | `GET /api/reports/footfall` | use server `total`, never sum buckets |
| Dashboard · Purchases KPI | `GET /api/reports/conversion` | |
| Dashboard · Revenue KPI | `GET /api/reports/conversion` | |
| Dashboard · Footfall chart | `GET /api/reports/footfall?bucket=day` | |
| Dashboard · Revenue chart | `GET /api/reports/conversion?bucket=day` | |
| Dashboard · Visitors vs purchases | both reports | |
| Dashboard · Conversion chart | `GET /api/reports/conversion` | |
| Dashboard · Peak hours heatmap | `GET /api/reports/footfall?bucket=hour` | 7×24 grid from hourly buckets |
| Dashboard · Period rollup | `…?bucket=week\|month` | |
| Dashboard · Store comparison | `GET /api/reports/footfall?site=` per site | |
| Dashboard · Recent activity feed | `GET /api/visits` | **arrivals only** — see below |
| Stores list | `GET /api/sites` | + `fraction_below_gate`, cameras up |
| Store detail | `GET /api/sites/{id}/check` | five-step smoke test |
| Settings · Team | `GET /api/team`, `PATCH /api/team/{id}` | replaces `INITIAL_STAFF` |
| Settings · Security → sessions | `GET/DELETE /api/auth/sessions`, `POST …/revoke-others` | replaces `INITIAL_SESSIONS` |
| Store switcher (`STORE_OPTIONS`) | `GET /api/sites` | **critical — scopes every request** |
**Customer journey** maps partially and honestly:
| stage | source |
|---|---|
| Visit | footfall `total` |
| Engage | ✗ no source |
| Purchase | conversion |
| Return | footfall `returning` |
| Refer | ✗ no source |
**Activity feed caveat.** The current feed renders five event kinds
(`reward_redeemed`, `staff_checked_in`, `purchase`, `reward_expired`,
`store_opened`). `/api/visits` supplies **arrivals only**. Four of the five kinds
have no source. The feed becomes an arrivals feed — which is arguably the better
screen, and is what the spec calls "the screen a mobile app is for".
---
## 2. What does NOT map — no endpoint exists
| Area | Frontend surface | Status |
|---|---|---|
| **LYT programme** | entire `/lyts` page: rewards, claimed/used, redemption rate, outstanding liability, expiry alerts, reward usage chart, LYTs issued | ✗ no rewards concept in the API |
| **Activity intelligence** | walk / selfie / spin / scratch / brand / challenge / friend / shop / event, impact chains, attribution, activity grouping | ✗ only *visit* has an analogue |
| **Campaign performance** | campaign funnels | ✗ |
| **Store insights** | "What needs your attention", AI briefing | ✗ |
| **Commerce** | orders, products, categories, stock, payment methods, refunds, AOV, alerts | ✗ except revenue/basket via conversion report; `POST /api/purchases` writes but there is no orders read |
| **Staff module** | attendance (present/absent/late/leave), punctuality, sales per head, rewards issued, performance score | ✗ **different concept** — `/api/team` is console *user accounts*, not shop-floor rostering |
| **Settings · Profile** | business name, GSTIN, timezone, currency, LYTs per ₹100 | ✗ no business-profile endpoint |
| **Settings · Roles matrix** | per-permission grid | ✗ role is a single enum via `PATCH /api/team/{id}` |
| **Settings · Integrations** | connected apps | ✗ |
| **Settings · API & Webhooks** | keys, endpoints, delivery logs | ✗ |
| **Settings · Billing** | plan, invoices, payout account | ✗ |
| **Settings · Security → audit log, 2FA** | | ✗ (sessions *do* exist) |
| **Loyaly AI** | chat panel | ✗ |
---
## 3. Architectural changes required
These are not cosmetic. Each one has a defined failure mode.
### 3.1 Auth: cookie+HMAC → Bearer JWT with rotation
Today: `proxy.ts` gates every route on an httpOnly cookie carrying an
HMAC-signed payload minted locally by `sessionToken.ts`. There is no upstream.
Behavision: `access_token` + `refresh_token`, both rotating.
**Recommendation — keep the Next routes as a BFF (Backend-For-Frontend).**
Tokens live server-side; the browser keeps only the existing httpOnly cookie.
This is not conservatism, it buys five specific things:
1. `proxy.ts` and the whole SSR gate keep working unchanged.
2. Tokens never reach JS, so an XSS cannot exfiltrate a refresh token.
3. **Web `<img>` cannot send `Authorization`.** A BFF can proxy
`/api/faces/…` and the browser just uses a normal `<img src>`. Otherwise
every avatar needs fetch + `createObjectURL` + revoke-on-unmount.
4. The single-flight refresh lock lives in one server process, not in N tabs.
5. CORS and `SameSite=None` disappear as problems.
**Three client rules the spec calls out — all must be implemented in the BFF:**
- `401` + `"error": "token_expired"` → refresh **once**, retry, silently. Any
other 401 is a real sign-out.
- **Serialise refresh behind one lock.** Refresh tokens are single-use; four
concurrent panels would each spend it and three would lose. This dashboard
fires **9 parallel requests on one page load** — it will hit this on the first
expiry, every time, without the lock.
- **Persist rotated tokens before using them.** A crash between refresh and
persist leaves a token the server has already invalidated.
Also: marshal the request body before the first attempt — a retry re-sends it,
and a stream is spent after the first read.
### 3.2 Response envelope
Frontend expects `{data, meta:{generatedAt, range, storeId}}`. Behavision
returns bare objects/arrays.
**`meta.generatedAt` is load-bearing** — every relative timestamp, the greeting,
and every days-to-expiry countdown measures against the server clock, never
`Date.now()`. The BFF must synthesise it (from `polled_at`, or the response
time) or ~20 `useResource` call sites all need rewriting.
Wrap in the BFF. Cheapest correct option by a wide margin.
### 3.3 Scope parameters
`?storeId=all&range=30d` → `?site=<slug>&from=YYYY-MM-DD&to=YYYY-MM-DD&tz=…&bucket=…`
- `storeId: 'all'` → omit `site` entirely.
- `RangeKey` → concrete `from`/`to`. `to` is **inclusive**.
- Send `tz`; buckets come back as **local wall time with no offset**.
- Use `site` (documented spelling). An unknown query param is *silently ignored*,
so `site_id` in the wrong place returns the whole estate instead of an error.
### 3.4 Report arithmetic — three traps
- **Do not sum buckets to get the total.** `total` is unique people over the
window; someone who came Monday and Thursday is 1 person, 2 bucket-visitors.
Show the server's `total`, visits underneath.
- **`new + returning` can be less than `total`** — a site sending counts without
templates records real footfall by an unidentified person.
- **Do not `new Date()` a bucket label.** They are wall-time strings with no
offset; parsing shifts every label into the viewer's zone.
`formatDayLabel()` currently does `new Date(iso)` — it pins `timeZone:'UTC'`
so it survives, but bucket labels should be passed through, not parsed.
### 3.5 Errors
Behavision: `{"error": "invalid_code", "message": "…"}` (flat).
Frontend: `{error: {code, message}}` (nested), with codes
`internal|not_found|bad_request|unauthorized`.
Map in the BFF. And **show the server's `message`** — it is written for humans;
branch on `error`, never on the prose. New codes to handle: `token_expired`,
`too_many_attempts` (429), `last_owner` (409), `invalid_code` (404),
**`501` = feature off for this deployment, not an error**.
`404` is also what another tenant's data returns — never surface it as "deleted".
### 3.6 Cursor pagination — new concept
`GET /api/visits` is cursor-based and lossless; polling by timestamp
**permanently skips rows** when a burst exceeds `limit`. `useResource` has no
cursor concept — the arrivals feed needs a cursor-aware hook.
- Echo the returned `cursor` on every poll.
- An empty poll returns **your own cursor**, not `""`.
- A cursor that fails to parse → drop it, re-poll without one.
- Delivery is at-least-once → de-duplicate on `visit_id`.
- `GET /api/visits/stream` (SSE) is the low-latency path; needs an HTTP client
that can set `Authorization` (browser `EventSource` cannot).
### 3.7 Images — new subsystem
- **Missing photo is data, not an error.** Images are off by default across the
product; `available: false` with a `reason` is the normal case. Render
initials, never an error state.
- `auth: true` → send Bearer. `auth` absent/false → presigned, use directly.
**Do not infer from the URL shape.** Treat any relative URL as needing auth.
- **Every hand-out is written to the audit log.** Fetch once per screen, not
once per component — two components asking for one face puts two rows in
"who looked at my customers" for one glance.
- Web: object URL + **revoke on unmount**, or a screen left open all afternoon
holds hundreds of copies of one photograph. (The BFF proxy in §3.1 avoids
this entirely.)
### 3.8 Roles
`UserRole = 'owner' | 'manager' | 'analyst'` → **`'staff' | 'manager' | 'owner'`**.
`analyst` does not exist. Platform admin = `role === 'admin'` **and** empty
`client_id` — the two together, never the role alone.
### 3.9 References instead of uuids
`V-42`, `chennai`, `Office1`, `priya@tenext.in` all work in paths and filters,
and are **immutable** — safe to put in a URL or a saved report. Display names are
not. The store switcher should key on `site_slug`, and deep links should use it.
Ambiguity resolves to nothing, not a guess. Unknown ref: **404 in a path, 400 in
a filter**.
---
## 4. The decision: what happens to the unmapped half
The API gives a clean idiom for this: **`501` — "the feature is off for this
deployment, not an error."**
| | option | result |
|---|---|---|
| **A** | Delete the unmapped modules | Smallest, most honest app. Lose `/lyts`, `/commerce`, activity intelligence, campaigns, insights, staff attendance, most of settings. Recoverable from git when a loyalty backend ships. |
| **B** | Keep the UI, render "not available in this deployment" | Nothing hardcoded, nothing invented, screens stay for when the backend arrives. Costs a small unavailable-state component. |
| **C** | Keep fixtures behind an explicit `DEMO_MODE` flag | Sales demos keep working; real deployments show B. Highest complexity. |
**My recommendation: B**, with A for `/commerce` specifically — commerce is the
one module with no seam at all (10 components import a service synchronously),
so there is nothing to preserve, and conversion-report revenue can move onto the
dashboard where it belongs.
Either way **no invented number survives**, which is what you asked for.
---
## 5. Real product this console is not exposing
Worth knowing before we decide what to delete — the API supports screens that do
not exist here yet:
- **Visitor directory** — `GET /api/visitors?q=` search by name/phone/`V-42`,
`/history`, `PUT /profile` (name, phone, notes), and **`DELETE` erasure**
(destroys face template + photo, keeps visits unlinked, irreversible; a `502`
means *nothing* was deleted and must be reported as failure, never swallowed).
- **Live camera view** — `GET /api/cameras/{id}/live`, SSE relayed from the shop PC.
- **Camera management** — `GET /api/cameras` with latest still, `PATCH /api/cameras/{id}`.
- **Site health** — `GET /api/sites/{id}/check`, five-step smoke test.
- **Invitation / join flow** — mint a code, preview it unauthenticated, redeem it
into a full session. Replaces the fake "Add Staff Member" form entirely.
- **Device management** — the user's own signed-in devices, with `current` marked.
- **Arrivals SSE stream** — the live feed.
---
## 6. Proposed sequence
1. **BFF + auth** — Behavision login/refresh/logout/me behind the existing
cookie; single-flight refresh; envelope + error adapters; scope→params
mapper. Nothing else can be wired until this exists.
2. **Kill the highest-risk hardcode** — `STORE_OPTIONS` → `GET /api/sites`.
3. **Reports** — dashboard KPIs, footfall, conversion, peak hours, rollup,
comparison.
4. **Arrivals** — cursor-aware feed replacing the mock activity timeline.
5. **Team + Sessions + Invitations** — replaces four fake settings screens with
real ones.
6. **Apply the §4 decision** to everything unmapped; delete `src/features/*/mock/**`
and `src/shared/mock/`.
7. **Verify** — typecheck, build, walk every screen against the live API.

60
docs/PLATFORM-STATUS.md Normal file
View File

@@ -0,0 +1,60 @@
# Live platform status — mcp.loyaly.ai
Probed 2026-09-09. `LOYALY_API_BASE=https://mcp.loyaly.ai`
The host is confirmed as the Behavision platform: it answers the documented flat
`{"error": "...", "message": "..."}` contract with the documented codes
(`bad_credentials`, `unauthorized`, `not_found`, `bad_request`).
**8 of the 16 endpoints the spec documents are not deployed yet.**
A `401 unauthorized` proves the route exists and is gated. A
`404 {"error":"not_found","message":"No such endpoint."}` means it is absent.
## Live
| Endpoint | Probe | Console feature |
|---|---|---|
| `POST /api/auth/login` | 401 `bad_credentials` | Sign in — **wired** |
| `POST /api/auth/refresh` | 400 `refresh_token is required` | Token rotation — **wired** |
| `POST /api/auth/logout` | 401 `unauthorized` | Sign out — **wired** |
| `GET /api/auth/me` | 401 `unauthorized` | Session confirmation — **wired** |
| `GET /api/sites` | 401 `unauthorized` | Site switcher + Store page — **wired** |
| `GET /api/visitors` | 401 `unauthorized` | Customer directory — service written, UI not built |
| `GET /api/reports/footfall` | 401 `unauthorized` | Visitors KPI + Footfall chart — **wired** |
| `GET /api/reports/conversion` | 401 `unauthorized` | Purchases/Revenue/Conversion + Sales — **wired** |
## Not deployed
| Endpoint | Probe | Blocks |
|---|---|---|
| `GET /api/visits` | 404 | **Recent arrivals feed, Activity page** |
| `POST /api/purchases` | 404 | **Mobile → dashboard purchase flow** |
| `GET /api/team` | 404 | Leaderboard team table |
| `GET /api/team/invitations` | 404 | Invite flow |
| `GET /api/auth/invitation` | 404 | Join preview |
| `POST /api/auth/register` | 404 | Redeeming an invitation |
| `GET /api/auth/sessions` | 404 | Device management |
| `GET /api/cameras` | 404 | Camera list / live view |
## Consequence for the "most important requirement"
The brief's §7 flow —
```
Mobile → POST /api/visits → DB → Dashboard GET /api/visits → new visit appears
Mobile → POST /api/purchases → DB → reports update
```
— cannot run today. **Neither `/api/visits` nor `/api/purchases` is deployed.**
The console side is built and pointed at both; they return 404 until the
platform ships them.
What *does* work end to end once there is an account: sign in, site switching,
and every KPI and chart on the Dashboard and Sales pages, since those read the
two report endpoints that are live.
## Still needed
A valid account on `mcp.loyaly.ai`. There is no open registration by design,
and `POST /api/auth/register` is not deployed either — so an account has to be
created directly on the platform side.

View File

@@ -11,7 +11,15 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
// Deploy artifact from `npm run bundle` — traced vendor code, not ours.
"dist/**",
"next-env.d.ts",
// Generated by `npm run theme:build` — edit src/theme/loyalyTheme.ts
// instead. The emitted .d.ts uses a triple-slash reference we do not own.
"src/theme/loyaly.css",
"src/theme/loyaly.js",
"src/theme/loyaly.d.ts",
"src/theme/loyaly.variants.d.ts",
]),
]);

View File

@@ -1,7 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
experimental: {
// Turbopack's build cache lives in .next/cache and only pays off when that
// directory survives between builds. A Docker/Dokploy build starts from a
// clean layer every time, so the cache is written and never read — ~117 MB
// of pure write cost per build. CI_BUILD is set in the Dockerfile only, so
// local `npm run build` keeps its warm cache.
turbopackFileSystemCacheForBuild: process.env.CI_BUILD !== "1",
},
allowedDevOrigins: ["192.168.0.117", "192.168.0.*", "192.168.1.*", "localhost", "127.0.0.1"],
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;

73
nginx.conf Normal file
View File

@@ -0,0 +1,73 @@
# ─────────────────────────────────────────────────────────────────────────────
# NOT USED BY THE DEPLOYED IMAGE. Kept only as a reference.
#
# The image ran `node server.js & nginx -g 'daemon off;'` and exposed port 80
# until commit 28258b5 ("fix docker port"), which dropped nginx and made the
# Next standalone server the container's only process on port 3000. Nothing
# installs nginx any more, and .dockerignore excludes this file from the build
# context, so editing it CANNOT affect production — the TLS termination and
# reverse proxy in front of the container are Dokploy's Traefik, configured in
# the Dokploy dashboard, not here.
#
# That matters when a 502 Bad Gateway shows up: this file is the obvious place
# to look and the wrong one. A 502 means Traefik had no healthy container to
# proxy to. Check the container's log for `[loyaly] configuration problem` and
# `GET /api/health` first.
# ─────────────────────────────────────────────────────────────────────────────
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
upstream nextjs_upstream {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name localhost;
# Serve Next.js compiled static assets directly via Nginx
location /_next/static/ {
alias /app/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Serve public directory assets
location /public/ {
alias /app/public/;
expires 30d;
access_log off;
}
# Reverse proxy dynamic routes, SSR, and API routes to Next.js standalone server
location / {
proxy_pass http://nextjs_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

1353
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,30 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3200",
"build": "next build",
"start": "next start",
"lint": "eslint"
"start": "next start -p 3200",
"lint": "eslint",
"clean": "rm -rf .next dist tsconfig.tsbuildinfo",
"bundle": "bash scripts/bundle.sh",
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
"typecheck": "tsc --noEmit",
"dev:preview": "next dev"
},
"dependencies": {
"@astryxdesign/core": "^0.2.0",
"@astryxdesign/theme-neutral": "^0.2.0",
"@stylexjs/stylex": "^0.19.0",
"framer-motion": "^12.43.0",
"lucide-react": "^1.28.0",
"next": "16.3.6",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"server-only": "^0.0.1"
},
"devDependencies": {
"@astryxdesign/cli": "^0.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

BIN
public/white-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

64
scripts/bundle.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Produce the deployable artifact for a non-Docker deploy.
#
# Copying .next/ wholesale is what filled the production disk: a working tree's
# .next/ reaches 2+ GB, but only three paths are read at runtime —
# .next/standalone the server + its traced node_modules
# .next/static hashed client assets (served by nginx at /_next/static/)
# public unhashed public assets
# Everything else (.next/cache, .next/dev, .next/server, .next/types) is
# build-host state and must never be shipped.
#
# Usage: npm run bundle -> dist/loyaly-mer-login.tar.gz
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
OUT="dist"
STAGE="$OUT/bundle"
for p in .next/standalone .next/static public; do
if [ ! -d "$p" ]; then
echo "error: $p missing — run 'npm run build' first" >&2
exit 1
fi
done
# .env is the production environment, not a secret — LOYALY_API_BASE lives in
# it and the server reads it at boot. It is tracked in git, so a missing one
# means the tree is wrong, not that this deploy opted out.
if [ ! -f .env ]; then
echo "error: .env missing — it is committed; restore it with 'git checkout .env'" >&2
exit 1
fi
rm -rf "$STAGE"
mkdir -p "$STAGE"
# standalone already contains server.js, package.json and traced node_modules,
# and expects static/ and public/ to sit beside it in the same layout.
cp -R .next/standalone/. "$STAGE/"
mkdir -p "$STAGE/.next"
cp -R .next/static "$STAGE/.next/static"
cp -R public "$STAGE/public"
# Beside server.js, which is where Next's loadEnvConfig looks. NOT .env.local —
# that is the dev override and would point the deployed server at 127.0.0.1.
cp .env "$STAGE/.env"
TARBALL="$OUT/loyaly-mer-login.tar.gz"
rm -f "$TARBALL"
tar -czf "$TARBALL" -C "$STAGE" .
echo
echo "bundle: $(du -sh "$STAGE" | cut -f1) ($STAGE)"
echo "tarball: $(du -sh "$TARBALL" | cut -f1) ($TARBALL)"
echo
echo "deploy: scp $TARBALL <host>:/srv/ && tar -xzf loyaly-mer-login.tar.gz -C /srv/app"
echo "run: AUTH_SECRET=... PORT=3000 HOSTNAME=0.0.0.0 NODE_ENV=production node server.js"
echo
echo "note: LOYALY_API_BASE ships in the bundled .env. AUTH_SECRET does not —"
echo " it signs sessions and must come from the host's environment."

16
src/app/(dev)/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import {notFound} from 'next/navigation';
/**
* /tokens and /charts are internal design-system tools, not product surface.
* Without this gate they appear in the production route table and are publicly
* reachable. `dynamic = 'force-dynamic'` is required so the check runs per
* request rather than being folded into a static prerender.
*/
export const dynamic = 'force-dynamic';
export default function DevLayout({children}: {children: React.ReactNode}) {
if (process.env.NODE_ENV === 'production') {
notFound();
}
return <>{children}</>;
}

View File

@@ -0,0 +1,339 @@
'use client';
/**
* Dev-only design-system audit page.
*
* Its job is to make the monochrome rule falsifiable: every Astryx surface
* that can carry a colour is rendered here, including the categorical hue
* variants we deliberately aliased to gray. Anything that still shows a hue
* other than success-green / warning-amber / error-red is a token we missed
* in aliasHueToGray(), and gets added there rather than patched locally.
*/
import {useState, useSyncExternalStore} from 'react';
import {Card} from '@astryxdesign/core/Card';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {Banner} from '@astryxdesign/core/Banner';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {Skeleton} from '@astryxdesign/core/Skeleton';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {TextInput} from '@astryxdesign/core/TextInput';
import {TextArea} from '@astryxdesign/core/TextArea';
import {Switch} from '@astryxdesign/core/Switch';
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
import {Divider} from '@astryxdesign/core/Divider';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Icon} from '@astryxdesign/core/Icon';
import {Heading, Text} from '@astryxdesign/core/Text';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
const HUES = [
'blue',
'cyan',
'green',
'orange',
'pink',
'purple',
'red',
'teal',
'yellow',
] as const;
const DATA_RAMP = [1, 2, 3, 4, 5] as const;
function Section({title, hint, children}: {title: string; hint?: string; children: React.ReactNode}) {
return (
<VStack gap={3}>
<VStack gap={1}>
<Heading level={3}>{title}</Heading>
{hint ? <Text size="sm" color="secondary">{hint}</Text> : null}
</VStack>
<Card>
<VStack gap={4}>{children}</VStack>
</Card>
</VStack>
);
}
function Swatch({label, color}: {label: string; color: string}) {
return (
<VStack gap={1}>
<div
style={{
height: 48,
borderRadius: 'var(--radius-element)',
background: color,
boxShadow: 'inset 0 0 0 1px var(--color-border)',
}}
/>
<Text size="xsm" color="secondary">{label}</Text>
</VStack>
);
}
export default function TokensPage() {
const [name, setName] = useState('');
const [query, setQuery] = useState('Free coffee');
const [notes, setNotes] = useState('');
const [auto, setAuto] = useState(true);
const [closed, setClosed] = useState(false);
// "Has this hydrated yet?" via useSyncExternalStore — false on the server,
// true on the client, with no setState in an effect body.
const showError = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
return (
<VStack gap={6} padding={6}>
<VStack gap={1}>
<Heading level={1}>Design tokens</Heading>
<Text color="secondary">
Monochrome audit. The only colour permitted below is semantic:
success, warning, error. Everything else must read as gray.
</Text>
</VStack>
<Section title="Surfaces" hint="body → surface → card → muted, the four elevation steps of the shell.">
<Grid columns={{minWidth: 140, repeat: 'fit'}} gap={3}>
<Swatch label="body #000000" color="var(--color-background-body)" />
<Swatch label="surface #0F0F10" color="var(--color-background-surface)" />
<Swatch label="card #171717" color="var(--color-background-card)" />
<Swatch label="muted #202124" color="var(--color-background-muted)" />
<Swatch label="border #2F2F2F" color="var(--color-border)" />
<Swatch label="border-emph" color="var(--color-border-emphasized)" />
</Grid>
</Section>
<Section title="Text & icon" hint="White / #A1A1AA / #71717A.">
<VStack gap={2}>
<Text>Primary text — white on black.</Text>
<Text color="secondary">Secondary text — #A1A1AA.</Text>
<Text color="disabled">Disabled text — #71717A.</Text>
<HStack gap={3}>
<Icon icon="search" />
<Icon icon="check" color="secondary" />
<Icon icon="warning" color="warning" />
<Icon icon="error" color="error" />
<Icon icon="success" color="success" />
</HStack>
</VStack>
</Section>
<Section
title="Buttons"
hint="Primary = gray fill. Secondary = transparent + border. Ghost = transparent, gray hover. Tab through these to check focus rings."
>
<VStack gap={3}>
<HStack gap={2} wrap="wrap">
<Button variant="primary" label="Primary" />
<Button variant="secondary" label="Secondary" />
<Button variant="ghost" label="Ghost" />
<Button variant="destructive" label="Destructive" />
</HStack>
<HStack gap={2} wrap="wrap">
<Button variant="primary" label="Disabled" isDisabled />
<Button variant="secondary" label="Disabled" isDisabled />
<Button variant="primary" label="Loading" isLoading />
<Button variant="primary" label="With icon" icon={<Icon icon="search" />} />
</HStack>
<HStack gap={2} wrap="wrap" vAlign="center">
<Button size="sm" variant="primary" label="Small" />
<Button size="md" variant="primary" label="Medium" />
<Button size="lg" variant="primary" label="Large" />
</HStack>
</VStack>
</Section>
<Section
title="Badges — semantic"
hint="These four are allowed to carry colour. They are the semantic language."
>
<HStack gap={2} wrap="wrap">
<Badge variant="neutral" label="Neutral" />
<Badge variant="info" label="Info" />
<Badge variant="success" label="Success" />
<Badge variant="warning" label="Warning" />
<Badge variant="error" label="Error" />
</HStack>
</Section>
<Section
title="Badges — categorical hues"
hint="AUDIT: blue/cyan/orange/pink/purple/teal must render gray. green/red/yellow stay tinted by design — Banner's semantic statuses resolve through those same hue tokens."
>
<HStack gap={2} wrap="wrap">
{HUES.map((hue) => (
<Badge key={hue} variant={hue} label={hue} />
))}
</HStack>
</Section>
<Section
title="Cards — categorical variants"
hint="AUDIT: same rule. default / transparent / muted / gray plus the nine hues."
>
<Grid columns={{minWidth: 150, repeat: 'fit'}} gap={3}>
{(['default', 'transparent', 'muted', 'gray', ...HUES] as const).map((v) => (
<Card key={v} variant={v}>
<Text size="sm">{v}</Text>
</Card>
))}
</Grid>
</Section>
<Section title="Status dots" hint="accent must be gray; the rest semantic.">
<HStack gap={4} wrap="wrap" vAlign="center">
{(['neutral', 'accent', 'success', 'warning', 'error'] as const).map((v) => (
<HStack key={v} gap={2} vAlign="center">
<StatusDot variant={v} label={v} />
<Text size="sm" color="secondary">{v}</Text>
</HStack>
))}
</HStack>
</Section>
<Section title="Banners" hint="Full-width status surfaces used for alerts and expiry warnings.">
<VStack gap={2}>
<Banner status="info" title="Peak hours start in 20 minutes" description="Staff coverage is below the weekday average." />
<Banner status="success" title="Weekend campaign hit its target" description="1,284 LYTs redeemed against a 1,000 goal." />
<Banner status="warning" title="3 rewards expire this week" description="Free Coffee, Combo 20% and Weekend Bonus." />
<Banner status="error" title="Koramangala store is offline" description="No footfall events received in 4 hours." />
</VStack>
</Section>
<Section title="Inputs" hint="Black background, gray border, white text, gray placeholder.">
<Grid columns={{minWidth: 240, repeat: 'fit'}} gap={4}>
<TextInput
label="Store name"
placeholder="e.g. Indiranagar Flagship"
value={name}
onChange={setName}
/>
<TextInput
label="Disabled"
placeholder="Not editable"
value=""
isDisabled
/>
{/*
Status is applied AFTER mount on purpose. Astryx's useEntryAnimation
assumes 'use client' modules never execute on the server, which is
false in the App Router: SSR renders without the slide-down class,
then hydration (which lands after the first rAF) renders with it,
and React reports a class mismatch it will not patch up.
Real forms set status on blur/submit, so they never hit this; only
a status present at initial paint does. See AGENTS.md.
*/}
<TextInput
label="With error"
placeholder="Required"
value=""
status={
showError
? {type: 'error', message: 'This field is required'}
: undefined
}
/>
<TextInput
label="Search"
placeholder="Find a reward…"
value={query}
onChange={setQuery}
startIcon="search"
hasClear
/>
</Grid>
<TextArea
label="Notes"
placeholder="Internal notes about this store…"
rows={3}
value={notes}
onChange={setNotes}
/>
<HStack gap={5} wrap="wrap" vAlign="center">
<Switch label="Auto-issue rewards" value={auto} onChange={setAuto} />
<Switch label="Disabled" value={false} isDisabled />
<CheckboxInput
label="Include closed stores"
value={closed}
onChange={setClosed}
/>
<CheckboxInput label="Indeterminate" value="indeterminate" />
</HStack>
</Section>
<Section title="Feedback" hint="Progress, skeleton and empty state.">
<VStack gap={4}>
<ProgressBar value={64} label="Monthly target" />
<VStack gap={2}>
<Skeleton height={16} width="40%" />
<Skeleton height={16} width="70%" />
<Skeleton height={16} width="55%" />
</VStack>
<Divider />
<EmptyState
icon={<Icon icon="search" size="lg" />}
title="No rewards match this filter"
description="Try widening the date range or clearing the store filter."
actions={<Button variant="secondary" label="Clear filters" />}
/>
</VStack>
</Section>
<Section
title="Avatars"
hint="AUDIT: Astryx auto-assigns a categorical hue per name. These must all read gray."
>
<HStack gap={2} wrap="wrap">
{['Anita R', 'Vikram S', 'Priya M', 'Rahul K', 'Deepa N', 'Suresh T'].map((n) => (
<Avatar key={n} name={n} />
))}
</HStack>
</Section>
<Section
title="Chart ramp"
hint="--color-data-gray-1..5, the ordered series palette. Lightest = most important series."
>
<Grid columns={{minWidth: 110, repeat: 'fit'}} gap={3}>
{DATA_RAMP.map((i) => (
<Swatch key={i} label={`data-gray-${i}`} color={`var(--color-data-gray-${i})`} />
))}
<Swatch label="data-neutral" color="var(--color-data-neutral)" />
</Grid>
</Section>
<Section
title="Chart categorical slots"
hint="AUDIT: Astryx's 10 categorical data tokens, all remapped onto the gray ramp."
>
<Grid columns={{minWidth: 110, repeat: 'fit'}} gap={3}>
{(['blue', 'orange', 'green', 'purple', 'cyan', 'red', 'teal', 'pink', 'brown', 'indigo'] as const).map(
(hue) => (
<Swatch
key={hue}
label={hue}
color={`var(--color-data-categorical-${hue})`}
/>
),
)}
</Grid>
</Section>
<Section title="Elevation" hint="On pure black, separation comes from the 1px inset rim, not the drop shadow.">
<Grid columns={{minWidth: 160, repeat: 'fit'}} gap={4}>
{(['none', 'low', 'med', 'high'] as const).map((e) => (
<Card key={e} elevation={e}>
<Text size="sm">elevation {e}</Text>
</Card>
))}
</Grid>
</Section>
</VStack>
);
}

View 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>
);
}

View File

@@ -0,0 +1,10 @@
import {PublicLayout} from '@/shared/layouts/PublicLayout';
/** The route-group boundary for screens reachable without a session. */
export default function PublicRouteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <PublicLayout>{children}</PublicLayout>;
}

View File

@@ -0,0 +1,20 @@
import type {Metadata} from 'next';
import {LoginSplit} from '@/features/auth/components/LoginSplit';
export const metadata: Metadata = {
title: 'Sign in · Loyaly.ai',
};
/**
* Sits in the (public) group on purpose: no shell, no nav, no store scope and
* no auth guard — see PublicLayout.
*
* 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 />;
}

View File

@@ -0,0 +1,47 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
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';
/**
* Every arrival, where the dashboard's "View all" leads.
*
* Same component and same resource as the dashboard panel, at a larger page
* 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 />}
/>
<ArrivalsFeed
resource={visits}
title="All arrivals"
subtitle="Customers recognised at the door"
/>
</VStack>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard';
import {List, ListItem} from '@astryxdesign/core/List';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {useSales} from '@/features/commerce/hooks/useSales';
import {SaleDetailDialog} from '@/features/commerce/components/SaleDetailDialog';
import {formatPaise} from '@/features/commerce/services/money';
import {CHART} from '@/shared/components/charts/palette';
import {useConversionReport} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {formatBucketLabel, formatInrCompact} from '@/shared/utils/format';
// TEMPORARY sample data — removal steps in shared/mocks/withSample.ts.
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_CONVERSION_DAILY, hasSignal} from '@/features/dashboard/mocks/dashboardMock';
import {
MOCK_PAYMENT_METHODS,
MOCK_SALES,
MOCK_TOP_PRODUCTS,
} from '@/features/commerce/mocks/commerceMock';
const formatPct = (v: number) => `${v}%`;
/**
* Sales.
*
* ── What changed and why ─────────────────────────────────────────────────
* This page previously rendered eight panels — product leaderboards, payment
* method splits, stock levels, refund rates, hourly targets — every number of
* which came from a hardcoded service imported synchronously by ten
* components. None of it had an API, a loading state, or a way to become real.
*
* The platform reports revenue and basket size through the conversion report,
* and nothing else on this page. Until a shop has history — and until the
* catalogue and payment resources exist — panels show clearly-tagged SAMPLE
* data (features/commerce/mocks) so the page explains itself. Real data
* replaces it automatically; removal steps are in shared/mocks/withSample.ts.
*/
export default function CommercePage() {
const [openSale, setOpenSale] = useState<string | null>(null);
const conversion = withSample(
useConversionReport({bucket: 'day'}),
MOCK_CONVERSION_DAILY,
(d) => hasSignal(d, 'revenue'),
);
const sales = withSample(useSales(), MOCK_SALES, (d) => !!d?.length);
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
<PageHeader
eyebrow="Sales & revenue"
title="Sales"
description={`Revenue and conversion across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
<ChartCard
title="Revenue"
subtitle="Daily takings, from the conversion report"
resource={conversion}
actions={<SampleTag show={conversion.isSample} />}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
xFormat={formatBucketLabel}
yFormat={formatInrCompact}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
<PanelCard
title="Recent sales"
subtitle="Every sale recorded against a visit"
resource={sales}
actions={<SampleTag show={sales.isSample} />}
loading={<SkeletonRows count={5} />}
empty={
<EmptyPanel
icon="commerce"
title="No sales recorded yet"
description="Sales appear here as staff record them in the merchant app."
/>
}
>
{/*
List/Item rather than Table: these rows open a detail view, and
Astryx's Table has no per-row action or custom cell renderer. Both
are approved dense-data patterns — this is the one that can be
clicked, so it is the one that fits.
*/}
{(rows) => (
<List density="balanced">
{rows.map((sale) => (
<ListItem
key={sale.id}
// Sample rows have no sale behind them to open.
onClick={sales.isSample ? undefined : () => setOpenSale(sale.id)}
label={sale.customerLabel ?? sale.customerRef ?? 'Not identified'}
description={[
sale.invoiceNo,
sale.staffName ? `Served by ${sale.staffName}` : null,
`${sale.purchasedLines} purchased`,
// Only mentioned when there were any: "0 enquiries" on
// every row is noise that hides the ones that had some.
sale.enquiryLines > 0 ? `${sale.enquiryLines} enquiries` : null,
]
.filter(Boolean)
.join(' · ')}
endContent={formatPaise(sale.totalPaise)}
/>
))}
</List>
)}
</PanelCard>
{openSale ? (
<SaleDetailDialog saleId={openSale} onClose={() => setOpenSale(null)} />
) : null}
{/*
No platform resource exists yet for the catalogue or payment methods,
so these two are ALWAYS sample and always tagged. Swap each constant
for a hook when the endpoint ships.
*/}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<StaticPanel
title="Top products"
subtitle="Best sellers by revenue in this period"
actions={<SampleTag show />}
>
<List density="balanced">
{MOCK_TOP_PRODUCTS.map((p, i) => (
<ListItem
key={p.id}
label={`${i + 1}. ${p.name}`}
description={`${p.sold} sold`}
endContent={formatPaise(p.revenuePaise)}
/>
))}
</List>
</StaticPanel>
<StaticPanel
title="Payment methods"
subtitle="Share of sales by how customers paid"
actions={<SampleTag show />}
>
<BarChartView
data={MOCK_PAYMENT_METHODS}
xKey="method"
yFormat={formatPct}
height={220}
series={[{key: 'share', label: 'Share', color: CHART.brand.cool}]}
/>
</StaticPanel>
</Grid>
</VStack>
);
}

View 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>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
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 {CustomerFlowChart} from '@/features/dashboard/components/CustomerFlowChart';
import {EngagementSection} from '@/features/engagement/components/EngagementSection';
import {CHART} from '@/shared/components/charts/palette';
import {
useConversionReport,
useDashboardKpis,
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 {
formatBucketLabel,
formatCompact,
formatInrCompact,
} from '@/shared/utils/format';
// TEMPORARY — see the header of this file for how to remove.
import {
MOCK_CONVERSION_DAILY,
MOCK_CONVERSION_WEEKLY,
MOCK_FOOTFALL_DAILY,
MOCK_FOOTFALL_WEEKLY,
hasSignal,
withSample,
} from '@/features/dashboard/mocks/dashboardMock';
import {SampleTag} from '@/shared/mocks/SampleTag';
const CHART_HEIGHT = 220;
/**
* The dashboard, as a READ MODEL over the platform.
*
* Every number traces to a platform resource — footfall, conversion, arrivals.
* The KPI row is always real. The charts fall back to clearly-tagged SAMPLE
* data (features/dashboard/mocks) only while a shop has no history, so a new
* merchant sees what each panel is for instead of an empty axis.
*
* To retire the sample, see `shared/mocks/withSample.ts`.
*
* Bucket labels are rendered as STRINGS — local wall time with no offset;
* parsing one into a Date shifts every label on the axis.
*/
export default function DashboardPage() {
// 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 weeklyFootfall = useFootfallReport({bucket: 'week'});
const weeklyConversion = useConversionReport({bucket: 'week'});
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();
// Chart feeds: real when the platform has signal, sample otherwise.
const footfallChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) =>
hasSignal(d, 'visitors'),
);
const revenueChart = withSample(conversion, MOCK_CONVERSION_DAILY, (d) =>
hasSignal(d, 'revenue'),
);
const mixChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) =>
hasSignal(d, 'newVisitors') || hasSignal(d, 'returningVisitors'),
);
// Two-report panels go sample as a PAIR — never half real, half invented.
const flowIsSample =
!hasSignal(footfall.data, 'visitors') || !(conversion.data?.purchases ?? 0);
// While the partner report is still loading, don't flash the sample.
const flowChart = withSample(
footfall,
MOCK_FOOTFALL_DAILY,
() => conversion.status === 'loading' || !flowIsSample,
);
const weeklyIsSample =
!hasSignal(weeklyFootfall.data, 'visitors') ||
!hasSignal(weeklyConversion.data, 'purchases');
const weeklyChart = withSample(
weeklyFootfall,
MOCK_FOOTFALL_WEEKLY,
() => weeklyConversion.status === 'loading' || !weeklyIsSample,
);
return (
<VStack gap={5}>
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
<PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)}
title="Dashboard"
description={`Business performance across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
</div>
<KpiRow resource={kpis} />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Footfall"
subtitle="Visitors per day"
resource={footfallChart}
height={CHART_HEIGHT}
actions={<SampleTag show={footfallChart.isSample} />}
>
{(report) => (
<AreaChartView
data={report.buckets}
xKey="label"
xFormat={formatBucketLabel}
height={CHART_HEIGHT}
yFormat={formatCompact}
isInteger
series={[
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
]}
/>
)}
</ChartCard>
<ChartCard
title="Revenue"
subtitle="Daily takings"
resource={revenueChart}
height={CHART_HEIGHT}
actions={<SampleTag show={revenueChart.isSample} />}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
xFormat={formatBucketLabel}
height={CHART_HEIGHT}
yFormat={formatInrCompact}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
</Grid>
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Customer flow"
subtitle="Share of visitors who came back, and who bought"
resource={flowChart}
height={CHART_HEIGHT}
actions={<SampleTag show={flowChart.isSample} />}
>
{(report) => (
<CustomerFlowChart
footfall={report}
conversion={
flowIsSample || !conversion.data
? MOCK_CONVERSION_DAILY
: conversion.data
}
height={CHART_HEIGHT}
/>
)}
</ChartCard>
<ChartCard
title="Weekly performance"
subtitle="Visitors against purchases, week by week"
resource={weeklyChart}
height={CHART_HEIGHT}
actions={<SampleTag show={weeklyChart.isSample} />}
>
{(report) => {
const purchases = new Map(
(weeklyIsSample
? MOCK_CONVERSION_WEEKLY
: weeklyConversion.data ?? MOCK_CONVERSION_WEEKLY
).buckets.map((b) => [b.label, b.purchases]),
);
const rows = report.buckets.map((b) => ({
label: b.label,
visitors: b.visitors,
purchases: purchases.get(b.label) ?? 0,
}));
return (
<BarChartView
data={rows}
xKey="label"
xFormat={formatBucketLabel}
height={CHART_HEIGHT}
yFormat={formatCompact}
isInteger
series={[
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
{key: 'purchases', label: 'Purchases', color: CHART.brand.warmBar},
]}
/>
);
}}
</ChartCard>
</Grid>
<ChartCard
title="New vs returning visitors"
subtitle="Who walked in, split by whether the platform had seen them before"
resource={mixChart}
height={CHART_HEIGHT}
actions={<SampleTag show={mixChart.isSample} />}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
xFormat={formatBucketLabel}
height={CHART_HEIGHT}
yFormat={formatCompact}
isInteger
isStacked
series={[
{key: 'newVisitors', label: 'New', color: CHART.brand.cool},
{key: 'returningVisitors', label: 'Returning', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
<ArrivalsFeed resource={visits} viewAllHref="/activity" />
<EngagementSection />
</VStack>
);
}

View File

@@ -0,0 +1,16 @@
import {ProtectedLayout} from '@/shared/layouts/ProtectedLayout';
/**
* The route-group boundary for every authenticated screen.
*
* Deliberately thin: the composition lives in ProtectedLayout, so this file
* never needs editing again and the shell can be reused (tests, a future
* embedded view) without a route existing for it.
*/
export default function WorkspaceRouteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <ProtectedLayout>{children}</ProtectedLayout>;
}

View File

@@ -0,0 +1,34 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
/**
* LYTs.
*
* The merchant-app specification (§2.1) states the product direction does NOT
* use loyalty points, LYT balances, redemption or tier calculation — so this
* is not a panel waiting on an endpoint, it is a feature the product dropped.
* The copy says that, rather than implying a reward catalogue is on its way.
*
* Every figure this page used to show was generated locally, including an
* "outstanding liability" in rupees that a merchant would reasonably read as
* money they owe. The route is kept so an existing bookmark still lands
* somewhere that explains itself. Nothing is simulated.
*/
export default function LytsPage() {
return (
<VStack gap={5}>
<PageHeader
title="Lyts"
description="Loyalty rewards are not part of the current product."
/>
<FeatureUnavailable
title="The LYT programme"
description="Loyalty points are not part of the current product. The merchant app records visits and sales, not point balances, redemptions or tiers — so there is no LYT liability to report here. This page previously showed generated figures, including an outstanding balance in rupees that a merchant could not tell from real money."
/>
</VStack>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ApiWebhooksManager} from '@/features/settings/components/ApiWebhooksManager';
export default function ApiSettingsPage() {
return (
<SettingsPage
title="API & Webhooks"
description="Developer credentials, secret signing tokens, webhook subscriptions and dispatch audit logs."
>
<ApiWebhooksManager />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {BillingOverview} from '@/features/settings/components/BillingOverview';
export default function SettingsBillingPage() {
return (
<SettingsPage
title="Billing & LYT Settlement"
description="Subscription plans, quota consumption, settlement bank accounts and invoice history."
>
<BillingOverview />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {IntegrationsGrid} from '@/features/settings/components/IntegrationsGrid';
export default function IntegrationsSettingsPage() {
return (
<SettingsPage
title="Integrations & Connectors"
description="E-commerce POS sync, payment gateways, WhatsApp marketing and ad channels."
>
<IntegrationsGrid />
</SettingsPage>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import {usePathname} from 'next/navigation';
import {
Layout,
LayoutContent,
LayoutPanel,
VStack,
} from '@astryxdesign/core/Layout';
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
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';
/**
* Settings gets its own sub-navigation.
*
* A nested Layout with a `start` panel rather than more entries in the primary
* sidebar: settings sections sit at a different level of the hierarchy, and
* promoting them would push Dashboard/Store/Lyts/Staff down among
* configuration screens they have nothing to do with.
*
* SideNav is reused (rather than a bespoke list) so a settings section looks
* and behaves exactly like a primary nav item — same selected treatment, same
* hover, same keyboard handling, no second definition to keep in sync.
*
* Below the laptop breakpoint a 240px panel would eat most of the width, so
* the sub-nav collapses to a section picker above the content instead.
*/
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const bp = useBreakpoint();
const isNarrow = bp === 'mobile' || bp === 'tablet';
if (isNarrow) {
const active =
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
SETTINGS_NAV[0];
// A dropdown, not a TabList.
//
// This was a TabList when Settings had three sections. At eleven it stops
// working: `layout="fill"` cannot fit eleven labels in a tablet-width
// column, so the strip wrapped into a full-height vertical list and
// squeezed the page content out entirely. A picker is what every dense
// settings UI uses at this width, and it stays one tap regardless of how
// many sections the module grows to.
return (
<VStack width="100%">
{/* Only the picker is padded here — SettingsPage still owns every
page gutter, so the container contract stays in one place. */}
<VStack paddingInline={8} width="100%" className="pt-8">
<DropdownMenu
button={{
variant: 'secondary',
label: active.label,
icon: <Icon icon={active.icon} size="sm" />,
}}
menuWidth={260}
items={SETTINGS_NAV.map((s) => ({
label: s.label,
icon: s.icon,
onClick: () => router.push(s.href),
}))}
/>
</VStack>
{children}
</VStack>
);
}
return (
<Layout
height="fill"
start={
// 240px rather than 220: "Roles & Permissions" and "API & Webhooks"
// were wrapping once the panel gained its own inset padding.
<LayoutPanel width={240} padding={0} role="navigation" label="Settings">
{/*
The panel's own inset. Without it the nav items sat flush against
the workspace rail on one side and the content gutter on the other,
and the first item started hard against the top of the viewport.
paddingBlock 6 (24px) is what gives the list somewhere to begin.
*/}
<VStack paddingInline={3} paddingBlock={6} width="100%">
{/*
w-full because SideNav's own width is a fixed 260px — inside a
240px panel that already spends 24px on padding it overhung by
32px and gave the sub-nav a horizontal scrollbar along its
bottom edge (measured: scrollWidth 272 in a 240px panel).
*/}
<SideNav className="w-full">
<SideNavSection title="Settings">
{SETTINGS_NAV.map((s) => (
<SideNavItem
key={s.href}
label={s.label}
icon={s.icon}
href={s.href}
isSelected={isSettingsActive(pathname, s.href)}
/>
))}
</SideNavSection>
</SideNav>
</VStack>
</LayoutPanel>
}
// padding stays 0 here on purpose — SettingsPage owns the page gutters,
// so every screen gets the identical container whether it renders in
// this branch or the narrow picker one above.
content={<LayoutContent padding={0}>{children}</LayoutContent>}
/>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {NotificationsForm} from '@/features/settings/components/NotificationsForm';
export default function NotificationsSettingsPage() {
return (
<SettingsPage
title="Notifications"
description="Delivery channels, instant alerts, weekly digest dispatches and trigger criteria."
>
<NotificationsForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {BusinessForm} from '@/features/settings/components/BusinessForm';
export default function BusinessSettingsPage() {
return (
<SettingsPage
title="Business Settings"
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
>
<BusinessForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {PreferencesForm} from '@/features/settings/components/PreferencesForm';
export default function PreferencesSettingsPage() {
return (
<SettingsPage
title="Workspace Preferences"
description="Theme customization, reporting currency, localized language and default landing views."
>
<PreferencesForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,33 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
/**
* Server Component: the record is read on the server and handed to the form as
* its initial state, so the inputs paint filled rather than flashing empty.
* The form then saves through the client repository over HTTP.
*
* The read goes through a repository rather than the fixture module the page
* used to import — a page that knows the shape of a mock is a page that breaks
* the day the mock is deleted.
*/
export default async function MerchantProfilePage() {
const profile = await settingsServerRepository.getProfile();
return (
<SettingsPage
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
{profile ? (
<ProfileForm initialData={profile} />
) : (
<FeatureUnavailable
title="Business profile"
description="Your sign-in details are shown above. The wider company record — business name, GSTIN, registered address, timezone and currency — is not editable here yet, so this section is left out rather than offering a form that would not save."
/>
)}
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {RoleMatrix} from '@/features/settings/components/RoleMatrix';
export default function RolesSettingsPage() {
return (
<SettingsPage
title="Roles & Permissions"
description="Enterprise role definition, module permission matrix and access control boundaries."
>
<RoleMatrix />
</SettingsPage>
);
}

View File

@@ -0,0 +1,17 @@
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() {
return (
<SettingsPage
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>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {StoreManagement} from '@/features/settings/components/StoreManagement';
export default function StoreSettingsPage() {
return (
<SettingsPage
title="Store Locations"
description="Open, rename and remove the shops in your company."
>
<StoreManagement />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {TeamManagement} from '@/features/settings/components/TeamManagement';
export default function TeamSettingsPage() {
return (
<SettingsPage
title="Team & Staff"
description="Manage employee access, invite new team members, assign store locations and reset credentials."
>
<TeamManagement />
</SettingsPage>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {TeamTable} from '@/features/team/components/TeamTable';
import {useTeam} from '@/features/team/hooks/useTeam';
/**
* Leaderboard.
*
* ── An important distinction ─────────────────────────────────────────────
* The platform's `/api/team` is who can SIGN IN to the console, at what
* privilege. It is not shop-floor rostering: there is no attendance, no shift,
* no sales-per-head and no performance score anywhere in the contract.
*
* The page used to show all of those from a fixture. The real team list is
* shown instead, and the ranking metrics are named as the gap they are —
* because a leaderboard built from invented performance scores is the single
* most damaging fake number in this product.
*/
export default function LeaderboardPage() {
const team = useTeam();
return (
<VStack gap={5}>
<PageHeader
title="Leaderboard"
description="People with access to this console."
/>
<TeamTable resource={team} />
<FeatureUnavailable
title="Attendance and performance ranking"
description="Ranking staff needs shift and attendance records, and sales credited to the person who made them. The platform records who can sign in to this console — not who was on the shop floor, or which sale was theirs. Until the app records that, any ranking here would be guesswork."
/>
</VStack>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
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 {useSites} from '@/features/stores/hooks/useSites';
import {ShopSection} from '@/features/stores/components/ShopSection';
/**
* The estate, from GET /api/sites.
*
* Health fields are nullable and rendered as "—" when the platform does not
* report them. A deployment that sends no camera health is not a deployment
* with zero cameras up, and printing "0/0" for "not reported" makes a working
* estate look broken.
*/
export default function StoresPage() {
const sites = useSites();
return (
<VStack gap={5}>
<PageHeader
title="Store"
description="Every shop, its cameras, and the shop PC that watches them."
/>
<AsyncBoundary
resource={sites}
loading={<SkeletonCardGrid count={4} height={170} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Shops appear here once they are registered on the platform."
/>
}
>
{(rows) => (
<VStack gap={6}>
{rows.map((site) => (
<ShopSection key={site.id} site={site} />
))}
</VStack>
)}
</AsyncBoundary>
</VStack>
);
}

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

View File

@@ -0,0 +1,27 @@
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';
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));
try {
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
} catch (err) {
if (err instanceof UpstreamError && err.status === 404) {
return [];
}
throw err;
}
});
}

View File

@@ -0,0 +1,38 @@
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) {
// If the upstream platform has not deployed /api/activities or /api/activities/impact yet,
// answer with an empty list so the activities panel renders its clean empty state
// rather than failing with 404.
if (err instanceof UpstreamError && err.status === 404) {
return [];
}
throw err;
}
});
}

View File

@@ -0,0 +1,78 @@
import type {NextRequest} from 'next/server';
import {assistantApi} from '@/services/api/assistantApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import type {ApiAssistantTurn} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* POST /api/assistant — Loyaly AI, against the platform's own assistant.
*
* ── What this replaced ───────────────────────────────────────────────────
* This route did not exist. The chat panel called
* `services/ai/mockAi.ts`, which classified the prompt by keyword and returned
* a hardcoded template — "Indiranagar Flagship · 12,400 visitors · ₹9.1L",
* "98% confidence", "Conduct staff training on checkout upsell workflows" —
* with no network call anywhere in the feature. Four shops that do not exist,
* revenue nobody earned, and a confidence score for a number that was a string
* literal. A merchant cannot tell that from a real answer, which is the whole
* reason it had to go.
*
* Every figure the assistant now quotes comes back from a platform tool that
* computed it, scoped to the signed-in user's own tenant.
*
* ── Errors are answers here, not failures ────────────────────────────────
* `501 assistant_off` means this deployment has no assistant configured. It is
* a supported state and the panel says so plainly. The one thing this route
* must never do is invent a reply to fill the gap.
*/
export async function POST(req: NextRequest) {
let history: ApiAssistantTurn[];
try {
const body = (await req.json()) as {history?: unknown};
if (!Array.isArray(body.history)) {
return Response.json(
{error: {code: 'bad_request', message: 'Ask a question.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
// Normalised here rather than trusted: the platform coerces anything that
// is not "assistant" to "user" anyway, and sending it a shape it has to
// repair is how a client starts depending on that repair.
history = body.history
.map((t) => t as {role?: unknown; text?: unknown})
.filter((t) => typeof t.text === 'string' && t.text.trim() !== '')
.map((t) => ({
role: t.role === 'assistant' ? ('assistant' as const) : ('user' as const),
text: String(t.text),
}));
} catch {
return Response.json(
{error: {code: 'bad_request', message: 'Malformed request body.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
if (history.length === 0) {
return Response.json(
{error: {code: 'bad_request', message: 'Ask a question.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
try {
const answer = await withUpstream((token) => assistantApi.ask(token, history));
return Response.json(answer, {headers: {'cache-control': 'no-store'}});
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — `assistant_off`,
// `assistant_misconfigured` — so the panel can distinguish "switched off
// here" from "broken" without matching on prose that is rewritten freely.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

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

View File

@@ -0,0 +1,331 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {UpstreamError} from '@/services/api/apiClient';
import {ConfigError} from '@/shared/errors/configError';
import {
LOGIN_ERROR_PARAM,
type LoginErrorCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
import {
REMEMBERED_MAX_AGE_SECONDS,
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 {destinationForUser} from '@/features/auth/services/roleDestination';
import type {AuthSession} from '@/features/auth/types/auth';
import type {ApiSuccess} from '@/shared/types/api';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/login — the credential exchange, now against the platform.
*
* This route is the BFF's front door. It swaps an email and password for a
* platform token pair, seals that pair into an httpOnly cookie the browser
* cannot read, and hands back only the user object. The access token never
* reaches JavaScript, so an XSS on this origin cannot steal a session.
*
* ── Two content types, one endpoint ──────────────────────────────────────
* It answers both `application/json` (the hydrated form) and
* `application/x-www-form-urlencoded` (the browser posting natively, before
* React has hydrated). That second path is not a nicety: the sign-in form has
* named inputs, and a <form> with no method submits GET to its own URL — which
* put the password in the address bar, in history, and in every access log
* between here and the user.
*/
interface ParsedLogin {
email: string;
password: string;
rememberMe: boolean;
isForm: boolean;
next: string | null;
}
async function parse(req: NextRequest): Promise<ParsedLogin> {
const type = req.headers.get('content-type') ?? '';
if (type.includes('application/json')) {
const body = (await req.json()) as Record<string, unknown>;
return {
email: String(body.email ?? '').trim(),
password: String(body.password ?? ''),
rememberMe: body.rememberMe === true,
isForm: false,
next: typeof body.next === 'string' ? body.next : null,
};
}
const form = await req.formData();
return {
email: String(form.get('email') ?? '').trim(),
password: String(form.get('password') ?? ''),
rememberMe: form.get('rememberMe') === 'on' || form.get('rememberMe') === 'true',
isForm: true,
next: typeof form.get('next') === 'string' ? String(form.get('next')) : null,
};
}
/**
* The envelope `code` is derived from the STATUS, not passed in, so the two can
* never disagree. `reason` carries the specific login code alongside it.
*
* 5xx maps to 'internal' rather than falling through to 'unauthorized': a
* misconfigured server telling the browser the credentials were rejected is a
* lie that costs somebody a password reset.
*/
function failJson(code: LoginErrorCode, message: string, status: number) {
const envelopeCode =
status >= 500 ? 'internal' : status === 429 ? 'bad_request' : 'unauthorized';
return Response.json(
{error: {code: envelopeCode, message}, field: 'form', reason: code},
{status, headers: {'cache-control': 'no-store'}},
);
}
export async function POST(req: NextRequest) {
const {email, password, rememberMe, isForm, next} = await parse(req);
if (!email || !password) {
const code: LoginErrorCode = !email ? 'email_required' : 'password_required';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, 'Enter your email address and password.', 400);
}
let bundle;
try {
bundle = await authApi.login(email, password);
} catch (err) {
/*
* A misconfigured server, before anything about the credentials matters.
*
* Checked FIRST and kept out of the unreachable branch below. Both used to
* land on 502 platform_unreachable — "Could not reach Loyaly, check your
* connection" — for a fault that is entirely ours and that no amount of
* checking a connection will fix. 500 is the honest status: this server
* cannot serve, as opposed to an upstream that is unwell.
*
* The detail names an environment variable, so it is logged rather than
* returned. An anonymous sign-in form is the last place to publish which
* hosts a deployment accepts.
*/
if (err instanceof ConfigError) {
console.error('[loyaly] configuration error:', err.message);
const code: LoginErrorCode = 'misconfigured';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(
code,
'Sign-in is unavailable right now. Please contact support.',
500,
);
}
const up = err instanceof UpstreamError ? err : null;
// The platform answers wrong-password and no-such-account identically, on
// purpose: telling them apart turns this form into a way to find out who
// works at a customer. Pass its message through rather than writing our own.
/*
* A failure to REACH the platform is not a failure to authenticate.
*
* `UpstreamError.status === 0` means the request never arrived — offline,
* DNS, TLS — and a contract mismatch (502) means it arrived somewhere that
* is not the Loyaly platform. Reporting either as "invalid email or
* password" sends somebody to reset a password that was never the problem,
* so those keep their own message and their own status.
*/
const isUnreachable = up ? up.status === 0 || up.status >= 500 : true;
const code: LoginErrorCode = isUnreachable
? 'platform_unreachable'
: up?.code === 'too_many_attempts'
? 'too_many_attempts'
: 'invalid_credentials';
const status = isUnreachable ? 502 : up?.status === 429 ? 429 : 401;
const message = up?.message ?? 'Invalid email or password.';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, message, status);
}
/**
* A platform admin is refused here.
*
* This console has no /admin page — the platform console lives in
* loyaly-mer-login. Every screen here is tenant-scoped and an operator has no
* tenant, so a session would buy them a dashboard of 500s. The upstream
* session minted moments ago is revoked rather than left to expire, because
* an unused refresh token is a credential left lying around.
*/
const user = toAuthUser(bundle.user);
if (user.isPlatformAdmin) {
try {
await authApi.logout(bundle.access_token);
} catch {
/* best-effort: the refusal stands whether or not the revoke lands */
}
const code: LoginErrorCode = 'platform_account';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(
code,
'This is a platform account. Sign in to the platform console instead.',
403,
);
}
/**
* Minting the local session, which is where AUTH_SECRET is first read.
*
* Both steps below need it — storeTokens ENCRYPTS the platform bundle with a
* key derived from it, createSessionToken SIGNS the identity cookie with it —
* and in production both refuse to fall back to the development key. They ran
* outside any catch, so an unset AUTH_SECRET surfaced as a bare 500 from a
* sign-in whose credentials were perfectly good, with nothing in the response
* to say which of the two required variables was missing.
*
* Worse than the status: the upstream session minted moments ago by
* `authApi.login` was ORPHANED. A live refresh token, issued to somebody who
* did not get logged in, left to expire on its own. The platform-admin branch
* above revokes for exactly this reason; declining because the server
* is broken is no different from declining because the account is wrong.
*
* Fails closed: no cookie is set, so a half-configured server cannot hand out
* a session it is unable to verify on the next request.
*/
/**
* 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, cookieMaxAge, tabId);
sessionCookie = createSessionToken(
{
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
organisation: user.organisation,
// Always false here — platform admins are refused above.
isPlatformAdmin: user.isPlatformAdmin,
},
tokenLifetime,
);
} catch (err) {
if (!(err instanceof ConfigError)) throw err;
console.error('[loyaly] configuration error:', err.message);
try {
await authApi.logout(bundle.access_token);
} catch {
/* best-effort, exactly as in the platform-admin branch above */
}
const code: LoginErrorCode = 'misconfigured';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(
code,
'Sign-in is unavailable right now. Please contact support.',
500,
);
}
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's route. Both paths read one
// function, so a browser with JS disabled cannot end up somewhere else.
const landing = resolveRedirectTargetFor(
next,
user.isPlatformAdmin,
destinationForUser(user),
);
const res = isForm
? NextResponse.redirect(new URL(landing, req.url), 303)
: NextResponse.json<ApiSuccess<AuthSession>>(
{data: session, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
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;
}

View File

@@ -0,0 +1,74 @@
import {NextResponse} from 'next/server';
import {authApi} from '@/services/api/authApi';
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 — sign THIS TAB 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();
if (accessToken) {
try {
await authApi.logout(accessToken);
} catch {
// Already-expired, revoked, or unreachable — all fine. The cookies below
// are what actually ends this tab's session.
}
}
const res = NextResponse.json(
{data: {ok: true}, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
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;
}

View 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;
}

View File

@@ -0,0 +1,104 @@
import {NextResponse} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {UpstreamError} from '@/services/api/apiClient';
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';
export const dynamic = 'force-dynamic';
/**
* GET /api/auth/session — is this browser really signed in?
*
* This asks the PLATFORM, every time, rather than trusting the cookie. That is
* the whole point: a signed cookie proves only that this server minted it, so
* a user who was deactivated, whose role changed, or whose session was revoked
* from another device would otherwise keep a working console until the cookie
* happened to expire.
*
* The cost is one upstream call per page load, and it buys the property the
* brief asks for: refreshing the browser preserves a session only when the
* auth provider confirms it is valid.
*
* On a confirmed 401 the cookies are cleared in the response, so the very next
* navigation is redirected to /login by the proxy rather than looping through
* a shell that cannot load data.
*/
export async function GET() {
try {
const user = await withUpstream((token) => authApi.me(token));
const session: AuthSession = {
user: toAuthUser(user),
// The cookie's own expiry is the browser-side lifetime; the platform's
// access token expiry is refreshed transparently underneath it.
expiresAt: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(),
};
return NextResponse.json(
{data: session, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const isAnonymous =
err instanceof NoSessionError ||
(err instanceof UpstreamError && err.status === 401);
// A network failure is NOT a sign-out. Returning null here would log
// everyone out the moment the platform blipped; a 503 lets the client keep
// the shell it already has and retry.
if (!isAnonymous) {
const message =
err instanceof UpstreamError
? err.message
: 'Could not reach Loyaly. Retrying shortly.';
return NextResponse.json(
{error: {code: 'internal', message}},
{status: 503, headers: {'cache-control': 'no-store'}},
);
}
const res = NextResponse.json(
{data: null, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
/**
* 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;
}
}

View File

@@ -0,0 +1,20 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
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 {id} = await params;
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
}

View File

@@ -0,0 +1,16 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
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) {
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
}

View File

@@ -0,0 +1,20 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
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) {
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
list.map(toDeviceSession),
);
}

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

View 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));
}

View 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));
}

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

View File

@@ -0,0 +1,25 @@
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) {
if (err instanceof UpstreamError && err.status === 404) {
return [];
}
throw err;
}
});
}

View File

@@ -0,0 +1,46 @@
import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* POST /api/customers — name somebody the cameras could not identify.
*
* `visit_id` is what makes this the first-visit flow rather than a directory
* entry: it links the new customer to the arrival that prompted the form, so
* the face on the floor stops being anonymous.
*/
export async function POST(req: NextRequest) {
let body: {name?: unknown; phone?: unknown; notes?: unknown; visitId?: unknown};
try {
body = (await req.json()) as typeof body;
} catch {
return Response.json(
{error: {code: 'bad_request', message: 'Malformed request body.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
try {
const created = await withUpstream((token) =>
floorApi.createCustomer(token, {
name: typeof body.name === 'string' ? body.name : '',
phone: typeof body.phone === 'string' ? body.phone : '',
notes: typeof body.notes === 'string' ? body.notes : undefined,
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
}),
);
return Response.json(
{data: {id: created.id, ref: created.ref, label: created.label}},
{status: 201, headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

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

View File

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

View File

@@ -0,0 +1,61 @@
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';
export const dynamic = 'force-dynamic';
/**
* GET /api/floor/visits — who is in the shop now.
*
* Absent fields become NULL rather than empty strings, because the screen
* branches on "is there a customer at all" and `''` would read as a customer
* with a blank name.
*/
export function toFloorVisit(v: ApiFloorVisit): FloorVisit {
return {
visitId: v.visit_id,
siteId: v.site_slug || v.site_id,
detectedAt: v.detected_at,
status: v.status,
visitorId: v.visitor_id || null,
customerRef: v.visitor_ref || null,
label: v.label || null,
phone: v.phone || null,
previousVisits: v.previous_visits ?? 0,
attendedBy: v.attended_by || null,
attendedByName: v.attended_by_name || null,
attendedByMe: v.attended_by_me ?? false,
// Proxied so an <img> works without the Authorization header it cannot send.
imageUrl:
v.image?.available && v.image.url
? v.image.url.startsWith('http')
? v.image.url
: `/api/faces?src=${encodeURIComponent(v.image.url)}`
: null,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(
req,
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.status === 404) {
return {items: []};
}
throw err;
}
},
(page) => (page.items ?? []).map(toFloorVisit),
);
}

View File

@@ -0,0 +1,62 @@
import {configStatus} from '@/shared/config/configCheck';
/**
* GET /api/health — LIVENESS. "Is this process answering HTTP?"
*
* Always 200 when the server can respond at all. It does NOT fail on a
* configuration problem, and that is the entire point of separating it from
* /api/ready.
*
* ── Why the split exists ─────────────────────────────────────────────────
* This route used to answer 503 while a required variable was missing, which
* is READINESS semantics living on the name every orchestrator probes by
* default. A Docker HEALTHCHECK was pointed at it for one commit, and because
* Dokploy runs applications as Docker Swarm services, Swarm did not merely
* report the task unhealthy — it removed it from the service load balancer and
* rescheduled it. Traefik then had no backend and answered 502 Bad Gateway on
* every url: the container was up, serving a 503 that named the fault, and
* nothing could reach it to read that 503.
*
* Splitting the two makes that choice explicit instead of accidental. A probe
* wired here can never remove a serving container from rotation. A probe wired
* to /api/ready can, deliberately, and is the right thing for a DEPLOY gate
* (Swarm Order=start-first + FailureAction=rollback) where failing keeps the
* PREVIOUS healthy task serving.
*
* The body still reports configuration, so this one endpoint answers both
* "is it alive?" and "why is it unhappy?" — it just never lies about the first
* to signal the second.
*/
export const dynamic = 'force-dynamic';
export function GET(): Response {
const {problems, authSecretSource, authSecretLength} = configStatus();
return Response.json(
{
status: 'alive',
/**
* Reported, never enforced here. `ok` vs `misconfigured` tells a human
* what is wrong without letting a probe tear the container down for it.
*/
configuration: problems.length === 0 ? 'ok' : 'misconfigured',
problems: problems.length,
/**
* Source and length only — never the value. `env` / `file` /
* `development` / `missing` plus a length distinguishes "not set" from
* "set but truncated", which is the distinction that costs the most time
* to make from outside a container. A length is not a meaningful
* disclosure about a 256-bit random value.
*/
authSecret: {source: authSecretSource, length: authSecretLength},
},
{
status: 200,
headers: {
'cache-control': 'no-store',
'x-loyaly-config': problems.length === 0 ? 'ok' : 'misconfigured',
},
},
);
}

View 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);
}
}

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

View File

@@ -0,0 +1,54 @@
import {configStatus} from '@/shared/config/configCheck';
/**
* GET /api/ready — READINESS. "Can this container serve real traffic?"
*
* 200 only when every required variable is present and acceptable; 503
* otherwise. Unlike /api/health (liveness), this one is MEANT to fail.
*
* ── What to point at it, and what not to ─────────────────────────────────
* Point a DEPLOY gate here: Dokploy → Advanced → Swarm Settings, a Health
* Check whose Test hits this path, with Update Config `Order: start-first` and
* `FailureAction: rollback`. A newly deployed task that is missing AUTH_SECRET
* then never becomes healthy, never replaces the running one, and is rolled
* back — the previous good version keeps serving, and the broken deploy is
* rejected before any production traffic reaches it. That is the contract this
* endpoint exists for.
*
* Understand the one case it cannot save: if NO healthy task exists to fall
* back to — a first deploy, or a service that is already broken — then a gate
* here means nothing is in rotation and Traefik answers 502. A readiness gate
* cannot invent a working version. Get production healthy FIRST, then turn the
* gate on; it protects every deploy after that.
*
* Do NOT put this path in a Dockerfile HEALTHCHECK. That applies to the
* container unconditionally, including when there is no predecessor, which is
* exactly how a 502 was recreated once already. The deploy gate belongs in
* Dokploy's Swarm settings, where start-first and rollback give it somewhere
* safe to fail to.
*/
export const dynamic = 'force-dynamic';
export function GET(): Response {
const {problems, authSecretSource, authSecretLength} = configStatus();
const ready = problems.length === 0;
return Response.json(
{
status: ready ? 'ready' : 'not_ready',
// A count, not the messages: this is public and anonymous, and the
// messages name environment variables, which is operator information.
// The names are printed once at boot, in the container log.
problems: problems.length,
authSecret: {source: authSecretSource, length: authSecretLength},
},
{
status: ready ? 200 : 503,
headers: {
'cache-control': 'no-store',
'x-loyaly-config': ready ? 'ok' : 'misconfigured',
},
},
);
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
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) {
if (err instanceof UpstreamError && err.status === 404) {
return {stages: [], attribution: 'observed'};
}
throw err;
}
});
}

View File

@@ -0,0 +1,33 @@
import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import {toSale} from '@/app/api/sales/route';
export const dynamic = 'force-dynamic';
/**
* GET /api/sales/{id} — one sale with its lines. §24.
*
* The list endpoint carries counts; this carries the lines themselves, so a
* screen showing a whole day of sales does not pull every line of every one.
*/
export async function GET(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const sale = await withUpstream((token) => salesApi.byId(token, id));
return Response.json(
{data: toSale(sale)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

163
src/app/api/sales/route.ts Normal file
View File

@@ -0,0 +1,163 @@
import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi';
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';
export const dynamic = 'force-dynamic';
/**
* GET /api/sales — the sale history the Sales screen reads.
*
* Money crosses this boundary as integer PAISE and is NOT converted. The
* console formats paise for display and never holds rupees, so there is no
* float round-trip and no component can disagree about the decimal point.
*/
export function toSale(s: ApiSale): Sale {
return {
id: s.id,
invoiceNo: s.invoice_no ?? null,
siteId: s.site_slug || s.site_id,
customerRef: s.visitor_ref ?? null,
customerLabel: s.customer_label || null,
staffName: s.staff_name || null,
totalPaise: s.total_paise ?? 0,
currency: s.currency ?? 'INR',
status: s.status,
at: s.server_created_at,
purchasedLines: s.purchased_lines ?? 0,
enquiryLines: s.enquiry_lines ?? 0,
lines: (s.lines ?? []).map((l) => ({
productName: l.product_name,
pricePaise: l.price_paise ?? 0,
intent: l.intent,
// Straight from the server. Re-deriving it here would put the
// enquiry-is-not-revenue rule in a second place, which is how the two
// start disagreeing.
billablePaise: l.billable_paise ?? 0,
})),
};
}
export async function GET(req: NextRequest) {
return serveUpstream(
req,
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 with 404.
if (err instanceof UpstreamError && err.status === 404) {
return {items: []};
}
throw err;
}
},
(page) => (page.items ?? []).map(toSale),
);
}
/**
* POST /api/sales — record a sale.
*
* ── What this route does NOT do ──────────────────────────────────────────
* It does not compute a total. §12 says the backend calculates it from the
* lines and must not trust a client-supplied one, and the platform's own
* request shape has no total field to send. It also does not carry a staff id:
* the platform derives that from the session, so a request structurally cannot
* attribute a sale to somebody else.
*
* Prices arrive as integer PAISE from the form and are forwarded unchanged.
* This is the only place the console converts money at all, and it converts in
* one direction: paise out of the platform become rupees for display (toSale
* above). Nothing multiplies by 100 on the way in, because the form never held
* rupees to begin with.
*/
export async function POST(req: NextRequest) {
let body: {
idempotencyKey?: unknown;
visitId?: unknown;
visitorId?: unknown;
invoiceNo?: unknown;
site?: unknown;
lines?: unknown;
};
try {
body = (await req.json()) as typeof body;
} catch {
return Response.json(
{error: {code: 'bad_request', message: 'Malformed request body.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
const lines = Array.isArray(body.lines)
? body.lines
.map((l) => l as {productName?: unknown; pricePaise?: unknown; intent?: unknown})
.filter((l) => typeof l.productName === 'string' && l.productName.trim() !== '')
.map((l) => ({
product_name: String(l.productName).trim(),
// Already an integer. Rounded rather than trusted blindly so a
// fractional paise from a hand-written request cannot reach a bigint
// column and be rejected three layers down.
price_paise: Math.max(0, Math.round(Number(l.pricePaise) || 0)),
intent: l.intent === 'enquired' ? 'enquired' : 'purchased',
}))
: [];
try {
const result = await withUpstream((token) =>
salesApi.create(token, {
idempotency_key:
typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '',
invoice_no: typeof body.invoiceNo === 'string' ? body.invoiceNo : '',
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(),
lines,
}),
);
return Response.json(
{
data: {
// "already_processed" is a SUCCESS carrying the original sale — a
// replay after a double tap or a retry has done nothing wrong.
status: result.status,
saleId: result.sale_id,
sale: result.sale ? toSale(result.sale) : null,
},
},
{status: 201, headers: {'cache-control': 'no-store'}},
);
} catch (err) {
if (err instanceof UpstreamError && err.status === 404) {
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},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View 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);
}

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

View 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));
}

View File

@@ -0,0 +1,62 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
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.
* 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
* was filtered by a store that might not exist.
*
* `slug` is carried through as the identifier the UI keys on because it is
* IMMUTABLE upstream and safe to persist in a URL or a saved report, while the
* display name is expected to change.
*/
function toSite(s: ApiSite): Site {
return {
// Slug first: it is immutable and is what every scoped request sends as
// `?site=`. `site_id` is the uuid — the server does not send a bare `id`.
id: s.slug || s.site_id,
uuid: s.site_id,
name: s.name,
timezone: s.timezone,
isOnline: s.online ?? null,
camerasTotal: s.cameras_total ?? null,
camerasUp: s.cameras_up ?? null,
fractionBelowGate: s.fraction_below_gate ?? null,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => sitesApi.list(token), (sites) =>
sites.map(toSite),
);
}
/**
* 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},
);
}

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

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

View 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));
}

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

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

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

@@ -0,0 +1,25 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {serveUpstream} from '@/shared/services/bff';
import {toMember} from '@/features/team/services/mapTeam';
export const dynamic = 'force-dynamic';
/**
* GET /api/team — console accounts for this company.
*
* The tenant is NOT a parameter. The platform takes it from the session and
* scopes the query itself, so this route has nothing to filter by and must not
* pretend otherwise — a client-side tenant filter is a check an attacker skips.
*
* ── What this used to drop ───────────────────────────────────────────────
* It ran each row through `toAuthUser`, which reads `client_name` — a field
* `GET /api/team` does not send. So `organisation` was `undefined` on every
* row, while `active`, `last_login_at` and `created_at` were discarded. The
* one field the team screen needs — who still has access — never arrived.
*/
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
members.map(toMember),
);
}

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

View 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);
}
}

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