first commit
16
.claude/launch.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
179
AGENTS.md
@@ -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
@@ -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.
|
||||
31
CLAUDE.md
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
280
docs/BEHAVISION-GAP-ANALYSIS.md
Normal 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
@@ -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.
|
||||
@@ -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",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
21
package.json
@@ -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",
|
||||
|
||||
BIN
public/brand/login-hero-1.jpeg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
public/brand/login-hero-2.jpeg
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
public/brand/loyaly-logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/brand/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -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 |
@@ -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
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
@@ -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 |
@@ -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
|
After Width: | Height: | Size: 124 KiB |
@@ -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
@@ -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
@@ -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}</>;
|
||||
}
|
||||
339
src/app/(dev)/tokens/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(public)/join/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {LoginSplit} from '@/features/auth/components/LoginSplit';
|
||||
import {JoinForm} from '@/features/auth/components/JoinForm';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join your team',
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an invitation code is redeemed. Public — see PUBLIC_PATHS in proxy.ts —
|
||||
* because the person here has no account yet; that is the point of the page.
|
||||
* Sits in the (public) group with /login and shares its frame.
|
||||
*/
|
||||
export default function JoinPage() {
|
||||
return (
|
||||
<LoginSplit
|
||||
title="Join your team"
|
||||
description="Enter the invitation code your manager gave you, then choose your own password. Nobody else ever sees it."
|
||||
>
|
||||
<JoinForm />
|
||||
</LoginSplit>
|
||||
);
|
||||
}
|
||||
10
src/app/(public)/layout.tsx
Normal 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>;
|
||||
}
|
||||
20
src/app/(public)/login/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
||||
47
src/app/(workspace)/activity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
src/app/(workspace)/commerce/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/(workspace)/customers/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory';
|
||||
|
||||
/**
|
||||
* The customer directory, from GET /api/visitors.
|
||||
*
|
||||
* Company-wide, not scoped by the store switcher: a customer belongs to the
|
||||
* business, and somebody who first walked into one branch is the same person
|
||||
* at another.
|
||||
*/
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Customers"
|
||||
description="Everyone the cameras have recognised — name them, see their visits, record a purchase."
|
||||
/>
|
||||
<CustomerDirectory />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
243
src/app/(workspace)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(workspace)/layout.tsx
Normal 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>;
|
||||
}
|
||||
34
src/app/(workspace)/lyts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/api/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/integrations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/app/(workspace)/settings/layout.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/preferences/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/(workspace)/settings/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/roles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(workspace)/settings/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/stores/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/(workspace)/staff/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/app/(workspace)/stores/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/api/activities/events/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {engagementApi} from '@/services/api/engagementApi';
|
||||
import {resolveVisitorId} from '@/services/api/refs';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function text(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/activities/events — record that a customer took part. Staff and above.
|
||||
*
|
||||
* `sourceEventId` is passed through, never minted here: an id generated per
|
||||
* REQUEST would make every retry a new event. The platform answers 200
|
||||
* `{duplicate: true}` for one it already has, which is success — the caller's
|
||||
* intent is satisfied.
|
||||
*
|
||||
* The customer may be given by number ("V-42"); this endpoint upstream takes a
|
||||
* uuid only, so it is resolved first — see refs.ts.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(req, async (token, body) => {
|
||||
const customer = text(body.visitorId);
|
||||
return engagementApi.recordEvent(token, {
|
||||
kind: text(body.kind) ?? '',
|
||||
source_event_id: text(body.sourceEventId) ?? '',
|
||||
site: text(body.site),
|
||||
visitor_id: customer ? await resolveVisitorId(token, customer) : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
27
src/app/api/activities/impact/route.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
38
src/app/api/activities/route.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
78
src/app/api/assistant/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {assistantApi} from '@/services/api/assistantApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import type {ApiAssistantTurn} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/assistant — Loyaly AI, against the platform's own assistant.
|
||||
*
|
||||
* ── What this replaced ───────────────────────────────────────────────────
|
||||
* This route did not exist. The chat panel called
|
||||
* `services/ai/mockAi.ts`, which classified the prompt by keyword and returned
|
||||
* a hardcoded template — "Indiranagar Flagship · 12,400 visitors · ₹9.1L",
|
||||
* "98% confidence", "Conduct staff training on checkout upsell workflows" —
|
||||
* with no network call anywhere in the feature. Four shops that do not exist,
|
||||
* revenue nobody earned, and a confidence score for a number that was a string
|
||||
* literal. A merchant cannot tell that from a real answer, which is the whole
|
||||
* reason it had to go.
|
||||
*
|
||||
* Every figure the assistant now quotes comes back from a platform tool that
|
||||
* computed it, scoped to the signed-in user's own tenant.
|
||||
*
|
||||
* ── Errors are answers here, not failures ────────────────────────────────
|
||||
* `501 assistant_off` means this deployment has no assistant configured. It is
|
||||
* a supported state and the panel says so plainly. The one thing this route
|
||||
* must never do is invent a reply to fill the gap.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let history: ApiAssistantTurn[];
|
||||
|
||||
try {
|
||||
const body = (await req.json()) as {history?: unknown};
|
||||
if (!Array.isArray(body.history)) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Ask a question.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
// Normalised here rather than trusted: the platform coerces anything that
|
||||
// is not "assistant" to "user" anyway, and sending it a shape it has to
|
||||
// repair is how a client starts depending on that repair.
|
||||
history = body.history
|
||||
.map((t) => t as {role?: unknown; text?: unknown})
|
||||
.filter((t) => typeof t.text === 'string' && t.text.trim() !== '')
|
||||
.map((t) => ({
|
||||
role: t.role === 'assistant' ? ('assistant' as const) : ('user' as const),
|
||||
text: String(t.text),
|
||||
}));
|
||||
} catch {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Malformed request body.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Ask a question.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const answer = await withUpstream((token) => assistantApi.ask(token, history));
|
||||
return Response.json(answer, {headers: {'cache-control': 'no-store'}});
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — `assistant_off`,
|
||||
// `assistant_misconfigured` — so the panel can distinguish "switched off
|
||||
// here" from "broken" without matching on prose that is rewritten freely.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/auth/invitation/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
import {fail} from '@/shared/services/apiRoute';
|
||||
import type {ApiSuccess} from '@/shared/types/api';
|
||||
import type {InvitationPreview} from '@/features/auth/types/join';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/auth/invitation?code=… — what an invitation is for. NO session.
|
||||
*
|
||||
* Asked before anybody chooses a password, so the join screen can say "Join
|
||||
* TeNext Retail as Priya R" and a mistyped code is caught before it costs a
|
||||
* password. Outside the proxy's session gate by its matcher (`api/auth` is
|
||||
* excluded), which is what lets somebody with no account call it.
|
||||
*
|
||||
* Unknown, expired, spent and withdrawn codes are all one 404 upstream, with
|
||||
* one message, on purpose; it is passed through as it is.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const code = req.nextUrl.searchParams.get('code')?.trim() ?? '';
|
||||
if (!code) {
|
||||
return fail('bad_request', 'Enter the invitation code you were given.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const p = await authApi.invitationPreview(code);
|
||||
const data: InvitationPreview = {
|
||||
companyName: p.client_name,
|
||||
email: p.email,
|
||||
fullName: p.full_name ?? '',
|
||||
role: p.role,
|
||||
};
|
||||
return Response.json(
|
||||
{data, meta: {generatedAt: new Date().toISOString()}} satisfies ApiSuccess<InvitationPreview>,
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
331
src/app/api/auth/login/route.ts
Normal 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;
|
||||
}
|
||||
74
src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
109
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {NextResponse} from 'next/server';
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {ConfigError} from '@/shared/errors/configError';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
import {fail} from '@/shared/services/apiRoute';
|
||||
import {
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
createSessionToken,
|
||||
sessionCookieOptions,
|
||||
} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
TAB_POINTER_COOKIE,
|
||||
sessionCookieFor,
|
||||
tabPointerOptions,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {newTabId, resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {storeTokens} from '@/features/auth/services/upstreamSession';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
import type {ApiSuccess} from '@/shared/types/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/auth/register — redeem an invitation and sign straight in. NO session.
|
||||
*
|
||||
* The platform answers with a full session, exactly like login, so this sets
|
||||
* the same two cookies login does and the person lands in the console without
|
||||
* ever seeing a sign-in form.
|
||||
*
|
||||
* Only the code, a name and a password are forwarded. `email` and `role` come
|
||||
* from the INVITATION upstream and a body naming either is refused there —
|
||||
* which is what stops a forwarded code becoming somebody else's account.
|
||||
*
|
||||
* Not "remember me": a first sign-in on a device nobody has vouched for gets
|
||||
* the ordinary browser-session lifetime, and the next sign-in can opt in.
|
||||
*
|
||||
* Errors pass through with the platform's wording: 400 (password under 8
|
||||
* characters), 404 `invalid_code`, 409 `conflict` (that address already has an
|
||||
* account — sign in instead). A rejected attempt does not spend the code.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = await req.json();
|
||||
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
/* an empty body is refused just below with a readable message */
|
||||
}
|
||||
|
||||
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
||||
const fullName = typeof body.fullName === 'string' ? body.fullName.trim() : '';
|
||||
const password = typeof body.password === 'string' ? body.password : '';
|
||||
if (!code || !password) {
|
||||
return fail('bad_request', 'Enter your invitation code and choose a password.', 400);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = await authApi.register(code, fullName, password);
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
|
||||
const user = toAuthUser(bundle.user);
|
||||
const tabId = (await resolveTabId()) ?? newTabId();
|
||||
|
||||
let sessionCookie: string;
|
||||
try {
|
||||
await storeTokens(bundle, undefined, tabId);
|
||||
sessionCookie = createSessionToken(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
organisation: user.organisation,
|
||||
isPlatformAdmin: user.isPlatformAdmin,
|
||||
},
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ConfigError)) throw err;
|
||||
console.error('[loyaly] configuration error:', err.message);
|
||||
// The account now exists upstream; only this console's session could not
|
||||
// be written. Release the platform session rather than leave it orphaned,
|
||||
// and tell them to sign in — their password already works.
|
||||
try {
|
||||
await authApi.logout(bundle.access_token);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return fail(
|
||||
'internal',
|
||||
'Your account was created, but signing in failed. Sign in with your email and new password.',
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const session: AuthSession = {user, expiresAt: bundle.expires_at};
|
||||
const res = NextResponse.json<ApiSuccess<AuthSession>>(
|
||||
{data: session, meta: {generatedAt: new Date().toISOString()}},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
res.cookies.set(sessionCookieFor(tabId), sessionCookie, sessionCookieOptions());
|
||||
res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions());
|
||||
return res;
|
||||
}
|
||||
104
src/app/api/auth/session/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
src/app/api/auth/sessions/[id]/route.ts
Normal 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));
|
||||
}
|
||||
16
src/app/api/auth/sessions/revoke-others/route.ts
Normal 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));
|
||||
}
|
||||
20
src/app/api/auth/sessions/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
34
src/app/api/cameras/[id]/check/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/cameras/{id}/check — ask the shop PC to prove this camera works.
|
||||
*
|
||||
* Two kinds: `connection` (can it be reached at all) and `placement` (is the
|
||||
* view usable for recognition). Anything else the platform rejects, so the
|
||||
* union is narrowed here rather than passed through as a free string.
|
||||
*
|
||||
* The platform answers 202 and the camera it returns still carries the PREVIOUS
|
||||
* check — the edge has not run the new one yet. The caller re-reads; it must
|
||||
* not render this response as the verdict.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
sitesApi.checkCamera(
|
||||
token,
|
||||
id,
|
||||
body.kind === 'placement' ? 'placement' : 'connection',
|
||||
),
|
||||
{map: toCamera, status: 202},
|
||||
);
|
||||
}
|
||||
21
src/app/api/cameras/[id]/live/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {streamUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/cameras/{id}/live — live view, relayed through the shop PC.
|
||||
*
|
||||
* `event: waiting` arrives at once; `event: frame` follows with a base64 JPEG
|
||||
* once the shop PC answers. Nothing is uploaded while nobody is watching, and
|
||||
* the platform caps one view at five minutes. Closing the viewer cancels this
|
||||
* request, which cancels the upstream one — see streamUpstream.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return streamUpstream(req, (token, signal) => sitesApi.live(token, id, signal));
|
||||
}
|
||||
36
src/app/api/cameras/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
import type {ApiCameraInput} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/cameras/{id} — edit one camera.
|
||||
* DELETE /api/cameras/{id} — remove it.
|
||||
*
|
||||
* PATCH rather than PUT, matching the platform: a form that leaves the password
|
||||
* blank means "keep the stored one", and a PUT would read that as "clear it".
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput),
|
||||
{map: toCamera},
|
||||
);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
// The platform answers 204 with no body; proxyUpstream sends `data: null`
|
||||
// rather than an empty object, so the client can tell "done" from "malformed".
|
||||
return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
|
||||
}
|
||||
39
src/app/api/cameras/route.ts
Normal file
@@ -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},
|
||||
);
|
||||
}
|
||||
25
src/app/api/campaigns/route.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
46
src/app/api/customers/route.ts
Normal 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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/dashboard/summary/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {reportsApi} from '@/services/api/reportsApi';
|
||||
import {DEFAULT_TZ, toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toTodaySummary} from '@/features/floor/services/mapSummary';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/summary — today, for the selected shop.
|
||||
*
|
||||
* Deliberately NOT scoped by the range picker: "today" is the business day in
|
||||
* the shop's zone, which is the whole value of this read. The platform
|
||||
* computes that window itself when none is sent.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) =>
|
||||
reportsApi.todaySummary(token, DEFAULT_TZ, toSiteParam(query.storeId)),
|
||||
toTodaySummary,
|
||||
);
|
||||
}
|
||||
57
src/app/api/faces/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {upstreamRaw} from '@/services/api/apiClient';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
|
||||
*
|
||||
* A browser `<img>` cannot send an Authorization header, and the platform's
|
||||
* own image URLs require one. The alternatives were fetch + createObjectURL +
|
||||
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
|
||||
* photograph on a screen left open all afternoon — or this: one hop through
|
||||
* the origin that already holds the token.
|
||||
*
|
||||
* ── Why `src` is validated rather than trusted ───────────────────────────
|
||||
* An unchecked pass-through would be an open proxy that attaches the
|
||||
* merchant's bearer token to any URL an attacker can get into a page. Only
|
||||
* same-origin platform paths under /api/faces/ are forwarded.
|
||||
*
|
||||
* Every hand-out of a photo is written to the platform's audit log, so this
|
||||
* must be requested once per screen rather than once per component: two
|
||||
* components asking for the same face puts two rows in "who looked at my
|
||||
* customers" for one glance at one person.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const src = new URL(req.url).searchParams.get('src') ?? '';
|
||||
|
||||
// Relative, no traversal, and inside the faces namespace. Anything else is
|
||||
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
|
||||
// attacker-supplied.
|
||||
if (!src.startsWith('/api/faces/') || src.includes('..')) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await withUpstream((token) =>
|
||||
upstreamRaw({path: src, accessToken: token}),
|
||||
);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
|
||||
// Private: this is one merchant's customer, and a shared cache holding
|
||||
// it would serve it across tenants.
|
||||
'cache-control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
61
src/app/api/floor/visits/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
||||
62
src/app/api/health/route.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
67
src/app/api/images/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {upstreamRaw} from '@/services/api/apiClient';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/images?src=<platform image path> — any authenticated picture, proxied.
|
||||
*
|
||||
* The same hop as /api/faces and for the same reason: a browser `<img>` cannot
|
||||
* send an Authorization header, and every platform image URL requires one.
|
||||
*
|
||||
* This exists alongside /api/faces rather than replacing it. That route accepts
|
||||
* exactly one namespace, which was right while faces were the only pictures in
|
||||
* the product; camera snapshots are not under /api/faces/, so they could not be
|
||||
* displayed through it at all. /api/faces is left untouched so nothing that
|
||||
* works today changes, and new callers use this.
|
||||
*
|
||||
* ── Why an allowlist of shapes, not a prefix test ────────────────────────
|
||||
* An unchecked pass-through is an open proxy that attaches the merchant's
|
||||
* bearer token to whatever URL an attacker can get into a page. Each pattern
|
||||
* below is anchored at both ends and permits no slash inside the id segment, so
|
||||
* `/api/faces/../../admin/clients` cannot masquerade as a face. The `..` test is
|
||||
* belt and braces on top of that.
|
||||
*
|
||||
* Adding a fourth kind of image means adding a line here, deliberately.
|
||||
*/
|
||||
const ALLOWED = [
|
||||
/^\/api\/faces\/[^/?]+$/,
|
||||
/^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/,
|
||||
/^\/api\/visitors\/[^/?]+\/image$/,
|
||||
];
|
||||
|
||||
/** Exported so a caller can decide whether to render an <img> at all. */
|
||||
export function isProxyableImage(src: string): boolean {
|
||||
return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0]));
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const src = req.nextUrl.searchParams.get('src') ?? '';
|
||||
|
||||
if (!isProxyableImage(src)) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await withUpstream((token) =>
|
||||
upstreamRaw({path: src, accessToken: token}),
|
||||
);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
|
||||
// Private: this is one merchant's shop floor, and a shared cache
|
||||
// holding it would serve it across tenants.
|
||||
'cache-control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
29
src/app/api/purchases/route.ts
Normal file
@@ -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},
|
||||
);
|
||||
}
|
||||
54
src/app/api/ready/route.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
35
src/app/api/reports/conversion/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {reportsApi} from '@/services/api/reportsApi';
|
||||
import {toConversion} from '@/services/api/reportMapper';
|
||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||
import {previousWindow} from '@/services/api/previousWindow';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import type {ApiConversionReport, BucketSize} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BUCKETS: BucketSize[] = ['hour', 'day', 'week', 'month'];
|
||||
|
||||
/** GET /api/reports/conversion — purchases, revenue and basket size. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const bucketParam = url.searchParams.get('bucket');
|
||||
const bucket = BUCKETS.includes(bucketParam as BucketSize)
|
||||
? (bucketParam as BucketSize)
|
||||
: undefined;
|
||||
const wantsPrevious = url.searchParams.get('compare') === 'previous';
|
||||
|
||||
return serveUpstream(req, async (token, query) => {
|
||||
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
|
||||
const site = toSiteParam(query.storeId);
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
reportsApi.conversion(token, window, site),
|
||||
wantsPrevious
|
||||
? reportsApi.conversion(token, previousWindow(window), site)
|
||||
: Promise.resolve(null as ApiConversionReport | null),
|
||||
]);
|
||||
|
||||
return toConversion(current, previous);
|
||||
});
|
||||
}
|
||||
52
src/app/api/reports/footfall/route.ts
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
30
src/app/api/reports/journey/route.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
33
src/app/api/sales/[id]/route.ts
Normal 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
@@ -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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/app/api/sites/[site]/check/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import type {ApiSiteCheck} from '@/services/api/types';
|
||||
import type {SiteCheck} from '@/features/stores/types/siteCheck';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function toSiteCheck(c: ApiSiteCheck): SiteCheck {
|
||||
return {
|
||||
siteName: c.site,
|
||||
ok: c.ok,
|
||||
steps: (c.steps ?? []).map((s) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
detail: s.detail,
|
||||
advice: s.advice || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sites/{site}/check — is this shop working, in five ordered steps.
|
||||
*
|
||||
* Answered from what head office already knows, so it works when the shop PC
|
||||
* is off — which is itself one of the answers. Read-only and cheap, so it is a
|
||||
* GET the screen can repeat as often as somebody taps it.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{site: string}>},
|
||||
) {
|
||||
const {site} = await params;
|
||||
return serveUpstream(req, (token) => sitesApi.check(token, site), toSiteCheck);
|
||||
}
|
||||
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/sites/{site}/enrolment-code — a one-time code that enrols a shop PC.
|
||||
*
|
||||
* The code comes back ONCE and is not recoverable: the platform stores a hash,
|
||||
* exactly as it does for a team invitation. So this is a POST even though it
|
||||
* reads like a fetch — asking twice mints two codes rather than showing the
|
||||
* same one, and a GET would invite a browser or a prefetch to do that silently.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{site: string}>},
|
||||
) {
|
||||
const {site} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
sitesApi.enrolmentCode(
|
||||
token,
|
||||
site,
|
||||
typeof body.label === 'string' ? body.label : undefined,
|
||||
),
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
40
src/app/api/sites/[site]/route.ts
Normal file
@@ -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));
|
||||
}
|
||||
62
src/app/api/sites/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
32
src/app/api/team/[id]/password/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/team/{id}/password — set a new password for somebody.
|
||||
*
|
||||
* The response carries the password ONCE. It is bcrypt-hashed on the way in and
|
||||
* is not recoverable afterwards, so the screen must show it immediately and
|
||||
* must not stash it anywhere it could be read back.
|
||||
*
|
||||
* Omitting `password` has the platform generate a strong one, which is the
|
||||
* better default — a password an operator invents for somebody else is weak and
|
||||
* ends up in a chat message.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, body) =>
|
||||
teamApi.resetPassword(
|
||||
token,
|
||||
id,
|
||||
typeof body.password === 'string' && body.password !== ''
|
||||
? body.password
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toMember} from '@/features/team/services/mapTeam';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/team/{id} — change somebody's role, or switch their access off.
|
||||
*
|
||||
* Deactivating revokes every session that person holds IMMEDIATELY; it is not a
|
||||
* soft flag that takes effect at next sign-in. The UI is expected to confirm
|
||||
* before calling this.
|
||||
*
|
||||
* The platform answers 409 `last_owner` when this would leave the company with
|
||||
* no active owner. That travels through `failResponse` with its reason intact,
|
||||
* so the screen can say which rule was hit rather than "something went wrong".
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.update(token, id, {
|
||||
role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined,
|
||||
active: typeof body.active === 'boolean' ? body.active : undefined,
|
||||
}),
|
||||
{map: toMember},
|
||||
);
|
||||
}
|
||||
19
src/app/api/team/invitations/[id]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* DELETE /api/team/invitations/{id} — withdraw an invitation.
|
||||
*
|
||||
* The code stops working immediately. There is no way to un-withdraw it; a
|
||||
* change of mind means minting a new one.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
|
||||
}
|
||||
39
src/app/api/team/invitations/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toInvitation} from '@/features/team/services/mapTeam';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/team/invitations — who has been invited and not yet joined.
|
||||
* POST /api/team/invitations — invite somebody.
|
||||
*
|
||||
* The invitation is the PREFERRED way to add a person: they redeem the code and
|
||||
* choose their own password, so the merchant never handles it. The code comes
|
||||
* back once on the POST and never again.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, (token) => teamApi.invitations(token), (list) =>
|
||||
list.map(toInvitation),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.invite(token, {
|
||||
email: String(body.email ?? '').trim(),
|
||||
full_name:
|
||||
typeof body.full_name === 'string' ? body.full_name : undefined,
|
||||
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
|
||||
expires_in_days:
|
||||
typeof body.expires_in_days === 'number'
|
||||
? body.expires_in_days
|
||||
: undefined,
|
||||
}),
|
||||
{map: toInvitation, status: 201},
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/members/route.ts
Normal file
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
19
src/app/api/visitors/[id]/history/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {visitorsApi} from '@/services/api/visitorsApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toCustomerVisit} from '@/features/customers/services/mapCustomer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/visitors/{id}/history — this customer's visits, newest first. */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => visitorsApi.history(token, id, 50),
|
||||
(rows) => (rows ?? []).map(toCustomerVisit),
|
||||
);
|
||||
}
|
||||
37
src/app/api/visitors/[id]/image/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||