Commit Graph

32 Commits

Author SHA1 Message Date
dccb1beda5 feat(floor): the shop floor, and a sale recorded against a visit
Two surfaces a merchant currently has no way to reach, and the BFF routes
behind them.

/floor is the only screen in this console aimed at somebody standing behind
a counter: who walked in, who is being served, and by whom. A visit is
claimed with attend, handed back with release, and closed with complete.
Claiming is single-winner — the platform decides, and a second person
tapping the same customer gets 409 CUSTOMER_ALREADY_TAKEN rather than a
silent overwrite. That was verified against a live platform: eight
concurrent claims, exactly one winner.

Naming a walk-in posts to /api/customers, which is the same permission as
PUT /api/visitors/{id}/profile upstream — attaching a name and a phone
number to a face is the floor's job, not a manager's.

Sale entry sits on the floor because that is where a sale happens. Lines
carry an intent, purchased or enquired, so a shop can record what somebody
asked about and did not buy; only purchased lines are billed. Money is
integer paise end to end and formatPaise is the only place it becomes
rupees — a float round-trip through the BFF was rejected once already and
must not come back.

Every write carries an idempotency key minted per dialog, so a double tap,
a timeout retry and a resubmit collapse into one sale rather than three.
Proven live: a replayed key returns 200 already_processed with the original
sale id.

/commerce gains the real list of those sales, replacing nothing invented —
it reads GET /api/sales and opens a detail view per row.

── What this does NOT do ────────────────────────────────────────────────
No mock, demo or placeholder data anywhere in it. Every figure comes off a
payload; an empty floor renders an empty state and says so.

── Known: the deployed backend does not serve these yet ─────────────────
/api/floor/visits, /api/customers, /api/sales and the three visit actions
all answer 404 on mcp.loyaly.ai today, which runs a build older than this
repository's first commit. Until that backend ships, /floor and the sales
panel will show error states, and the Floor nav entry points at a page that
cannot load its data. Committed deliberately so the two halves can be
deployed together rather than drifting further apart.

staff -> /floor has been the committed destination since 697b0d9; this is
the page it was always pointing at.

Verified: tsc clean, production build clean, lint unchanged at the existing
baseline. Exercised against a live local platform for all three roles —
attend/release/steal-prevention, customer creation, sale creation and
idempotent replay, and two-tenant isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
2026-09-18 11:12:04 +05:30
781757d377 fix(deploy): harden runtime configuration
Production answered 502 on every url, /favicon.ico included, because the
container had no AUTH_SECRET and the boot check called process.exit(1): the
container died, so Traefik had no upstream and the one line explaining it was
trapped inside a restart-looping container. The exit is already gone (0dc865b).
This makes the configuration contract itself hard to get wrong.

One required variable, one resolver, two probe endpoints.

shared/config/authSecret.ts is now the only place the signing secret is
resolved. sessionToken.ts (HMAC of the identity cookie) and tokenStore.ts
(AES-256-GCM key for the platform token bundle) each read process.env
independently before, under rules that disagreed — one accepted a
whitespace-only value the other rejected. It accepts AUTH_SECRET, or
AUTH_SECRET_FILE for the Docker/Swarm secret convention when a dashboard field
mangles a value, trims both, and is a pure function of the environment.

That purity is load-bearing. Next 16 compiles proxy.ts for the NODE runtime
(its own docs: "Proxy defaults to using the Node.js runtime"), confirmed in the
build output — the proxy is in .next/server/chunks, not .next/server/edge. But
the proxy entry and the route entries are still SEPARATE BUNDLES with their own
copy of this module, so a secret invented in module scope would differ between
them, the proxy would reject every cookie the login route signed, and /login
would redirect forever. There is no generated fallback and there must not be.

LOYALY_API_BASE is no longer required in production. It accepted exactly one
origin, so an unset value could never have meant another, and requiring it added
a failure mode without adding a choice. Verified against the installed @next/env:
a real variable set to the EMPTY STRING is left empty and .env is NOT consulted,
so one blank dashboard field defeated the value shipped in the image and took
production down with "required in production". Any other host set explicitly is
still rejected by name, platform.loyaly.ai included.

