From 30d10921c29930cf98f7e68e556bdc3157ab7ab1 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Fri, 18 Sep 2026 11:45:13 +0530 Subject: [PATCH 1/2] One backend, and only the screens it can stand behind The console defaulted to a backend on localhost, and features were built against a locally modified server that production never had: Floor, Commerce and their sales/customers routes answered 404 the day they were deployed. The platform API is now https://mcp.loyaly.ai in every environment; LOYALY_API_BASE remains only as an explicit override. Removed what had no server behind it - Floor, Commerce, Lyts, Leaderboard, and the Roles, Notifications, Billing, Integrations, API keys and Preferences settings pages, all of which rendered hard-coded arrays as if they were the merchant's data. Navigation is what the API can honestly back. The removed code is in history if a real backend for any of it is ever built. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- .env | 83 +--- .env.example | 46 +-- src/app/(workspace)/commerce/page.tsx | 122 ------ src/app/(workspace)/floor/page.tsx | 197 --------- src/app/(workspace)/lyts/page.tsx | 34 -- src/app/(workspace)/settings/api/page.tsx | 13 - src/app/(workspace)/settings/billing/page.tsx | 13 - .../settings/integrations/page.tsx | 13 - .../settings/notifications/page.tsx | 13 - .../(workspace)/settings/preferences/page.tsx | 13 - src/app/(workspace)/settings/roles/page.tsx | 13 - src/app/(workspace)/staff/page.tsx | 40 -- src/app/api/customers/route.ts | 46 --- src/app/api/floor/visits/route.ts | 47 --- src/app/api/sales/[id]/route.ts | 33 -- src/app/api/sales/route.ts | 136 ------- src/app/api/visits/[id]/attend/route.ts | 37 -- src/app/api/visits/[id]/complete/route.ts | 37 -- src/app/api/visits/[id]/release/route.ts | 37 -- src/features/auth/services/roleDestination.ts | 6 +- .../commerce/components/SaleDetailDialog.tsx | 127 ------ .../commerce/components/SaleEntryDialog.tsx | 329 --------------- src/features/commerce/hooks/useSales.ts | 11 - .../commerce/repositories/saleRepository.ts | 13 - src/features/commerce/services/money.ts | 57 --- src/features/commerce/types/commerce.ts | 72 ---- src/features/commerce/types/sale.ts | 35 -- .../floor/components/NameCustomerDialog.tsx | 107 ----- src/features/floor/hooks/useFloor.ts | 53 --- .../floor/repositories/floorRepository.ts | 9 - src/features/floor/types/floor.ts | 24 -- src/features/lyts/types/reward.ts | 19 - .../components/ApiWebhooksManager.tsx | 383 ------------------ .../settings/components/BillingOverview.tsx | 229 ----------- .../settings/components/IntegrationsGrid.tsx | 213 ---------- .../settings/components/NotificationsForm.tsx | 157 ------- .../settings/components/PreferencesForm.tsx | 154 ------- .../settings/components/RoleMatrix.tsx | 221 ---------- src/features/settings/config/settingsNav.ts | 26 +- src/features/staff/types/staff.ts | 45 -- src/features/staff/utils/attendance.ts | 29 -- src/services/api/floorApi.ts | 47 --- src/services/api/salesApi.ts | 38 -- src/shared/config/platformApi.ts | 187 ++------- src/shared/layouts/workspace/nav-config.ts | 6 +- 45 files changed, 53 insertions(+), 3517 deletions(-) delete mode 100644 src/app/(workspace)/commerce/page.tsx delete mode 100644 src/app/(workspace)/floor/page.tsx delete mode 100644 src/app/(workspace)/lyts/page.tsx delete mode 100644 src/app/(workspace)/settings/api/page.tsx delete mode 100644 src/app/(workspace)/settings/billing/page.tsx delete mode 100644 src/app/(workspace)/settings/integrations/page.tsx delete mode 100644 src/app/(workspace)/settings/notifications/page.tsx delete mode 100644 src/app/(workspace)/settings/preferences/page.tsx delete mode 100644 src/app/(workspace)/settings/roles/page.tsx delete mode 100644 src/app/(workspace)/staff/page.tsx delete mode 100644 src/app/api/customers/route.ts delete mode 100644 src/app/api/floor/visits/route.ts delete mode 100644 src/app/api/sales/[id]/route.ts delete mode 100644 src/app/api/sales/route.ts delete mode 100644 src/app/api/visits/[id]/attend/route.ts delete mode 100644 src/app/api/visits/[id]/complete/route.ts delete mode 100644 src/app/api/visits/[id]/release/route.ts delete mode 100644 src/features/commerce/components/SaleDetailDialog.tsx delete mode 100644 src/features/commerce/components/SaleEntryDialog.tsx delete mode 100644 src/features/commerce/hooks/useSales.ts delete mode 100644 src/features/commerce/repositories/saleRepository.ts delete mode 100644 src/features/commerce/services/money.ts delete mode 100644 src/features/commerce/types/commerce.ts delete mode 100644 src/features/commerce/types/sale.ts delete mode 100644 src/features/floor/components/NameCustomerDialog.tsx delete mode 100644 src/features/floor/hooks/useFloor.ts delete mode 100644 src/features/floor/repositories/floorRepository.ts delete mode 100644 src/features/floor/types/floor.ts delete mode 100644 src/features/lyts/types/reward.ts delete mode 100644 src/features/settings/components/ApiWebhooksManager.tsx delete mode 100644 src/features/settings/components/BillingOverview.tsx delete mode 100644 src/features/settings/components/IntegrationsGrid.tsx delete mode 100644 src/features/settings/components/NotificationsForm.tsx delete mode 100644 src/features/settings/components/PreferencesForm.tsx delete mode 100644 src/features/settings/components/RoleMatrix.tsx delete mode 100644 src/features/staff/types/staff.ts delete mode 100644 src/features/staff/utils/attendance.ts delete mode 100644 src/services/api/floorApi.ts delete mode 100644 src/services/api/salesApi.ts diff --git a/.env b/.env index 303636b..558121e 100644 --- a/.env +++ b/.env @@ -1,79 +1,14 @@ -# --------------------------------------------------------------------------- -# Production runtime configuration. COMMITTED ON PURPOSE — carries no secret. -# --------------------------------------------------------------------------- +# Runtime configuration. Committed on purpose - it carries no secret. +# Precedence: process.env > .env.production.local > .env.local > .env.production > .env # -# 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". +# The platform API. https://mcp.loyaly.ai is the default in every environment +# and the only value production accepts; it is written here so `docker run` +# is self-describing. NOT platform.loyaly.ai - that host serves this console. 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. +# Browser -> this app's own routes, same origin. Empty is correct. 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. +# AUTH_SECRET is deliberately NOT here: it signs sessions, so a committed value +# is a session-forging key in git. Set it in the runtime environment (or point +# AUTH_SECRET_FILE at a mounted secret). Generate with: openssl rand -hex 32 diff --git a/.env.example b/.env.example index a2ab085..6f7e9cc 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,12 @@ -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- +# Copy to .env.local for development. Only AUTH_SECRET is required. -# 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 '=') +# Signs the session cookie and encrypts the platform tokens inside it. +# Generate with: openssl rand -hex 32 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. +# The platform API. Defaults to https://mcp.loyaly.ai in EVERY environment; +# set this only if you are deliberately developing against another backend. +# LOYALY_API_BASE= + +# Browser -> this app's own routes. Empty is correct. NEXT_PUBLIC_API_BASE= diff --git a/src/app/(workspace)/commerce/page.tsx b/src/app/(workspace)/commerce/page.tsx deleted file mode 100644 index 90bb7ce..0000000 --- a/src/app/(workspace)/commerce/page.tsx +++ /dev/null @@ -1,122 +0,0 @@ -'use client'; - -import {useState} from 'react'; - -import {VStack} from '@astryxdesign/core/Layout'; -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 {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable'; -import {PanelCard} 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 {formatInrCompact} from '@/shared/utils/format'; - -/** - * 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. So the page now shows the part that is real - * and names the resources the rest is waiting for, rather than presenting - * invented inventory as though a merchant could act on it. - */ -export default function CommercePage() { - const [openSale, setOpenSale] = useState(null); - const conversion = useConversionReport({bucket: 'day'}); - const sales = useSales(); - const scopeLabel = useScopeLabel(); - - return ( - - } - /> - - - {(report) => ( - - )} - - - } - empty={ - - } - > - {/* - 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) => ( - - {rows.map((sale) => ( - 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)} - /> - ))} - - )} - - - {openSale ? ( - setOpenSale(null)} /> - ) : null} - - - - ); -} diff --git a/src/app/(workspace)/floor/page.tsx b/src/app/(workspace)/floor/page.tsx deleted file mode 100644 index d9d8a49..0000000 --- a/src/app/(workspace)/floor/page.tsx +++ /dev/null @@ -1,197 +0,0 @@ -'use client'; - -import {useState} from 'react'; -import {VStack, HStack} from '@astryxdesign/core/Layout'; -import {Grid} from '@astryxdesign/core/Grid'; -import {Card} from '@astryxdesign/core/Card'; -import {Text, Heading} from '@astryxdesign/core/Text'; -import {Button} from '@astryxdesign/core/Button'; -import {Banner} from '@astryxdesign/core/Banner'; -import {StatusDot} from '@astryxdesign/core/StatusDot'; -import {PageHeader} from '@/shared/components/primitives/PageHeader'; -import {ScopeControls} from '@/shared/components/scope/ScopeControls'; -import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; -import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState'; -import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; -import {NameCustomerDialog} from '@/features/floor/components/NameCustomerDialog'; -import {SaleEntryDialog} from '@/features/commerce/components/SaleEntryDialog'; -import {useFloor} from '@/features/floor/hooks/useFloor'; -import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; -import type {FloorVisit} from '@/features/floor/types/floor'; - -/** - * The shop floor — who is here, and who is serving them. LOYALY.md §6/§7/§20. - * - * Every row is a visit the CAMERA created. This screen never invents an - * arrival, and it never decides ownership: a Take that loses a race comes back - * 409 from the platform and the list is re-read, because who holds a customer - * is a fact only the server has. - */ -function whenSeen(iso: string): string { - const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000)); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins} min ago`; - return `${Math.floor(mins / 60)} h ago`; -} - -export default function FloorPage() { - const {resource, act, pending, conflict} = useFloor(); - const scopeLabel = useScopeLabel(); - const [naming, setNaming] = useState(null); - const [selling, setSelling] = useState(null); - - return ( - - } - /> - - {/* The platform's own refusal, shown verbatim — it names who holds the - customer, which is the part staff need. */} - {conflict ? : null} - - } - empty={ - - } - > - {(rows) => ( - - {rows.map((v) => { - const unknown = v.visitorId === null; - const heldByOther = v.attendedBy !== null && !v.attendedByMe; - return ( - - - - - {v.label ?? 'Unrecognised customer'} - - {v.customerRef ? ( - - {v.customerRef} - - ) : null} - - - - - - {v.status === 'attending' && v.attendedByName - ? `With ${v.attendedByName}` - : 'Waiting'} - {' · '} - {whenSeen(v.detectedAt)} - - - - {/* Real profile data only. An unrecognised arrival says so - and offers the form; it never shows a placeholder name. */} - {unknown ? ( - - The cameras have not seen this person before. - - ) : ( - - {v.previousVisits === 0 - ? 'First visit' - : `${v.previousVisits} previous ${ - v.previousVisits === 1 ? 'visit' : 'visits' - }`} - {v.phone ? ` · ${v.phone}` : ''} - - )} - - - {v.attendedByMe ? ( - <> -