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 ? ( - <> -