/api/health and /api/ready are split. Health was returning 503 on a
configuration fault — readiness semantics on the name every orchestrator probes
by default. A Dockerfile HEALTHCHECK pointed there for one commit, and because
Dokploy runs applications as Swarm services, Swarm removed the task from the
load balancer and rescheduled it: the container was up, serving a 503 that named
the fault, and nothing could reach it to read that 503. Health is now liveness
and always 200 while the process answers; ready is readiness and 503 while a
variable is missing, for a DEPLOY gate (Order start-first + FailureAction
rollback) where failing keeps the previous good task serving. No HEALTHCHECK is
reintroduced.

Diagnostics answer the question that could not be answered from outside the
container: whether the variable never arrived or arrived empty, the secret's
source and length (never its value), and any environment variable whose NAME is
a near-miss for AUTH_SECRET — wrapped (NEXT_PUBLIC_AUTH_SECRET) or mistyped
(AUTH_SECERT, via bounded edit distance). Dokploy's Build Arguments and Build
Secrets are build-time only and absent at runtime, which from inside the
container is indistinguishable from never setting it; the boot log now tells
those apart.

Dockerfile, .env and .env.example changes are comments only — every directive
and every variable value is byte-identical to before.

Verified on the standalone payload the image ships: absent / empty / whitespace
/ typo'd name / wrong API host all keep the container ALIVE and answering 503
with x-loyaly-config: misconfigured; a valid secret gives / 307, /login 200,
/favicon.ico 200, health 200, ready 200. Cross-bundle auth, for both sources: a
cookie signed with the live secret is accepted by the proxy bundle (200) and
independently re-verified by the app/layout.tsx render bundle, while one signed
with a different secret is rejected by both (307). tsc --noEmit clean, eslint
clean on changed files, production build exit 0.

This does not by itself end the outage: AUTH_SECRET still has to be set on the
container, in Dokploy's runtime Environment Variables panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:22:11 +05:30
0dc865ba66 fix(deploy): drop the healthcheck that reproduced the 502 it was meant to end
The HEALTHCHECK added an hour ago recreated the exact symptom. Dokploy runs
applications as Docker Swarm services, and Swarm does not merely report an
unhealthy task — it removes it from the service load balancer and reschedules
it. /api/health answers 503 while a required variable is missing, so:

  AUTH_SECRET unset -> /api/health 503 -> task unhealthy -> pulled out of the
  load balancer -> Traefik has no backend -> 502 Bad Gateway on every url.

The container was up and serving a 503 that names the fault, and nothing could
reach it to read that 503. "A broken deploy must not look healthy" is a real
concern, but enforcing it in the orchestrator destroys the diagnostics, and an
outage whose reason cannot be seen is the more expensive failure. The container
now stays in rotation whenever it can serve HTTP at all.

Also, two things that make a secret that was SET look like one that was not:

- Recommend `openssl rand -hex 32` everywhere instead of `openssl rand -base64
  48`. A base64 value ends in '=' and may contain '+' and '/'; pasted into a
  dashboard field or a KEY=VALUE editor that splits on the first '=', it can be
  stored truncated or empty, which is indistinguishable from never setting it.
  Hex is [0-9a-f] only, so there is nothing for a parser to mangle.
- Treat a whitespace-only AUTH_SECRET as missing, and print the secret's LENGTH
  (never its value) in the boot log. `openssl rand -hex 32` is 64 characters, so
  a much shorter number there is a value that arrived truncated — which
  otherwise presents as sessions that do not verify, with nothing to explain it.

Verified on the rebuilt standalone payload: absent -> container alive, 503
`x-loyaly-config: misconfigured` on /, /login and /api/sites; whitespace -> the
same; a real hex secret -> / 307, /login 200, /favicon.ico 200, /api/health 200
and `auth secret set (64 chars)` in the log. tsc --noEmit and eslint clean,
production build exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:08:03 +05:30
aed8598eb9 fix(deploy): serve a 503 that names the fault instead of dying into a 502
A container started without AUTH_SECRET called process.exit(1) from the boot
check. The container died, Dokploy's Traefik had no upstream to proxy to, and
every url answered 502 Bad Gateway — /favicon.ico first, which is the line that
shows up in a browser console. The one message that explained it was on stderr
inside a restart-looping container, so the fastest fault in this app to fix
became the slowest to identify.

Reproduced against the real standalone payload: without AUTH_SECRET the process
exited 1; with it, /favicon.ico 200, /login 200, / 307.

The container now boots and stays up. While a required variable is missing the
proxy answers 503 with `x-loyaly-config: misconfigured` on every gated request,
and the boot log names the variable. A Docker HEALTHCHECK against the new
/api/health keeps the property the exit was protecting — a broken deploy still
reports unhealthy rather than presenting itself as a working one.

- configCheck.ts: one runtime-neutral check shared by the boot log, the proxy
  and the health route, memoised so a healthy server pays an array-length read
  per request rather than re-reading the environment.
- proxy.ts: the config gate runs before the session gate. verifySessionToken
  reads AUTH_SECRET and throws ConfigError without one, which Next turns into a
  500 per request — a status that says "this server has a bug" for a server that
  is merely unconfigured.
- Neither the 503 body nor /api/health names the missing variable. Those
  messages are operator information (configError.ts states the rule, the login
  route already follows it); the names go to the container log.
- HEALTHCHECK probes with node, already the entrypoint, so it adds no package
  and cannot break because a base image dropped a busybox applet.
- nginx.conf: marked dead. Nothing has installed nginx since 28258b5 and
  .dockerignore keeps it out of the build context, but it is the first place
  anyone looks at a 502 and the wrong one.

Verified: tsc --noEmit clean, eslint clean, and the Docker builder stage's
`NODE_ENV=production CI_BUILD=1 next build` exits 0. Misconfigured -> 503 on
/login, /dashboard, /api/sites with healthcheck exit 1; configured -> 200/307
with healthcheck exit 0.

This does not by itself end the outage: AUTH_SECRET still has to be set on the
container (Dokploy -> Environment). It makes the next occurrence legible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:54:41 +05:30
958d352abe fix(config): exit on a failed boot check instead of serving 500s
The check added in 1ee4b5d did not do what its commit message claimed. It said
"refuses to start"; it did not. Throwing from `register()` does not stop the
server — the port is already bound by then, so Next logs "Failed to prepare
server" and the process STAYS ALIVE, answering 500 to every route.

That is worse than the problem it replaced. A TCP or HTTP healthcheck sees an
open port and reports the container healthy, so a dead deploy goes green and
keeps receiving traffic, and the operator sees 500s on /login AND
/favicon.ico AND everything else with no indication of why.

Measured, both before and after:

  before   port LISTENING, GET /login -> 500, GET /favicon.ico -> 500
  after    nothing listening, connection refused, EXIT_CODE=1

A non-zero exit is what a deployment platform reads as a failed deploy. The
numbered problem list goes to stderr before exiting, so every restart attempt
reprints the reason.

The Node-only half moves to src/instrumentation-node.ts, reached by dynamic
import. instrumentation.ts is compiled for EVERY runtime the app uses, and
src/proxy.ts makes this app compile an Edge one, where process.exit does not
exist — Turbopack flagged it ("A Node.js API is used (process.exit) which is
not supported in the Edge Runtime") even though the call sits behind a
NEXT_RUNTIME guard that can never let it run there. A runtime guard is not a
bundling boundary; a separate module behind a dynamic import is. The build is
warning-free again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:00:27 +05:30
1ee4b5d24c feat(config): refuse to start a misconfigured container
Three misconfigurations in a row were each diagnosed the same slow way: deploy,
try to sign in, read a status code, guess, read a response body, guess again.
That is a property of the design, not of bad luck. Every required variable is
read lazily, on the first request that needs it, so a container missing its
environment starts clean, serves the sign-in page, and passes a healthcheck
while being completely unable to authenticate anybody.

The lazy reads have to stay — reading at module scope fails `next build`, which
collects page data with NODE_ENV=production and none of these variables set
(that is the "Failed to collect page data for /api/assistant" failure already
documented in apiClient). So the check goes in an instrumentation hook instead,
which Next runs once per server start and NEVER during a build: it returns early
when NEXT_PHASE is 'phase-production-build', in
server/lib/router-utils/instrumentation-globals.external.js.

Boot now either prints what it resolved:

  [loyaly] config ok — platform https://mcp.loyaly.ai, auth secret set, NODE_ENV=production

or refuses to start, naming every problem at once rather than one per deploy:

  refusing to start — 2 configuration problem(s):
    1. LOYALY_API_BASE is invalid: https://api.example.com is not a supported production API host...
    2. AUTH_SECRET is required in production — it signs the session cookie...

Verified against a real standalone build, booted four ways: unconfigured, fully
configured, LOYALY_API_BASE pointed at the console, and both wrong at once.

The platform origin and its validation move to shared/config/platformApi. This
is load-bearing, not tidying: apiClient is `server-only`, and that package
resolves to a module which THROWS ON IMPORT outside a react-server condition —
which the instrumentation bundle is not. Importing apiClient from the boot check
would have crashed every start, correctly configured or not. The extracted
module imports nothing but ConfigError, so the boot check and the request path
run the same function against the same allowlist.

Also corrects a claim I made in the Dockerfile two commits ago. `next build`
DOES copy .env into .next/standalone — writeStandaloneDirectory takes exactly
.env and .env.production and nothing else — so the explicit COPY is redundant
rather than required. It stays, with an accurate reason: it only happens when
.env is in the build context, and .dockerignore excluded it until recently.
Keeping the COPY makes that dependency fail the Docker build loudly instead of
producing an unconfigured image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:53:15 +05:30
c01c750436 fix(auth): handle a missing AUTH_SECRET instead of dying mid sign-in
`storeTokens()` and `createSessionToken()` ran outside any catch. Both read
AUTH_SECRET — one derives the AES key that encrypts the platform bundle, the
other signs the identity cookie — and in production both refuse to fall back to
the development key. So an unset AUTH_SECRET threw after the credentials had
already been accepted upstream, and the browser got a bare 500 on a sign-in
that was entirely valid.

The status was the smaller half. The upstream session minted moments earlier by
`authApi.login` was ORPHANED: a live refresh token, issued to somebody who did
not end up logged in, left to expire on its own. The platform-admin branch a few
lines above already revokes for precisely this reason — declining because the
server is broken is no different from declining because the account is wrong —
so this now revokes too, best-effort, on the same terms.

It fails closed. No cookie is set on this path, so a half-configured server
cannot hand out a session it will be unable to verify on the next request.

ConfigError moves to src/shared/errors/configError.ts because its throwers now
span two runtimes: apiClient and tokenStore are server-only, while sessionToken
is reached from src/proxy.ts, which Next compiles for Edge. Declaring it in
apiClient would have dragged the whole platform client, `server-only` guard and
all, into the proxy bundle to name one class. The new module imports nothing.

Verified by exercising both functions directly: with AUTH_SECRET unset under
NODE_ENV=production, sealTokens and createSessionToken each raise ConfigError
rather than a bare Error; with it set, both succeed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:43:57 +05:30
3dc0bba6f4 fix(api): stop reporting a misconfigured server as an unreachable platform
`POST /api/auth/login` answered 502 platform_unreachable — "Could not reach
Loyaly. Check your connection and try again." — for a fault that is entirely
ours and that no connection check can fix.

`buildUrl()` is where LOYALY_API_BASE is read, and it was called INSIDE the
try block whose catch turns a failed fetch into UpstreamError(0, 'network').
So the config guard threw, the catch swallowed it, and "nobody set
LOYALY_API_BASE" arrived at the route indistinguishable from "the platform is
down". Measured on the pre-fix code, all of these produced the identical
UpstreamError(status=0, code=network):

  LOYALY_API_BASE unset
  LOYALY_API_BASE=https://platform.loyaly.ai   (the known-wrong host)
  LOYALY_API_BASE=not-a-url
  nothing listening on the far end             (the only real network failure)

The message survived, so the truth was reachable, but only by reading the
prose of an error the code had already classified as a network fault — and
the login route had by then replaced it with advice about the user's wifi.

ConfigError now exists for this, buildUrl is resolved before the try in both
upstreamRequest and upstreamRaw, and callers branch on it: 500 misconfigured,
not 502 unreachable. 500 is the honest status — a bad gateway says the thing
upstream is unwell, and this server has not got as far as having an upstream.
Verified after the change: the four config faults raise ConfigError, and a
dead port still raises UpstreamError(0, 'network').

The detail is logged, never returned. It names an environment variable and the
hosts this console accepts, which belongs in the Dokploy log pane rather than
in an anonymous sign-in form's response body. `[loyaly] configuration error:`
is now the line to grep for.

Also fixes failJson labelling a 5xx as `unauthorized` in the envelope: the
code is derived from the status now, so a misconfigured server can no longer
tell a browser the password was wrong. That one costs somebody a password
reset for a fault they cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:35:54 +05:30
a40afb9e7a fix(stores): hold the estate fetch until there is a session
`GET /api/sites 401 (Unauthorized)` fired on the sign-in page for every
visitor who had not signed in yet.

WorkspaceProvider sits above the whole route tree, /login included, and it
already carried a comment saying the estate was "gated on the session ...
asking for the estate before anyone has signed in would put a guaranteed 401
in the console on every visit to the sign-in page". The gate was never
applied. `useSites()` was called unconditionally and `isAuthenticated` only
guarded the derived `stores` value below it — and a gate on a derived value
cannot hold a fetch that has already gone out.

useResource has taken `Endpoint<T> | null` for exactly this all along; the
effect returns early on a null key, so nothing is sent. The gate goes in
useSites rather than in one consumer because the rule belongs to the endpoint
— no session, no estate — and /stores calls it too.

Costs a signed-in user nothing: SessionProvider resolves `status` synchronously
from the server-rendered `initialSession`, so there is no 'loading' pass to
wait through before the request goes out. /stores is unaffected in the other
direction too — AuthGuard renders a spinner instead of children once status is
'unauthenticated', so no consumer sits on a permanently held resource.

Verified in the browser against a clean network buffer: with the gate reverted,
/login issues GET /api/sites → 401; with it in place, /login issues 31 requests
and none of them are /api/*.

Worth being explicit about what this does NOT change: platform.loyaly.ai/api/*
is the correct address for these calls. It is this app's own BFF, same-origin
by design, and the hop to mcp.loyaly.ai happens server-side where the token
lives. The 401 was a request that should never have been made, not a request
made to the wrong host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:22:00 +05:30
befe4307c3 fix(deploy): ship the production environment instead of injecting it
The deployed console called its own origin instead of the platform. The BFF
route would throw "LOYALY_API_BASE is required in production — refusing to
guess the Loyaly platform host", and from the browser that reads as a broken
login form rather than as a missing variable.

The guard was right; nothing ever set the variable. `.gitignore` had a blanket
`.env*` and `.dockerignore` excluded `.env` and `.env.*`, so the image carried
no environment at all and the only copy of the production host was a comment
in `.env.example`. Injecting it by hand at the orchestrator was the single
point of failure, and it failed.

The platform host is not a secret, so it is now committed in `.env` and copied
into the runner stage. `next build` does not fold `.env` into
`.next/standalone`, which is why the COPY is explicit; server.js chdirs to
/app and Next runs loadEnvConfig there, so the file sits beside it at the
WORKDIR root. `npm run bundle` stages it the same way for a non-Docker deploy.

This pins nothing. @next/env never overwrites a variable already present in
process.env, so anything set in Dokploy still wins — verified against
@next/env directly: a bare image resolves https://mcp.loyaly.ai, an injected
LOYALY_API_BASE overrides it, and a leaked .env.local beats both.

That last case is why `.dockerignore` still excludes `.env.*`. A developer's
.env.local points at http://127.0.0.1:8088 and loads AHEAD of .env, so one
leaking into the build context would make the deployed console call localhost
with no error to read. Confirmed the context now carries `.env` and nothing
else.

AUTH_SECRET stays out of every committed file and out of the image. It signs
the session cookie and encrypts the token bundle, so a committed value is a
session-forging key in git — the thing 8b3fbab removed from the Dockerfile.
It remains a Dokploy secret, and production still refuses to sign without it.

`.env.example` is now the template for `.env.local` rather than a second copy
of the production values, so the two files cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:55:53 +05:30
8b3fbab7a0 fix(deploy): resolve the API base at request time, and stop shipping a secret
The Dokploy build failed at "Collecting page data for /api/assistant" with
"LOYALY_API_BASE is required in production". The guard was right; where it ran
was not.

  const BASE = resolveBase();   // evaluated on import

next build imports every route module to collect page data, the Docker builder
stage sets NODE_ENV=production, and LOYALY_API_BASE is a RUNTIME value that is
not present while an image is being built. So the check fired against the build
instead of against a misconfigured server. The previous comment claimed this
matched tokenStore's stance on AUTH_SECRET; it did not - tokenStore.key() is a
function, called per use. It is now genuinely the same shape: resolved on first
use and memoised, so building needs no platform host and serving still refuses
to guess one.

The value is also validated rather than merely present. It used to accept any
string, so platform.loyaly.ai - which serves THIS console, not the API - was
taken and only failed later as a contract_mismatch at first login. Production
is now an allowlist of exactly one origin, and the known-wrong host is rejected
everywhere with the reason attached, because "rejected" alone sends somebody
hunting for a firewall when the fix is one word in a variable. Development
stays permissive (LAN, tunnel, container host) minus that same host - nothing a
dev machine reaches is production.

  production   https://mcp.loyaly.ai only; missing, http://, platform.loyaly.ai,
               any other origin, a bare hostname and a non-http scheme all throw
  development  loopback and friends, or unset -> http://127.0.0.1:8088

An invalid or missing value is never cached, so a misconfigured process fails
identically on every request rather than once and then differently.

AUTH_SECRET is no longer an ENV line in the Dockerfile. A session-signing key
in git means anyone who can read the repo can forge a cookie for any user, and
every built image carried it in a layer `docker history` will print; Docker's
own linter flags the pattern. Both AUTH_SECRET and LOYALY_API_BASE are now
supplied by the orchestrator at runtime, and the file says so.

  ROTATE the old AUTH_SECRET - it remains in this repository's history.

Verified: docker build --no-cache with neither variable set compiles, passes
TypeScript and collects page data. The built container starts without them,
serves /login, and answers the first API call with the configuration error
naming the variable. With LOYALY_API_BASE set it reaches the real platform.
tsc clean; lint unchanged at the existing baseline.

REQUIRED in Dokploy before this deploys:
  LOYALY_API_BASE=https://mcp.loyaly.ai
  AUTH_SECRET=<openssl rand -base64 48>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
2026-09-17 16:41:32 +05:30
759f3b79fd fix(config): require a real API base, ship the env template, show the real team
Three configuration defects and one screen of invented people.

API base. The client defaulted to https://platform.loyaly.ai when
LOYALY_API_BASE was unset, and that host serves THIS console, not the
Behavision API - verified live: it answers /api/auth/me with the console's
own 404 HTML and a login POST with the console's own BFF envelope. So an
unset variable in production made the BFF call its own origin, which fails
looking like a broken login form rather than a misconfiguration. There is
now no remote fallback: development defaults to 127.0.0.1:8088 and
production throws, naming the variable, the way tokenStore already refuses
to run without AUTH_SECRET. A wrong host that appears to work is worse than
a startup failure that says what is missing.

The template. .env.example documented that same wrong host, and .gitignore's
`.env*` matched the template itself, so it was never committed - a fresh
clone got no template at all, for an app that cannot start in production
without AUTH_SECRET. Added `!.env.example` after the ignore rule; .env.local
and every other .env* stay ignored. The template carries placeholders only,
no values.

Team. /settings/team listed five invented people - aravind@nearle.in,
Vikram Seth, Priya Sharma - with store names no endpoint supplies and roles
that do not exist upstream, behind four controls that mutated local state
and were lost on refresh. A merchant could not tell any of it from the real
thing. It now reads GET /api/team, which the platform already serves and
scopes by session, and renders what actually comes back: name, email, role,
whether the account is still active, and last sign-in (or "Never", which is
a fact worth seeing).

The route used to map 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. ApiTeamMember
now describes that payload properly and ApiUser is left to authentication.

The screen is READ-ONLY on purpose. Accounts are born from invitations, and
that flow already exists in the platform's own web app: a manager mints a
code, the holder redeems it and chooses their own password. A second way to
create a login does not belong here, least of all on the screen that lists
them. Role changes and deactivation are supported upstream by
PATCH /api/team/{id} and are deliberately not wired: deactivating revokes
every session that person holds immediately, so it wants a confirmation step
and 409 last_owner handling, neither of which belongs in a change whose
purpose is removing invented data.

types.ts also gains the Sales/Floor/Customer interfaces. They are inert here
- nothing imports them yet - and land with this commit so the screens that
consume them arrive as one reviewable change.

Verified against the live local platform: two tenants, correct member lists
for each, and no cross-tenant leakage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
2026-09-17 12:05:41 +05:30
697b0d9ef2 feat(auth): unify login flow and update login visuals
One sign-in page for everyone. /login is the only entry point; the
short-lived /admin/login and /staff/login routes are gone, along with the
per-route branding that came with them. Admin, owner, manager and staff see
the same form, post the same {email, password} to the same
POST /api/auth/login, and are never asked to say who they are.

Where somebody lands is decided by the role the BACKEND returns, never by
the URL they arrived at:

  owner, manager -> /dashboard
  staff          -> /floor

roleDestination is the single map, read by all three redirect paths - the
hydrated form, the no-JavaScript form POST, and GuestGuard. GuestGuard
raced the form to a hardcoded /dashboard, so a staff member landed in a
different place depending on which effect fired first; it now resolves
through the same map.

A platform admin authenticates correctly and still gets no session here.
Every surface in this console is tenant-scoped and an admin has no tenant
(auth.go: "ClientID empty means a platform admin"). Measured against a real
admin token: /api/sites 500, /api/visits 500, /api/visitors 500, /api/team
403 "This account does not belong to a company." So the BFF declines to set
the cookie rather than handing out a dashboard of server errors, revokes the
upstream session it will not use, and says so on /login through the existing
fixed-code table. Their surface is Companies in the platform's own web app,
which this console does not link to and does not hand a token - no session
handoff exists between the two, and inventing one would mean putting a
credential in a URL.

No enumeration is given up: a wrong password for an admin is answered
exactly like every other wrong password, so the "wrong console" message only
ever reaches somebody who has already proved they own the account.

Login visuals: the hero carousel now anchors each slide independently -
slide 1 (mascot with bag) to the bottom so the white bag clears the white
caption, slide 2 (selfie booth) to the top so the arch and wordmark are not
cropped by the rounded corner.

Unchanged: the BFF, the sealed httpOnly token cookie, the signed session
cookie, refresh, logout, route protection and the open-redirect guard on
?next=.

Verified against the live local platform with real accounts for all four
roles, plus wrong-password, unknown-email, empty-field, invalid-format and
inactive-user cases, session persistence, a forced token refresh, logout,
and two-tenant isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
2026-09-16 15:45:17 +05:30
d2e090214a update the api 2026-09-10 19:25:07 +05:30
bfe4d3d2f2 update ui and remove mock data 2026-09-09 19:50:52 +05:30
5ce580dced ligth theme design fix 2026-08-10 15:33:13 +05:30
6cc3c9a0b7 update dark and ligth theme 2026-08-10 12:35:18 +05:30
f397e94418 Fix login 500: set AUTH_SECRET, revert API base override
sessionToken.ts throws when AUTH_SECRET is unset under NODE_ENV=production,
so /api/auth/login answered 500 on valid credentials while still returning
401/400 correctly on bad ones. Verified against platform.loyaly.ai, whose
responses match that signature exactly.

Also reverts NEXT_PUBLIC_API_BASE. platform.loyaly.ai is this same app
already deployed (identical /login markup), so the override pointed the
app at itself cross-origin, and that endpoint returns no CORS headers.

Verified on the production build: valid credentials 200 + session cookie,
wrong password 401, malformed 400, and the cookie opens /dashboard,
/settings, /stores and /api/stores while an unauthenticated request still
gets 307/401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:20:28 +05:30
450fa0d6a0 Point API base at platform.loyaly.ai
Set NEXT_PUBLIC_API_BASE in the builder stage so login and every other
httpClient call target the platform backend instead of the local mock
route handlers.

Set in the Dockerfile rather than Dokploy because NEXT_PUBLIC_* is
inlined into the client bundle at build time — a runtime env var has no
effect. Origin only, since authRepository appends /api/auth/login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:12:08 +05:30
0ea52551aa build size reduse it 2026-08-06 20:59:19 +05:30
28258b5a69 fix docker port 2026-08-06 19:26:30 +05:30
f80c1d9f5d fix sales issue 2026-08-06 18:13:59 +05:30
32847159ae update sales page 2026-08-06 17:46:17 +05:30
469ad3021c fix mobile issue 2026-08-06 17:09:08 +05:30
cec6f0f412 change names for dashboard 2026-08-06 16:37:31 +05:30
11daadc0a3 comment voice icon 2026-08-06 16:13:58 +05:30
b23ea760c8 fix chatbox 2026-08-06 15:43:46 +05:30
e5f8144fb3 update ui update and fix layout issue 2026-08-06 13:21:10 +05:30
92322bff16 login page logo fix 2026-08-05 18:43:07 +05:30
9d7bbad32c dashboard design changes 2026-08-05 18:34:37 +05:30
2c8c394795 first commit 2026-08-05 15:26:50 +05:30
c000e5dc95 Initial commit from Create Next App 2026-08-05 12:41:28 +05:30